Fix/fdb fr usrs (#1125)

* fix(invoices): return attachment filename in delivery history summaries

The 20260723003000 hardening dropped attachment_filename from
list_invoice_delivery_summaries, so the delivery history UI always fell
back to the generic "faktura.pdf" label. Recreate the RPC with the
filename included: it is derived from company name, customer name,
invoice number, and date, all already visible to every company member,
so the minimization boundary is unchanged. Addresses stay masked and
message content, BCC, and checksums stay server-side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): surface own-account transfer legs in match-to-voucher by default

The second (incoming) leg of a transfer between two of the company's own
bank accounts was hidden in the 'Matcha mot befintlig verifikation' dialog
because the voucher counted as 'already matched' once its outgoing leg was
linked, even though the incoming account's line had no settling transaction.
Users read the empty default list as 'the app won't let me link this'.

get_account_gl_lines_for_matching now counts links per settlement account:
a transaction provably on another cash account no longer marks the voucher
as matched for the requested account, so the unsettled transfer leg surfaces
by default (and auto-selects on an exact match). Same-account N:1 stays
behind the 'Visa aven matchade verifikationer' opt-in, and transactions
without a resolvable cash account conservatively keep counting everywhere.
get_unlinked_gl_lines is deliberately untouched (feeds auto-reconcile).

Companion guard: mark_entry_as_opening_balance now refuses entries with
linked bank transactions, since half-settled transfer vouchers became
reachable in the reconciliation view's unmatched table where 'Mark som IB'
renders; re-tagging one would strand its transaction against a movement-
excluded entry. getReconciliationStatus counts unmatched GL lines with the
account-scoped RPC so the status card agrees with the table.

Fixes #1026

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(api): cut prod p95 latency via local JWT auth, single-RT company resolution, and report aggregate RPCs

Baseline 2026-07-23 (487 prod samples): p50 160ms, p95 480ms, 13% of
requests over 300ms. Target: p95 under 300ms.

- requireAuth: verify JWTs locally via getClaims (ES256/JWKS) instead of
  a second network getUser per request; getUser fallback keeps HS256
  self-hosted and existing test mocks working; middleware still
  revocation-checks every /api request
- resolve_active_company RPC (20260723161000): one round trip replaces
  2-3 queries in getActiveCompanyId and middleware; PGRST202/42501 fall
  back to the legacy query path
- arsredovisning build-data: ~33 sequential round trips down to ~7,
  output byte-identical (snapshot-proven)
- currency rate route: stop bypassing the exchange_rates cache (missing
  supabase arg caused an external Riksbanken call on every request)
- document.get: parallelize row fetch, signed URL and audit event
- list_company_accounts RPC (20260723170000): accounts list in one round
  trip instead of paging past PostgREST's 1000-row cap
- vat-declaration route: drop a dead sequential company_settings query
- get_kpi_report_aggregates RPC (20260723180000): KPI report's three
  full-period line scans collapsed into one aggregate call; dimension-
  filtered path unchanged
- lint: fix 9 baseline errors, downgrade 4 react-hooks compiler rules to
  warn, zero the eslint baseline ratchet

All four gates green: lint 0 errors, 9163 tests, check:guards, build.
Migrations applied idempotently to staging only; prod receives them via
Supabase branching on merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): resolve PR review findings across auth, VAT declaration, and IB retag

- requireAuth getClaims fast path: pin iss (project URL) and aud
  ('authenticated'), log every fallback to getUser (ASVS V9.1 finding)
- remove the ignored accountingMethod parameter from calculateVatDeclaration
  and the dead company_settings.accounting_method reads in xlsx/pdf/eskd
  routes; v1 API keeps accepting the query param but documents it as a no-op
- close the mark_entry_as_opening_balance TOCTOU race with a transactions
  trigger (20260723190000, FOR KEY SHARE on journal_entries) + pg tests;
  applied to staging and smoke-verified both directions
- re-add the 42501 tenant guard to branch-local migration 20260723160000
  (function body had silently reverted to the pre-20260619130100 definition)
- document the buildK3Noter tbFullRows full-TB contract (uppskjuten skatt
  opening balance per BFNAR 2012:1 ch.29)
- add KPI VAT-liability test covering reduced-rate output accounts 2621/2631

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): use NULL-safe caller_is_company_member in opening-balance retag guard

The re-added tenant guard carried the pre-20260703180000 raw
NOT IN (SELECT user_company_ids()) pattern, which the
null-safe-tenant-guards ratchet blocks. Staging re-synced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-23 16:16:55 +02:00
committed by GitHub
parent 43f7ccab9e
commit 288915c152
65 changed files with 4357 additions and 451 deletions
+13
View File
@@ -318,3 +318,16 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-23] Declined the compliance-swarm suggestion to add DROP COLUMN kvotvarde to the share-capital migration: the column never existed in any migration, so there is nothing to drop.
[2026-07-23] Kept the missing-aktiekapital annual-report path as a warning rather than a hard block on generation: users must be able to preview an in-progress arsredovisning; the warning surfaces in the wizard/validation before filing, and no placeholder text lands in the filed PDF.
[2026-07-23] Declined moving ArticleForm's class 1-3 account filter server-side: the chart of accounts is company-scoped and non-sensitive, the authoritative gate is server-side at booking time, and the combobox intentionally sees the full chart for the activate-account flow.
[2026-07-23] requireAuth switched to supabase.auth.getClaims() local JWT verification with getUser() fallback: supersedes the 2026-07-13 deferral; prod telemetry shows authMs 28-162ms per request even in arn1, and proxy.ts middleware getUser() already performs the per-request revocation check, so route-level local verification no longer changes auth semantics.
[2026-07-23] resolve_active_company() RPC returns (company_id, locale, used_fallback) rather than the pinned 2-column shape: without used_fallback the middleware cannot know when to fire its user_preferences write-back; role text deliberately NOT added (no JS caller consumes it). JS callers fall back to the query path on PGRST202/42501/zero rows because mcp-oauth token and events routes resolve companies with createServiceClientNoCookies() (NULL auth.uid(), no EXECUTE grant).
[2026-07-23] Expose attachment_filename in invoice delivery summaries: filename derives from company/customer/invoice number already visible to all members, so returning it does not widen the 20260723003000 minimization boundary; addresses stay masked, BCC server-side.
[2026-07-23] Account-scoped "already matched" applies to get_account_gl_lines_for_matching (manual match dialog + reconciliation table) only; get_unlinked_gl_lines stays voucher-scoped: it feeds auto-reconcile, where surfacing the transfer's second leg could auto-link ambiguous same-amount rows. Manual matching keeps the human in the loop (issue #1026, migration 20260723160000).
[2026-07-23] mark_entry_as_opening_balance now refuses entries with linked bank transactions: the account-scoped matching change made "Mark som IB" reachable on half-settled transfer vouchers, and re-tagging one would strand its linked transaction against a movement-excluded entry (permanent phantom reconciliation difference).
[2026-07-23] Downgraded the 4 firing eslint-plugin-react-hooks v7 compiler rules (set-state-in-effect, static-components, purity, preserve-manual-memoization) to "warn" instead of refactoring 33 legacy component sites: effect restructuring is behavior-sensitive per-component work, and eslint-baseline.json shows the repo already accepted these as burn-down debt after the plugin bump (#1013). Fixed the 16 mechanical legacy errors (no-explicit-any in tests/scripts, prefer-const, no-assign-module-variable) for real and ratcheted the check:lint baseline to 0, so any new error-severity violation now fails CI immediately.
[2026-07-23] bookkeeping.accounts.list latency: chose a JSON-aggregating SECURITY INVOKER RPC (list_company_accounts, 20260723170000) over count-first or speculative parallel fetchAllRows pages: count-first parallelism still costs 2 sequential waves at the p95 company (1243 rows) and speculative dual-page fetch doubles request volume for the ~92% of companies under 1000 accounts; the RPC returns one json scalar (bypasses PostgREST db-max-rows=1000) so every company size pays exactly 1 round trip, with the old paged fetch kept as the PGRST202/42883/42501 fallback for self-hosted and the deploy-ordering window.
[2026-07-23] report.kpi hot path moved to one get_kpi_report_aggregates RPC (20260723180000) + pure builders instead of three PostgREST journal-line scans; accepted failure-path delta: an aggregates RPC error now 500s the whole route where a monthly-breakdown DB error previously degraded silently to months: []; also new rounding goes through roundOre (antipattern guard forbids raw Math.round(x*100)/100), identical to the legacy expression within float epsilon, and monthly rounding became round-once-per-bucket instead of per-line (same tolerance).
[2026-07-23] Edited unmerged migration 20260723160000 in place (re-added 42501 tenant guard to mark_entry_as_opening_balance) instead of adding a new migration: the file is branch-local (fix/fdb-fr-usrs, not on main), its function body silently reverted to the pre-20260619130100 definition and dropped the tenant guard (caught by securitydefiner_write_rpc_tenant_guards.pg.test), and staging was re-synced with the corrected CREATE OR REPLACE so no drift remains.
[2026-07-23] requireAuth getClaims fast path now pins iss (project URL + /auth/v1) and aud ('authenticated') and logs every fallback: PR review (ASVS V9.1) asked for defense-in-depth beyond signature/expiry; a mismatch degrades to the authoritative getUser() round trip instead of rejecting, so a config drift can never lock users out, only slow them down (visibly, via the new console.error).
[2026-07-23] Removed the accountingMethod parameter from calculateVatDeclaration (and the dead company_settings.accounting_method reads in the xlsx/pdf/eskd routes) instead of restoring the settings read: the value was verifiably unused (declared _accountingMethod, zero body references) because the method is baked into journal entry timing, and keeping an ignored parameter invites a future branch that silently sees the caller's hard-coded 'accrual'; the v1 API still accepts accounting_method for wire compat but its docs now state it has no effect.
[2026-07-23] Closed the mark_entry_as_opening_balance TOCTOU (link committing between the RPC's EXISTS check and commit) with a transactions-side trigger (20260723190000, FOR KEY SHARE on journal_entries) instead of a shared advisory lock in every linking code path: the trigger enforces the invariant from both directions in one place, needs no app-code changes, and FOR KEY SHARE conflicts with exactly the RPC's FOR UPDATE and nothing weaker; prod verified to have zero pre-existing violating rows.
[2026-07-23] Declined the suggested composite index (company_id, is_active, account_class, sort_order, id) on chart_of_accounts for list_company_accounts: the RPC exists to eliminate cross-region HTTP round trips, per-company row counts (p95 ~1250) make the filter+sort a few ms via the existing company_id index, and a 5-column index taxes every account write for no user-visible gain.
@@ -35,7 +35,7 @@ interface CapturedCall {
args: unknown[]
}
/** Chainable builder recording calls; resolves queued {data,error,count} per from(). */
/** Chainable builder recording calls; resolves queued {data,error,count} per from()/rpc(). */
function createCapturingSupabase(
results: { data?: unknown; error?: unknown; count?: number | null }[]
) {
@@ -60,6 +60,11 @@ function createCapturingSupabase(
calls.push({ method: 'from', args: [table] })
return makeBuilder()
},
rpc: (...args: unknown[]) => {
calls.push({ method: 'rpc', args })
const result = results[idx++] ?? { data: null, error: null, count: null }
return Promise.resolve({ data: result.data ?? null, error: result.error ?? null })
},
}
return { supabase, calls }
}
@@ -95,7 +100,7 @@ describe('GET /api/bookkeeping/accounts', () => {
expect(status).toBe(400)
})
it('lists accounts for the company', async () => {
it('lists accounts via the single-round-trip RPC', async () => {
const { supabase, calls } = createCapturingSupabase([
{ data: [{ account_number: '1930', account_name: 'Företagskonto' }] },
])
@@ -105,11 +110,67 @@ describe('GET /api/bookkeeping/accounts', () => {
)
expect(status).toBe(200)
expect(body.data).toHaveLength(1)
const rpcCall = calls.find((c) => c.method === 'rpc')
expect(rpcCall?.args).toEqual([
'list_company_accounts',
{ p_company_id: 'company-1', p_active_only: true, p_account_class: null },
])
// The RPC path must not also hit the table: exactly one round trip.
expect(calls.some((c) => c.method === 'from')).toBe(false)
})
it('maps ?class=3&active=false onto the RPC arguments', async () => {
const { supabase, calls } = createCapturingSupabase([{ data: [] }])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts', {
searchParams: { class: '3', active: 'false' },
})
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(
await listGET(req, routeParams)
)
expect(status).toBe(200)
expect(body.data).toEqual([])
const rpcCall = calls.find((c) => c.method === 'rpc')
expect(rpcCall?.args[1]).toEqual({
p_company_id: 'company-1',
p_active_only: false,
p_account_class: 3,
})
})
it('falls back to the paged fetch when the RPC is not deployed (PGRST202)', async () => {
const { supabase, calls } = createCapturingSupabase([
{ error: { code: 'PGRST202', message: 'function not found in schema cache' } },
{ data: [{ account_number: '1930', account_name: 'Företagskonto' }] },
])
auth(supabase)
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(
await listGET(createMockRequest('/api/bookkeeping/accounts'), routeParams)
)
expect(status).toBe(200)
expect(body.data).toHaveLength(1)
expect(calls.filter((c) => c.method === 'from').map((c) => c.args)).toContainEqual([
'chart_of_accounts',
])
expect(calls.filter((c) => c.method === 'eq').map((c) => c.args)).toContainEqual([
'company_id',
'company-1',
])
})
it('returns the legacy 500 { error: string } on a non-fallback RPC error', async () => {
const { supabase, calls } = createCapturingSupabase([
{ error: { code: 'XX000', message: 'boom' } },
])
auth(supabase)
const { status, body } = await parseJsonResponse<{ error: string }>(
await listGET(createMockRequest('/api/bookkeeping/accounts'), routeParams)
)
expect(status).toBe(500)
expect(typeof body.error).toBe('string')
// A non-deploy error must NOT silently fall back to the paged fetch.
expect(calls.some((c) => c.method === 'from')).toBe(false)
})
})
describe('POST /api/bookkeeping/accounts', () => {
+22
View File
@@ -26,6 +26,28 @@ export const GET = withRouteContext('bookkeeping.accounts.list', async (request,
const activeOnly = validated.data.active !== 'false'
try {
// Single-round-trip path: the RPC aggregates the whole list into one json
// scalar server-side, bypassing PostgREST's 1000-row page cap. Large
// charts (95/1250 prod companies exceed 1000 active accounts) previously
// paid 2-5 sequential cross-region round trips through fetchAllRows.
const rpc = await supabase.rpc('list_company_accounts', {
p_company_id: companyId,
p_active_only: activeOnly,
p_account_class: accountClass ?? null,
})
if (!rpc.error) return NextResponse.json({ data: rpc.data ?? [] })
if (rpc.error.code === 'PGRST202' || rpc.error.code === '42883' || rpc.error.code === '42501') {
// Function not deployed yet (self-hosted instance not migrated, or the
// deploy-ordering window before the branching merge applies the
// migration) or EXECUTE not granted: fall back to the paged fetch.
// Mirrors the load-bearing fallback in lib/company/context.ts.
log.warn('list_company_accounts RPC unavailable, falling back to paged fetch', {
code: rpc.error.code,
})
} else {
throw new Error(rpc.error.message)
}
const data = await fetchAllRows(({ from, to }) => {
let query = supabase
.from('chart_of_accounts')
@@ -84,14 +84,23 @@ describe('annual report compliance route', () => {
})
it('returns 404 for another company period', async () => {
const { enqueue } = setup()
enqueue({ data: null })
// GET has no periodExists preflight: the 404 comes from the report
// builder throwing 'Fiscal period not found' (same company_id filter).
setup()
vi.mocked(buildCanonicalAnnualReport).mockRejectedValue(
new Error('Fiscal period not found'),
)
expect((await GET(createMockRequest('/x'), params)).status).toBe(404)
})
it('maps unexpected builder failures to the generic error envelope', async () => {
setup()
vi.mocked(buildCanonicalAnnualReport).mockRejectedValue(new Error('boom'))
expect((await GET(createMockRequest('/x'), params)).status).toBe(500)
})
it('returns the canonical compliance result', async () => {
const { enqueue } = setup()
enqueue({ data: { id: 'period-1' } })
setup()
const { status, body } = await parseJsonResponse<{ data: typeof model }>(
await GET(createMockRequest('/x'), params),
)
@@ -99,6 +108,20 @@ describe('annual report compliance route', () => {
expect(body.data.validation.ok).toBe(true)
})
it('keeps the periodExists preflight on PATCH (404 before any write)', async () => {
const { enqueue } = setup()
enqueue({ data: null })
const response = await PATCH(
createMockRequest('/x', {
method: 'PATCH',
body: { is_public_limited_company: false },
}),
params,
)
expect(response.status).toBe(404)
expect(upsertAnnualReportProfile).not.toHaveBeenCalled()
})
it('persists legal facts and confirmation timestamps', async () => {
const { enqueue } = setup()
enqueue({ data: { id: 'period-1' } })
@@ -70,15 +70,22 @@ export const GET = withRouteContext(
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
try {
if (!(await periodExists(supabase, companyId, id))) {
return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
}
const model = await buildCanonicalAnnualReport(supabase, companyId, id, {
stage: 'draft',
includeIxbrl: false,
})
return NextResponse.json({ data: responseData(model) })
} catch (err) {
// No periodExists preflight here: buildArsredovisningData applies the
// same company_id filter and throws 'Fiscal period not found' for
// missing/foreign periods, so mapping that message (mirroring the data
// route) yields the identical 404 envelope without the extra round
// trip. PATCH keeps the preflight since it guards the profile upsert
// before any write.
const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) {
return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
}
return errorResponse(err, log, { requestId })
}
},
@@ -0,0 +1,162 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
// Exercised through the real withRouteContext wrapper: mock its auth/company
// dependencies and inject the Supabase client via requireAuth.
const { supabase: mockSupabase } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/currency/riksbanken', () => ({
fetchExchangeRate: vi.fn(),
readCachedRate: vi.fn(),
}))
vi.mock('@/lib/sandbox/guard', () => ({
guardSandbox: vi.fn(),
}))
// Sentinel service client: the route must pass THIS client (not the user
// client) to the cache read and to fetchExchangeRate, so the first live
// fetch can warm the shared exchange_rates cache (INSERT is service-role
// only since migration 20260710100000).
const serviceSentinel = {}
vi.mock('@/lib/auth/api-keys', () => ({
createServiceClientNoCookies: vi.fn(() => serviceSentinel),
}))
import { GET } from '../route'
import { fetchExchangeRate, readCachedRate } from '@/lib/currency/riksbanken'
import { guardSandbox } from '@/lib/sandbox/guard'
const mockUser = { id: 'user-1', email: 'test@test.se' }
function makeReq(query: string) {
return new Request(`http://localhost/api/currency/rate${query}`)
}
const noParams = { params: Promise.resolve({}) }
beforeEach(() => {
vi.clearAllMocks()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
vi.mocked(guardSandbox).mockResolvedValue(null)
vi.mocked(readCachedRate).mockResolvedValue(null)
vi.mocked(fetchExchangeRate).mockResolvedValue(null)
})
describe('GET /api/currency/rate', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await GET(makeReq('?currency=EUR'), noParams)
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 400 for an invalid currency', async () => {
const res = await GET(makeReq('?currency=CHF'), noParams)
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(400)
expect(body.error).toBe('Invalid currency')
expect(fetchExchangeRate).not.toHaveBeenCalled()
})
it('returns 400 for a malformed date', async () => {
const res = await GET(makeReq('?currency=EUR&date=2025-1-5'), noParams)
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(400)
expect(body.error).toBe('Invalid date (expected YYYY-MM-DD)')
expect(fetchExchangeRate).not.toHaveBeenCalled()
})
it('returns 400 for a shape-valid but impossible date', async () => {
// Passes the YYYY-MM-DD regex but parses to an Invalid Date.
const res = await GET(makeReq('?currency=EUR&date=2025-13-45'), noParams)
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(400)
expect(body.error).toBe('Invalid date (expected YYYY-MM-DD)')
expect(fetchExchangeRate).not.toHaveBeenCalled()
})
it('returns 403 for sandbox companies without any Riksbanken traffic', async () => {
vi.mocked(guardSandbox).mockResolvedValue(
NextResponse.json({ error: 'Inte tillgängligt i sandlådan.' }, { status: 403 }),
)
// Even with a cache miss the external fetch must never fire.
vi.mocked(readCachedRate).mockResolvedValue(null)
const res = await GET(makeReq('?currency=EUR&date=2025-01-15'), noParams)
const { status } = await parseJsonResponse(res)
expect(status).toBe(403)
expect(guardSandbox).toHaveBeenCalledWith(mockSupabase, 'company-1')
expect(fetchExchangeRate).not.toHaveBeenCalled()
})
it('serves a cache hit without calling fetchExchangeRate', async () => {
vi.mocked(readCachedRate).mockResolvedValue({
currency: 'EUR',
rate: 11.11,
date: '2025-01-15',
})
const res = await GET(makeReq('?currency=EUR&date=2025-01-15'), noParams)
const { status, body } = await parseJsonResponse<{
data: { currency: string; rate: number; date: string }
}>(res)
expect(status).toBe(200)
expect(body).toEqual({ data: { currency: 'EUR', rate: 11.11, date: '2025-01-15' } })
expect(readCachedRate).toHaveBeenCalledWith(serviceSentinel, 'EUR', '2025-01-15')
expect(fetchExchangeRate).not.toHaveBeenCalled()
})
it('falls through to fetchExchangeRate with the service client on cache miss', async () => {
vi.mocked(readCachedRate).mockResolvedValue(null)
vi.mocked(fetchExchangeRate).mockResolvedValue({
currency: 'EUR',
rate: 11.42,
date: '2025-01-15',
})
const res = await GET(makeReq('?currency=EUR&date=2025-01-15'), noParams)
const { status, body } = await parseJsonResponse<{
data: { currency: string; rate: number; date: string }
}>(res)
expect(status).toBe(200)
expect(body).toEqual({ data: { currency: 'EUR', rate: 11.42, date: '2025-01-15' } })
expect(readCachedRate).toHaveBeenCalledWith(serviceSentinel, 'EUR', '2025-01-15')
expect(fetchExchangeRate).toHaveBeenCalledWith('EUR', expect.any(Date), serviceSentinel)
})
it('returns 502 when both the cache and Riksbanken come up empty', async () => {
vi.mocked(readCachedRate).mockResolvedValue(null)
vi.mocked(fetchExchangeRate).mockResolvedValue(null)
const res = await GET(makeReq('?currency=EUR&date=2025-01-15'), noParams)
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(502)
expect(body.error).toBe('Could not fetch exchange rate')
})
})
+27 -7
View File
@@ -1,20 +1,18 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { fetchExchangeRate, readCachedRate } from '@/lib/currency/riksbanken'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { guardSandbox } from '@/lib/sandbox/guard'
import type { Currency } from '@/types'
const VALID_CURRENCIES: Currency[] = ['EUR', 'USD', 'GBP', 'NOK', 'DKK']
// Riksbanken's open API is IP rate-limited the sandbox guard keeps demo
// Riksbanken's open API is IP rate-limited: the sandbox guard keeps demo
// traffic from eating that budget (withRouteContext already refuses
// sessions without an active company).
export const GET = withRouteContext('currency.rate', async (request, ctx) => {
const { supabase, companyId } = ctx
const blocked = await guardSandbox(supabase, companyId)
if (blocked) return blocked
const { searchParams } = new URL(request.url)
const currency = searchParams.get('currency') as Currency | null
const dateStr = searchParams.get('date')
@@ -23,14 +21,36 @@ export const GET = withRouteContext('currency.rate', async (request, ctx) => {
return NextResponse.json({ error: 'Invalid currency' }, { status: 400 })
}
// Reject malformed dates up front an Invalid Date would otherwise reach
// Reject malformed dates up front: an Invalid Date would otherwise reach
// the Riksbanken request as "NaN-NaN-NaN".
if (dateStr && !/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
return NextResponse.json({ error: 'Invalid date (expected YYYY-MM-DD)' }, { status: 400 })
}
const date = dateStr ? new Date(dateStr) : undefined
const rate = await fetchExchangeRate(currency, date)
// The regex accepts shapes like 2025-13-45 that still parse to an Invalid
// Date; catch those here so toISOString below cannot throw.
if (date && Number.isNaN(date.getTime())) {
return NextResponse.json({ error: 'Invalid date (expected YYYY-MM-DD)' }, { status: 400 })
}
// Same cache key fetchExchangeRate computes internally.
const formattedDate = (date ?? new Date()).toISOString().split('T')[0]
// exchange_rates is tenant-free public reference data (no company_id,
// SELECT policy USING(true)), so a service-role read is safe here, and it
// is the only client that can also WARM the cache: INSERT is service-role
// only since migration 20260710100000. The cache read runs in parallel
// with the sandbox guard (both are DB-only round trips); no Riksbanken
// traffic happens until the guard has resolved false, and only on a miss.
const service = createServiceClientNoCookies()
const [blocked, cached] = await Promise.all([
guardSandbox(supabase, companyId),
readCachedRate(service, currency, formattedDate),
])
if (blocked) return blocked
const rate = cached ?? (await fetchExchangeRate(currency, date, service))
if (!rate) {
return NextResponse.json({ error: 'Could not fetch exchange rate' }, { status: 502 })
+73 -3
View File
@@ -4,6 +4,7 @@ import {
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
makeDocumentAttachment,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
@@ -26,7 +27,7 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
import { DELETE } from '../route'
import { GET, DELETE } from '../route'
import { requireWritePermission } from '@/lib/auth/require-write'
import { NextResponse } from 'next/server'
@@ -41,10 +42,79 @@ beforeEach(() => {
vi.mocked(requireWritePermission).mockResolvedValue({ ok: true })
})
function makeReq() {
return new Request('http://localhost/api/documents/doc-1', { method: 'DELETE' })
function makeReq(method: 'GET' | 'DELETE' = 'DELETE') {
return new Request('http://localhost/api/documents/doc-1', { method })
}
describe('GET /api/documents/[id]', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await GET(makeReq('GET'), createMockRouteParams({ id: 'doc-1' }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 404 when the document is not found in the company', async () => {
enqueue({ data: null, error: null }) // doc lookup
const res = await GET(makeReq('GET'), createMockRouteParams({ id: 'doc-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(404)
expect(body.error).toBe('Document not found')
})
it('returns 500 when the signed URL cannot be created', async () => {
enqueue({ data: makeDocumentAttachment({ id: 'doc-1' }), error: null })
mockSupabase.storage.from.mockReturnValueOnce({
createSignedUrl: vi.fn().mockResolvedValue({ data: null, error: { message: 'boom' } }),
} as never)
const res = await GET(makeReq('GET'), createMockRouteParams({ id: 'doc-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(500)
expect(body.error).toContain('Failed to create download URL')
})
it('returns the document with a signed download URL and emits document.accessed', async () => {
const row = makeDocumentAttachment({
id: 'doc-1',
file_name: 'kvitto.pdf',
storage_path: 'documents/user-1/kvitto.pdf',
})
enqueue({ data: row, error: null })
const handler = vi.fn()
eventBus.on('document.accessed', handler)
const res = await GET(makeReq('GET'), createMockRouteParams({ id: 'doc-1' }))
const { status, body } = await parseJsonResponse<{
data: { id: string; download_url: string }
}>(res)
expect(status).toBe(200)
expect(body.data.id).toBe('doc-1')
expect(body.data.download_url).toBe('https://example.com/signed')
expect(mockSupabase.storage.from).toHaveBeenCalledWith('documents')
const storageBucket = mockSupabase.storage.from.mock.results[0]?.value
expect(storageBucket.createSignedUrl).toHaveBeenCalledWith('documents/user-1/kvitto.pdf', 3600)
expect(handler).toHaveBeenCalledOnce()
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
document: expect.objectContaining({ id: 'doc-1', file_name: 'kvitto.pdf' }),
userId: 'user-1',
companyId: 'company-1',
}),
)
})
})
describe('DELETE /api/documents/[id]', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
+18 -13
View File
@@ -28,10 +28,24 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
// Create signed download URL (60 minutes)
const { data: signedUrl, error: signError } = await supabase.storage
.from('documents')
.createSignedUrl(doc.storage_path, 3600)
// Sign the download URL (60 minutes) and persist the access event in
// parallel: both depend only on the row fetch and are independent of
// each other. The emit stays awaited (event-log-handler's insert must
// not race Vercel function suspension) and never rejects (the bus
// settles handlers via Promise.allSettled), so it cannot fail this
// Promise.all.
const [signResult] = await Promise.all([
supabase.storage.from('documents').createSignedUrl(doc.storage_path, 3600),
eventBus.emit({
type: 'document.accessed',
payload: {
document: { id: doc.id, file_name: doc.file_name },
userId: user.id,
companyId,
},
}),
])
const { data: signedUrl, error: signError } = signResult
if (signError) {
return NextResponse.json(
@@ -40,15 +54,6 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
)
}
await eventBus.emit({
type: 'document.accessed',
payload: {
document: { id: doc.id, file_name: doc.file_name },
userId: user.id,
companyId,
},
})
return NextResponse.json({
data: {
...doc,
@@ -107,6 +107,7 @@ describe('GET /api/invoices/[id]/deliveries', () => {
provider: 'resend',
error_code: null,
document_attachment_id: 'document-1',
attachment_filename: 'faktura-f-1001.pdf',
sent_at: '2026-07-22T10:30:00.000Z',
failed_at: null,
created_at: '2026-07-22T10:29:59.000Z',
@@ -118,7 +119,6 @@ describe('GET /api/invoices/[id]/deliveries', () => {
expect(body.data[0]).not.toHaveProperty('body_text')
expect(body.data[0]).not.toHaveProperty('body_html')
expect(body.data[0]).not.toHaveProperty('provider_message_id')
expect(body.data[0]).not.toHaveProperty('attachment_filename')
expect(body.data[0]).not.toHaveProperty('attachment_content_type')
expect(body.data[0]).not.toHaveProperty('attachment_sha256')
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
+7 -3
View File
@@ -13,6 +13,7 @@ interface InvoiceDeliverySummaryRow {
provider: string | null
error_code: string | null
document_attachment_id: string | null
attachment_filename: string | null
sent_at: string | null
failed_at: string | null
created_at: string
@@ -31,9 +32,11 @@ interface MaskedInvoiceDeliverySummaryRow
*
* Returns minimized delivery metadata for an invoice. Exact message content,
* BCC recipients, provider identifiers, checksums, and full recipient
* addresses stay server-side. The database allow-list and masking boundary is
* defined by list_invoice_delivery_summaries in migration 20260723003000; this
* route masks returned addresses again as defense in depth.
* addresses stay server-side. The attachment filename passes through: it is
* derived from data the invoice already exposes to every company member. The
* database allow-list and masking boundary is defined by
* list_invoice_delivery_summaries in migration 20260723150000; this route
* masks returned addresses again as defense in depth.
*/
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.deliveries.list',
@@ -78,6 +81,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
provider: delivery.provider,
error_code: delivery.error_code,
document_attachment_id: delivery.document_attachment_id,
attachment_filename: delivery.attachment_filename,
sent_at: delivery.sent_at,
failed_at: delivery.failed_at,
created_at: delivery.created_at,
+288
View File
@@ -0,0 +1,288 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
import type { KPIReport } from '@/types'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
// Legacy generators are mocked as spies: the no-dimension hot path must never
// call them, the dimension path must still route through them. The pure
// builders (buildIncomeStatementFromRows, assembleMonthlyBreakdown) stay real
// so the happy path asserts the full KPI JSON end to end.
vi.mock('@/lib/reports/trial-balance', () => ({
generateTrialBalance: vi.fn(),
}))
vi.mock('@/lib/reports/ar-ledger', () => ({
generateARLedger: vi.fn(),
}))
vi.mock('@/lib/reports/income-statement', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/reports/income-statement')>()
return { ...actual, generateIncomeStatement: vi.fn() }
})
vi.mock('@/lib/reports/monthly-breakdown', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/reports/monthly-breakdown')>()
return { ...actual, generateMonthlyBreakdown: vi.fn() }
})
import { GET } from '../route'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateARLedger } from '@/lib/reports/ar-ledger'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
const mockTrialBalance = vi.mocked(generateTrialBalance)
const mockARLedger = vi.mocked(generateARLedger)
const mockIncomeStatement = vi.mocked(generateIncomeStatement)
const mockMonthlyBreakdown = vi.mocked(generateMonthlyBreakdown)
const noParams = { params: Promise.resolve({}) }
function authed() {
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
}
function unauthed() {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
}
function makePeriod(overrides: Record<string, unknown> = {}) {
return {
id: 'period-1',
company_id: 'company-1',
period_start: '2026-01-01',
period_end: '2026-03-31',
is_closed: false,
opening_balance_entry_id: null,
...overrides,
}
}
/** Wire payload as returned by the get_kpi_report_aggregates RPC. */
function aggPayload() {
return {
tb: [
{ account_number: '1930', debit: 12500, credit: 0 },
{ account_number: '2611', debit: 0, credit: 2500 },
{ account_number: '3001', debit: 0, credit: 10000 },
{ account_number: '5010', debit: 3000, credit: 0 },
],
tb_ex_year_end: [
{ account_number: '1930', debit: 12500, credit: 0 },
{ account_number: '2611', debit: 0, credit: 2500 },
{ account_number: '3001', debit: 0, credit: 10000 },
{ account_number: '5010', debit: 3000, credit: 0 },
],
ob: [],
monthly: [
{ year: 2026, month: 1, income: 10000, expenses: 0 },
{ year: 2026, month: 2, income: 0, expenses: 3000 },
],
}
}
const CHART = [
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
{ account_number: '3001', account_name: 'Försäljning 25%', account_class: 3 },
{ account_number: '5010', account_name: 'Lokalhyra', account_class: 5 },
]
const PAID_INVOICES = Array.from({ length: 5 }, () => ({
invoice_date: '2026-01-01',
paid_at: '2026-01-11',
}))
const SUPPLIER_ROWS = [
{ supplier_id: 'sup-1', total_sek: 400, total: 400, supplier: { id: 'sup-1', name: 'Leverantören AB' } },
{ supplier_id: 'sup-1', total_sek: 100, total: 100, supplier: { id: 'sup-1', name: 'Leverantören AB' } },
{ supplier_id: 'sup-2', total_sek: 200, total: 200, supplier: { id: 'sup-2', name: 'Andra AB' } },
]
function kpiRequest(searchParams: Record<string, string> = { period_id: 'period-1' }) {
return createMockRequest('/api/reports/kpi', { searchParams })
}
beforeEach(() => {
vi.clearAllMocks()
reset()
authed()
mockARLedger.mockResolvedValue({ total_outstanding: 1500, total_overdue: 500 } as never)
})
describe('GET /api/reports/kpi', () => {
it('returns 401 when not authenticated', async () => {
unauthed()
const res = await GET(kpiRequest(), noParams)
expect(res.status).toBe(401)
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('returns 400 when period_id is missing', async () => {
const res = await GET(kpiRequest({}), noParams)
expect(res.status).toBe(400)
})
it('returns 404 for an unknown fiscal period', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const res = await GET(kpiRequest(), noParams)
expect(res.status).toBe(404)
})
it('returns 400 for a half-provided dimension pair', async () => {
enqueue({ data: makePeriod() })
const res = await GET(kpiRequest({ period_id: 'period-1', dim_no: '6' }), noParams)
expect(res.status).toBe(400)
})
it('happy path: builds the full KPI report from one aggregate round trip', async () => {
enqueue({ data: makePeriod() }) // fiscal_periods
enqueue({ data: aggPayload() }) // rpc get_kpi_report_aggregates
enqueue({ data: [{ account_number: '1930', debit: 5000, credit: 0 }] }) // rpc compute_prior_opening_balances
enqueue({ data: CHART }) // chart_of_accounts
enqueue({ data: null }) // extension_data prefs (no row)
enqueue({ data: PAID_INVOICES }) // invoices
enqueue({ data: SUPPLIER_ROWS }) // supplier_invoices
const res = await GET(kpiRequest(), noParams)
const { status, body } = await parseJsonResponse<{ data: KPIReport }>(res)
expect(status).toBe(200)
expect(body.data).toEqual({
netResult: 7000,
cashPosition: 17500, // 5000 IB + 12500 period debit on 1930
outstandingReceivables: 1500,
overdueReceivables: 500,
vatLiability: 2500,
totalRevenue: 10000,
totalExpenses: 3000,
grossMargin: 100,
expenseRatio: 30,
avgPaymentDays: 10,
periodComplete: false,
months: [
{ label: 'Jan', income: 10000, expenses: 0, net: 10000 },
{ label: 'Feb', income: 0, expenses: 3000, net: -3000 },
{ label: 'Mar', income: 0, expenses: 0, net: 0 },
],
period: { start: '2026-01-01', end: '2026-03-31' },
expenseComposition: { class4: 0, class5: 3000, class6: 0, class7: 0 },
topSuppliers: [
{ supplier_id: 'sup-1', supplier_name: 'Leverantören AB', total: 500 },
{ supplier_id: 'sup-2', supplier_name: 'Andra AB', total: 200 },
],
})
expect(supabase.rpc).toHaveBeenCalledWith('get_kpi_report_aggregates', {
p_company_id: 'company-1',
p_fiscal_period_id: 'period-1',
p_ob_entry_id: null,
})
expect(supabase.rpc).toHaveBeenCalledWith('compute_prior_opening_balances', {
p_company_id: 'company-1',
p_period_start: '2026-01-01',
})
// The hot path must not touch the legacy line-scanning generators.
expect(mockTrialBalance).not.toHaveBeenCalled()
expect(mockIncomeStatement).not.toHaveBeenCalled()
expect(mockMonthlyBreakdown).not.toHaveBeenCalled()
})
it('skips the prior-balance RPC when the period has an opening balance entry', async () => {
enqueue({ data: makePeriod({ opening_balance_entry_id: 'ob-1' }) }) // fiscal_periods
enqueue({
data: {
...aggPayload(),
ob: [
{ account_number: '1930', debit: 5000, credit: 0 },
{ account_number: '2081', debit: 0, credit: 5000 },
],
},
}) // rpc get_kpi_report_aggregates (no prior RPC follows)
enqueue({ data: CHART }) // chart_of_accounts
enqueue({ data: null }) // extension_data prefs
enqueue({ data: [] }) // invoices
enqueue({ data: [] }) // supplier_invoices
const res = await GET(kpiRequest(), noParams)
const { status, body } = await parseJsonResponse<{ data: KPIReport }>(res)
expect(status).toBe(200)
expect(body.data.cashPosition).toBe(17500) // OB 5000 + period 12500 on 1930
expect(supabase.rpc).toHaveBeenCalledTimes(1)
expect(supabase.rpc).toHaveBeenCalledWith('get_kpi_report_aggregates', {
p_company_id: 'company-1',
p_fiscal_period_id: 'period-1',
p_ob_entry_id: 'ob-1',
})
})
it('dimension-filtered path still uses the legacy generators and never the RPC', async () => {
enqueue({ data: makePeriod() }) // fiscal_periods
enqueue({ data: null }) // extension_data prefs
enqueue({ data: [] }) // invoices
enqueue({ data: [] }) // supplier_invoices
mockIncomeStatement.mockResolvedValue({
revenue_sections: [],
total_revenue: 0,
expense_sections: [],
total_expenses: 0,
financial_sections: [],
total_financial: 0,
net_result: 0,
period: { start: '', end: '' },
})
mockTrialBalance.mockResolvedValue({
rows: [],
totalDebit: 0,
totalCredit: 0,
isBalanced: true,
})
mockMonthlyBreakdown.mockResolvedValue({ months: [] })
const res = await GET(
kpiRequest({ period_id: 'period-1', dim_no: '6', dim_code: 'P001' }),
noParams
)
expect(res.status).toBe(200)
const dimensions = { '6': 'P001' }
expect(mockIncomeStatement).toHaveBeenCalledWith(supabase, 'company-1', 'period-1', {
dimensions,
})
expect(mockMonthlyBreakdown).toHaveBeenCalledWith(supabase, 'company-1', 'period-1', {
dimensions,
})
// Unfiltered TB for balance-side KPIs + dimension-scoped TB for the
// expense composition.
expect(mockTrialBalance).toHaveBeenCalledTimes(2)
expect(mockTrialBalance).toHaveBeenNthCalledWith(1, supabase, 'company-1', 'period-1')
expect(mockTrialBalance).toHaveBeenNthCalledWith(2, supabase, 'company-1', 'period-1', {
dimensions,
})
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('returns 500 when the aggregates RPC fails', async () => {
enqueue({ data: makePeriod() }) // fiscal_periods
enqueue({ data: null, error: { message: 'connection reset' } }) // rpc get_kpi_report_aggregates
const res = await GET(kpiRequest(), noParams)
expect(res.status).toBe(500)
const body = (await res.json()) as { error: { code: string } }
expect(body.error).toBeDefined()
})
})
+142 -39
View File
@@ -1,9 +1,22 @@
import { withRouteContext } from '@/lib/api/with-route-context'
import { NextResponse } from 'next/server'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import {
generateIncomeStatement,
buildIncomeStatementFromRows,
} from '@/lib/reports/income-statement'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateARLedger } from '@/lib/reports/ar-ledger'
import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
import { generateARLedger, type ARLedgerReport } from '@/lib/reports/ar-ledger'
import {
generateMonthlyBreakdown,
assembleMonthlyBreakdown,
type MonthlyBreakdown,
} from '@/lib/reports/monthly-breakdown'
import {
fetchKpiAggregates,
buildOpeningBalances,
buildTrialBalanceRows,
} from '@/lib/reports/kpi-aggregates'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
calculateCashPosition,
calculateGrossMargin,
@@ -13,7 +26,12 @@ import {
} from '@/lib/reports/kpi'
import { mergeWithDefaults } from '@/lib/reports/kpi-definitions'
import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter'
import type { KPIReport, KPIPreferences } from '@/types'
import type {
KPIReport,
KPIPreferences,
IncomeStatementReport,
TrialBalanceRow,
} from '@/types'
export const GET = withRouteContext('report.kpi', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
@@ -44,52 +62,137 @@ export const GET = withRouteContext('report.kpi', async (request, { supabase, co
}
const dimensions = dimFilter.dimensions
// Load user preferences for account overrides
const { data: prefsData } = await supabase
.from('extension_data')
.select('value')
.eq('company_id', companyId)
.eq('extension_id', 'core/kpi')
.eq('key', 'preferences')
.single()
const preferences = mergeWithDefaults(
(prefsData?.value as Partial<KPIPreferences>) ?? {}
)
const [
incomeStatement,
trialBalanceResult,
arLedger,
monthlyBreakdown,
paidInvoicesResult,
topSuppliersResult,
filteredTrialBalance,
] = await Promise.all([
generateIncomeStatement(supabase, companyId, periodId, { dimensions }),
generateTrialBalance(supabase, companyId, periodId),
generateARLedger(supabase, companyId),
generateMonthlyBreakdown(supabase, companyId, periodId, { dimensions }),
// The company-wide queries both paths share. Factories, not promises, so
// each Promise.all issues them inside its own single round-trip wave.
const prefsQuery = () =>
supabase
.from('extension_data')
.select('value')
.eq('company_id', companyId)
.eq('extension_id', 'core/kpi')
.eq('key', 'preferences')
.single()
const paidInvoicesQuery = () =>
supabase
.from('invoices')
.select('invoice_date, paid_at')
.eq('company_id', companyId)
.eq('status', 'paid')
.not('paid_at', 'is', null),
.not('paid_at', 'is', null)
const topSuppliersQuery = () =>
supabase
.from('supplier_invoices')
.select('supplier_id, total_sek, total, supplier:suppliers(id, name)')
.eq('company_id', companyId)
.gte('invoice_date', period.period_start)
.lte('invoice_date', period.period_end)
.neq('status', 'credited'),
// Second, dimension-scoped TB only when filtered: feeds the expense
// composition (classes 4-7, P&L) without touching the unfiltered TB the
// balance-side KPIs read.
dimensions
? generateTrialBalance(supabase, companyId, periodId, { dimensions })
: Promise.resolve(null),
])
.neq('status', 'credited')
let prefsValue: unknown
let incomeStatement: IncomeStatementReport
let trialBalanceResult: { rows: TrialBalanceRow[] }
let arLedger: ARLedgerReport
let monthlyBreakdown: MonthlyBreakdown
let paidInvoicesResult: { data: Array<{ invoice_date: string; paid_at: string }> | null }
let topSuppliersResult: { data: unknown[] | null; error: unknown }
let filteredTrialBalance: { rows: TrialBalanceRow[] } | null
if (dimensions) {
// Dimension-filtered path: the legacy generators, unchanged. The second,
// dimension-scoped TB feeds the expense composition (classes 4-7, P&L)
// without touching the unfiltered TB the balance-side KPIs read.
const [prefsRes, is, tb, ar, mb, paid, sup, filteredTb] = await Promise.all([
prefsQuery(),
generateIncomeStatement(supabase, companyId, periodId, { dimensions }),
generateTrialBalance(supabase, companyId, periodId),
generateARLedger(supabase, companyId),
generateMonthlyBreakdown(supabase, companyId, periodId, { dimensions }),
paidInvoicesQuery(),
topSuppliersQuery(),
generateTrialBalance(supabase, companyId, periodId, { dimensions }),
])
prefsValue = prefsRes.data?.value
incomeStatement = is
trialBalanceResult = tb
arLedger = ar
monthlyBreakdown = mb
paidInvoicesResult = paid
topSuppliersResult = sup
filteredTrialBalance = filteredTb
} else {
// Hot path (no dimension filter): one Promise.all round trip. The
// get_kpi_report_aggregates RPC replaces three full journal-line scans
// (unfiltered TB, income-statement TB, monthly breakdown) with a single
// SQL pass; the pure builders below reproduce the legacy merge/rounding.
const obEntryId: string | null = period.opening_balance_entry_id ?? null
const [agg, priorResult, accounts, prefsRes, ar, paid, sup] = await Promise.all([
fetchKpiAggregates(supabase, companyId, periodId, obEntryId),
// Opening balances without an OB entry fall back to the server-side
// prior-period aggregate, exactly like getOpeningBalances.
obEntryId
? Promise.resolve(null)
: supabase.rpc('compute_prior_opening_balances', {
p_company_id: companyId,
p_period_start: period.period_start,
}),
fetchAllRows<{
account_number: string
account_name: string
account_class: number
}>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name, account_class')
.eq('company_id', companyId)
.order('account_number', { ascending: true })
.range(from, to)
),
prefsQuery(),
generateARLedger(supabase, companyId),
paidInvoicesQuery(),
topSuppliersQuery(),
])
if (priorResult?.error) {
// Mirrors the fallback branch of lib/reports/opening-balances.ts.
throw new Error(priorResult.error.message)
}
const accountMap = new Map<string, { name: string; class: number }>()
for (const acc of accounts) {
accountMap.set(acc.account_number, {
name: acc.account_name,
class: acc.account_class,
})
}
const openingBalances = buildOpeningBalances(
agg,
obEntryId ? null : (priorResult?.data ?? [])
)
trialBalanceResult = { rows: buildTrialBalanceRows(openingBalances, agg.tb, accountMap) }
const rowsExYearEnd = buildTrialBalanceRows(openingBalances, agg.tb_ex_year_end, accountMap)
incomeStatement = buildIncomeStatementFromRows(rowsExYearEnd)
monthlyBreakdown = assembleMonthlyBreakdown(
period.period_start,
period.period_end,
agg.monthly.map((m) => ({
year: m.year,
month0: m.month - 1,
income: m.income,
expenses: m.expenses,
}))
)
prefsValue = prefsRes.data?.value
arLedger = ar
paidInvoicesResult = paid
topSuppliersResult = sup
filteredTrialBalance = null
}
const preferences = mergeWithDefaults(
(prefsValue as Partial<KPIPreferences>) ?? {}
)
// Cash position: use account overrides if set
const cashOverrides = preferences.accountOverrides['cashPosition']
@@ -0,0 +1,177 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
const mockSupabase = {
auth: { getUser: vi.fn() },
from: vi.fn(),
rpc: vi.fn(),
}
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: vi.fn(),
}))
// Deliberately NOT mocking @/lib/reports/vat-declaration: the test drives the
// real calculateVatDeclaration -> fetchVatAccountTotals path off the rpc mock,
// so the ruta assertions below prove the öre-exact projection end to end.
import { GET } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
const mockUser = { id: 'user-1', email: 'test@test.se' }
/** Wire payload as returned by the get_vat_declaration_totals RPC. */
function rpcPayload() {
return {
totals: [
{ account_number: '3001', debit: 0, credit: 100000 },
{ account_number: '2611', debit: 0, credit: 25000 },
{ account_number: '2641', debit: 3200, credit: 0 },
],
settlement_shaped_entries: [],
source_type_counts: { invoice_created: 2, bank_transaction: 3, manual: 1 },
}
}
function makeRequest(query: string) {
return new Request(`http://localhost/api/reports/vat-declaration${query}`)
}
describe('GET /api/reports/vat-declaration', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: mockSupabase as never,
error: null,
})
mockSupabase.rpc.mockResolvedValue({ data: rpcPayload(), error: null })
})
it('returns 401 when not authenticated', async () => {
vi.mocked(requireAuth).mockResolvedValue({
user: null as never,
supabase: mockSupabase as never,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await GET(
makeRequest('?periodType=quarterly&year=2026&period=3'),
{ params: Promise.resolve({}) },
)
expect(res.status).toBe(401)
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
it('returns 400 when required params are missing', async () => {
const res = await GET(makeRequest(''), { params: Promise.resolve({}) })
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VAT_REPORT_MISSING_PARAMS')
})
it('returns 400 for an invalid periodType', async () => {
const res = await GET(
makeRequest('?periodType=weekly&year=2026&period=1'),
{ params: Promise.resolve({}) },
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VAT_REPORT_INVALID_PERIOD_TYPE')
})
it('returns 400 for an invalid year', async () => {
const res = await GET(
makeRequest('?periodType=monthly&year=1999&period=1'),
{ params: Promise.resolve({}) },
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VAT_REPORT_INVALID_YEAR')
})
it('returns 400 for out-of-range periods per period type', async () => {
for (const query of [
'?periodType=monthly&year=2026&period=13',
'?periodType=quarterly&year=2026&period=5',
'?periodType=yearly&year=2026&period=2',
]) {
const res = await GET(makeRequest(query), { params: Promise.resolve({}) })
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VAT_REPORT_INVALID_PERIOD')
}
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
it('happy path quarterly: öre-exact rutor from a single RPC round trip', async () => {
const res = await GET(
makeRequest('?periodType=quarterly&year=2026&period=3'),
{ params: Promise.resolve({}) },
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.rutor.ruta05).toBe(100000)
expect(body.data.rutor.ruta10).toBe(25000)
expect(body.data.rutor.ruta48).toBe(3200)
expect(body.data.rutor.ruta49).toBe(21800)
expect(body.data.breakdown.invoices.base25).toBe(100000)
expect(body.data.invoiceCount).toBe(2)
expect(body.data.transactionCount).toBe(3)
expect(body.data.periodLabel).toBe('Kvartal 3 2026')
// Regression guard: the dead company_settings round trip is gone and
// resolvePeriodDates makes no DB call for calendar quarters, so the
// handler issues exactly one PostgREST call: the totals RPC.
expect(mockSupabase.from).not.toHaveBeenCalled()
expect(mockSupabase.rpc).toHaveBeenCalledTimes(1)
expect(mockSupabase.rpc).toHaveBeenCalledWith(
'get_vat_declaration_totals',
expect.objectContaining({
p_company_id: 'company-1',
p_start: '2026-07-01',
p_end: '2026-09-30',
}),
)
})
it('happy path monthly: no table queries, one RPC', async () => {
const res = await GET(
makeRequest('?periodType=monthly&year=2026&period=7'),
{ params: Promise.resolve({}) },
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.rutor.ruta49).toBe(21800)
expect(body.data.periodLabel).toBe('Juli 2026')
expect(mockSupabase.from).not.toHaveBeenCalled()
expect(mockSupabase.rpc).toHaveBeenCalledTimes(1)
expect(mockSupabase.rpc).toHaveBeenCalledWith(
'get_vat_declaration_totals',
expect.objectContaining({ p_start: '2026-07-01', p_end: '2026-07-31' }),
)
})
it('returns the VAT_REPORT_GENERATION_FAILED envelope when the RPC errors', async () => {
mockSupabase.rpc.mockResolvedValue({
data: null,
error: { message: 'connection reset' },
})
const res = await GET(
makeRequest('?periodType=quarterly&year=2026&period=3'),
{ params: Promise.resolve({}) },
)
expect(res.status).toBe(500)
const body = await res.json()
expect(body.error.code).toBe('VAT_REPORT_GENERATION_FAILED')
})
})
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { calculateVatDeclaration } from '@/lib/reports/vat-declaration'
import { buildESkdFile } from '@/lib/reports/vat-eskd-file'
import type { VatPeriodType, AccountingMethod } from '@/types'
import type { VatPeriodType } from '@/types'
/**
* Momsdeklaration eSKDUpload (v6.0) XML file for filing at skatteverket.se via
@@ -64,15 +64,12 @@ export const GET = withRouteContext(
)
}
const accountingMethod = (companyRow.accounting_method as AccountingMethod) || 'accrual'
const declaration = await calculateVatDeclaration(
supabase,
companyId,
periodType,
year,
period,
accountingMethod,
{ fiscalPeriodId },
)
+1 -4
View File
@@ -7,7 +7,7 @@ import {
} from '@/lib/reports/vat-declaration'
import { buildManualFilingRows } from '@/lib/reports/vat-manual-filing'
import { VatDeclarationPDF } from '@/lib/reports/vat-declaration-pdf-template'
import type { VatPeriodType, AccountingMethod, CompanySettings } from '@/types'
import type { VatPeriodType, CompanySettings } from '@/types'
/**
* Momsdeklaration PDF for manual filing at skatteverket.se. The declaration is
@@ -51,15 +51,12 @@ export const GET = withRouteContext(
return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 })
}
const accountingMethod = (companyRow.accounting_method as AccountingMethod) || 'accrual'
const declaration = await calculateVatDeclaration(
supabase,
companyId,
periodType,
year,
period,
accountingMethod,
{ fiscalPeriodId },
)
+5 -10
View File
@@ -5,7 +5,7 @@ import {
} from '@/lib/reports/vat-declaration'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { VatPeriodType, AccountingMethod } from '@/types'
import type { VatPeriodType } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
@@ -77,17 +77,12 @@ export const GET = withRouteContext(
})
}
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('company_id', companyId)
.single()
const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual'
try {
// No accounting-method argument: the method is baked into journal entry
// timing (see the invariant note on calculateVatDeclaration), so no
// company_settings round trip is needed here.
const declaration = await calculateVatDeclaration(
supabase, companyId!, periodType, year, period, accountingMethod,
supabase, companyId!, periodType, year, period,
{ fiscalPeriodId },
)
+6 -16
View File
@@ -14,7 +14,6 @@ import {
VAT_RUTA_LABELS,
type VatPeriodType,
type VatDeclarationRutor,
type AccountingMethod,
} from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -48,24 +47,15 @@ export const GET = withRouteContext('report.vat_declaration.xlsx', async (reques
return NextResponse.json({ error: 'Invalid year or period' }, { status: 400 })
}
const [{ data: settings }, { data: companyRow }] = await Promise.all([
supabase
.from('company_settings')
.select('accounting_method')
.eq('company_id', companyId)
.single(),
supabase
.from('company_settings')
.select('company_name')
.eq('company_id', companyId)
.single(),
])
const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual'
const { data: companyRow } = await supabase
.from('company_settings')
.select('company_name')
.eq('company_id', companyId)
.single()
try {
const declaration = await calculateVatDeclaration(
supabase, companyId, periodType, year, period, accountingMethod,
supabase, companyId, periodType, year, period,
{ fiscalPeriodId },
)
@@ -431,7 +431,6 @@ describe('GET /reports/vat-declaration', () => {
'monthly',
2026,
4,
undefined,
)
})
})
@@ -13,7 +13,7 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { safeGenerate } from '@/lib/api/v1/report-period'
import { calculateVatDeclaration } from '@/lib/reports/vat-declaration'
import type { AccountingMethod, VatPeriodType } from '@/types'
import type { VatPeriodType } from '@/types'
const VatPeriodTypeEnum = z.enum(['monthly', 'quarterly', 'yearly'])
const AccountingMethodEnum = z.enum(['accrual', 'cash'])
@@ -32,7 +32,7 @@ registerEndpoint({
pitfalls: [
'`period_type` (monthly|quarterly|yearly), `year`, and `period` are all required.',
'For monthly: period is 1-12. For quarterly: period is 1-4. For yearly: period is 1.',
'`accounting_method` defaults to accrual (faktureringsmetoden); pass cash for kontantmetoden to honor the VAT-on-payment rule per ML 15 kap 8-11 §§ (ML 2023:200, which replaced ML 1994:200 on 1 July 2023: the prior ML 13 kap reference is outdated).',
'`accounting_method` is accepted for backward compatibility but has no effect on the figures: the declaration is a pure ledger projection, and the method (faktureringsmetoden vs kontantmetoden per ML 15 kap 8-11 §§, ML 2023:200) is already reflected in when VAT-bearing journal entries are posted.',
'Output ruta 49 = (10+11+12+30+31+32+60+61+62) 48. Positive = pay; negative = refund.',
],
example: {
@@ -123,7 +123,9 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
},
})
}
const { period_type, year, period, accounting_method } = filters.data
// accounting_method is still accepted (public API back-compat) but has no
// effect: see the invariant note on calculateVatDeclaration.
const { period_type, year, period } = filters.data
const gen = await safeGenerate(
() =>
@@ -133,7 +135,6 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
period_type as VatPeriodType,
year,
period,
accounting_method as AccountingMethod | undefined,
),
{ log: ctx.log, requestId: ctx.requestId, reportName: 'vat-declaration' },
)
@@ -17,6 +17,7 @@ export type InvoiceDeliveryView = Pick<
| 'provider'
| 'error_code'
| 'document_attachment_id'
| 'attachment_filename'
| 'sent_at'
| 'failed_at'
| 'created_at'
@@ -123,7 +124,9 @@ export function InvoiceDeliveryHistory({
>
<ExternalLink className="mr-2 h-4 w-4" />
<span className="truncate">
{t('delivery_open_pdf', { filename: t('delivery_pdf_fallback') })}
{t('delivery_open_pdf', {
filename: delivery.attachment_filename || t('delivery_pdf_fallback'),
})}
</span>
</a>
</Button>
@@ -45,9 +45,11 @@ export interface UnlinkedGLLine {
entry_description: string
source_type: string
confidence?: number
/** How many bank transactions already point at this entry. > 0 means the
* voucher is already matched: surfaced (behind the "visa matchade" opt-in)
* so a second/third transaction can be attached to it (N:1). */
/** How many bank transactions already settle this entry on the account being
* matched (links on OTHER cash accounts, e.g. a transfer's outgoing leg,
* don't count). > 0 means the voucher is already matched on this account:
* surfaced (behind the "visa matchade" opt-in) so a second/third
* transaction can be attached to it (N:1). */
linked_transaction_count?: number
}
+15
View File
@@ -14,6 +14,21 @@ const eslintConfig = defineConfig([
}],
},
},
// eslint-plugin-react-hooks 7 (pulled in by eslint-config-next) ships new
// React Compiler-powered rules at error severity. ~33 legacy components
// predate them; refactoring those effects is behavior-sensitive work that
// happens per component, not in a lint sweep. Same pattern as no-console
// below: warn (not error) until the legacy sites are migrated, then flip
// each rule back to "error" so the floor is enforced. New violations still
// surface as warnings in every lint run and PR review.
{
rules: {
"react-hooks/set-state-in-effect": "warn",
"react-hooks/static-components": "warn",
"react-hooks/purity": "warn",
"react-hooks/preserve-manual-memoization": "warn",
},
},
// No raw console.* in lib/ or app/api/. Use createLogger from @/lib/logger
// so log lines carry requestId + structured context. lib/logger.ts and
// app/api/log/route.ts are the two intentional exemptions because they ARE
@@ -228,7 +228,7 @@ describe('arcim-client', () => {
)
)
await createConsent('fortnox' as any, 'Test', '5591234567', 'Test AB')
await createConsent('fortnox', 'Test', '5591234567', 'Test AB')
const [url, opts] = fetchSpy.mock.calls[0]
expect(url).toBe('https://arcim.test.com/api/v1/consents')
@@ -266,7 +266,7 @@ describe('arcim-client', () => {
const result = await fetchCustomers('consent-1')
expect(result).toHaveLength(3)
expect(result.map((c: any) => c.id)).toEqual(['c1', 'c2', 'c3'])
expect(result.map((c) => c.id)).toEqual(['c1', 'c2', 'c3'])
expect(fetchSpy).toHaveBeenCalledTimes(2)
})
@@ -69,7 +69,7 @@ describe('buildMomsuppgift', () => {
expect(result.momsuppgift.ingaendeMomsAvdrag).toBe(100)
expect(result.momsuppgift.summaMoms).toBe(150)
expect(mockCalculateVatDeclaration).toHaveBeenCalledWith(
expect.anything(), 'company-1', 'monthly', 2025, 3, 'accrual', { fiscalPeriodId: undefined },
expect.anything(), 'company-1', 'monthly', 2025, 3, { fiscalPeriodId: undefined },
)
// Sub-annual periods are calendar periods: no fiscal-period lookup.
expect(mockResolvePeriodDates).not.toHaveBeenCalled()
@@ -93,7 +93,7 @@ describe('buildMomsuppgift', () => {
)
// The figures must describe the same räkenskapsår as the period id.
expect(mockCalculateVatDeclaration).toHaveBeenCalledWith(
expect.anything(), 'company-1', 'yearly', 2026, 1, 'accrual', { fiscalPeriodId: 'fp-1' },
expect.anything(), 'company-1', 'yearly', 2026, 1, { fiscalPeriodId: 'fp-1' },
)
})
@@ -89,7 +89,6 @@ export async function buildMomsuppgift(
periodType,
year,
period,
'accrual',
{ fiscalPeriodId },
)
+18 -10
View File
@@ -1,9 +1,13 @@
import { describe, it, expect } from 'vitest'
import { createTestLogger } from '../logger'
// The sink element type is the (unexported) LogRecord from lib/logger.ts,
// recovered via Parameters<> so the test stays in sync with the real signature.
type Sink = Parameters<typeof createTestLogger>[1]
describe('logger', () => {
it('emits records with module + msg + level + ts', () => {
const sink: any[] = []
const sink: Sink = []
const log = createTestLogger('test/module', sink)
log.info('hello')
@@ -17,14 +21,14 @@ describe('logger', () => {
})
it('merges base context into every record', () => {
const sink: any[] = []
const sink: Sink = []
const log = createTestLogger('m', sink, { requestId: 'req_1' })
log.info('hi')
expect(sink[0].requestId).toBe('req_1')
})
it('child() returns a logger that merges extra context', () => {
const sink: any[] = []
const sink: Sink = []
const log = createTestLogger('m', sink, { requestId: 'req_1' })
const child = log.child({ companyId: 'co_1', userId: 'u_1' })
child.warn('oops')
@@ -36,31 +40,35 @@ describe('logger', () => {
})
it('treats Error args as the err field with name/message/code', () => {
const sink: any[] = []
const sink: Sink = []
const log = createTestLogger('m', sink)
const err = new Error('boom')
;(err as any).code = '23505'
;(err as Error & { code?: string }).code = '23505'
log.error('insert failed', err)
expect(sink[0].err).toMatchObject({ name: 'Error', message: 'boom', code: '23505' })
})
it('merges plain-object args into context', () => {
const sink: any[] = []
const sink: Sink = []
const log = createTestLogger('m', sink)
log.info('done', { durationMs: 42, status: 200 })
expect(sink[0]).toMatchObject({ durationMs: 42, status: 200 })
})
it('redacts sensitive keys recursively', () => {
const sink: any[] = []
const sink: Sink = []
const log = createTestLogger('m', sink)
log.info('login', {
user: 'alice',
headers: { authorization: 'Bearer secret', cookie: 'sess=xxx' },
payload: { password: 'hunter2', token: 'tok' },
})
const rec = sink[0]
const rec = sink[0] as {
user: string
headers: { authorization: string; cookie: string }
payload: { password: string; token: string }
}
expect(rec.headers.authorization).toBe('[REDACTED]')
expect(rec.headers.cookie).toBe('[REDACTED]')
expect(rec.payload.password).toBe('[REDACTED]')
@@ -69,7 +77,7 @@ describe('logger', () => {
})
it('redacts personnummer-shaped strings while preserving UUIDs', () => {
const sink: any[] = []
const sink: Sink = []
const log = createTestLogger('m', sink)
log.info('processing for 800101-1234', { uuid: '57484518-3409-4b29-9d23-5d22f08bda63' })
expect(sink[0].msg).toBe('[REDACTED]')
@@ -77,7 +85,7 @@ describe('logger', () => {
})
it('routes non-object, non-Error args into details', () => {
const sink: any[] = []
const sink: Sink = []
const log = createTestLogger('m', sink)
log.warn('legacy', 'string arg', 42)
expect(sink[0].details).toEqual(['string arg', 42])
+194
View File
@@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
import { requireAuth } from '../require-auth'
import { createClient } from '@/lib/supabase/server'
const CLAIMS = {
iss: 'https://test.supabase.co/auth/v1',
sub: 'user-1',
aud: 'authenticated',
exp: 9999999999,
iat: 0,
role: 'authenticated',
aal: 'aal1',
session_id: 'sess-1',
email: 'test@test.se',
is_anonymous: false,
app_metadata: { provider: 'email' },
user_metadata: {},
}
const MOCK_USER = {
id: 'user-1',
aud: 'authenticated',
email: 'test@test.se',
app_metadata: { provider: 'email' },
user_metadata: {},
created_at: '2026-01-01T00:00:00Z',
}
type MockAuth = Record<string, unknown>
function useSupabase(auth: MockAuth) {
const supabase = { auth }
vi.mocked(createClient).mockResolvedValue(supabase as never)
return supabase
}
describe('requireAuth', () => {
beforeEach(() => {
vi.clearAllMocks()
// Deterministic baseline: MFA off unless a test stubs it on.
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'false')
// Matches CLAIMS.iss so the fast path passes the issuer pinning.
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', 'https://test.supabase.co')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('uses locally verified claims without calling getUser (fast path)', async () => {
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
const getUser = vi.fn()
useSupabase({ getClaims, getUser })
const result = await requireAuth()
expect(result.error).toBeNull()
expect(result.user?.id).toBe('user-1')
expect(result.user?.email).toBe('test@test.se')
expect(result.user?.app_metadata).toEqual({ provider: 'email' })
expect(getUser).not.toHaveBeenCalled()
})
it('falls back to getUser when the client has no getClaims (legacy mocks)', async () => {
const getUser = vi.fn().mockResolvedValue({ data: { user: MOCK_USER }, error: null })
useSupabase({ getUser })
const result = await requireAuth()
expect(result.error).toBeNull()
expect(result.user?.id).toBe('user-1')
expect(getUser).toHaveBeenCalledTimes(1)
})
it('returns 401 when neither claims nor getUser yield a user', async () => {
const getClaims = vi.fn().mockResolvedValue({ data: null, error: null })
const getUser = vi.fn().mockResolvedValue({ data: { user: null }, error: null })
useSupabase({ getClaims, getUser })
const result = await requireAuth()
expect(result.user).toBeNull()
expect(result.error?.status).toBe(401)
const body = await result.error?.json()
expect(body).toEqual({ error: 'Unauthorized' })
expect(getUser).toHaveBeenCalledTimes(1)
})
it('falls back to getUser when getClaims throws (JWKS outage)', async () => {
const getClaims = vi.fn().mockRejectedValue(new Error('jwks fetch failed'))
const getUser = vi.fn().mockResolvedValue({ data: { user: MOCK_USER }, error: null })
useSupabase({ getClaims, getUser })
const result = await requireAuth()
expect(result.error).toBeNull()
expect(result.user?.id).toBe('user-1')
expect(getUser).toHaveBeenCalledTimes(1)
})
it('falls back to getUser when the claims issuer does not match the project URL', async () => {
const claims = { ...CLAIMS, iss: 'https://evil.example.com/auth/v1' }
const getClaims = vi.fn().mockResolvedValue({ data: { claims }, error: null })
const getUser = vi.fn().mockResolvedValue({ data: { user: MOCK_USER }, error: null })
useSupabase({ getClaims, getUser })
const result = await requireAuth()
expect(result.error).toBeNull()
expect(result.user?.id).toBe('user-1')
expect(getUser).toHaveBeenCalledTimes(1)
})
it('falls back to getUser when the claims audience is not authenticated', async () => {
const claims = { ...CLAIMS, aud: 'something-else' }
const getClaims = vi.fn().mockResolvedValue({ data: { claims }, error: null })
const getUser = vi.fn().mockResolvedValue({ data: { user: MOCK_USER }, error: null })
useSupabase({ getClaims, getUser })
const result = await requireAuth()
expect(result.error).toBeNull()
expect(result.user?.id).toBe('user-1')
expect(getUser).toHaveBeenCalledTimes(1)
})
it('accepts an array audience containing authenticated', async () => {
const claims = { ...CLAIMS, aud: ['authenticated', 'other'] }
const getClaims = vi.fn().mockResolvedValue({ data: { claims }, error: null })
const getUser = vi.fn()
useSupabase({ getClaims, getUser })
const result = await requireAuth()
expect(result.error).toBeNull()
expect(result.user?.id).toBe('user-1')
expect(getUser).not.toHaveBeenCalled()
})
it('returns 403 when MFA is required and AAL2 is not verified', async () => {
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', '')
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
const getAuthenticatorAssuranceLevel = vi.fn().mockResolvedValue({
data: { currentLevel: 'aal1', nextLevel: 'aal2' },
error: null,
})
useSupabase({ getClaims, mfa: { getAuthenticatorAssuranceLevel } })
const result = await requireAuth()
expect(result.user).toBeNull()
expect(result.error?.status).toBe(403)
const body = await result.error?.json()
expect(body).toEqual({ error: 'MFA verification required' })
})
it('skips the MFA check for bankid_linked users', async () => {
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', '')
const claims = { ...CLAIMS, app_metadata: { provider: 'email', bankid_linked: true } }
const getClaims = vi.fn().mockResolvedValue({ data: { claims }, error: null })
const getAuthenticatorAssuranceLevel = vi.fn()
useSupabase({ getClaims, mfa: { getAuthenticatorAssuranceLevel } })
const result = await requireAuth()
expect(result.error).toBeNull()
expect(result.user?.id).toBe('user-1')
expect(getAuthenticatorAssuranceLevel).not.toHaveBeenCalled()
})
it('passes when MFA is required and the session is already AAL2', async () => {
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', '')
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
const getAuthenticatorAssuranceLevel = vi.fn().mockResolvedValue({
data: { currentLevel: 'aal2', nextLevel: 'aal2' },
error: null,
})
useSupabase({ getClaims, mfa: { getAuthenticatorAssuranceLevel } })
const result = await requireAuth()
expect(result.error).toBeNull()
expect(result.user?.id).toBe('user-1')
expect(getAuthenticatorAssuranceLevel).toHaveBeenCalledTimes(1)
})
})
+79 -2
View File
@@ -1,21 +1,98 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { shouldEnforceMfa } from './mfa'
import type { User, SupabaseClient } from '@supabase/supabase-js'
import type { User, SupabaseClient, JwtPayload } from '@supabase/supabase-js'
type AuthResult =
| { user: User; supabase: SupabaseClient; error: null }
| { user: null; supabase: SupabaseClient; error: NextResponse }
/**
* Maps verified JWT claims onto the User subset routes actually consume
* (id, email, is_anonymous, app_metadata, user_metadata, role, phone).
*
* Server-only fields (identities, factors, created_at timestamps) are absent
* from the token and verified unused by any route (2026-07-23 audit);
* created_at is set to '' only to satisfy the type.
*/
/**
* Defense-in-depth pinning on top of getClaims' signature/expiry verification:
* the token must come from THIS project's auth server (iss) and be an
* end-user access token (aud 'authenticated'; anonymous sign-ins share it).
* A mismatch is not treated as unauthenticated: we fall back to the
* server-side getUser() check, which is authoritative.
*/
function claimsPinned(claims: JwtPayload): boolean {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.replace(/\/+$/, '')
// Without a configured URL (unit tests) there is nothing to pin against.
const issOk = !supabaseUrl || claims.iss === `${supabaseUrl}/auth/v1`
const aud = claims.aud
const audOk = Array.isArray(aud) ? aud.includes('authenticated') : aud === 'authenticated'
return issOk && audOk
}
function userFromClaims(claims: JwtPayload): User {
return {
id: claims.sub,
aud: Array.isArray(claims.aud) ? (claims.aud[0] ?? 'authenticated') : (claims.aud ?? 'authenticated'),
role: claims.role,
email: claims.email,
phone: claims.phone,
app_metadata: claims.app_metadata ?? {},
user_metadata: claims.user_metadata ?? {},
is_anonymous: claims.is_anonymous ?? false,
created_at: '',
}
}
/**
* Auth + MFA guard for API routes.
*
* Returns the authenticated user and Supabase client, or a JSON error response.
* When MFA is required (hosted deployment), verifies AAL2 assurance level.
*
* Fast path: getClaims() performs local WebCrypto verification against the
* shared 10-minute JWKS cache instead of a per-request network getUser()
* round trip. HS256/self-hosted projects fall back to a server call inside
* getClaims itself (identical semantics; NEXT_PUBLIC_SELF_HOSTED needs no
* special-casing). Revocation is still checked on every request by proxy.ts
* middleware getUser() before any route runs. Claims-sourced metadata
* (email, app_metadata, is_anonymous) can be up to one access-token TTL
* stale, which is acceptable for all current consumers: bankid_linked
* staleness is covered because the middleware MFA gate
* (lib/supabase/middleware.ts) uses the FRESH getUser result.
*/
export async function requireAuth(): Promise<AuthResult> {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
let user: User | null = null
try {
// The typeof guard keeps legacy test mocks (auth object with only
// getUser) on the old path.
if (typeof supabase.auth.getClaims === 'function') {
const { data } = await supabase.auth.getClaims()
const claims = data?.claims
if (claims?.sub) {
if (claimsPinned(claims)) {
user = userFromClaims(claims)
} else {
console.error('requireAuth: getClaims iss/aud pinning failed; falling back to getUser', {
iss: claims.iss,
aud: claims.aud,
})
}
}
}
} catch (err) {
// JWKS outage or malformed token: fall through to the server-side check.
// Logged because every hit here degrades the request to the slower
// getUser round trip; a spike must be visible in production.
console.error('requireAuth: getClaims failed; falling back to getUser', err)
}
if (!user) {
const { data } = await supabase.auth.getUser()
user = data?.user ?? null
}
if (!user) {
return {
@@ -0,0 +1,72 @@
{
"flerarsoversikt": [
{
"year": "2022",
"net_revenue": 0,
"result_after_financial": 0,
"soliditet_pct": null
},
{
"year": "2023",
"net_revenue": 0,
"result_after_financial": 0,
"soliditet_pct": null
},
{
"year": "2024",
"net_revenue": 0,
"result_after_financial": 0,
"soliditet_pct": null
},
{
"year": "2025",
"net_revenue": 0,
"result_after_financial": 0,
"soliditet_pct": null
}
],
"noter": [
{
"number": 1,
"title": "Redovisnings- och värderingsprinciper",
"body": "Årsredovisningen är upprättad i enlighet med Årsredovisningslagen (1995:1554) och Bokföringsnämndens allmänna råd BFNAR 2012:1 Årsredovisning och koncernredovisning (K3).\n\nVärderingsprinciper: Tillgångar och skulder värderas till anskaffningsvärde om inget annat anges. Materiella anläggningstillgångar redovisas till anskaffningsvärde med avdrag för ackumulerade avskrivningar och eventuella nedskrivningar. Avskrivning sker linjärt över tillgångens bedömda nyttjandeperiod.\n\nUppskjuten skatt: Uppskjuten skatt redovisas enligt balansräkningsmetoden för temporära skillnader mellan redovisade och skattemässiga värden på tillgångar och skulder. Uppskjuten skatt värderas till nominellt belopp utan diskontering och beräknas utifrån den skattesats som är beslutad på balansdagen.\n\nIntäktsredovisning: Intäkter redovisas till det verkliga värdet av det som erhållits eller kommer att erhållas och redovisas när väsentliga risker och förmåner har överförts till köparen, beloppet kan mätas tillförlitligt och det är sannolikt att de ekonomiska fördelarna tillfaller företaget.\n\nLeasing: Leasingavtal klassificeras som finansiell eller operationell leasing. Operationella leasingavgifter redovisas linjärt i resultaträkningen under leasingperioden. Finansiella leasingavtal redovisas som anläggningstillgång med motsvarande skuld i balansräkningen.\n\nFinansiella instrument: Finansiella instrument redovisas initialt till anskaffningsvärde inklusive transaktionskostnader. Kundfordringar värderas till det belopp som beräknas inflyta. Övriga finansiella tillgångar och skulder redovisas till upplupet anskaffningsvärde."
},
{
"number": 2,
"title": "Uppskjutna skatter",
"body": "Uppskjuten skatteskuld avser i huvudsak temporära skillnader på obeskattade reserver (periodiseringsfonder och överavskrivningar), beräknad med skattesatsen 20,6 %.\n\nIngående saldo (2240): 50 000 kr\nÅrets förändring (8940): 20 600 kr\nUtgående saldo (2240): 70 600 kr"
},
{
"number": 3,
"title": "Medelantal anställda",
"body": "Bolaget har inte haft några anställda under räkenskapsåret."
},
{
"number": 4,
"title": "Långfristiga skulder",
"body": "Inga skulder förfaller till betalning senare än fem år efter balansdagen."
},
{
"number": 5,
"title": "Eventualförpliktelser",
"body": "Inga."
},
{
"number": 6,
"title": "Ställda säkerheter",
"body": "Inga."
},
{
"number": 7,
"title": "Väsentliga händelser efter balansdagen",
"body": "Inga väsentliga händelser har inträffat efter räkenskapsårets utgång som påverkar bedömningen av företagets ställning och resultat."
}
],
"warnings": [
"Årets resultat enligt resultaträkningen (-20600 kr) stämmer inte med konto 2099 (0 kr). Kontrollera att bokslutet är genomfört (resultatdisposition bokad).",
"Balansräkningen balanserar inte: Summa tillgångar 0 kr ≠ Summa eget kapital och skulder 70600 kr (kontrollera-kod 3005).",
"Aktiekapitalnoten saknas eftersom uppgifter om aktiekapital inte finns i Inställningar → Företag. K3 / ÅRL kräver att noten innehåller registrerat belopp innan inlämning till Bolagsverket.",
"Bolaget redovisar enligt K3 (BFNAR 2012:1). Soliditeten är beräknad med 79,4 % av obeskattade reserver inräknat i eget kapital. PDF:en innehåller kassaflödesanalys, förändring av eget kapital och utökade noter: granska innehållet mot er specifika redovisning innan inlämning.",
"Datum för årsstämma saknas. Fastställelseintyget i PDF:en lämnas tomt på datumraden tills det fylls i nedan."
]
}
@@ -37,6 +37,9 @@ import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateKassaflodesanalys } from '@/lib/reports/kassaflodesanalys'
import { listAssets } from '@/lib/bokslut/assets/asset-service'
// Captured from the sequential (pre-dedupe) implementation: the parallel
// TB-pair fetch must reproduce it byte for byte.
import multiYearSnapshot from './arsredovisning-k3-multiyear-snapshot.json'
interface ChainableMock {
from: ReturnType<typeof vi.fn>
@@ -48,6 +51,7 @@ function makeSupabase(opts: {
aktiekapital?: number | null
antalAktier?: number | null
agmDate?: string | null
previousPeriodId?: string | null
}): ChainableMock {
const from = vi.fn((table: string) => {
if (table === 'fiscal_periods') {
@@ -62,7 +66,7 @@ function makeSupabase(opts: {
name: '2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
previous_period_id: null,
previous_period_id: opts.previousPeriodId ?? null,
closing_entry_id: null,
},
error: null,
@@ -486,3 +490,53 @@ describe('buildArsredovisningData: K2 byte-equivalence', () => {
expect(mockedKassaflode).not.toHaveBeenCalled()
})
})
describe('buildArsredovisningData: prior-period TB dedupe (multi-year)', () => {
// Current period + previous year + 2 older years: exercises both the
// comparative pair and the full flerårsöversikt window at once.
const FOUR_PERIODS = [
{ id: 'fp1', name: '2025', period_start: '2025-01-01', period_end: '2025-12-31' },
{ id: 'fp0', name: '2024', period_start: '2024-01-01', period_end: '2024-12-31' },
{ id: 'fpA', name: '2023', period_start: '2023-01-01', period_end: '2023-12-31' },
{ id: 'fpB', name: '2022', period_start: '2022-01-01', period_end: '2022-12-31' },
]
it('fetches each prior-period TB pair exactly once (previous year is no longer fetched twice)', async () => {
mockFetchAllRows.mockResolvedValue(FOUR_PERIODS)
const supabase = makeSupabase({ accountingFramework: 'k2', previousPeriodId: 'fp0' })
// @ts-expect-error: chainable mock isn't fully typed as SupabaseClient
await buildArsredovisningData(supabase, 'co1', 'fp1')
const callsFor = (periodId: string) =>
mockedTrialBalance.mock.calls.filter((call) => call[2] === periodId)
// Current period: full + statutory pre-closing from the statement batch.
expect(callsFor('fp1')).toHaveLength(2)
// Previous year: ONE pair, shared by the comparatives and the overview
// (the sequential version fetched it twice: 4 calls).
expect(callsFor('fp0')).toHaveLength(2)
// Each older overview year: one pair.
expect(callsFor('fpA')).toHaveLength(2)
expect(callsFor('fpB')).toHaveLength(2)
expect(mockedTrialBalance).toHaveBeenCalledTimes(8)
})
it('K3: drops the duplicate noter TB fetch and keeps output byte-identical', async () => {
mockFetchAllRows.mockResolvedValue(FOUR_PERIODS)
const supabase = makeSupabase({ accountingFramework: 'k3', previousPeriodId: 'fp0' })
// @ts-expect-error: chainable mock isn't fully typed as SupabaseClient
const data = await buildArsredovisningData(supabase, 'co1', 'fp1')
// buildK3Noter used to fetch the current-period full TB a third time;
// it now reuses the statement batch's rows.
const currentPeriodCalls = mockedTrialBalance.mock.calls.filter((call) => call[2] === 'fp1')
expect(currentPeriodCalls).toHaveLength(2)
// Deep-equality against the output captured from the sequential
// implementation: same rows, same note numbering, same warning order.
expect(data.forvaltningsberattelse.flerarsoversikt).toEqual(
multiYearSnapshot.flerarsoversikt,
)
expect(data.noter).toEqual(multiYearSnapshot.noter)
expect(data.warnings).toEqual(multiYearSnapshot.warnings)
})
})
+157 -102
View File
@@ -28,7 +28,7 @@ import type {
NoteEntry,
KassaflodesAnalysisSummary,
} from './types'
import type { AccountingFramework, Asset } from '@/types'
import type { AccountingFramework, Asset, TrialBalanceRow } from '@/types'
/**
* Pre-populate the K2 årsredovisning data for a fiscal period. Loads:
@@ -129,24 +129,51 @@ export async function buildArsredovisningData(
const prevPeriodRow = period.previous_period_id
? ((periodList ?? []) as PeriodRow[]).find((p) => p.id === period.previous_period_id) ?? null
: null
let previousTb: TrialBalancePair | null = null
if (prevPeriodRow) {
try {
const [prevFull, prevPreClosing] = await Promise.all([
generateTrialBalance(supabase, companyId, prevPeriodRow.id),
// Comparative RR figures need the same statutory view as the current
// year: keep booked depreciation, appropriations, and tax, excluding
// only the linked final result-closing entry.
generateTrialBalance(supabase, companyId, prevPeriodRow.id, {
excludeFinalClosingEntry: true,
}),
])
previousTb = { full: prevFull.rows, preClosing: prevPreClosing.rows }
} catch {
statementWarnings.push(
'Jämförelsesiffror kunde inte hämtas för föregående räkenskapsår, balans- och resultaträkningen visas utan jämförelseår. Kontrollera det föregående årets bokföring.',
)
}
// Flerårsöversikt window: the current period + up to 3 prior (oldest
// first). Resolved here so the prior-period trial balances it needs can
// share one parallel wave with the comparative-year pair instead of being
// fetched sequentially (and, for the previous year, twice).
const sortedPeriods = [...((periodList ?? []) as PeriodRow[])].sort((a, b) =>
a.period_start.localeCompare(b.period_start),
)
const currentIdx = sortedPeriods.findIndex((p) => p.id === fiscalPeriodId)
const overviewSlice =
currentIdx === -1 ? [] : sortedPeriods.slice(Math.max(0, currentIdx - 3), currentIdx + 1)
// Every prior period needed by the comparatives and/or the flerårsöversikt
// gets its TB pair fetched exactly once. Comparative RR figures need the
// same statutory view as the current year: keep booked depreciation,
// appropriations, and tax, excluding only the linked final result-closing
// entry. A failed pair downgrades to null so a broken prior year (e.g. a
// partial SIE import without IB continuity) never blocks the document.
const tbTargets = new Map<string, PeriodRow>()
if (prevPeriodRow) tbTargets.set(prevPeriodRow.id, prevPeriodRow)
for (const p of overviewSlice) {
if (p.id !== fiscalPeriodId) tbTargets.set(p.id, p)
}
const tbPairs = new Map<string, TrialBalancePair | null>()
await Promise.all(
[...tbTargets.values()].map(async (p) => {
try {
const [full, preClosing] = await Promise.all([
generateTrialBalance(supabase, companyId, p.id),
generateTrialBalance(supabase, companyId, p.id, { excludeFinalClosingEntry: true }),
])
tbPairs.set(p.id, { full: full.rows, preClosing: preClosing.rows })
} catch {
tbPairs.set(p.id, null)
}
}),
)
// Previous fiscal year comparison (jämförelsesiffror): a TB failure
// downgrades to "no comparison year" with a warning instead of blocking.
const previousTb = prevPeriodRow ? tbPairs.get(prevPeriodRow.id) ?? null : null
if (prevPeriodRow && !previousTb) {
statementWarnings.push(
'Jämförelsesiffror kunde inte hämtas för föregående räkenskapsår, balans- och resultaträkningen visas utan jämförelseår. Kontrollera det föregående årets bokföring.',
)
}
const mapping = mapTrialBalancesToK2(
{ full: tbFull.rows, preClosing: tbPreClosing.rows },
@@ -167,13 +194,7 @@ export async function buildArsredovisningData(
const persistedRd = narrative?.resultatdisposition ?? undefined
const persistedAgmDate = narrative?.agm_date ?? null
const flerarsoversikt = await buildFlerarsoversikt(
supabase,
companyId,
fiscalPeriodId,
(periodList ?? []) as Array<{ id: string; name: string; period_start: string; period_end: string }>,
mapping,
)
const flerarsoversikt = buildFlerarsoversikt(overviewSlice, fiscalPeriodId, mapping, tbPairs)
const egen_kapital_changes = buildEquityChanges(mapping)
const proposedDividend = narrative?.proposed_dividend ?? 0
@@ -206,40 +227,39 @@ export async function buildArsredovisningData(
// K3 vs K2 split: K3 has a richer note set + a kassaflöde + a separate
// equity-changes statement. The 18a/b warning that flagged "K3 noter not
// yet emitted" is removed below now that we actually emit them.
const { notes: noter, warnings: noterWarnings } =
accountingFramework === 'k3'
? await buildK3Noter(
supabase,
companyId,
fiscalPeriodId,
entityType,
period.period_start,
period.period_end,
narrative,
)
: await buildK2Noter(
supabase,
companyId,
entityType,
period.period_start,
period.period_end,
narrative,
)
//
// Kassaflödesanalys + separate equity-changes statement, K3 only. K2
// mindre företag is exempt from kassaflödesanalys (BFNAR 2016:10 punkt
// 5.2) and keeps equity changes inside förvaltningsberättelsen.
// 5.2) and keeps equity changes inside förvaltningsberättelsen. The K3
// noter and the kassaflödesanalys are independent reads, so they share
// one round trip; the kassaflöde failure warning still lands AFTER the
// noter warnings so the warnings array order is unchanged.
let noter: NoteEntry[]
let noterWarnings: string[]
let kassaflodesanalys: KassaflodesAnalysisSummary | undefined
let equity_changes_statement:
| { rows: EgenKapitalRow[]; closing_total: number }
| undefined
if (accountingFramework === 'k3') {
try {
const cashFlow = await generateKassaflodesanalys(
const [noterResult, cashFlowSettled] = await Promise.all([
buildK3Noter(
supabase,
companyId,
fiscalPeriodId,
)
entityType,
period.period_start,
period.period_end,
narrative,
tbFull.rows,
),
generateKassaflodesanalys(supabase, companyId, fiscalPeriodId).then(
(cashFlow) => ({ ok: true as const, cashFlow }),
() => ({ ok: false as const }),
),
])
noter = noterResult.notes
noterWarnings = noterResult.warnings
if (cashFlowSettled.ok) {
const { cashFlow } = cashFlowSettled
// Strip fiscal_period_id from the embedded report: period info is
// already on ArsredovisningData.fiscal_period; carrying it twice in
// the payload would be redundant.
@@ -252,7 +272,7 @@ export async function buildArsredovisningData(
total_cash_flow: cashFlow.total_cash_flow,
reconciliation: cashFlow.reconciliation,
}
} catch {
} else {
// A partial SIE import can leave 1xxx without an IB row: the report
// throws. Surface as a warning instead of blocking the whole ÅR.
noterWarnings.push(
@@ -264,6 +284,17 @@ export async function buildArsredovisningData(
// reuse buildEquityChangesNote's roll-forward to keep one source of
// truth for the closing total.
equity_changes_statement = buildK3EquityChangesStatement(mapping)
} else {
const k2Noter = await buildK2Noter(
supabase,
companyId,
entityType,
period.period_start,
period.period_end,
narrative,
)
noter = k2Noter.notes
noterWarnings = k2Noter.warnings
}
const resultatrakning = buildRrRows(mapping)
@@ -414,32 +445,26 @@ export function calculateSoliditet(mapping: K2MappingResult): number | null {
return Math.round((adjustedEquity / totalAssets) * 1000) / 10
}
async function buildFlerarsoversikt(
supabase: SupabaseClient,
companyId: string,
/**
* Flerårsöversikt from pre-fetched trial-balance pairs. `overviewSlice` is
* the current period + up to 3 prior, oldest first (resolved by the caller
* so the pairs could be fetched in one parallel wave); `tbPairs` holds the
* prior-period pairs, with null marking a period whose TB fetch failed.
*/
function buildFlerarsoversikt(
overviewSlice: PeriodRow[],
currentPeriodId: string,
allPeriods: PeriodRow[],
currentMapping: K2MappingResult,
): Promise<FlerarsoversiktRow[]> {
// Take the current period + 3 prior (oldest first).
const sorted = [...allPeriods].sort((a, b) => a.period_start.localeCompare(b.period_start))
const currentIdx = sorted.findIndex((p) => p.id === currentPeriodId)
if (currentIdx === -1) return []
const slice = sorted.slice(Math.max(0, currentIdx - 3), currentIdx + 1)
tbPairs: Map<string, TrialBalancePair | null>,
): FlerarsoversiktRow[] {
const rows: FlerarsoversiktRow[] = []
for (const p of slice) {
for (const p of overviewSlice) {
try {
let mapping = currentMapping
if (p.id !== currentPeriodId) {
const [tbFull, tbPreClosing] = await Promise.all([
generateTrialBalance(supabase, companyId, p.id),
generateTrialBalance(supabase, companyId, p.id, { excludeFinalClosingEntry: true }),
])
mapping = mapTrialBalancesToK2(
{ full: tbFull.rows, preClosing: tbPreClosing.rows },
null,
)
const pair = tbPairs.get(p.id)
if (!pair) throw new Error('trial balance unavailable')
mapping = mapTrialBalancesToK2(pair, null)
}
const netRevenue = mapping.rr['Nettoomsattning']?.current ?? 0
const resultAfterFinancial = mapping.totals.resultatEfterFinansiellaPoster.current
@@ -524,14 +549,29 @@ async function buildK2Noter(
// an AB the user just hasn't configured yet: staying silent would let
// them download an incomplete K2 ÅR without realising.
const maybeAb = isAbK2 || entityType === 'unknown'
// The three reads feeding the notes below (aktiekapital settings, asset
// register, employee windows) are independent, so they share one parallel
// round trip instead of three sequential ones. Note bodies, push order,
// and numbering (notes.length + 1) are unchanged.
const [settingsResult, assets, employeesResult] = await Promise.all([
maybeAb
? supabase
.from('company_settings')
.select('aktiekapital, antal_aktier')
.eq('company_id', companyId)
.maybeSingle()
: Promise.resolve({ data: null }),
listAssets(supabase, companyId),
supabase
.from('employees')
.select('employment_start, employment_end, employment_degree')
.eq('company_id', companyId),
])
if (maybeAb) {
const { data: settings } = await supabase
.from('company_settings')
.select('aktiekapital, antal_aktier')
.eq('company_id', companyId)
.maybeSingle()
type AktiekapitalShape = { aktiekapital?: number | null; antal_aktier?: number | null }
const ak = settings as AktiekapitalShape | null
const ak = (settingsResult.data ?? null) as AktiekapitalShape | null
const aktiekapital = ak?.aktiekapital ?? null
const antalAktier = ak?.antal_aktier ?? null
// Kvotvärde is defined (ABL 1 kap 6 §) as aktiekapital / antal aktier;
@@ -563,7 +603,6 @@ async function buildK2Noter(
// Avskrivningstider: derive from asset register (supplementary
// disclosure; the statutory ÅRL 5:8 § roll-forward follows below).
const assets = await listAssets(supabase, companyId)
if (assets.length > 0) {
const byCategory = new Map<string, Set<number>>()
for (const a of assets) {
@@ -622,12 +661,8 @@ async function buildK2Noter(
// ÅRL 5:20 § requires the note for AB regardless of value: "0" must be
// disclosed as "Inga anställda". For enskild firma the disclosure is
// discretionary, so we still skip when medelantal === 0 there.
const { data: employeeRows } = await supabase
.from('employees')
.select('employment_start, employment_end, employment_degree')
.eq('company_id', companyId)
const medelantal = computeMedelantalAnstallda(
(employeeRows ?? []) as Array<{
(employeesResult.data ?? []) as Array<{
employment_start: string
employment_end: string | null
employment_degree: number
@@ -706,19 +741,49 @@ async function buildK2Noter(
*
* The aktiekapital note is shared with K2 logic: K3 punkt 18.x also
* mandates the share-capital disclosure for AB.
*
* tbFullRows MUST be the FULL current-period trial balance (tbFull.rows:
* opening balances included, year-end closing entries NOT excluded). The
* uppskjutna-skatter note derives its BFNAR 2012:1 ch.29 opening balance,
* movement, and closing balance for 2240/8940 from these rows; passing
* tbPreClosing.rows would zero the opening balance and misstate the note.
* The K3 multiyear snapshot test pins a non-zero 2240 opening balance to
* guard this contract.
*/
async function buildK3Noter(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
entityType: string,
periodStartIso: string,
periodEndIso: string,
narrative: NarrativeRow | null,
tbFullRows: TrialBalanceRow[],
): Promise<{ notes: NoteEntry[]; warnings: string[] }> {
const notes: NoteEntry[] = []
const warnings: string[] = []
const isAb = entityType === 'aktiebolag'
const maybeAb = isAb || entityType === 'unknown'
// The three reads feeding the notes below (asset register, aktiekapital
// settings, employee windows) are independent, so they share one parallel
// round trip instead of three sequential ones. Note bodies, push order,
// and numbering (notes.length + 1) are unchanged.
const [assetsResult, settingsResult, employeesResult] = await Promise.all([
listAssets(supabase, companyId),
maybeAb
? supabase
.from('company_settings')
.select('aktiekapital, antal_aktier')
.eq('company_id', companyId)
.maybeSingle()
: Promise.resolve({ data: null }),
supabase
.from('employees')
.select('employment_start, employment_end, employment_degree')
.eq('company_id', companyId),
])
// 1. Redovisningsprinciper. We check whether any asset has K3 components
// configured so the principles paragraph only mentions komponentavskrivning
// when it's actually in use.
@@ -732,7 +797,7 @@ async function buildK3Noter(
// (months elapsed / useful life) which matches what the per-component
// depreciation engine (computeComponentDepreciation) produces over a year.
// The fiscal period end is the as-of date for the depreciation snapshot.
const assets = (await listAssets(supabase, companyId)) as Asset[]
const assets = assetsResult as Asset[]
const monthsBetween = (fromIso: string, toIso: string): number => {
const from = new Date(`${fromIso}T00:00:00Z`)
const to = new Date(`${toIso}T00:00:00Z`)
@@ -778,19 +843,12 @@ async function buildK3Noter(
// 2. Aktiekapital (shared with K2 logic: K3 punkt 18.x mandates the same
// disclosure for AB).
const isAb = entityType === 'aktiebolag'
const maybeAb = isAb || entityType === 'unknown'
if (maybeAb) {
const { data: settings } = await supabase
.from('company_settings')
.select('aktiekapital, antal_aktier')
.eq('company_id', companyId)
.maybeSingle()
type AktiekapitalShape = {
aktiekapital?: number | null
antal_aktier?: number | null
}
const ak = settings as AktiekapitalShape | null
const ak = (settingsResult.data ?? null) as AktiekapitalShape | null
const aktiekapital = ak?.aktiekapital ?? null
const antalAktier = ak?.antal_aktier ?? null
// Kvotvärde is defined (ABL 1 kap 6 §) as aktiekapital / antal aktier;
@@ -846,10 +904,11 @@ async function buildK3Noter(
// 4. Uppskjutna skatter. K3 ch.29 requires disclosure of opening,
// movement, and closing balance of uppskjuten skatteskuld. We derive
// these from the trial balance for 2240 (latent tax liability) and
// 8940 (latent tax expense).
// these from the current-period full trial balance (passed in by the
// caller, which already fetched it for the statements) for 2240 (latent
// tax liability) and 8940 (latent tax expense).
try {
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
const rows = tbFullRows
const row2240 = rows.find((r) => r.account_number === '2240')
const row8940 = rows.find((r) => r.account_number === '8940')
// 2240 is credit-normal liability: opening = opening_credit - opening_debit
@@ -886,12 +945,8 @@ async function buildK3Noter(
// 5. Medelantal anställda: FTE-weighted average per ÅRL 5:20 §. The note is
// statutory for AB regardless of value (disclose "0" explicitly); for non-AB
// entities we still skip when there are no employees.
const { data: employeeRows } = await supabase
.from('employees')
.select('employment_start, employment_end, employment_degree')
.eq('company_id', companyId)
const medelantal = computeMedelantalAnstallda(
(employeeRows ?? []) as Array<{
(employeesResult.data ?? []) as Array<{
employment_start: string
employment_end: string | null
employment_degree: number
+83 -1
View File
@@ -20,8 +20,19 @@ type TerminalResult = { data?: unknown; error?: unknown }
* A terminal seeded as an ARRAY is consumed in call order, for functions
* that query the same table twice (getActiveCompanyId's fallback fetch +
* preference validation both end on company_members.maybeSingle()).
*
* `rpcResult` seeds supabase.rpc('resolve_active_company'). The default is a
* PGRST202 "function not found" error so every pre-RPC test keeps passing
* unchanged: they now exercise the query fallback path, which is exactly the
* behavior on a not-yet-migrated database.
*/
function buildSupabase(results: Record<string, Record<string, TerminalResult | TerminalResult[]>>) {
function buildSupabase(
results: Record<string, Record<string, TerminalResult | TerminalResult[]>>,
rpcResult: TerminalResult = {
data: null,
error: { code: 'PGRST202', message: 'Could not find the function' },
},
) {
const calls: CapturedCall[] = []
function makeChain(table: string) {
@@ -44,6 +55,10 @@ function buildSupabase(results: Record<string, Record<string, TerminalResult | T
const supabase = {
from: vi.fn().mockImplementation((table: string) => makeChain(table)),
rpc: vi.fn(async () => ({
data: rpcResult.data ?? null,
error: rpcResult.error ?? null,
})),
}
return { supabase, calls }
@@ -231,6 +246,73 @@ describe('getActiveCompanyId', () => {
})
})
describe('getActiveCompanyId via resolve_active_company RPC', () => {
it('resolves from the RPC in one call without touching any table', async () => {
const { supabase } = buildSupabase(
{},
{ data: [{ company_id: 'company-1', locale: 'sv', used_fallback: false }] },
)
const id = await getActiveCompanyId(supabase as never, 'user-1')
expect(id).toBe('company-1')
expect(supabase.rpc).toHaveBeenCalledWith('resolve_active_company')
// The whole point of the RPC: zero PostgREST table round trips.
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns null from an RPC row with a null company_id (no companies) without table queries', async () => {
const { supabase } = buildSupabase(
{},
{ data: [{ company_id: null, locale: 'en', used_fallback: true }] },
)
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBeNull()
expect(supabase.from).not.toHaveBeenCalled()
})
it('throws resolution_failed on a non-fallback RPC error instead of masking it', async () => {
const { supabase } = buildSupabase(
{},
{ data: null, error: { code: '57014', message: 'statement timeout' } },
)
const err = await getActiveCompanyId(supabase as never, 'user-1').catch((e) => e)
expect(err).toBeInstanceOf(CompanyContextError)
expect(err.code).toBe('resolution_failed')
expect(supabase.from).not.toHaveBeenCalled()
})
it('falls back to the query path on 42501 (service-role client lacks EXECUTE)', async () => {
// The mcp-oauth token route and the events route (API-key branch) call
// requireCompanyId with createServiceClientNoCookies(): EXECUTE is
// granted to `authenticated` only, so the RPC refuses with 42501 and the
// query path (filtered by the explicit userId param) must take over.
const { supabase } = buildSupabase(
{
user_preferences: { maybeSingle: { data: { active_company_id: 'company-1' } } },
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
},
{ data: null, error: { code: '42501', message: 'permission denied for function' } },
)
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
})
it('falls back to the query path on zero RPC rows (NULL auth.uid(), service client)', async () => {
const { supabase } = buildSupabase(
{
user_preferences: { maybeSingle: { data: null } },
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
},
{ data: [] },
)
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
})
})
describe('getCompanyDisplayName', () => {
it('returns company_settings.company_name and never reads companies when set', async () => {
const { supabase, calls } = buildSupabase({
+48
View File
@@ -34,6 +34,12 @@ export class CompanyContextError extends Error {
* Having Next.js and RLS both read from `user_preferences` keeps them
* perfectly in sync.
*
* RPC-first: tries `resolve_active_company()` (one round trip, semantically
* identical to the query path and to `current_active_company_id()`), falling
* back to the original query path when the function is not deployed
* (PGRST202), the caller lacks EXECUTE (42501: service-role clients), or the
* RPC returns zero rows (NULL auth.uid(), also service-role clients).
*
* Returns null only when the user positively has no non-archived companies.
* Throws CompanyContextError('resolution_failed') when a query fails: a
* transient failure must never read as "no companies", because callers
@@ -42,6 +48,48 @@ export class CompanyContextError extends Error {
export async function getActiveCompanyId(
supabase: SupabaseClient,
userId: string
): Promise<string | null> {
const { data, error } = await supabase.rpc('resolve_active_company')
if (error) {
// PGRST202: function not in the schema cache (self-hosted instance not
// migrated yet, or a deploy racing the branch merge).
// 42501: EXECUTE is granted to `authenticated` only, so a service-role
// client is refused. These fallbacks are LOAD-BEARING, not defensive:
// app/api/mcp-oauth/token/route.ts and app/api/events/route.ts (API-key
// branch) call requireCompanyId with createServiceClientNoCookies(), and
// must silently resolve via the query path or the OAuth token flow breaks.
if (error.code === 'PGRST202' || error.code === '42501') {
return getActiveCompanyIdViaQueries(supabase, userId)
}
throw new CompanyContextError(
`Active company resolution failed: ${error.message}`,
'resolution_failed'
)
}
const row = (Array.isArray(data) ? data[0] : data) as
| { company_id: string | null; locale: string | null; used_fallback: boolean }
| undefined
| null
if (!row) {
// Zero rows = NULL auth.uid() inside the RPC, i.e. a service-role client
// (same call sites as the 42501 branch above). The query path filters by
// the explicit userId param and still resolves correctly.
return getActiveCompanyIdViaQueries(supabase, userId)
}
return row.company_id ?? null
}
/**
* Query-path resolution: the pre-RPC implementation, kept verbatim as the
* fallback for getActiveCompanyId (see the fallback conditions there).
*/
async function getActiveCompanyIdViaQueries(
supabase: SupabaseClient,
userId: string
): Promise<string | null> {
// user_preferences (authoritative) + first membership, fetched in parallel:
// the fallback query result doubles as validation when the preferred
+51
View File
@@ -4,6 +4,7 @@ import {
fetchMultipleRates,
fetchRateRange,
fetchLatestRate,
readCachedRate,
convertToSEK,
formatCurrencyAmount,
} from '../riksbanken'
@@ -223,6 +224,56 @@ describe('fetchExchangeRate', () => {
})
})
// Exported so the currency.rate route can hit the exchange_rates cache in
// parallel with its sandbox guard instead of always going through
// fetchExchangeRate's sequential path.
describe('readCachedRate', () => {
it('maps an exact-date cache row to an ExchangeRate', async () => {
const maybeSingle = vi.fn().mockResolvedValue({
// rate arrives as a numeric string from PostgREST: must be Number()ed.
data: { rate: '11.25', observation_date: '2025-01-14' },
error: null,
})
const supabase = {
from: vi.fn(() => ({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
maybeSingle,
})),
} as never
const result = await readCachedRate(supabase, 'EUR', '2025-01-15')
expect(result).toEqual({ currency: 'EUR', rate: 11.25, date: '2025-01-14' })
})
it('returns null when the cache has no row for the date', async () => {
const supabase = {
from: vi.fn(() => ({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }),
})),
} as never
const result = await readCachedRate(supabase, 'EUR', '2025-01-15')
expect(result).toBeNull()
})
it('swallows thrown client errors and returns null (best-effort cache)', async () => {
const supabase = {
from: vi.fn(() => {
throw new Error('connection refused')
}),
} as never
const result = await readCachedRate(supabase, 'EUR', '2025-01-15')
expect(result).toBeNull()
})
})
describe('fetchMultipleRates', () => {
beforeEach(() => {
vi.restoreAllMocks()
+2 -2
View File
@@ -32,8 +32,8 @@ async function fetchWithRetry(url: string): Promise<Response> {
return fetch(url, { headers: RIKSBANKEN_HEADERS, next: { revalidate: 3600 } })
}
/** Cache reads/writes are best-effort a cache failure must never block a rate. */
async function readCachedRate(
/** Cache reads/writes are best-effort: a cache failure must never block a rate. */
export async function readCachedRate(
supabase: SupabaseClient,
currency: Currency,
rateDate: string,
@@ -300,6 +300,7 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
id: deliveryId,
to_addresses: ['***@example.com'],
cc_addresses: ['***@example.com'],
attachment_filename: 'invoice.pdf',
}),
])
expect(summary.rows[0]).not.toHaveProperty('bcc_addresses')
+26 -12
View File
@@ -573,9 +573,13 @@ export async function getReconciliationStatus(
(tx) => tx.journal_entry_id === null && tx.is_ignored !== true
).length
// Unlinked GL lines count (RPC excludes opening_balance, storno and correction
// since 20260601120000_unlinked_gl_lines_exclude_storno_correction.sql)
const unlinkedLines = await fetchUnlinkedGLLines(supabase, companyId, bankAccount, dateFrom, dateTo)
// Unmatched GL lines count (RPC excludes opening_balance, storno and correction
// since 20260601120000_unlinked_gl_lines_exclude_storno_correction.sql).
// Account-scoped since 20260723160000: a voucher whose links all sit on another
// cash account (a transfer's other leg) counts as unmatched HERE, keeping this
// number in agreement with the "Omatchade verifikationer" table the
// reconciliation view derives from the same RPC.
const unlinkedLines = await fetchGLLinesForMatching(supabase, companyId, bankAccount, dateFrom, dateTo)
const difference = Math.round((bankTotal - glPeriodMovement) * 100) / 100
@@ -684,9 +688,11 @@ export async function manualLink(
// links net to zero and any mis-link surfaces as a non-zero difference on the
// status card: there's no need to forbid a second link here. (A given
// transaction still can't be double-linked: the tx.journal_entry_id guard
// above already blocks that.) The candidate list only surfaces an
// already-matched voucher when the user opts in via "Visa även matchade
// verifikationer", so this can't happen by accident.
// above already blocks that.) The candidate list surfaces a voucher already
// settled on THIS account only when the user opts in via "Visa även matchade
// verifikationer"; a voucher whose links all sit on another cash account (the
// second leg of an own-account transfer, issue #1026) surfaces by default,
// which is exactly the N:1-across-accounts case this permits.
// Apply link. The write re-checks the pointer we validated inside the write
// itself (the read above is advisory): null for a free row, or the exact
@@ -1033,17 +1039,25 @@ export async function fetchUnlinkedGLLines(
/** A match candidate that carries how many transactions already point at it. */
export interface GLLineForMatching extends UnlinkedGLLine {
/** Transactions settling this entry ON THE REQUESTED ACCOUNT (plus legacy
* rows with no cash_account_id, which count everywhere). A transaction on
* another cash account, e.g. the outgoing leg of an own-account transfer,
* does not mark the voucher as matched here (issue #1026). */
linked_transaction_count: number
}
/**
* Fetch GL lines on a settlement account as match candidates. With
* `includeMatched=false` this is parity with fetchUnlinkedGLLines (unmatched
* only); with `includeMatched=true` it also returns already-matched vouchers,
* each carrying `linked_transaction_count`, so a second/third bank transaction
* can be attached to the same verifikat (N:1, a salary run paid in several
* transfers, a supplier invoice paid in instalments). Server-only: like the rest
* of this module it must never reach the client bundle.
* `includeMatched=false` this returns vouchers not yet settled on the requested
* account: unlike fetchUnlinkedGLLines, a voucher whose only links are
* transactions on ANOTHER cash account (the second leg of an own-account
* transfer) still surfaces, since from this account's perspective it is
* unmatched (issue #1026). With `includeMatched=true` it also returns vouchers
* already settled on this account, each carrying `linked_transaction_count`, so
* a second/third bank transaction can be attached to the same verifikat (N:1,
* a salary run paid in several transfers, a supplier invoice paid in
* instalments). Server-only: like the rest of this module it must never reach
* the client bundle.
*/
export async function fetchGLLinesForMatching(
supabase: SupabaseClient,
@@ -0,0 +1,204 @@
import { describe, it, expect, vi } from 'vitest'
import {
fetchKpiAggregates,
buildOpeningBalances,
buildTrialBalanceRows,
type KpiAggregates,
} from '../kpi-aggregates'
function emptyAgg(overrides: Partial<KpiAggregates> = {}): KpiAggregates {
return { tb: [], tb_ex_year_end: [], ob: [], monthly: [], ...overrides }
}
describe('fetchKpiAggregates', () => {
it('calls the RPC with the expected args and coerces numbers', async () => {
const rpc = vi.fn().mockResolvedValue({
data: {
tb: [{ account_number: '1930', debit: '125.5', credit: 0 }],
tb_ex_year_end: [{ account_number: '3001', debit: null, credit: 100 }],
ob: [],
monthly: [{ year: 2026, month: '2', income: '10.25', expenses: undefined }],
},
error: null,
})
const supabase = { rpc } as never
const agg = await fetchKpiAggregates(supabase, 'company-1', 'period-1', 'ob-1')
expect(rpc).toHaveBeenCalledWith('get_kpi_report_aggregates', {
p_company_id: 'company-1',
p_fiscal_period_id: 'period-1',
p_ob_entry_id: 'ob-1',
})
expect(agg.tb).toEqual([{ account_number: '1930', debit: 125.5, credit: 0 }])
expect(agg.tb_ex_year_end).toEqual([{ account_number: '3001', debit: 0, credit: 100 }])
expect(agg.ob).toEqual([])
expect(agg.monthly).toEqual([{ year: 2026, month: 2, income: 10.25, expenses: 0 }])
})
it('defaults missing sections to empty arrays', async () => {
const rpc = vi.fn().mockResolvedValue({ data: {}, error: null })
const supabase = { rpc } as never
const agg = await fetchKpiAggregates(supabase, 'company-1', 'period-1', null)
expect(agg).toEqual(emptyAgg())
expect(rpc).toHaveBeenCalledWith('get_kpi_report_aggregates', {
p_company_id: 'company-1',
p_fiscal_period_id: 'period-1',
p_ob_entry_id: null,
})
})
it('throws a prefixed error when the RPC fails', async () => {
const rpc = vi.fn().mockResolvedValue({ data: null, error: { message: 'boom' } })
const supabase = { rpc } as never
await expect(
fetchKpiAggregates(supabase, 'company-1', 'period-1', null)
).rejects.toThrow('get_kpi_report_aggregates failed: boom')
})
})
describe('buildOpeningBalances', () => {
it('OB-entry path (priorRows null): additive accumulation with Number coercion', () => {
const agg = emptyAgg({
ob: [
{ account_number: '1930', debit: 5000, credit: 0 },
// A duplicate account accumulates additively, mirroring the
// per-line loop in opening-balances.ts lines 57-62.
{ account_number: '1930', debit: 250.5, credit: 0 },
{ account_number: '2010', debit: 0, credit: 5250.5 },
],
})
const balances = buildOpeningBalances(agg, null)
expect(balances.get('1930')).toEqual({ debit: 5250.5, credit: 0 })
expect(balances.get('2010')).toEqual({ debit: 0, credit: 5250.5 })
expect(balances.size).toBe(2)
})
it('fallback path: uses priorRows with Number()||0 coercion, ignoring the ob section', () => {
const agg = emptyAgg({
ob: [{ account_number: '9999', debit: 1, credit: 1 }],
})
// compute_prior_opening_balances returns numerics that may arrive as
// strings through PostgREST: mirrors opening-balances.ts lines 77-86.
const balances = buildOpeningBalances(agg, [
{ account_number: '1930', debit: '1500.25', credit: '0' },
{ account_number: '2440', debit: 'not-a-number', credit: 300 },
])
expect(balances.get('1930')).toEqual({ debit: 1500.25, credit: 0 })
expect(balances.get('2440')).toEqual({ debit: 0, credit: 300 })
expect(balances.has('9999')).toBe(false)
})
it('empty inputs produce an empty map in both shapes', () => {
expect(buildOpeningBalances(emptyAgg(), null).size).toBe(0)
expect(buildOpeningBalances(emptyAgg(), []).size).toBe(0)
})
})
describe('buildTrialBalanceRows', () => {
const accountMap = new Map<string, { name: string; class: number }>([
['1930', { name: 'Företagskonto', class: 1 }],
['3001', { name: 'Försäljning 25%', class: 3 }],
])
it('merges opening and period accounts and computes IB + period = UB', () => {
const opening = new Map([
['1930', { debit: 5000, credit: 0 }],
// Account only in opening: must still get a row.
['2081', { debit: 0, credit: 25000 }],
])
const periodSums = [
{ account_number: '1930', debit: 12500, credit: 3000 },
// Account only in period: must still get a row.
{ account_number: '3001', debit: 0, credit: 10000 },
]
const rows = buildTrialBalanceRows(opening, periodSums, accountMap)
expect(rows.map((r) => r.account_number)).toEqual(['1930', '2081', '3001'])
expect(rows[0]).toEqual({
account_number: '1930',
account_name: 'Företagskonto',
account_class: 1,
opening_debit: 5000,
opening_credit: 0,
period_debit: 12500,
period_credit: 3000,
closing_debit: 17500,
closing_credit: 3000,
})
expect(rows[1]).toMatchObject({
account_number: '2081',
opening_credit: 25000,
period_debit: 0,
period_credit: 0,
closing_credit: 25000,
})
expect(rows[2]).toMatchObject({
account_number: '3001',
account_name: 'Försäljning 25%',
account_class: 3,
opening_debit: 0,
period_credit: 10000,
closing_credit: 10000,
})
})
it('falls back to "Konto <n>" naming and first-digit class for unknown accounts', () => {
const rows = buildTrialBalanceRows(
new Map(),
[
{ account_number: '2611', debit: 0, credit: 2500 },
{ account_number: 'X99', debit: 1, credit: 0 },
],
accountMap
)
const unknown = rows.find((r) => r.account_number === '2611')!
expect(unknown.account_name).toBe('Konto 2611')
expect(unknown.account_class).toBe(2)
// parseInt(n[0]) || 0 fallback: non-numeric first char lands class 0,
// same as trial-balance.ts line 306.
const weird = rows.find((r) => r.account_number === 'X99')!
expect(weird.account_name).toBe('Konto X99')
expect(weird.account_class).toBe(0)
})
it('rounds all six amount fields with Math.round(x * 100) / 100', () => {
const opening = new Map([['1930', { debit: 0.1, credit: 0 }]])
const rows = buildTrialBalanceRows(
opening,
[{ account_number: '1930', debit: 0.2, credit: 0.005 }],
accountMap
)
// 0.1 + 0.2 = 0.30000000000000004 raw: closing must land on 0.3 exactly.
expect(rows[0].opening_debit).toBe(0.1)
expect(rows[0].period_debit).toBe(0.2)
expect(rows[0].closing_debit).toBe(0.3)
expect(rows[0].period_credit).toBe(0.01)
expect(rows[0].closing_credit).toBe(0.01)
})
it('sorts rows by account_number with localeCompare', () => {
const rows = buildTrialBalanceRows(
new Map(),
[
{ account_number: '8999', debit: 1, credit: 0 },
{ account_number: '1510', debit: 1, credit: 0 },
{ account_number: '2440', debit: 1, credit: 0 },
],
accountMap
)
expect(rows.map((r) => r.account_number)).toEqual(['1510', '2440', '8999'])
})
it('returns no rows when both inputs are empty', () => {
expect(buildTrialBalanceRows(new Map(), [], accountMap)).toEqual([])
})
})
+10
View File
@@ -116,6 +116,16 @@ describe('calculateVatLiability', () => {
expect(calculateVatLiability(rows)).toBe(15000)
})
it('includes reduced-rate output VAT (12% and 6%) in the liability', () => {
const rows = [
makeTrialBalanceRow({ account_number: '2611', closing_credit: 25000 }),
makeTrialBalanceRow({ account_number: '2621', closing_credit: 1200 }),
makeTrialBalanceRow({ account_number: '2631', closing_credit: 600 }),
makeTrialBalanceRow({ account_number: '2641', closing_debit: 10000 }),
]
expect(calculateVatLiability(rows)).toBe(16800)
})
it('nets EU reverse charge (2614 + 2645) to zero: issue #715', () => {
const rows = [
makeTrialBalanceRow({ account_number: '2614', closing_credit: 2500 }),
+6 -1
View File
@@ -608,7 +608,12 @@ describe('generateTrialBalance', () => {
}),
).rejects.toThrow(/missing closing_entry_id/i)
expect(supabase.from).toHaveBeenCalledTimes(1)
// The guard throws before any journal data is read. The chart of
// accounts is part of the first parallel wave (alongside the period
// fetch), so it may have been queried; the entry/line tables must not be.
const tables = supabase.from.mock.calls.map((c: unknown[]) => c[0])
expect(tables).not.toContain('journal_entries')
expect(tables).not.toContain('journal_entry_lines')
})
it('keeps year-end adjustments for an open period without a final closing entry', async () => {
@@ -371,12 +371,6 @@ describe('calculateVatDeclaration', () => {
expect(result.rutor.ruta49).toBe(-2500) // 500 - 3000
})
it('accepts accountingMethod parameter for backward compatibility', async () => {
seedLedger([])
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1, 'cash')
expect(result.rutor.ruta49).toBe(0)
})
it('throws a labelled error when the RPC fails', async () => {
results = [{ data: null, error: { message: 'permission denied' } }]
-1
View File
@@ -375,7 +375,6 @@ async function generatePeriodReports(
'yearly',
startDate.getFullYear(),
1,
'accrual',
{ fiscalPeriodId: period.id }
)
} catch {
+14
View File
@@ -33,6 +33,20 @@ export async function generateIncomeStatement(
dimensions: options?.dimensions,
})
return buildIncomeStatementFromRows(rows)
}
/**
* Pure income-statement assembly from trial balance rows. Extracted so
* callers that already hold pre-computed rows (e.g. the KPI route's
* single-round-trip aggregate path) can reuse the section/rounding logic
* without re-fetching journal lines. The rows must come from a trial
* balance generated with excludeYearEndClosing (see generateIncomeStatement
* above for why).
*/
export function buildIncomeStatementFromRows(
rows: TrialBalanceRow[]
): IncomeStatementReport {
// Filter to income/expense accounts (class 3-8)
const incomeExpenseRows = rows.filter(
(r) => r.account_class >= 3 && r.account_class <= 8
+185
View File
@@ -0,0 +1,185 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { roundOre } from '@/lib/money'
import type { TrialBalanceRow } from '@/types'
/**
* Client-side companion for the get_kpi_report_aggregates RPC
* (supabase/migrations/20260723180000_kpi_report_aggregates_rpc.sql).
*
* The KPI route used to scan every journal line of the fiscal period three
* times through PostgREST (unfiltered trial balance, income-statement trial
* balance with excludeYearEndClosing, monthly breakdown) and aggregate in
* JS. The RPC returns the three pre-summed shapes in one round trip; the
* builders here reproduce the exact merge/rounding semantics of the legacy
* generators so the report JSON stays identical.
*/
export interface AccountSums {
account_number: string
debit: number
credit: number
}
export interface KpiMonthlyBucket {
year: number
/** Calendar month 1-12 (SQL EXTRACT). Callers convert to 0-based before
* handing buckets to assembleMonthlyBreakdown. */
month: number
income: number
expenses: number
}
export interface KpiAggregates {
tb: AccountSums[]
tb_ex_year_end: AccountSums[]
ob: AccountSums[]
monthly: KpiMonthlyBucket[]
}
function toAccountSums(rows: unknown[] | null | undefined): AccountSums[] {
return (rows ?? []).map((r) => {
const row = r as { account_number?: unknown; debit?: unknown; credit?: unknown }
return {
account_number: String(row.account_number ?? ''),
debit: Number(row.debit) || 0,
credit: Number(row.credit) || 0,
}
})
}
/**
* Fetch the KPI aggregates in one round trip. Throws on RPC failure (the
* route surfaces it as a 500 via withRouteContext; matches the
* get_vat_declaration_totals precedent in lib/reports/vat-declaration.ts).
*/
export async function fetchKpiAggregates(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
obEntryId: string | null
): Promise<KpiAggregates> {
const { data, error } = await supabase.rpc('get_kpi_report_aggregates', {
p_company_id: companyId,
p_fiscal_period_id: fiscalPeriodId,
p_ob_entry_id: obEntryId,
})
if (error) {
throw new Error(`get_kpi_report_aggregates failed: ${error.message}`)
}
const payload = (data ?? {}) as {
tb?: unknown[]
tb_ex_year_end?: unknown[]
ob?: unknown[]
monthly?: unknown[]
}
return {
tb: toAccountSums(payload.tb),
tb_ex_year_end: toAccountSums(payload.tb_ex_year_end),
ob: toAccountSums(payload.ob),
monthly: (payload.monthly ?? []).map((r) => {
const row = r as { year?: unknown; month?: unknown; income?: unknown; expenses?: unknown }
return {
year: Number(row.year) || 0,
month: Number(row.month) || 0,
income: Number(row.income) || 0,
expenses: Number(row.expenses) || 0,
}
}),
}
}
/**
* Build the opening-balance map for the fiscal period.
*
* Pass `priorRows: null` when the period has an opening-balance entry: the
* balances then come from the RPC's `ob` section, mirroring the OB-entry
* branch of getOpeningBalances (lib/reports/opening-balances.ts lines
* 57-62: additive accumulation with Number()||0 coercion). Otherwise pass
* the rows returned by the compute_prior_opening_balances RPC, mirroring
* the fallback branch (lines 77-86: per-row set with Number()||0).
*/
export function buildOpeningBalances(
agg: KpiAggregates,
priorRows:
| Array<{ account_number: string; debit: number | string; credit: number | string }>
| null
): Map<string, { debit: number; credit: number }> {
const balances = new Map<string, { debit: number; credit: number }>()
if (priorRows === null) {
for (const line of agg.ob) {
const existing = balances.get(line.account_number) || { debit: 0, credit: 0 }
existing.debit += Number(line.debit) || 0
existing.credit += Number(line.credit) || 0
balances.set(line.account_number, existing)
}
} else {
for (const row of priorRows) {
balances.set(row.account_number, {
debit: Number(row.debit) || 0,
credit: Number(row.credit) || 0,
})
}
}
return balances
}
/**
* Assemble TrialBalanceRow[] from pre-summed per-account period activity.
*
* Pinned to the row-building tail of generateTrialBalance
* (lib/reports/trial-balance.ts lines 287-322, which is read-only): merged
* key set of opening + period accounts, `Konto <n>` /
* `parseInt(n[0]) || 0` fallback for accounts missing from the chart,
* öre rounding on all six amount fields, and a final localeCompare sort.
* Rounding goes through roundOre (the antipattern guard forbids new raw
* Math.round(x * 100) / 100): identical to the source expression except
* within float epsilon of half-öre boundaries. Keep the two in sync if
* the source ever changes.
*/
export function buildTrialBalanceRows(
openingBalances: Map<string, { debit: number; credit: number }>,
periodSums: AccountSums[],
accountMap: Map<string, { name: string; class: number }>
): TrialBalanceRow[] {
const periodBalances = new Map<string, { debit: number; credit: number }>()
for (const sums of periodSums) {
const existing = periodBalances.get(sums.account_number) || { debit: 0, credit: 0 }
existing.debit += Number(sums.debit) || 0
existing.credit += Number(sums.credit) || 0
periodBalances.set(sums.account_number, existing)
}
// Merge account numbers from both opening and period
const allAccountNumbers = new Set([...openingBalances.keys(), ...periodBalances.keys()])
// Build rows: IB + period = UB
const rows: TrialBalanceRow[] = []
for (const accountNumber of allAccountNumbers) {
const opening = openingBalances.get(accountNumber) || { debit: 0, credit: 0 }
const periodActivity = periodBalances.get(accountNumber) || { debit: 0, credit: 0 }
const accountInfo = accountMap.get(accountNumber) || {
name: `Konto ${accountNumber}`,
class: parseInt(accountNumber[0]) || 0,
}
rows.push({
account_number: accountNumber,
account_name: accountInfo.name,
account_class: accountInfo.class,
opening_debit: roundOre(opening.debit),
opening_credit: roundOre(opening.credit),
period_debit: roundOre(periodActivity.debit),
period_credit: roundOre(periodActivity.credit),
closing_debit: roundOre(opening.debit + periodActivity.debit),
closing_credit: roundOre(opening.credit + periodActivity.credit),
})
}
rows.sort((a, b) => a.account_number.localeCompare(b.account_number))
return rows
}
+88 -39
View File
@@ -1,5 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import { roundOre } from '@/lib/money'
export interface MonthlyBreakdownMonth {
label: string
@@ -17,6 +18,77 @@ const MONTH_LABELS = [
'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dec',
]
/** Pre-summed month bucket for assembleMonthlyBreakdown. */
export interface MonthlyBucket {
year: number
/** 0-based month (JS Date convention, indexes MONTH_LABELS). */
month0: number
income: number
expenses: number
}
/**
* Pure assembly of the monthly breakdown from pre-summed buckets: month
* range initialization, bucket fill, natural "YYYY-MM" sort, and Swedish
* month labels. Extracted from generateMonthlyBreakdown so callers that
* already hold per-month sums (e.g. the KPI route's single-round-trip
* aggregate path) can reuse the assembly without re-scanning lines.
*
* Rounding happens once per bucket (income, expenses, then net over the
* rounded pair) instead of the old incremental per-line rounding: equal
* within float epsilon for real öre-denominated amounts.
*/
export function assembleMonthlyBreakdown(
periodStart: string,
periodEnd: string,
buckets: MonthlyBucket[]
): MonthlyBreakdown {
// Build monthly aggregates using year-aware keys ("2024-03", "2024-04",
// etc.) to avoid data corruption for non-calendar fiscal years (Apr-Mar).
const monthMap = new Map<string, { year: number; month: number; income: number; expenses: number }>()
// Initialize all months in the period range
const startDate = new Date(periodStart)
const endDate = new Date(periodEnd)
for (
let y = startDate.getFullYear(), m = startDate.getMonth();
y < endDate.getFullYear() || (y === endDate.getFullYear() && m <= endDate.getMonth());
m === 11 ? (y++, m = 0) : m++
) {
const key = `${y}-${String(m).padStart(2, '0')}`
monthMap.set(key, { year: y, month: m, income: 0, expenses: 0 })
}
for (const bucket of buckets) {
const key = `${bucket.year}-${String(bucket.month0).padStart(2, '0')}`
if (!monthMap.has(key)) {
monthMap.set(key, { year: bucket.year, month: bucket.month0, income: 0, expenses: 0 })
}
const target = monthMap.get(key)!
target.income += bucket.income
target.expenses += bucket.expenses
}
// Convert to sorted array (keys sort naturally as "YYYY-MM")
const months: MonthlyBreakdownMonth[] = []
const sortedKeys = Array.from(monthMap.keys()).sort()
for (const key of sortedKeys) {
const data = monthMap.get(key)!
const income = roundOre(data.income)
const expenses = roundOre(data.expenses)
months.push({
label: MONTH_LABELS[data.month],
income,
expenses,
net: roundOre(income - expenses),
})
}
return { months }
}
/**
* Generate monthly income vs expenses breakdown for a fiscal period.
*
@@ -73,22 +145,9 @@ export async function generateMonthlyBreakdown(
return { months: [] }
}
// Build monthly aggregates using year-aware keys ("2024-03", "2024-04", etc.)
// to avoid data corruption for non-calendar fiscal years (e.g., Apr-Mar)
const monthMap = new Map<string, { year: number; month: number; income: number; expenses: number }>()
// Initialize all months in the period range
const startDate = new Date(period.period_start)
const endDate = new Date(period.period_end)
for (
let y = startDate.getFullYear(), m = startDate.getMonth();
y < endDate.getFullYear() || (y === endDate.getFullYear() && m <= endDate.getMonth());
m === 11 ? (y++, m = 0) : m++
) {
const key = `${y}-${String(m).padStart(2, '0')}`
monthMap.set(key, { year: y, month: m, income: 0, expenses: 0 })
}
// Sum lines into per-month buckets (raw sums; assembleMonthlyBreakdown
// rounds once per bucket), keyed year-aware for non-calendar fiscal years.
const bucketMap = new Map<string, MonthlyBucket>()
for (const line of lines) {
const entry = line.journal_entry as {
@@ -101,18 +160,18 @@ export async function generateMonthlyBreakdown(
const entryDate = new Date(entry.entry_date)
const key = `${entryDate.getFullYear()}-${String(entryDate.getMonth()).padStart(2, '0')}`
if (!monthMap.has(key)) {
monthMap.set(key, { year: entryDate.getFullYear(), month: entryDate.getMonth(), income: 0, expenses: 0 })
let bucket = bucketMap.get(key)
if (!bucket) {
bucket = { year: entryDate.getFullYear(), month0: entryDate.getMonth(), income: 0, expenses: 0 }
bucketMap.set(key, bucket)
}
const bucket = monthMap.get(key)!
if (accountClass === 3) {
// Revenue accounts: credit side represents revenue
bucket.income = Math.round((bucket.income + line.credit_amount - line.debit_amount) * 100) / 100
bucket.income += line.credit_amount - line.debit_amount
} else if (accountClass >= 4 && accountClass <= 7) {
// Expense accounts: debit side represents expenses
bucket.expenses = Math.round((bucket.expenses + line.debit_amount - line.credit_amount) * 100) / 100
bucket.expenses += line.debit_amount - line.credit_amount
} else if (accountClass === 8 && line.account_number !== '8999') {
// Financial items (class 8): interest, exchange gains/losses, etc.
// 8999 "Årets resultat" is a year-end closing account: its debit/credit
@@ -120,26 +179,16 @@ export async function generateMonthlyBreakdown(
// period's income-vs-expense signal on the month of closing.
const amount = line.credit_amount - line.debit_amount
if (amount >= 0) {
bucket.income = Math.round((bucket.income + amount) * 100) / 100
bucket.income += amount
} else {
bucket.expenses = Math.round((bucket.expenses + Math.abs(amount)) * 100) / 100
bucket.expenses += Math.abs(amount)
}
}
}
// Convert to sorted array (keys sort naturally as "YYYY-MM")
const months: MonthlyBreakdownMonth[] = []
const sortedKeys = Array.from(monthMap.keys()).sort()
for (const key of sortedKeys) {
const data = monthMap.get(key)!
months.push({
label: MONTH_LABELS[data.month],
income: data.income,
expenses: data.expenses,
net: Math.round((data.income - data.expenses) * 100) / 100,
})
}
return { months }
return assembleMonthlyBreakdown(
period.period_start,
period.period_end,
Array.from(bucketMap.values())
)
}
+145 -121
View File
@@ -49,18 +49,53 @@ export async function generateTrialBalance(
isBalanced: boolean
}> {
// Fetch period for opening balance computation
const { data: period } = await supabase
.from('fiscal_periods')
.select('period_start, period_end, opening_balance_entry_id, closing_entry_id, is_closed')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
const dimensionFilter =
options?.dimensions && Object.keys(options.dimensions).length > 0
? options.dimensions
: undefined
const excludeAllYearEndEntries = options?.excludeYearEndClosing
// Wave 1: the period row (for opening balance computation), the reversed
// year-end entry ids (only needed for excludeYearEndClosing), and the
// chart of accounts are mutually independent, so they share one parallel
// round trip instead of three sequential ones. The accounts list is now
// also fetched for reports that turn out empty or fail the closed-period
// guard below; that occasional extra read-only query is the price of a
// short critical path, and the returned data is unchanged.
const [periodResult, yearEndIdRows, accounts] = await Promise.all([
supabase
.from('fiscal_periods')
.select('period_start, period_end, opening_balance_entry_id, closing_entry_id, is_closed')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single(),
excludeAllYearEndEntries
? fetchAllRows<{ id: string }>(({ from, to }) =>
supabase
.from('journal_entries')
.select('id')
.eq('company_id', companyId)
.eq('source_type', 'year_end')
.eq('status', 'reversed')
.order('id', { ascending: true })
.range(from, to)
)
: Promise.resolve([] as Array<{ id: string }>),
// Account names for row labelling.
fetchAllRows<{
account_number: string
account_name: string
account_class: number
}>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name, account_class')
.eq('company_id', companyId)
.order('account_number', { ascending: true })
.range(from, to)
),
])
const { data: period } = periodResult
// Existing operational reports intentionally exclude every year_end entry.
// Statutory annual reports must exclude only the linked final closing entry:
@@ -76,22 +111,7 @@ export async function generateTrialBalance(
'Closed fiscal period is missing closing_entry_id; statutory pre-closing balances cannot be generated safely',
)
}
const excludeAllYearEndEntries = options?.excludeYearEndClosing
let yearEndEntryIds: string[] = []
if (excludeAllYearEndEntries) {
yearEndEntryIds = (
await fetchAllRows<{ id: string }>(({ from, to }) =>
supabase
.from('journal_entries')
.select('id')
.eq('company_id', companyId)
.eq('source_type', 'year_end')
.eq('status', 'reversed')
.order('id', { ascending: true })
.range(from, to)
)
).map((r) => r.id)
}
const yearEndEntryIds: string[] = yearEndIdRows.map((r) => r.id)
const excludeYearEndChain = (query: EntryLinesQuery): EntryLinesQuery => {
let q = query.neq('source_type', 'year_end')
if (yearEndEntryIds.length > 0) {
@@ -114,30 +134,79 @@ export async function generateTrialBalance(
? query.or(`id.neq.${closingEntryId},status.neq.posted`)
: query
// ── Opening balances (IB) at period_start ──────────────────────
const { balances: obBalances, obEntryId } = await getOpeningBalances(
supabase, companyId, period
)
// A dimension-filtered view cannot use company-wide opening balances (the
// OB entry and the prior-period RPC are not dimension-aware). Drop them so
// every reported amount is dimension-scoped activity: correct for the P&L
// reports the filter is whitelisted for, and never fabricates balances if
// misapplied. obEntryId is still needed to exclude the OB entry from lines.
const openingBalances = dimensionFilter
? new Map<string, { debit: number; credit: number }>()
: obBalances
// getOpeningBalances always reports the period's opening_balance_entry_id
// back as obEntryId (see lib/reports/opening-balances.ts), so the id is
// known before that fetch resolves and the line queries below can run in
// the same round trip as the opening-balance read.
const obEntryId = period?.opening_balance_entry_id ?? null
// ── Roll IB forward from period_start up to fromDate ───────────
// When the caller requests a sub-range starting after period_start, the
// "opening" of that window must include all activity since the period
// started. We additively fold those lines into openingBalances so the
// downstream IB/period split stays correct without changing call sites.
if (
options?.fromDate &&
period?.period_start &&
options.fromDate > period.period_start
) {
const priorLines = await fetchEntryLines<{
// started (rolled forward below).
const rollForwardWindow =
options?.fromDate && period?.period_start && options.fromDate > period.period_start
? { periodStart: period.period_start, fromDate: options.fromDate }
: null
// Wave 2: opening balances (IB) at period_start, the IB roll-forward
// slice, and the period lines are independent reads. The array order
// [OB, roll-forward, lines] keeps each table's queries in the same order
// the sequential version issued them.
const [obResult, priorLines, lines] = await Promise.all([
// ── Opening balances (IB) at period_start ──────────────────────
getOpeningBalances(supabase, companyId, period),
// ── Roll IB forward from period_start up to fromDate ───────────
rollForwardWindow
? fetchEntryLines<{
id: string
account_number: string
debit_amount: number
credit_amount: number
}>({
supabase,
lineColumns: 'id, account_number, debit_amount, credit_amount',
filterEntries: (q: EntryLinesQuery) => {
let query = q
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.in('status', ['posted', 'reversed'])
.gte('entry_date', rollForwardWindow.periodStart)
.lt('entry_date', rollForwardWindow.fromDate)
if (obEntryId) {
query = query.neq('id', obEntryId)
}
if (excludeAllYearEndEntries) {
query = excludeYearEndChain(query)
}
if (options?.excludeFinalClosingEntry) {
query = excludeClosingEntry(query)
}
return query
},
filterLines: dimensionFilter
? // jsonb containment (@>): served by idx_jel_dimensions_gin.
(q: EntryLinesQuery) => q.contains('dimensions', dimensionFilter)
: undefined,
})
: Promise.resolve(
[] as Array<{
id: string
account_number: string
debit_amount: number
credit_amount: number
}>
),
// ── Period lines (excluding opening balance entry) ─────────────
// If year-end closing set an OB entry, exclude it from period lines so
// its values aren't double-counted (they're already captured as IB).
// Race condition note: if year-end closing runs concurrently and sets
// obEntryId between the period query and this query, the OB entry could
// be missed from both IB and period. The window is sub-second and the
// consequence is a single stale report: acceptable.
fetchEntryLines<{
id: string
account_number: string
debit_amount: number
@@ -150,8 +219,19 @@ export async function generateTrialBalance(
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.in('status', ['posted', 'reversed'])
.gte('entry_date', period.period_start)
.lt('entry_date', options.fromDate)
// Date filters are only applied when the caller explicitly asks. The
// period itself is already enforced via fiscal_period_id, so adding
// redundant entry_date bounds for the default case would just
// increase query complexity (and break older mocks that don't stub gte
// /lte). The fiscal_period_id constraint plus a CHECK on entry_date in
// the engine keep activity inside the period.
if (options?.fromDate) {
query = query.gte('entry_date', options.fromDate)
}
if (options?.toDate) {
query = query.lte('entry_date', options.toDate)
}
if (obEntryId) {
query = query.neq('id', obEntryId)
@@ -170,87 +250,31 @@ export async function generateTrialBalance(
? // jsonb containment (@>): served by idx_jel_dimensions_gin.
(q: EntryLinesQuery) => q.contains('dimensions', dimensionFilter)
: undefined,
})
}),
])
for (const line of priorLines) {
const existing = openingBalances.get(line.account_number) || { debit: 0, credit: 0 }
existing.debit += Number(line.debit_amount) || 0
existing.credit += Number(line.credit_amount) || 0
openingBalances.set(line.account_number, existing)
}
// A dimension-filtered view cannot use company-wide opening balances (the
// OB entry and the prior-period RPC are not dimension-aware). Drop them so
// every reported amount is dimension-scoped activity: correct for the P&L
// reports the filter is whitelisted for, and never fabricates balances if
// misapplied. obEntryId is still needed to exclude the OB entry from lines.
const openingBalances = dimensionFilter
? new Map<string, { debit: number; credit: number }>()
: obResult.balances
// Additively fold the roll-forward lines into openingBalances so the
// downstream IB/period split stays correct without changing call sites.
for (const line of priorLines) {
const existing = openingBalances.get(line.account_number) || { debit: 0, credit: 0 }
existing.debit += Number(line.debit_amount) || 0
existing.credit += Number(line.credit_amount) || 0
openingBalances.set(line.account_number, existing)
}
// ── Period lines (excluding opening balance entry) ─────────────
// If year-end closing set an OB entry, exclude it from period lines so
// its values aren't double-counted (they're already captured as IB).
// Race condition note: if year-end closing runs concurrently and sets
// obEntryId between the period query and this query, the OB entry could
// be missed from both IB and period. The window is sub-second and the
// consequence is a single stale report: acceptable.
const lines = await fetchEntryLines<{
id: string
account_number: string
debit_amount: number
credit_amount: number
}>({
supabase,
lineColumns: 'id, account_number, debit_amount, credit_amount',
filterEntries: (q: EntryLinesQuery) => {
let query = q
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.in('status', ['posted', 'reversed'])
// Date filters are only applied when the caller explicitly asks. The
// period itself is already enforced via fiscal_period_id, so adding
// redundant entry_date bounds for the default case would just
// increase query complexity (and break older mocks that don't stub gte
// /lte). The fiscal_period_id constraint plus a CHECK on entry_date in
// the engine keep activity inside the period.
if (options?.fromDate) {
query = query.gte('entry_date', options.fromDate)
}
if (options?.toDate) {
query = query.lte('entry_date', options.toDate)
}
if (obEntryId) {
query = query.neq('id', obEntryId)
}
if (excludeAllYearEndEntries) {
query = excludeYearEndChain(query)
}
if (options?.excludeFinalClosingEntry) {
query = excludeClosingEntry(query)
}
return query
},
filterLines: dimensionFilter
? // jsonb containment (@>): served by idx_jel_dimensions_gin.
(q: EntryLinesQuery) => q.contains('dimensions', dimensionFilter)
: undefined,
})
if (lines.length === 0 && openingBalances.size === 0) {
return { rows: [], totalDebit: 0, totalCredit: 0, isBalanced: true }
}
// Get account names
const accounts = await fetchAllRows<{
account_number: string
account_name: string
account_class: number
}>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name, account_class')
.eq('company_id', companyId)
.order('account_number', { ascending: true })
.range(from, to)
)
const accountMap = new Map<string, { name: string; class: number }>()
for (const acc of accounts) {
accountMap.set(acc.account_number, {
+7 -4
View File
@@ -3,7 +3,6 @@ import type {
VatDeclaration,
VatDeclarationRutor,
VatPeriodType,
AccountingMethod,
} from '@/types'
/**
@@ -410,8 +409,13 @@ export function rutorFromTotals(
*
* - ruta 49 = (10 + 11 + 12 + 30 + 31 + 32 + 60 + 61 + 62) - 48
*
* The accounting method parameter is accepted for backward compatibility
* but not used: the method is already baked into journal entry timing.
* INVARIANT: the company's accounting method (faktureringsmetoden vs
* kontantmetoden) needs no parameter here and must not become one. The method
* is already baked into journal entry TIMING: kontantmetod companies post
* VAT-bearing entries at payment date, faktureringsmetod companies at invoice
* date, so summing posted lines per period is correct for both. A method
* parameter existed until 2026-07-23 and was silently ignored; it was removed
* so no future code path can branch on a value that callers hard-code.
*/
export async function calculateVatDeclaration(
supabase: SupabaseClient,
@@ -419,7 +423,6 @@ export async function calculateVatDeclaration(
periodType: VatPeriodType,
year: number,
period: number,
_accountingMethod: AccountingMethod = 'accrual',
options: { fiscalPeriodId?: string } = {}
): Promise<VatDeclaration> {
// For yearly VAT this resolves to the räkenskapsår bounds (when a fiscal
+64
View File
@@ -307,12 +307,76 @@ export async function updateSession(request: NextRequest) {
* we also upsert user_preferences so subsequent RLS lookups agree with
* us without needing the fallback scan.
*
* RPC-first: `resolve_active_company()` collapses the whole resolution into
* one round trip and is semantically identical to both the query path below
* and `current_active_company_id()` (what RLS reads). `used_fallback` is
* true exactly when the preference was missing, null, or stale, which is
* exactly the condition under which the query path writes the resolved
* company back to user_preferences: the write-back behavior is preserved.
* Falls back to the query path on PGRST202 (self-hosted instance not
* migrated yet, or a deploy racing the branch merge).
*
* Cannot use lib/company/context.ts because middleware runs on Edge.
*/
async function resolveCompanyForMiddleware(
supabase: ReturnType<typeof createServerClient>,
userId: string,
_request: NextRequest
): Promise<{ companyId: string | null; locale: string | null; degraded: boolean }> {
const { data, error } = await supabase.rpc('resolve_active_company')
if (error) {
if (error.code === 'PGRST202') {
// Function not deployed here: use the query path.
return resolveCompanyForMiddlewareViaQueries(supabase, userId, _request)
}
// Issue #1053: a FAILED call degrades (fail open), never reads as "no
// companies". locale null is fine because the degraded flag already
// suppresses the locale-cookie sync at the call site.
console.error('[middleware] resolve_active_company rpc failed', error)
return { companyId: null, locale: null, degraded: true }
}
const row = Array.isArray(data) ? data[0] : data
if (!row) {
// Zero rows = NULL auth.uid(); impossible for the cookie-auth middleware
// client, so treat as degraded rather than redirecting to onboarding.
console.error('[middleware] resolve_active_company returned no row for authenticated user')
return { companyId: null, locale: null, degraded: true }
}
if (row.company_id && row.used_fallback) {
// Write the fallback back to user_preferences so future RLS lookups see
// the same active company without needing the fallback scan. Non-fatal
// on failure: resolution already succeeded, but log it so silent
// persistence failures (#701) are observable.
const { error: writeBackError } = await supabase
.from('user_preferences')
.upsert(
{ user_id: userId, active_company_id: row.company_id },
{ onConflict: 'user_id' }
)
if (writeBackError) {
console.error('[middleware] active company write-back failed', writeBackError)
}
}
return {
companyId: row.company_id ?? null,
locale: row.locale ?? null,
degraded: false,
}
}
/**
* Query-path resolution: the pre-RPC implementation, kept verbatim as the
* fallback for resolveCompanyForMiddleware (see the fallback conditions
* there).
*/
async function resolveCompanyForMiddlewareViaQueries(
supabase: ReturnType<typeof createServerClient>,
userId: string,
_request: NextRequest
): Promise<{ companyId: string | null; locale: string | null; degraded: boolean }> {
// 1. user_preferences (authoritative) + first membership, fetched in
// parallel: the fallback query result doubles as validation when the
+2 -10
View File
@@ -1,12 +1,4 @@
{
"totalErrors": 53,
"perRule": {
"@next/next/no-assign-module-variable": 1,
"@typescript-eslint/no-explicit-any": 14,
"prefer-const": 1,
"react-hooks/preserve-manual-memoization": 5,
"react-hooks/purity": 1,
"react-hooks/set-state-in-effect": 25,
"react-hooks/static-components": 6
}
"totalErrors": 0,
"perRule": {}
}
+3 -3
View File
@@ -14,9 +14,9 @@ const errors = await import('@/lib/docs/content/errors')
const reference = await import('@/lib/docs/content/reference')
const connectClaude = await import('@/lib/docs/content/connect-claude')
const buildErrorReferenceMd = errors.buildErrorReferenceMd ?? (errors as any).default?.buildErrorReferenceMd
const buildResourcePages = reference.buildResourcePages ?? (reference as any).default?.buildResourcePages
const buildReferenceOverviewMd = reference.buildReferenceOverviewMd ?? (reference as any).default?.buildReferenceOverviewMd
const buildErrorReferenceMd = errors.buildErrorReferenceMd ?? (errors as { default?: typeof errors }).default?.buildErrorReferenceMd
const buildResourcePages = reference.buildResourcePages ?? (reference as { default?: typeof reference }).default?.buildResourcePages
const buildReferenceOverviewMd = reference.buildReferenceOverviewMd ?? (reference as { default?: typeof reference }).default?.buildReferenceOverviewMd
if (!buildErrorReferenceMd || !buildResourcePages || !buildReferenceOverviewMd) {
console.error('Missing builder exports. Inspect:', {
+3 -3
View File
@@ -172,9 +172,9 @@ function main() {
const totalRows = tables.reduce((sum, t) => sum + t.rows.length, 0)
console.log(`Parsed ${tables.length} tables (${tables.map(t => t.tableNumber).join(', ')}), ${totalRows} B-rows total`)
const module = emitModule(year, tables)
writeFileSync(outputPath, module, 'utf-8')
console.log(`Wrote ${outputPath} (${module.length.toLocaleString()} bytes)`)
const moduleSource = emitModule(year, tables)
writeFileSync(outputPath, moduleSource, 'utf-8')
console.log(`Wrote ${outputPath} (${moduleSource.length.toLocaleString()} bytes)`)
}
main()
+1 -1
View File
@@ -1397,7 +1397,7 @@ async function seedFY2026Konsult(
// 18 weekly Klient AB Jan-Apr 2026 (16 weeks * but 18 invoices means biweekly-ish)
// Distribute 18 weekly across 16 weeks Jan 6 to Apr 27
const klientDates: { date: string; week: number }[] = []
let kd = new Date('2026-01-06')
const kd = new Date('2026-01-06')
for (let i = 0; i < 18; i++) {
klientDates.push({ date: kd.toISOString().slice(0, 10), week: i + 2 })
kd.setDate(kd.getDate() + 7)
@@ -0,0 +1,91 @@
-- The delivery summary hid attachment_filename, so the UI could only show a
-- generic "faktura.pdf" label for the archived send snapshot. The filename is
-- derived from company name, customer name, invoice number, and date: the same
-- information the invoice itself already exposes to every company member, so
-- returning it does not widen the minimization boundary set in 20260723003000.
-- Addresses stay masked; message content, BCC, and checksums stay server-side.
DROP FUNCTION IF EXISTS public.list_invoice_delivery_summaries(uuid, uuid);
CREATE FUNCTION public.list_invoice_delivery_summaries(
p_company_id uuid,
p_invoice_id uuid
)
RETURNS TABLE (
id uuid,
channel text,
status text,
to_addresses text[],
cc_addresses text[],
provider text,
error_code text,
document_attachment_id uuid,
attachment_filename text,
sent_at timestamptz,
failed_at timestamptz,
created_at timestamptz
)
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
BEGIN
IF auth.uid() IS NULL
OR p_company_id IS DISTINCT FROM public.current_active_company_id()
OR NOT EXISTS (
SELECT 1
FROM public.company_members cm
WHERE cm.company_id = p_company_id
AND cm.user_id = auth.uid()
)
THEN
RAISE EXCEPTION 'not authorized to list invoice delivery summaries'
USING ERRCODE = '42501';
END IF;
RETURN QUERY
SELECT
d.id,
d.channel,
d.status,
ARRAY(
SELECT CASE
WHEN recipient.address ~ '^[^@]+@[^@]+$'
THEN '***@' || split_part(recipient.address, '@', 2)
ELSE '***'
END
FROM unnest(d.to_addresses) WITH ORDINALITY AS recipient(address, position)
ORDER BY recipient.position
),
ARRAY(
SELECT CASE
WHEN recipient.address ~ '^[^@]+@[^@]+$'
THEN '***@' || split_part(recipient.address, '@', 2)
ELSE '***'
END
FROM unnest(d.cc_addresses) WITH ORDINALITY AS recipient(address, position)
ORDER BY recipient.position
),
d.provider,
d.error_code,
d.document_attachment_id,
d.attachment_filename,
d.sent_at,
d.failed_at,
d.created_at
FROM public.invoice_deliveries d
WHERE d.company_id = p_company_id
AND d.invoice_id = p_invoice_id
AND d.status <> 'preparing'
ORDER BY d.created_at DESC;
END;
$$;
REVOKE ALL ON FUNCTION public.list_invoice_delivery_summaries(uuid, uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.list_invoice_delivery_summaries(uuid, uuid) TO authenticated;
COMMENT ON FUNCTION public.list_invoice_delivery_summaries(uuid, uuid) IS
'Returns active-company invoice delivery status with masked To and CC addresses plus the attachment filename. Exact payload and BCC remain server-side.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,266 @@
-- Migration: make get_account_gl_lines_for_matching count links per settlement
-- account instead of per voucher (issue #1026).
--
-- An own-account transfer books ONE verifikat touching two cash accounts
-- (e.g. credit 1940, debit 1930) while the bank feed delivers TWO transactions,
-- one per account. Once the outgoing leg is matched, the voucher counted as
-- "already matched" for every account, so the incoming leg's match dialog hid
-- it behind the "Visa även matchade verifikationer" opt-in. From the incoming
-- account's point of view the voucher is genuinely unmatched: its 1930 line has
-- no transaction settling it. Users read the empty default list as "the app
-- won't let me link this" (support case: two transfer legs stuck for a week).
--
-- Fix: linked_transaction_count now counts only transactions that are
-- settle-relevant for p_account_number:
-- * transactions whose cash account resolves to p_account_number, and
-- * transactions with no resolvable cash account (legacy rows, NULL
-- cash_account_id): they could belong to any account, so they keep
-- counting everywhere. This preserves today's behavior for
-- single-account companies where cash_account_id is often NULL.
-- The p_include_matched=false default filter uses the same account-scoped
-- check, so a transfer voucher's unsettled leg surfaces by default while a
-- voucher already settled on THIS account (genuine N:1, e.g. a salary run paid
-- in several transfers from one account) stays behind the opt-in toggle.
--
-- get_unlinked_gl_lines is deliberately untouched: it feeds auto-reconcile
-- flows, where surfacing the transfer's second leg could auto-link ambiguous
-- amounts. Manual matching keeps the human in the loop.
--
-- Signature, return shape, tenant guard (20260611140000) and grants
-- (20260611130000) are unchanged.
CREATE OR REPLACE FUNCTION public.get_account_gl_lines_for_matching(
p_company_id UUID,
p_account_number TEXT DEFAULT '1930',
p_date_from DATE DEFAULT NULL,
p_date_to DATE DEFAULT NULL,
p_include_matched BOOLEAN DEFAULT false
)
RETURNS TABLE (
line_id UUID,
journal_entry_id UUID,
debit_amount NUMERIC,
credit_amount NUMERIC,
line_description TEXT,
entry_date DATE,
voucher_number INT,
voucher_series TEXT,
entry_description TEXT,
source_type TEXT,
linked_transaction_count INT
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT
jel.id AS line_id,
je.id AS journal_entry_id,
jel.debit_amount,
jel.credit_amount,
jel.line_description,
je.entry_date,
je.voucher_number,
je.voucher_series,
je.description AS entry_description,
je.source_type,
-- Account-scoped: a transaction provably on ANOTHER cash account (its
-- cash_accounts row resolves to a different ledger_account) does not make
-- this voucher "matched" for p_account_number. A NULL / unresolvable cash
-- account keeps counting for every account (conservative legacy behavior).
(
SELECT count(*)
FROM public.transactions t
LEFT JOIN public.cash_accounts ca ON ca.id = t.cash_account_id
WHERE t.journal_entry_id = je.id
AND t.company_id = p_company_id
AND (ca.ledger_account IS NULL OR ca.ledger_account = p_account_number)
)::int AS linked_transaction_count
FROM public.journal_entry_lines jel
JOIN public.journal_entries je ON je.id = jel.journal_entry_id
WHERE jel.account_number = p_account_number
AND je.company_id = p_company_id
AND je.status = 'posted'
AND je.source_type IS DISTINCT FROM 'opening_balance'
AND je.source_type IS DISTINCT FROM 'storno'
AND je.source_type IS DISTINCT FROM 'correction'
AND (p_date_from IS NULL OR je.entry_date >= p_date_from)
AND (p_date_to IS NULL OR je.entry_date <= p_date_to)
AND (
p_include_matched
OR NOT EXISTS (
SELECT 1
FROM public.transactions t
LEFT JOIN public.cash_accounts ca ON ca.id = t.cash_account_id
WHERE t.journal_entry_id = je.id
AND t.company_id = p_company_id
AND (ca.ledger_account IS NULL OR ca.ledger_account = p_account_number)
)
)
-- Tenant guard: anon/authenticated may only read their own companies;
-- service_role and direct/superuser access (no JWT role) bypass.
AND (
coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '')
NOT IN ('anon', 'authenticated')
OR je.company_id IN (SELECT public.user_company_ids())
)
ORDER BY je.entry_date, je.voucher_number;
$$;
-- CREATE OR REPLACE preserves the function's ACL, but re-assert least privilege
-- (20260611130000) so this migration stands alone if replayed on a fresh DB.
REVOKE EXECUTE ON FUNCTION public.get_account_gl_lines_for_matching(uuid, text, date, date, boolean) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.get_account_gl_lines_for_matching(uuid, text, date, date, boolean) TO authenticated, service_role;
-- =============================================================================
-- 2. Guard mark_entry_as_opening_balance against entries with linked
-- transactions.
--
-- Before the account-scoped semantics above, a voucher with ANY linked bank
-- transaction never appeared in the reconciliation view's "Omatchade
-- verifikationer" table, so the "Märk som IB" button was unreachable for it.
-- A half-settled transfer voucher (manual/import source) now surfaces there,
-- making the button clickable on an entry whose other leg is already matched.
-- Re-tagging such an entry to 'opening_balance' would leave a live transaction
-- pointing at an IB entry and drop the voucher out of the period movement while
-- its transaction stays in the bank total: a permanent phantom difference.
-- An entry with a bank-feed counterpart is by definition not an ingående
-- balans, so refuse outright. Body otherwise verbatim from 20260619130100
-- (NOT 20260613120000: the 130100 revision added the claims-based 42501
-- tenant guard, which must survive this replace).
-- =============================================================================
CREATE OR REPLACE FUNCTION public.mark_entry_as_opening_balance(
p_company_id uuid,
p_entry_id uuid
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $function$
DECLARE
v_caller_role text;
v_entry record;
v_is_closed boolean;
v_locked_at timestamptz;
v_has_bank_line boolean;
v_old_source_type text;
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
BEGIN
-- Tenant guard: anon/authenticated may only act on their own companies;
-- service_role / direct access (no JWT role) bypasses BY DESIGN.
-- NULL-safe membership predicate (20260703180000): the raw
-- NOT IN (SELECT user_company_ids()) pattern skips the deny branch on
-- UNKNOWN and is ratchet-blocked by null-safe-tenant-guards.pg.test.
IF v_jwt_role IN ('anon', 'authenticated')
AND NOT public.caller_is_company_member(p_company_id) THEN
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
USING ERRCODE = '42501';
END IF;
-- Owner/admin only (defense in depth alongside RLS; the function is SECURITY
-- DEFINER so it must enforce tenancy + role itself).
SELECT cm.role INTO v_caller_role
FROM company_members cm
WHERE cm.company_id = p_company_id
AND cm.user_id = auth.uid();
IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin') THEN
RAISE EXCEPTION 'Only company owners and admins can re-tag opening balances';
END IF;
SELECT * INTO v_entry
FROM journal_entries
WHERE id = p_entry_id
AND company_id = p_company_id
FOR UPDATE;
IF v_entry IS NULL THEN
RAISE EXCEPTION 'Journal entry not found';
END IF;
IF v_entry.status <> 'posted' THEN
RAISE EXCEPTION 'Only posted entries can be re-tagged as opening balance (current status: %)', v_entry.status;
END IF;
IF v_entry.source_type NOT IN ('manual', 'import') THEN
RAISE EXCEPTION 'Only manual/import entries can be re-tagged as opening balance (current source_type: %)', v_entry.source_type;
END IF;
-- Must touch a bank/cash account. Re-tagging excludes the WHOLE entry from the
-- reconciliation period movement, so it must genuinely be a bank-account IB.
SELECT EXISTS (
SELECT 1 FROM journal_entry_lines l
WHERE l.journal_entry_id = p_entry_id
AND l.account_number IN ('1910','1920','1930','1931','1932','1940','1941','1950')
) INTO v_has_bank_line;
IF NOT v_has_bank_line THEN
RAISE EXCEPTION 'Entry does not touch a bank/cash account (19xx); refusing to tag as opening balance';
END IF;
-- An entry with a linked bank transaction has a bank-feed counterpart and is
-- therefore not an opening balance (a genuine IB predates the feed). It would
-- also strand the linked transaction against an excluded entry, creating a
-- permanent reconciliation difference. Unlink first if the tag is truly right.
IF EXISTS (
SELECT 1 FROM transactions t
WHERE t.journal_entry_id = p_entry_id
AND t.company_id = p_company_id
) THEN
RAISE EXCEPTION 'Entry has linked bank transactions; unlink them before re-tagging as opening balance';
END IF;
-- Respect period lock (mirror delete_last_voucher). enforce_period_lock would
-- block the UPDATE anyway; we refuse first with a clearer message.
SELECT is_closed, locked_at INTO v_is_closed, v_locked_at
FROM fiscal_periods
WHERE id = v_entry.fiscal_period_id;
IF v_is_closed THEN
RAISE EXCEPTION 'Cannot re-tag an entry in a closed fiscal period';
END IF;
IF v_locked_at IS NOT NULL THEN
RAISE EXCEPTION 'Cannot re-tag an entry in a locked fiscal period';
END IF;
v_old_source_type := v_entry.source_type;
-- Transaction-local bypass consumed by the immutability carve-out
-- (20260613120000).
PERFORM set_config('gnubok.allow_source_type_retag', 'true', true);
UPDATE journal_entries
SET source_type = 'opening_balance'
WHERE id = p_entry_id
AND company_id = p_company_id;
-- Provenance row (write_audit_log also logs old/new state via the AFTER trigger;
-- this adds the human-readable reason, matching the delete_last_voucher pattern).
INSERT INTO audit_log (user_id, company_id, action, table_name, record_id, actor_id, description)
VALUES (
v_entry.user_id,
p_company_id,
'UPDATE',
'journal_entries',
p_entry_id,
auth.uid(),
'Re-tagged source_type ' || v_old_source_type || ' -> opening_balance ' ||
'(mark_entry_as_opening_balance RPC, caller: ' || auth.uid() || ')'
);
RETURN jsonb_build_object(
'retagged', true,
'entry_id', p_entry_id,
'previous_source_type', v_old_source_type,
'voucher_series', v_entry.voucher_series,
'voucher_number', v_entry.voucher_number
);
END;
$function$;
REVOKE ALL ON FUNCTION public.mark_entry_as_opening_balance(uuid, uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.mark_entry_as_opening_balance(uuid, uuid) TO authenticated;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,75 @@
-- Single-round-trip active-company resolution for the app layer.
--
-- WHY THIS MIGRATION EXISTS
-- -------------------------
-- lib/company/context.ts (getActiveCompanyId) and lib/supabase/middleware.ts
-- (resolveCompanyForMiddleware) both resolve the active company with one
-- parallel round trip (user_preferences + first membership) plus a SECOND,
-- sequential validation round trip for every multi-company/consultant user.
-- This function collapses the whole resolution into one RPC call.
--
-- INVARIANTS (do not break these when editing):
-- 1. This function must stay SEMANTICALLY IDENTICAL to
-- public.current_active_company_id() (20260702093000). RLS reads that
-- function; the app reads this one. If they diverge, Next.js and RLS
-- disagree about which company is active and tenant isolation desyncs.
-- Resolution order, in both: validated preference (user_preferences.
-- active_company_id backed by a live membership in a non-archived
-- company) wins, else the earliest non-archived membership by
-- company_members.created_at, else NULL.
-- 2. NULL auth.uid() (service-role clients: createServiceClient /
-- createServiceClientNoCookies are cookieless, so auth.uid() is NULL in
-- RPCs) returns ZERO ROWS by design; the WHERE clause below produces
-- that. JS callers treat zero rows as "use the query fallback path".
-- 3. No table writes inside: middleware does its own conditional
-- write-back using the used_fallback flag; read paths must stay reads.
--
-- used_fallback is true exactly when the validated preference is absent
-- (no prefs row, NULL active_company_id, archived company, or membership
-- gone), which is exactly the condition under which middleware writes the
-- resolved company back to user_preferences today.
--
-- locale is NULL only when the user has no user_preferences row (the column
-- itself is NOT NULL DEFAULT 'sv', 20260521120000), matching what the
-- middleware's own query returns today.
CREATE OR REPLACE FUNCTION public.resolve_active_company()
RETURNS TABLE(company_id uuid, locale text, used_fallback boolean)
LANGUAGE sql
STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $function$
WITH pref AS (
SELECT up.active_company_id, up.locale
FROM public.user_preferences up
WHERE up.user_id = auth.uid()
),
validated AS (
SELECT cm.company_id
FROM pref p
JOIN public.company_members cm
ON cm.user_id = auth.uid() AND cm.company_id = p.active_company_id
JOIN public.companies c
ON c.id = cm.company_id AND c.archived_at IS NULL
LIMIT 1
),
fallback AS (
SELECT cm.company_id
FROM public.company_members cm
JOIN public.companies c
ON c.id = cm.company_id AND c.archived_at IS NULL
WHERE cm.user_id = auth.uid()
ORDER BY cm.created_at ASC
LIMIT 1
)
SELECT
COALESCE((SELECT v.company_id FROM validated v), (SELECT f.company_id FROM fallback f)) AS company_id,
(SELECT p.locale FROM pref p) AS locale,
((SELECT v.company_id FROM validated v) IS NULL) AS used_fallback
WHERE auth.uid() IS NOT NULL;
$function$;
REVOKE ALL ON FUNCTION public.resolve_active_company() FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.resolve_active_company() TO authenticated;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,46 @@
-- Single-round-trip chart-of-accounts listing for the app layer.
--
-- WHY THIS MIGRATION EXISTS
-- -------------------------
-- app/api/bookkeeping/accounts (GET) fetches chart_of_accounts through
-- fetchAllRows(): sequential 1000-row PostgREST pages, one HTTP round trip
-- each (Vercel iad1 to Supabase eu-north-1). 95/1250 prod companies have
-- more than 1000 active accounts, so their list requests pay 2-5 sequential
-- cross-region round trips while Postgres itself needs ~5ms per page.
-- Returning the whole list as ONE json scalar bypasses PostgREST's
-- db-max-rows=1000 cap, so every company size costs exactly one round trip.
--
-- INVARIANTS (do not break these when editing):
-- 1. SECURITY INVOKER: the existing chart_of_accounts RLS select policy
-- (company_id IN (SELECT user_company_ids())) must keep applying. The
-- explicit p_company_id filter is defense in depth on top of RLS, same
-- as the route's .eq('company_id', ...) today.
-- 2. to_json(c) emits every column with its column name: the exact field
-- set select('*') returns today. Do not switch to an explicit column
-- list; the pg-real parity test locks this.
-- 3. Ordering is (sort_order, id): sort_order is today's route order; id
-- is only a deterministic tiebreaker (tie order was previously
-- unspecified, so this is not a behavior change).
-- 4. Filters mirror the route exactly: p_active_only true keeps
-- is_active rows only; p_account_class NULL means no class filter.
create or replace function public.list_company_accounts(
p_company_id uuid,
p_active_only boolean default true,
p_account_class integer default null
) returns json
language sql stable security invoker
set search_path = public
as $$
select coalesce(json_agg(to_json(c) order by c.sort_order, c.id), '[]'::json)
from public.chart_of_accounts c
where c.company_id = p_company_id
and (not p_active_only or c.is_active)
and (p_account_class is null or c.account_class = p_account_class)
$$;
revoke all on function public.list_company_accounts(uuid, boolean, integer) from public, anon;
grant execute on function public.list_company_accounts(uuid, boolean, integer) to authenticated;
grant execute on function public.list_company_accounts(uuid, boolean, integer) to service_role;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,190 @@
-- RPC: get_kpi_report_aggregates: one-round-trip aggregation for the KPI
-- report (app/api/reports/kpi).
--
-- WHY THIS MIGRATION EXISTS
-- -------------------------
-- The report.kpi handler used to scan every journal line of the fiscal
-- period THREE times through PostgREST and aggregate in JS:
-- 1. generateTrialBalance (unfiltered) for balance-side KPIs,
-- 2. generateIncomeStatement, which re-runs generateTrialBalance with
-- excludeYearEndClosing,
-- 3. generateMonthlyBreakdown with its own paginated line fetch.
-- Each scan pays sequential cross-region round trips (Vercel iad1 to
-- Supabase eu-north-1) per 1000-row page. One SQL pass now returns all
-- three aggregate shapes; the JS side only merges opening balances and
-- formats rows, so the payload is O(accounts + months), not O(lines).
--
-- SECTION SEMANTICS (each mirrors an existing TypeScript fetch; the
-- pg-real test pins them):
-- tb per-account debit/credit sums over the period's posted
-- and reversed entries, excluding the opening-balance
-- entry (p_ob_entry_id) so its values are not double
-- counted against the IB. Mirrors the period-lines fetch
-- in lib/reports/trial-balance.ts (no entry_date filter:
-- fiscal_period_id already bounds activity).
-- tb_ex_year_end same, additionally excluding every source_type
-- 'year_end' entry plus stornos/corrections of REVERSED
-- year-end entries. ye_reversed is COMPANY-WIDE (no
-- period filter), mirroring trial-balance.ts wave-1.
-- source_type is NOT NULL, so IS DISTINCT FROM matches
-- the PostgREST .neq() exactly.
-- ob per-account sums of the opening-balance entry's lines.
-- NO status filter, mirroring getOpeningBalances
-- (lib/reports/opening-balances.ts) which only checks
-- id + company_id. Empty when p_ob_entry_id IS NULL.
-- monthly income/expenses per calendar month over POSTED entries
-- only (lib/reports/monthly-breakdown.ts): class 3 =
-- income (credit-debit), classes 4-7 = expenses
-- (debit-credit), class 8 split PER LINE by sign of
-- credit-debit, and 8999 excluded entirely (the closing
-- account would cancel the income-vs-expense signal).
-- The OB entry and year_end entries are NOT excluded
-- here: the JS scan never excluded them either.
--
-- SECURITY INVOKER: journal_entries/journal_entry_lines RLS is
-- company-scoped via user_company_ids(), so the caller's own membership
-- bounds what is aggregated; a non-member calling with a foreign company
-- id gets empty sections, not an error. Service-role callers rely on the
-- explicit p_company_id filter.
--
-- pg-test: tests/pg/kpi-report-aggregates-rpc.pg.test.ts
CREATE OR REPLACE FUNCTION public.get_kpi_report_aggregates(
p_company_id uuid,
p_fiscal_period_id uuid,
p_ob_entry_id uuid DEFAULT NULL
)
RETURNS jsonb
LANGUAGE sql
STABLE
SECURITY INVOKER
SET search_path TO 'public'
AS $$
WITH period_entries AS (
SELECT id, entry_date, status, source_type, reverses_id, correction_of_id
FROM public.journal_entries
WHERE company_id = p_company_id
AND fiscal_period_id = p_fiscal_period_id
AND status IN ('posted', 'reversed')
),
tb_entries AS (
SELECT * FROM period_entries
WHERE p_ob_entry_id IS NULL OR id <> p_ob_entry_id
),
ye_reversed AS (
-- Company-wide (no period filter), mirroring the wave-1 fetch in
-- lib/reports/trial-balance.ts: a storno in this period can reverse a
-- year-end entry from another period.
SELECT id
FROM public.journal_entries
WHERE company_id = p_company_id
AND source_type = 'year_end'
AND status = 'reversed'
),
tb_ex_ye_entries AS (
SELECT * FROM tb_entries
WHERE source_type IS DISTINCT FROM 'year_end'
AND (reverses_id IS NULL
OR reverses_id NOT IN (SELECT id FROM ye_reversed))
AND (correction_of_id IS NULL
OR correction_of_id NOT IN (SELECT id FROM ye_reversed))
)
SELECT jsonb_build_object(
'tb', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'account_number', t.account_number,
'debit', t.debit,
'credit', t.credit
) ORDER BY t.account_number)
FROM (
SELECT l.account_number,
sum(l.debit_amount)::float8 AS debit,
sum(l.credit_amount)::float8 AS credit
FROM public.journal_entry_lines l
JOIN tb_entries e ON e.id = l.journal_entry_id
GROUP BY l.account_number
) t
), '[]'::jsonb),
'tb_ex_year_end', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'account_number', t.account_number,
'debit', t.debit,
'credit', t.credit
) ORDER BY t.account_number)
FROM (
SELECT l.account_number,
sum(l.debit_amount)::float8 AS debit,
sum(l.credit_amount)::float8 AS credit
FROM public.journal_entry_lines l
JOIN tb_ex_ye_entries e ON e.id = l.journal_entry_id
GROUP BY l.account_number
) t
), '[]'::jsonb),
'ob', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'account_number', t.account_number,
'debit', t.debit,
'credit', t.credit
) ORDER BY t.account_number)
FROM (
-- No status filter: getOpeningBalances only checks id + company_id.
SELECT l.account_number,
sum(l.debit_amount)::float8 AS debit,
sum(l.credit_amount)::float8 AS credit
FROM public.journal_entry_lines l
JOIN public.journal_entries e
ON e.id = l.journal_entry_id
AND e.id = p_ob_entry_id
AND e.company_id = p_company_id
GROUP BY l.account_number
) t
), '[]'::jsonb),
'monthly', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'year', m.year,
'month', m.month,
'income', m.income,
'expenses', m.expenses
) ORDER BY m.year, m.month)
FROM (
SELECT EXTRACT(YEAR FROM e.entry_date)::int AS year,
EXTRACT(MONTH FROM e.entry_date)::int AS month,
(
COALESCE(sum(CASE
WHEN l.account_number ~ '^3'
THEN l.credit_amount - l.debit_amount
END), 0)
+ COALESCE(sum(CASE
WHEN l.account_number ~ '^8'
AND l.account_number <> '8999'
AND (l.credit_amount - l.debit_amount) >= 0
THEN l.credit_amount - l.debit_amount
END), 0)
)::float8 AS income,
(
COALESCE(sum(CASE
WHEN l.account_number ~ '^[4-7]'
THEN l.debit_amount - l.credit_amount
END), 0)
+ COALESCE(sum(CASE
WHEN l.account_number ~ '^8'
AND l.account_number <> '8999'
AND (l.credit_amount - l.debit_amount) < 0
THEN l.debit_amount - l.credit_amount
END), 0)
)::float8 AS expenses
FROM public.journal_entry_lines l
JOIN period_entries e
ON e.id = l.journal_entry_id
AND e.status = 'posted'
WHERE l.account_number ~ '^[3-8]'
GROUP BY 1, 2
) m
), '[]'::jsonb)
)
$$;
REVOKE ALL ON FUNCTION public.get_kpi_report_aggregates(uuid, uuid, uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.get_kpi_report_aggregates(uuid, uuid, uuid) TO authenticated, service_role;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,66 @@
-- Enforce "no bank transaction may point at an opening balance entry" from the
-- transactions side as well.
--
-- 20260723160000 made mark_entry_as_opening_balance refuse entries with linked
-- transactions, but that guard alone has a TOCTOU race: the RPC locks the
-- journal_entries row FOR UPDATE, while linking paths (e.g. manualLink in
-- lib/reconciliation/bank-reconciliation.ts) write transactions.journal_entry_id
-- without touching journal_entries. Under READ COMMITTED a link that commits
-- between the RPC's EXISTS check and its commit is invisible to the check, so an
-- entry could still end up retagged to opening_balance with a live transaction
-- pointing at it: the exact "permanent phantom difference" the guard exists to
-- prevent.
--
-- This trigger closes the race without touching the linking code paths: its
-- FOR KEY SHARE locking read on journal_entries conflicts with the RPC's
-- FOR UPDATE (and with nothing weaker, so ordinary entry updates are not
-- serialized against links).
-- * Link starts first: its FOR KEY SHARE blocks the RPC's FOR UPDATE until
-- the link commits; the RPC's EXISTS check then runs on a fresh snapshot
-- and refuses.
-- * Retag starts first: the trigger's FOR KEY SHARE blocks until the RPC
-- commits; the locking read then sees source_type = 'opening_balance' and
-- raises.
--
-- Fires only on INSERT with a non-NULL journal_entry_id and on UPDATEs that
-- actually change journal_entry_id to a non-NULL value: existing rows,
-- unlinks (SET NULL), and unrelated column updates are untouched.
CREATE OR REPLACE FUNCTION public.block_transaction_link_to_opening_balance()
RETURNS trigger
LANGUAGE plpgsql
-- SECURITY DEFINER so RLS can never blind the invariant check (the linking
-- paths already enforce tenancy; this only READS source_type).
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_source_type text;
BEGIN
IF NEW.journal_entry_id IS NULL THEN
RETURN NEW;
END IF;
IF TG_OP = 'UPDATE' AND NEW.journal_entry_id IS NOT DISTINCT FROM OLD.journal_entry_id THEN
RETURN NEW;
END IF;
SELECT je.source_type INTO v_source_type
FROM public.journal_entries je
WHERE je.id = NEW.journal_entry_id
FOR KEY SHARE;
IF v_source_type = 'opening_balance' THEN
RAISE EXCEPTION 'Cannot link a bank transaction to an opening balance entry (%): an ingaende balans predates the bank feed', NEW.journal_entry_id;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS check_transaction_link_not_opening_balance ON public.transactions;
CREATE TRIGGER check_transaction_link_not_opening_balance
BEFORE INSERT OR UPDATE OF journal_entry_id ON public.transactions
FOR EACH ROW
EXECUTE FUNCTION public.block_transaction_link_to_opening_balance();
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,228 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { insertAuthUser, seedCompany } from '@/tests/pg/fixtures'
import { getPool, withUserContext } from '@/tests/pg/setup'
/**
* Locks the behavior of public.list_company_accounts (20260723170000): the
* single-round-trip replacement for the paged fetchAllRows chart-of-accounts
* fetch in app/api/bookkeeping/accounts.
*
* The route treats the RPC result as a drop-in for select('*') ordered by
* sort_order, so the critical properties are:
* - filter parity: p_active_only / p_account_class mirror the route's
* .eq('is_active', true) / .eq('account_class', n) filters
* - ordering: (sort_order, id); id only breaks ties deterministically
* - field-set parity: every element carries the exact column set of
* chart_of_accounts (to_json of the whole row), so response shapes
* do not change when the route switches paths
* - SECURITY INVOKER: RLS still gates rows for non-members
*/
const MIGRATION_SQL = readFileSync(
join(process.cwd(), 'supabase/migrations/20260723170000_list_company_accounts_rpc.sql'),
'utf8',
)
interface AccountJson {
id: string
account_number: string
account_class: number
sort_order: number
is_active: boolean
[key: string]: unknown
}
async function callRpc(
companyId: string,
activeOnly: boolean = true,
accountClass: number | null = null,
): Promise<AccountJson[]> {
const res = await getPool().query<{ result: AccountJson[] }>(
`SELECT public.list_company_accounts($1::uuid, $2::boolean, $3::integer) AS result`,
[companyId, activeOnly, accountClass],
)
return res.rows[0]!.result
}
async function seedChart(companyId: string): Promise<void> {
await getPool().query(`SELECT public.seed_chart_of_accounts($1::uuid, 'aktiebolag')`, [
companyId,
])
}
async function insertAccount(params: {
userId: string
companyId: string
accountNumber: string
accountClass: number
isActive?: boolean
sortOrder?: number
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.chart_of_accounts
(id, user_id, company_id, account_number, account_name, account_class,
account_group, account_type, normal_balance, is_active, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'expense', 'debit', $8, $9)`,
[
id,
params.userId,
params.companyId,
params.accountNumber,
`Testkonto ${params.accountNumber}`,
params.accountClass,
params.accountNumber.substring(0, 2),
params.isActive ?? true,
params.sortOrder ?? 0,
],
)
return id
}
describe('list_company_accounts: filtering', () => {
it('returns only active rows by default and includes inactive with p_active_only=false', async () => {
const { userId, companyId } = await seedCompany()
await seedChart(companyId)
const inactiveId = await insertAccount({
userId,
companyId,
accountNumber: '9998',
accountClass: 8,
isActive: false,
})
const activeRows = await callRpc(companyId)
expect(activeRows.length).toBeGreaterThan(0)
expect(activeRows.every((r) => r.is_active)).toBe(true)
expect(activeRows.some((r) => r.id === inactiveId)).toBe(false)
const allRows = await callRpc(companyId, false)
expect(allRows.some((r) => r.id === inactiveId)).toBe(true)
expect(allRows.length).toBe(activeRows.length + 1)
})
it('filters by p_account_class', async () => {
const { companyId } = await seedCompany()
await seedChart(companyId)
const class3 = await callRpc(companyId, true, 3)
expect(class3.length).toBeGreaterThan(0)
expect(class3.every((r) => r.account_class === 3)).toBe(true)
const expected = await getPool().query<{ n: string }>(
`SELECT count(*)::text AS n FROM public.chart_of_accounts
WHERE company_id = $1 AND is_active AND account_class = 3`,
[companyId],
)
expect(class3.length).toBe(Number(expected.rows[0]!.n))
})
it('returns [] (not NULL) for an unknown company id', async () => {
const rows = await callRpc(randomUUID())
expect(rows).toEqual([])
})
})
describe('list_company_accounts: ordering', () => {
it('orders by (sort_order, id) with id as the deterministic tiebreaker', async () => {
const { userId, companyId } = await seedCompany()
await seedChart(companyId)
// Two accounts sharing a sort_order: the tie must resolve by id.
const tieA = await insertAccount({
userId,
companyId,
accountNumber: '9901',
accountClass: 8,
sortOrder: 5000,
})
const tieB = await insertAccount({
userId,
companyId,
accountNumber: '9902',
accountClass: 8,
sortOrder: 5000,
})
const rows = await callRpc(companyId)
const expected = await getPool().query<{ id: string }>(
`SELECT id FROM public.chart_of_accounts
WHERE company_id = $1 AND is_active
ORDER BY sort_order, id`,
[companyId],
)
expect(rows.map((r) => r.id)).toEqual(expected.rows.map((r) => r.id))
// Explicit tie assertion: uuid ordering in Postgres is bytewise, which
// matches lexicographic order of the canonical lowercase hex form.
const [first, second] = [tieA, tieB].sort()
expect(rows.findIndex((r) => r.id === first)).toBeLessThan(
rows.findIndex((r) => r.id === second),
)
// And the tied pair sits adjacent at the end (highest sort_order).
expect(rows.slice(-2).map((r) => r.id)).toEqual([first, second])
})
})
describe('list_company_accounts: select(*) parity', () => {
it('each element carries the exact column set of chart_of_accounts', async () => {
const { companyId } = await seedCompany()
await seedChart(companyId)
const rows = await callRpc(companyId)
expect(rows.length).toBeGreaterThan(0)
const cols = await getPool().query<{ column_name: string }>(
`SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'chart_of_accounts'`,
)
const expectedKeys = cols.rows.map((r) => r.column_name).sort()
for (const row of rows) {
expect(Object.keys(row).sort()).toEqual(expectedKeys)
}
})
})
describe('list_company_accounts: security', () => {
it('is SECURITY INVOKER: a non-member sees zero rows through RLS', async () => {
const { companyId } = await seedCompany()
await seedChart(companyId)
const outsiderId = await insertAuthUser()
const rows = await withUserContext(outsiderId, async (client) => {
const res = await client.query<{ result: AccountJson[] }>(
`SELECT public.list_company_accounts($1::uuid) AS result`,
[companyId],
)
return res.rows[0]!.result
})
expect(rows).toEqual([])
})
it('a member sees the company chart through the same RLS policy', async () => {
const { userId, companyId } = await seedCompany()
await seedChart(companyId)
const rows = await withUserContext(userId, async (client) => {
const res = await client.query<{ result: AccountJson[] }>(
`SELECT public.list_company_accounts($1::uuid) AS result`,
[companyId],
)
return res.rows[0]!.result
})
expect(rows.length).toBeGreaterThan(0)
})
})
describe('list_company_accounts: migration idempotency', () => {
it('re-executing the migration SQL succeeds and the function still works', async () => {
await getPool().query(MIGRATION_SQL)
const { companyId } = await seedCompany()
await seedChart(companyId)
const rows = await callRpc(companyId)
expect(rows.length).toBeGreaterThan(0)
})
})
+8
View File
@@ -79,6 +79,10 @@ export function createMockSupabase() {
error: null,
}),
remove: vi.fn().mockResolvedValue({ data: [], error: null }),
createSignedUrl: vi.fn().mockResolvedValue({
data: { signedUrl: 'https://example.com/signed' },
error: null,
}),
getPublicUrl: vi.fn().mockReturnValue({
data: { publicUrl: 'https://example.com/file.jpg' },
}),
@@ -772,6 +776,10 @@ export function createQueuedMockSupabase() {
error: null,
}),
remove: vi.fn().mockResolvedValue({ data: [], error: null }),
createSignedUrl: vi.fn().mockResolvedValue({
data: { signedUrl: 'https://example.com/signed' },
error: null,
}),
getPublicUrl: vi.fn().mockReturnValue({
data: { publicUrl: 'https://example.com/file.jpg' },
}),
@@ -1,16 +1,31 @@
/**
* pg-real test for get_account_gl_lines_for_matching
* (20260610120000_gl_lines_for_matching.sql).
* (20260610120000_gl_lines_for_matching.sql, link-count semantics reworked in
* 20260723160000_gl_lines_matching_account_scoped_count.sql).
*
* This RPC backs the N:1 "lägga på flera" feature: it mirrors get_unlinked_gl_lines
* but can ALSO surface already-matched vouchers (so a second/third bank
* transaction can be attached to one verifikat), each carrying how many
* transactions already point at it.
*
* Since 20260723090000 the link count is scoped to the requested settlement
* account: a transaction provably on ANOTHER cash account does not mark the
* voucher as matched for p_account_number. This surfaces the unsettled second
* leg of an own-account transfer by default (issue #1026) while transactions
* with no resolvable cash account keep counting for every account.
* (The companion mark_entry_as_opening_balance guard from the same migration
* is covered in mark-entry-as-opening-balance.pg.test.ts.)
*/
import { describe, it, expect } from 'vitest'
import { randomUUID } from 'node:crypto'
import { getPool } from './setup'
import { insertAuthUser, insertCompany, insertFiscalPeriod, insertTransaction } from './fixtures'
import {
insertAuthUser,
insertCashAccount,
insertCompany,
insertFiscalPeriod,
insertTransaction,
} from './fixtures'
async function insertPostedJournalEntry(params: {
userId: string
@@ -20,6 +35,8 @@ async function insertPostedJournalEntry(params: {
sourceType: 'opening_balance' | 'manual' | 'bank_transaction' | 'import' | 'storno' | 'correction'
voucherNumber: number
amount?: number
/** Line rows to book; defaults to the classic 1930 debit / 2091 credit pair. */
lines?: Array<{ account: string; debit: number; credit: number }>
}): Promise<string> {
const id = randomUUID()
const amount = params.amount ?? 1000
@@ -39,13 +56,18 @@ async function insertPostedJournalEntry(params: {
params.sourceType,
],
)
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '1930', $2, 0),
($1, '2091', 0, $2)`,
[id, amount],
)
const lines = params.lines ?? [
{ account: '1930', debit: amount, credit: 0 },
{ account: '2091', debit: 0, credit: amount },
]
for (const line of lines) {
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, $2, $3, $4)`,
[id, line.account, line.debit, line.credit],
)
}
return id
}
@@ -132,3 +154,130 @@ describe('get_account_gl_lines_for_matching RPC: N:1 candidates', () => {
expect(rows.find((r) => r.source_type === 'correction')).toBeUndefined()
})
})
describe('get_account_gl_lines_for_matching RPC: account-scoped link count (#1026)', () => {
it('surfaces the unsettled leg of an own-account transfer by default', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
const fiscalPeriodId = await insertFiscalPeriod({
userId, companyId, periodStart: '2026-01-01', periodEnd: '2026-12-31',
})
await insertCashAccount({ companyId, ledgerAccount: '1930' })
const acc1940 = await insertCashAccount({ companyId, ledgerAccount: '1940' })
// Own-account transfer: one voucher, debit 1930 / credit 1940. The outgoing
// leg (a transaction on the 1940 account) is already matched to it.
const transferEntry = await insertPostedJournalEntry({
userId, companyId, fiscalPeriodId,
entryDate: '2026-06-26', sourceType: 'manual', voucherNumber: 1,
lines: [
{ account: '1930', debit: 2344.16, credit: 0 },
{ account: '1940', debit: 0, credit: 2344.16 },
],
})
await insertTransaction({
companyId, userId, amount: -2344.16, date: '2026-06-26',
journalEntryId: transferEntry, cashAccountId: acc1940,
})
// From 1930's perspective the voucher is unmatched: it must appear in the
// DEFAULT list (no toggle) with a zero link count, so ranking/auto-select
// treat it as a normal candidate.
const { rows: on1930 } = await getPool().query(
`SELECT journal_entry_id, linked_transaction_count
FROM public.get_account_gl_lines_for_matching(p_company_id => $1, p_account_number => '1930')`,
[companyId],
)
const row1930 = on1930.find((r) => r.journal_entry_id === transferEntry)
expect(row1930).toBeDefined()
expect(row1930.linked_transaction_count).toBe(0)
// From 1940's perspective it IS settled: hidden by default, visible with
// the opt-in and carrying the link.
const { rows: on1940Default } = await getPool().query(
`SELECT journal_entry_id
FROM public.get_account_gl_lines_for_matching(p_company_id => $1, p_account_number => '1940')`,
[companyId],
)
expect(on1940Default.find((r) => r.journal_entry_id === transferEntry)).toBeUndefined()
const { rows: on1940Matched } = await getPool().query(
`SELECT journal_entry_id, linked_transaction_count
FROM public.get_account_gl_lines_for_matching(
p_company_id => $1, p_account_number => '1940', p_include_matched => true)`,
[companyId],
)
expect(on1940Matched.find((r) => r.journal_entry_id === transferEntry).linked_transaction_count).toBe(1)
})
it('keeps same-account N:1 vouchers behind the include_matched opt-in', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
const fiscalPeriodId = await insertFiscalPeriod({
userId, companyId, periodStart: '2026-01-01', periodEnd: '2026-12-31',
})
const acc1930 = await insertCashAccount({ companyId, ledgerAccount: '1930' })
// A salary-run shape: one voucher on 1930, partially settled by a first
// transfer FROM THE SAME account. The second instalment must still require
// the deliberate opt-in; account scoping must not open the N:1 floodgate.
const salaryEntry = await insertPostedJournalEntry({
userId, companyId, fiscalPeriodId,
entryDate: '2026-06-25', sourceType: 'manual', voucherNumber: 1, amount: 30000,
})
await insertTransaction({
companyId, userId, amount: -10000, date: '2026-06-25',
journalEntryId: salaryEntry, cashAccountId: acc1930,
})
const { rows: byDefault } = await getPool().query(
`SELECT journal_entry_id
FROM public.get_account_gl_lines_for_matching(p_company_id => $1, p_account_number => '1930')`,
[companyId],
)
expect(byDefault.find((r) => r.journal_entry_id === salaryEntry)).toBeUndefined()
const { rows: withMatched } = await getPool().query(
`SELECT journal_entry_id, linked_transaction_count
FROM public.get_account_gl_lines_for_matching(
p_company_id => $1, p_account_number => '1930', p_include_matched => true)`,
[companyId],
)
expect(withMatched.find((r) => r.journal_entry_id === salaryEntry).linked_transaction_count).toBe(1)
})
it('treats transactions without a resolvable cash account as settling every account', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
const fiscalPeriodId = await insertFiscalPeriod({
userId, companyId, periodStart: '2026-01-01', periodEnd: '2026-12-31',
})
// Legacy shape: the linked transaction carries no cash_account_id, so it
// could belong to any account. The voucher must stay hidden by default
// (conservative: pre-account-scoping behavior).
const legacyEntry = await insertPostedJournalEntry({
userId, companyId, fiscalPeriodId,
entryDate: '2026-06-20', sourceType: 'bank_transaction', voucherNumber: 1, amount: 500,
})
await insertTransaction({
companyId, userId, amount: 500, date: '2026-06-20',
journalEntryId: legacyEntry, cashAccountId: null,
})
const { rows: byDefault } = await getPool().query(
`SELECT journal_entry_id
FROM public.get_account_gl_lines_for_matching(p_company_id => $1, p_account_number => '1930')`,
[companyId],
)
expect(byDefault.find((r) => r.journal_entry_id === legacyEntry)).toBeUndefined()
const { rows: withMatched } = await getPool().query(
`SELECT journal_entry_id, linked_transaction_count
FROM public.get_account_gl_lines_for_matching(
p_company_id => $1, p_account_number => '1930', p_include_matched => true)`,
[companyId],
)
expect(withMatched.find((r) => r.journal_entry_id === legacyEntry).linked_transaction_count).toBe(1)
})
})
@@ -0,0 +1,360 @@
/**
* pg-real test for get_kpi_report_aggregates.
*
* The RPC backs the KPI report's no-dimension hot path: one SQL pass
* returns the per-account trial-balance sums (with and without the
* year-end chain), the opening-balance entry's sums, and per-month
* income/expenses. The exclusion semantics used to live in JS
* (lib/reports/trial-balance.ts + monthly-breakdown.ts) behind PostgREST
* filters; this suite pins them against real Postgres:
*
* - tb covers posted AND reversed entries, excluding ONLY the
* opening-balance entry (p_ob_entry_id);
* - tb_ex_year_end additionally drops source_type year_end entries and
* the stornos/corrections of REVERSED year-end entries (the undone
* year-end chain), mirroring excludeYearEndClosing;
* - ob sums the OB entry's lines with NO status filter (mirrors
* getOpeningBalances) but is company-guarded;
* - monthly is posted-only, classes 3-8, 8999 excluded, class 8 split
* per line by the sign of credit - debit;
* - SECURITY INVOKER: a non-member gets empty sections under RLS.
*/
import { describe, it, expect } from 'vitest'
import { randomUUID } from 'node:crypto'
import { getPool, withUserContext } from './setup'
import {
insertAuthUser,
insertCompany,
insertCompanyMember,
insertFiscalPeriod,
} from './fixtures'
interface AccountSums {
account_number: string
debit: number
credit: number
}
interface RpcPayload {
tb: AccountSums[]
tb_ex_year_end: AccountSums[]
ob: AccountSums[]
monthly: Array<{ year: number; month: number; income: number; expenses: number }>
}
async function callRpc(
companyId: string,
fiscalPeriodId: string,
obEntryId: string | null = null,
): Promise<RpcPayload> {
const { rows } = await getPool().query(
`SELECT public.get_kpi_report_aggregates($1, $2, $3) AS payload`,
[companyId, fiscalPeriodId, obEntryId],
)
return rows[0].payload as RpcPayload
}
function byAccount(section: AccountSums[]) {
return new Map(section.map((t) => [t.account_number, t]))
}
function monthOf(payload: RpcPayload, year: number, month: number) {
return payload.monthly.find((m) => m.year === year && m.month === month)
}
async function insertJournalEntry(params: {
userId: string
companyId: string
fiscalPeriodId: string
voucherNumber: number
status?: 'draft' | 'posted' | 'reversed'
sourceType?: string
entryDate?: string
reversesId?: string | null
correctionOfId?: string | null
lines: Array<{ account: string; debit: number; credit: number }>
}): Promise<string> {
const id = randomUUID()
// Insert directly, bypassing commit_journal_entry's voucher sequencing:
// fine for a read-side RPC that only aggregates line/account references.
await getPool().query(
`INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status, reverses_id, correction_of_id)
VALUES ($1, $2, $3, $4, $5, 'A', $6, 'KPI RPC test', $7, $8, $9, $10)`,
[
id,
params.userId,
params.companyId,
params.fiscalPeriodId,
params.voucherNumber,
params.entryDate ?? '2026-03-15',
params.sourceType ?? 'manual',
params.status ?? 'posted',
params.reversesId ?? null,
params.correctionOfId ?? null,
],
)
for (const line of params.lines) {
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, $2, $3, $4)`,
[id, line.account, line.debit, line.credit],
)
}
return id
}
async function seedCompany() {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
return { userId, companyId, fiscalPeriodId }
}
/**
* Full scenario: posted entries across three months, a reversed manual
* entry, an undone year-end chain (reversed year_end + storno +
* correction), a still-posted year_end entry with 8999 and 8910, and a
* linked OB entry.
*/
async function seedFullScenario() {
const ctx = await seedCompany()
const obEntryId = await insertJournalEntry({
...ctx, voucherNumber: 1, sourceType: 'opening_balance', entryDate: '2026-01-01',
lines: [
{ account: '1930', debit: 5000, credit: 0 },
{ account: '2010', debit: 0, credit: 5000 },
],
})
await getPool().query(
`UPDATE public.fiscal_periods SET opening_balance_entry_id = $1 WHERE id = $2`,
[obEntryId, ctx.fiscalPeriodId],
)
// January: revenue
await insertJournalEntry({
...ctx, voucherNumber: 2, entryDate: '2026-01-15',
lines: [
{ account: '3001', debit: 0, credit: 10000 },
{ account: '2611', debit: 0, credit: 2500 },
{ account: '1930', debit: 12500, credit: 0 },
],
})
// February: expense, plus a REVERSED entry (in tb, not in monthly)
await insertJournalEntry({
...ctx, voucherNumber: 3, entryDate: '2026-02-10',
lines: [
{ account: '5010', debit: 3000, credit: 0 },
{ account: '1930', debit: 0, credit: 3000 },
],
})
await insertJournalEntry({
...ctx, voucherNumber: 4, status: 'reversed', entryDate: '2026-02-20',
lines: [{ account: '3001', debit: 0, credit: 700 }],
})
// March: mixed-sign class 8 lines in the same entry
await insertJournalEntry({
...ctx, voucherNumber: 5, entryDate: '2026-03-05',
lines: [
{ account: '8310', debit: 0, credit: 200 },
{ account: '8410', debit: 500, credit: 0 },
],
})
// December: posted year_end entry (8999 + 8910)
await insertJournalEntry({
...ctx, voucherNumber: 6, sourceType: 'year_end', entryDate: '2026-12-31',
lines: [
{ account: '8910', debit: 1000, credit: 0 },
{ account: '2512', debit: 0, credit: 1000 },
{ account: '8999', debit: 5000, credit: 0 },
{ account: '2099', debit: 0, credit: 5000 },
],
})
// Undone year-end chain: reversed year_end + its storno + a correction
const reversedYearEndId = await insertJournalEntry({
...ctx, voucherNumber: 7, sourceType: 'year_end', status: 'reversed', entryDate: '2026-12-31',
lines: [
{ account: '8999', debit: 400, credit: 0 },
{ account: '2099', debit: 0, credit: 400 },
],
})
await insertJournalEntry({
...ctx, voucherNumber: 8, sourceType: 'storno', entryDate: '2026-12-31',
reversesId: reversedYearEndId,
lines: [
{ account: '8999', debit: 0, credit: 400 },
{ account: '2099', debit: 400, credit: 0 },
],
})
await insertJournalEntry({
...ctx, voucherNumber: 9, sourceType: 'correction', entryDate: '2026-12-31',
correctionOfId: reversedYearEndId,
lines: [
{ account: '6200', debit: 250, credit: 0 },
{ account: '1930', debit: 0, credit: 250 },
],
})
return { ...ctx, obEntryId }
}
describe('get_kpi_report_aggregates RPC', () => {
it('tb covers posted and reversed period entries, excluding only the OB entry', async () => {
const ctx = await seedFullScenario()
const payload = await callRpc(ctx.companyId, ctx.fiscalPeriodId, ctx.obEntryId)
const tb = byAccount(payload.tb)
// OB entry excluded: 1930 period debit is 12500, not 17500; 2010 absent.
expect(tb.get('1930')).toMatchObject({ debit: 12500, credit: 3250 })
expect(tb.has('2010')).toBe(false)
// Reversed manual entry included (posted + reversed base filter).
expect(tb.get('3001')).toMatchObject({ debit: 0, credit: 10700 })
expect(tb.get('2611')).toMatchObject({ debit: 0, credit: 2500 })
expect(tb.get('5010')).toMatchObject({ debit: 3000, credit: 0 })
// Year-end chain present in the plain tb.
expect(tb.get('8999')).toMatchObject({ debit: 5400, credit: 400 })
expect(tb.get('2099')).toMatchObject({ debit: 400, credit: 5400 })
expect(tb.get('8910')).toMatchObject({ debit: 1000, credit: 0 })
expect(tb.get('6200')).toMatchObject({ debit: 250, credit: 0 })
})
it('tb includes the OB entry when p_ob_entry_id is NULL, and ob is empty', async () => {
const ctx = await seedFullScenario()
const payload = await callRpc(ctx.companyId, ctx.fiscalPeriodId, null)
expect(byAccount(payload.tb).get('1930')).toMatchObject({ debit: 17500, credit: 3250 })
expect(byAccount(payload.tb).get('2010')).toMatchObject({ debit: 0, credit: 5000 })
expect(payload.ob).toEqual([])
})
it('tb_ex_year_end drops year_end entries plus stornos/corrections of reversed year-ends', async () => {
const ctx = await seedFullScenario()
const payload = await callRpc(ctx.companyId, ctx.fiscalPeriodId, ctx.obEntryId)
const tb = byAccount(payload.tb_ex_year_end)
// Ordinary activity retained...
expect(tb.get('3001')).toMatchObject({ debit: 0, credit: 10700 })
expect(tb.get('5010')).toMatchObject({ debit: 3000, credit: 0 })
expect(tb.get('8310')).toMatchObject({ debit: 0, credit: 200 })
expect(tb.get('8410')).toMatchObject({ debit: 500, credit: 0 })
// ...the whole year-end chain gone: year_end entries, the storno that
// reverses one, and the correction that corrects one.
expect(tb.has('8999')).toBe(false)
expect(tb.has('2099')).toBe(false)
expect(tb.has('8910')).toBe(false)
expect(tb.has('2512')).toBe(false)
expect(tb.has('6200')).toBe(false)
// The correction's 1930 credit (250) disappears with it.
expect(tb.get('1930')).toMatchObject({ debit: 12500, credit: 3000 })
})
it('keeps stornos/corrections that do not point at a reversed year-end', async () => {
const ctx = await seedCompany()
const plainReversed = await insertJournalEntry({
...ctx, voucherNumber: 1, status: 'reversed', entryDate: '2026-04-01',
lines: [{ account: '5010', debit: 100, credit: 0 }],
})
await insertJournalEntry({
...ctx, voucherNumber: 2, sourceType: 'storno', entryDate: '2026-04-02',
reversesId: plainReversed,
lines: [{ account: '5010', debit: 0, credit: 100 }],
})
const payload = await callRpc(ctx.companyId, ctx.fiscalPeriodId)
// Only reversals of REVERSED year_end entries are chained out.
expect(byAccount(payload.tb_ex_year_end).get('5010')).toMatchObject({
debit: 100,
credit: 100,
})
})
it('ob sums the OB entry regardless of status, guarded by company', async () => {
const ctx = await seedCompany()
// Draft OB entry: getOpeningBalances has no status filter, neither may we.
const draftOb = await insertJournalEntry({
...ctx, voucherNumber: 1, status: 'draft', sourceType: 'opening_balance',
entryDate: '2026-01-01',
lines: [
{ account: '1930', debit: 800, credit: 0 },
{ account: '2081', debit: 0, credit: 800 },
],
})
const payload = await callRpc(ctx.companyId, ctx.fiscalPeriodId, draftOb)
expect(byAccount(payload.ob).get('1930')).toMatchObject({ debit: 800, credit: 0 })
expect(byAccount(payload.ob).get('2081')).toMatchObject({ debit: 0, credit: 800 })
// Draft entries never enter tb.
expect(payload.tb).toEqual([])
// Another company's entry id yields nothing (defense in depth for
// service-role callers that bypass RLS).
const other = await seedCompany()
const foreign = await callRpc(other.companyId, other.fiscalPeriodId, draftOb)
expect(foreign.ob).toEqual([])
})
it('monthly is posted-only, 8999 excluded, class 8 sign-split per line', async () => {
const ctx = await seedFullScenario()
const payload = await callRpc(ctx.companyId, ctx.fiscalPeriodId, ctx.obEntryId)
// January: revenue only (class 2 VAT line ignored).
expect(monthOf(payload, 2026, 1)).toMatchObject({ income: 10000, expenses: 0 })
// February: the reversed 3001 entry (700) must NOT appear.
expect(monthOf(payload, 2026, 2)).toMatchObject({ income: 0, expenses: 3000 })
// March: 8310 credit 200 -> income; 8410 debit 500 -> expenses.
expect(monthOf(payload, 2026, 3)).toMatchObject({ income: 200, expenses: 500 })
// December: year_end entries are NOT excluded from monthly. 8910 debit
// 1000 -> expenses; posted storno's 8999 credit excluded by account; the
// correction's 6200 debit 250 -> expenses. Total 1250.
expect(monthOf(payload, 2026, 12)).toMatchObject({ income: 0, expenses: 1250 })
// No phantom months.
expect(payload.monthly.map((m) => m.month).sort((a, b) => a - b)).toEqual([1, 2, 3, 12])
})
it('scopes to the requested company and returns empty sections for an empty one', async () => {
const a = await seedFullScenario()
const b = await seedCompany()
const payload = await callRpc(b.companyId, b.fiscalPeriodId)
expect(payload.tb).toEqual([])
expect(payload.tb_ex_year_end).toEqual([])
expect(payload.ob).toEqual([])
expect(payload.monthly).toEqual([])
// And company A's data is intact when asked for correctly.
const payloadA = await callRpc(a.companyId, a.fiscalPeriodId, a.obEntryId)
expect(payloadA.tb.length).toBeGreaterThan(0)
})
it('SECURITY INVOKER: a member reads sums, a non-member gets empty sections under RLS', async () => {
const ctx = await seedFullScenario()
const outsider = await seedCompany() // member of their own company only
const asMember = await withUserContext(ctx.userId, async (client) => {
const { rows } = await client.query(
`SELECT public.get_kpi_report_aggregates($1, $2, $3) AS payload`,
[ctx.companyId, ctx.fiscalPeriodId, ctx.obEntryId],
)
return rows[0].payload as RpcPayload
})
expect(byAccount(asMember.tb).get('3001')).toMatchObject({ debit: 0, credit: 10700 })
expect(byAccount(asMember.ob).get('1930')).toMatchObject({ debit: 5000, credit: 0 })
const asOutsider = await withUserContext(outsider.userId, async (client) => {
const { rows } = await client.query(
`SELECT public.get_kpi_report_aggregates($1, $2, $3) AS payload`,
[ctx.companyId, ctx.fiscalPeriodId, ctx.obEntryId],
)
return rows[0].payload as RpcPayload
})
expect(asOutsider.tb).toEqual([])
expect(asOutsider.tb_ex_year_end).toEqual([])
expect(asOutsider.ob).toEqual([])
expect(asOutsider.monthly).toEqual([])
})
})
@@ -5,6 +5,7 @@ import {
insertCompany,
insertCompanyMember,
insertFiscalPeriod,
insertTransaction,
} from '@/tests/pg/fixtures'
import { getClient, getPool, withUserContext } from '@/tests/pg/setup'
@@ -160,6 +161,34 @@ describe('mark_entry_as_opening_balance RPC', () => {
})
})
it('refuses an entry with a linked bank transaction (20260723160000 guard)', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
// A half-settled own-account transfer: since the account-scoped matching
// semantics (same migration), this voucher surfaces in the reconciliation
// view's unmatched table on the OTHER account, making "Märk som IB"
// reachable. Re-tagging it would strand the linked transaction against an
// excluded entry, so the RPC must refuse.
const entryId = await insertPostedEntry({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
lines: [
{ account: '1930', debit: 2500, credit: 0 },
{ account: '1940', debit: 0, credit: 2500 },
],
})
await insertTransaction({
companyId, userId, amount: -2500, journalEntryId: entryId,
})
await withUserContext(userId, async (client) => {
await expect(
client.query(`SELECT mark_entry_as_opening_balance($1, $2)`, [companyId, entryId]),
).rejects.toThrow(/linked bank transactions/i)
})
})
it('refuses re-tagging in a locked fiscal period', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
@@ -255,6 +284,78 @@ describe('enforce_journal_entry_immutability: source_type retag carve-out', () =
})
})
describe('check_transaction_link_not_opening_balance trigger (20260723190000)', () => {
it('refuses INSERTing a transaction linked to an opening_balance entry', async () => {
const { userId, companyId, fiscalPeriodId } = await seedOwner()
const entryId = await insertPostedEntry({
userId, companyId, fiscalPeriodId, voucherNumber: 1, sourceType: 'opening_balance',
})
await expect(
insertTransaction({ companyId, userId, amount: -2500, journalEntryId: entryId }),
).rejects.toThrow(/opening balance entry/i)
})
it('refuses UPDATEing journal_entry_id to point at an opening_balance entry', async () => {
const { userId, companyId, fiscalPeriodId } = await seedOwner()
const obEntryId = await insertPostedEntry({
userId, companyId, fiscalPeriodId, voucherNumber: 1, sourceType: 'opening_balance',
})
const txId = await insertTransaction({ companyId, userId, amount: -2500 })
await expect(
getPool().query(
`UPDATE public.transactions SET journal_entry_id = $1 WHERE id = $2`,
[obEntryId, txId],
),
).rejects.toThrow(/opening balance entry/i)
})
it('allows linking to an ordinary entry and unlinking back to NULL', async () => {
const { userId, companyId, fiscalPeriodId } = await seedOwner()
const entryId = await insertPostedEntry({ userId, companyId, fiscalPeriodId, voucherNumber: 1 })
const txId = await insertTransaction({ companyId, userId, amount: -2500 })
await getPool().query(
`UPDATE public.transactions SET journal_entry_id = $1 WHERE id = $2`,
[entryId, txId],
)
const linked = await getPool().query<{ journal_entry_id: string | null }>(
`SELECT journal_entry_id FROM public.transactions WHERE id = $1`,
[txId],
)
expect(linked.rows[0]!.journal_entry_id).toBe(entryId)
await getPool().query(
`UPDATE public.transactions SET journal_entry_id = NULL WHERE id = $1`,
[txId],
)
const unlinked = await getPool().query<{ journal_entry_id: string | null }>(
`SELECT journal_entry_id FROM public.transactions WHERE id = $1`,
[txId],
)
expect(unlinked.rows[0]!.journal_entry_id).toBeNull()
})
it('leaves updates that do not change journal_entry_id alone', async () => {
const { userId, companyId, fiscalPeriodId } = await seedOwner()
const entryId = await insertPostedEntry({ userId, companyId, fiscalPeriodId, voucherNumber: 1 })
const txId = await insertTransaction({ companyId, userId, amount: -2500, journalEntryId: entryId })
// Same-value SET (e.g. a generic column-list UPDATE) must not raise even
// though the trigger's UPDATE OF column list matches.
await getPool().query(
`UPDATE public.transactions SET journal_entry_id = journal_entry_id, description = 'touched' WHERE id = $1`,
[txId],
)
const after = await getPool().query<{ description: string }>(
`SELECT description FROM public.transactions WHERE id = $1`,
[txId],
)
expect(after.rows[0]!.description).toBe('touched')
})
})
// Local owner seed (company + owner membership + open period).
async function seedOwner(): Promise<{ userId: string; companyId: string; fiscalPeriodId: string }> {
const userId = await insertAuthUser()
@@ -0,0 +1,174 @@
import { describe, it, expect } from 'vitest'
import { getPool, getClient, withUserContext } from './setup'
import { insertAuthUser, insertCompany, insertCompanyMember, seedCompany } from './fixtures'
// Validates migration 20260723161000_resolve_active_company_rpc:
// 1. resolve_active_company() mirrors the JS resolution exactly: validated
// preference wins, else earliest non-archived membership by
// company_members.created_at, else NULL.
// 2. used_fallback is true exactly when the validated preference is
// absent (middleware's write-back condition).
// 3. NULL auth.uid() (service-role clients) returns ZERO rows by design.
// 4. EXECUTE is granted to authenticated only, not anon.
// 5. Divergence guard: it never disagrees with
// current_active_company_id(), which RLS reads.
const RESOLVE = `SELECT r.company_id::text AS company_id, r.locale, r.used_fallback
FROM public.resolve_active_company() r`
type ResolveRow = { company_id: string | null; locale: string | null; used_fallback: boolean }
async function setPrefs(
userId: string,
activeCompanyId: string | null,
locale?: string,
): Promise<void> {
if (locale === undefined) {
await getPool().query(
`INSERT INTO public.user_preferences (user_id, active_company_id)
VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET active_company_id = EXCLUDED.active_company_id`,
[userId, activeCompanyId],
)
return
}
await getPool().query(
`INSERT INTO public.user_preferences (user_id, active_company_id, locale)
VALUES ($1, $2, $3)
ON CONFLICT (user_id) DO UPDATE
SET active_company_id = EXCLUDED.active_company_id, locale = EXCLUDED.locale`,
[userId, activeCompanyId, locale],
)
}
describe('resolve_active_company()', () => {
it('resolves a valid preference with used_fallback false and the default locale', async () => {
const { userId, companyId } = await seedCompany()
await setPrefs(userId, companyId)
await withUserContext(userId, async (client) => {
const res = await client.query<ResolveRow>(RESOLVE)
expect(res.rows).toHaveLength(1)
expect(res.rows[0].company_id).toBe(companyId)
expect(res.rows[0].used_fallback).toBe(false)
// locale column is NOT NULL DEFAULT 'sv' (20260521120000): a prefs row
// always carries a locale.
expect(res.rows[0].locale).toBe('sv')
})
})
it('falls back to the first membership with used_fallback true and null locale when there is no prefs row', async () => {
const { userId, companyId } = await seedCompany()
await withUserContext(userId, async (client) => {
const res = await client.query<ResolveRow>(RESOLVE)
expect(res.rows).toHaveLength(1)
expect(res.rows[0].company_id).toBe(companyId)
expect(res.rows[0].used_fallback).toBe(true)
expect(res.rows[0].locale).toBeNull()
})
})
it('orders the fallback by company_members.created_at ASC, not insertion order', async () => {
const userId = await insertAuthUser()
const firstInserted = await insertCompany({ createdBy: userId, name: 'First Inserted AB' })
const backdated = await insertCompany({ createdBy: userId, name: 'Backdated AB' })
await insertCompanyMember({ companyId: firstInserted, userId })
await insertCompanyMember({ companyId: backdated, userId })
// Backdate the SECOND membership: it must win despite later insertion.
await getPool().query(
`UPDATE public.company_members SET created_at = now() - interval '1 day'
WHERE company_id = $1 AND user_id = $2`,
[backdated, userId],
)
await withUserContext(userId, async (client) => {
const res = await client.query<ResolveRow>(RESOLVE)
expect(res.rows[0].company_id).toBe(backdated)
expect(res.rows[0].used_fallback).toBe(true)
})
})
it('ignores a preference pointing at an archived company and falls back', async () => {
const userId = await insertAuthUser()
const archived = await insertCompany({ createdBy: userId, name: 'Archived AB' })
const alive = await insertCompany({ createdBy: userId, name: 'Alive AB' })
await insertCompanyMember({ companyId: archived, userId })
await insertCompanyMember({ companyId: alive, userId })
await getPool().query(`UPDATE public.companies SET archived_at = now() WHERE id = $1`, [
archived,
])
await setPrefs(userId, archived)
await withUserContext(userId, async (client) => {
const res = await client.query<ResolveRow>(RESOLVE)
expect(res.rows[0].company_id).toBe(alive)
expect(res.rows[0].used_fallback).toBe(true)
})
})
it('ignores a preference pointing at a company the user is not a member of', async () => {
const { companyId: foreignCompany } = await seedCompany() // someone else's
const { userId, companyId: ownCompany } = await seedCompany()
await setPrefs(userId, foreignCompany)
await withUserContext(userId, async (client) => {
const res = await client.query<ResolveRow>(RESOLVE)
expect(res.rows[0].company_id).toBe(ownCompany)
expect(res.rows[0].used_fallback).toBe(true)
})
})
it('returns one row with a null company_id but the stored locale for a user with prefs and zero memberships', async () => {
const userId = await insertAuthUser()
await setPrefs(userId, null, 'en')
await withUserContext(userId, async (client) => {
const res = await client.query<ResolveRow>(RESOLVE)
expect(res.rows).toHaveLength(1)
expect(res.rows[0].company_id).toBeNull()
expect(res.rows[0].locale).toBe('en')
expect(res.rows[0].used_fallback).toBe(true)
})
})
it('returns ZERO rows when auth.uid() is NULL (service-role clients)', async () => {
const client = await getClient()
try {
await client.query('BEGIN')
// A claims object with no `sub`: auth.uid() resolves to NULL, the
// shape a service-role/backend connection presents.
await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [
JSON.stringify({ role: 'authenticated' }),
])
await client.query(`SET LOCAL ROLE authenticated`)
const res = await client.query<ResolveRow>(RESOLVE)
expect(res.rows).toHaveLength(0)
await client.query('ROLLBACK')
} catch (err) {
await client.query('ROLLBACK').catch(() => {})
throw err
} finally {
client.release()
}
})
it('grants EXECUTE to authenticated but not anon', async () => {
const res = await getPool().query<{ anon_can: boolean; authenticated_can: boolean }>(
`SELECT
has_function_privilege('anon', 'public.resolve_active_company()', 'EXECUTE') AS anon_can,
has_function_privilege('authenticated', 'public.resolve_active_company()', 'EXECUTE') AS authenticated_can`,
)
expect(res.rows[0].anon_can).toBe(false)
expect(res.rows[0].authenticated_can).toBe(true)
})
it('never diverges from current_active_company_id(), which RLS reads', async () => {
// Stale-pref scenario: the most divergence-prone shape (pref set, but
// membership on the preferred company is gone).
const { companyId: foreignCompany } = await seedCompany()
const { userId } = await seedCompany()
await setPrefs(userId, foreignCompany)
await withUserContext(userId, async (client) => {
const res = await client.query<{ agrees: boolean }>(
`SELECT (SELECT r.company_id FROM public.resolve_active_company() r)
IS NOT DISTINCT FROM public.current_active_company_id() AS agrees`,
)
expect(res.rows[0].agrees).toBe(true)
})
})
})