diff --git a/DECISIONS.md b/DECISIONS.md index f5f4198a..1112b601 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1649,3 +1649,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-07] WooCommerce wc-auth return_url is built on the initiating request's trusted origin (resolveRequestAppOrigin, brands-table validated, canonical on unknown host or lookup failure) while callback_url stays on the canonical app URL: sessions are per domain, so a white-label user returned to the canonical host hit the initiator check with no session and a foreign-branded login. No stored origin column and no provider_otc handoff (PR #2305 pattern) needed, because unlike a registered OAuth redirect URI the wc-auth return_url is free-form per handshake and can simply point at the brand host; the return route resolves its redirect base from the host it was reached on the same way. [2026-09-07] PR #2373 review: the Skatteverket callback now peeks the flow row for its initiator and binds the session BEFORE consuming state or handoff (Superagent P2: a DELETE-before-auth let a signed-out or wrong-user arrival burn a live consent). A session-less arrival is sent to /login on the initiating origin and resumes into the same URL; a wrong user is refused with the row left claimable. This reverses the earlier 'no login resume' decision for this flow; the consume stays the atomic gate, the peek only decides who may attempt it. Handoff TTL raised from two to five minutes to fit a sign-in. [2026-09-07] BankID confirmation mail and the Send Email hook resolve their link host through lib/domains/trusted-app-origin instead of the raw forwarded host / GoTrue's redirect_to: the BankID mail is the one auth link GoTrue's redirect allowlist never sees (built here, sent via Resend), and the hook's signature proves the sender, not the destination, while the GoTrue allowlist is a hand-configured glob. Unknown, lookalike, credential-bearing, non-default-port and malformed destinations collapse to the canonical /auth/callback with no next path; a registered brand host over http is upgraded to https. Brand sender identity is resolved from the RESOLVED host so mail branding and link destination always agree. A brands-table read failure refuses (BankID: step resolve_origin, signup rolls back; hook: 500 so Supabase retries) rather than mailing a canonical link to a white-label user. Dropped from the audit's plan 7 as already in place after #2376: signup route, HTTPS enforcement, credential/port checks, recovery/invite/email-change coverage. +[2026-09-07] Klarmarkera (markPeriodClosedExternally) now asks whether the period has any lines on result accounts (BAS class 3-8) instead of whether it has any non-imported verifikat at all. The old test was a proxy for the thing the guard protects (3xxx-8xxx transferred by a bokslutsverifikat, BFL 5-6 kap) and it shut out the migrated first year whose only native voucher re-keys the opening balance (1930/2081 aktiekapital) after a failed SIE import; with the next year's IB already imported the normal year-end refused too (NEXT_PERIOD_HAS_IB), so the year had no closing path at all (EHAL AB support case 2026-09-07). Existence check via id-only entry fetch plus per-chunk head counts with early exit, no line fetch, no journal_entries!inner embed. Same session: the loaded årsredovisning view got the FyPicker in its header, because the no-period branch auto-jumps to the remembered scope before its own picker is ever seen, which left users with no way to reach another year's årsredovisning. +[2026-09-07] Klarmarkera's new balance-sheet-only leg additionally requires the NEXT period to already carry IB (opening_balance_entry_id set). Without that, a natively bookkept year with only balance-sheet vouchers (dormant AB with just the aktiekapital deposit) could be klarmarkerad and its balances would never reach the next year, since klarmarkera skips the IB posting the normal year-end does. With the IB in place the normal year-end refuses (NEXT_PERIOD_HAS_IB) and klarmarkera is the only path; without it the normal year-end works and is the right path. Raised by the swedish-compliance bot on PR #2393 (its suggested alternative, a dedicated IB-correction tag on entries, was rejected: it adds a concept for the user to know about where the ledger already answers the question). Same round: the result-account range is bounded to class 3-8 (lt '9'), class 9 interna poster are not transferred by a bokslut. diff --git a/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx b/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx index 8f49c18d..7bb07fab 100644 --- a/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx +++ b/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx @@ -115,6 +115,11 @@ export default function ArsredovisningPage() { useEffect(() => { if (!periodId) return let cancelled = false + // A year switch from the header picker re-runs this effect: show the + // skeleton instead of the previous year's numbers while the new one + // loads, and drop a stale error so the picker stays reachable. + setLoading(true) + setError(null) Promise.all([ fetch(`/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning`).then((r) => r.json()), fetch(`/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/signatures`).then((r) => @@ -551,11 +556,28 @@ export default function ArsredovisningPage() { } action={ - +
+ {/* The year switch has to live on the loaded view too: the + no-period branch above auto-jumps to the remembered scope (or + the newest year) before anyone sees its picker, so without + this the only way to another year's årsredovisning was to + change the scope on some other page and come back. */} + { + if (id && id !== periodId) { + router.replace(`/bookkeeping/year-end/arsredovisning?period=${id}`) + } + }} + includeAllOption={false} + hideFuturePeriods + /> + +
} /> diff --git a/lib/core/bookkeeping/__tests__/period-service.test.ts b/lib/core/bookkeeping/__tests__/period-service.test.ts index 904ab869..49fd46e7 100644 --- a/lib/core/bookkeeping/__tests__/period-service.test.ts +++ b/lib/core/bookkeeping/__tests__/period-service.test.ts @@ -11,7 +11,7 @@ let results: Array<{ data?: unknown; error?: unknown; count?: number | null }> function makeBuilder() { const b: Record = {} - for (const m of ['select', 'eq', 'insert', 'update', 'delete', 'lte', 'gte', 'in', 'not', 'or', 'order', 'limit', 'is', 'range']) { + for (const m of ['select', 'eq', 'insert', 'update', 'delete', 'lt', 'lte', 'gte', 'in', 'not', 'or', 'order', 'limit', 'is', 'range']) { b[m] = vi.fn().mockReturnValue(b) } b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }) @@ -691,7 +691,7 @@ describe('markPeriodClosedExternally', () => { results = [ { data: period, error: null }, // fetch { count: 0, data: null, error: null }, // imported-verifikat count - { count: 0, data: null, error: null }, // total-verifikat count + { data: [], error: null }, // period entries (id-only page): none { count: 0, data: null, error: null }, // guard leg 1: untriaged count { data: [], error: null }, // guard leg 2: business-unbooked candidates { data: updated, error: null }, // update @@ -703,12 +703,90 @@ describe('markPeriodClosedExternally', () => { expect(result.closed_externally).toBe(true) }) - it('refuses a period bookkept natively in Accounted (no imported verifikat)', async () => { + it('allows a migrated first year whose only native voucher is balance-sheet only and the next year has IB', async () => { + // The EHAL shape (2026-09-07): SIE import into the historical year failed, + // so the owner re-keyed the opening voucher by hand (1930 D / 2081 K + // aktiekapital). Nothing on 3xxx-8xxx, so there is no result for a + // bokslutsverifikat to transfer; the next year's IB is already imported, + // which blocks the normal year-end. Klarmarkera must stay open here. + const period = makeFiscalPeriod({ id: 'fp-1', period_end: '2024-12-31' }) + const next = makeFiscalPeriod({ + id: 'fp-2', + period_start: '2025-01-01', + period_end: '2025-12-31', + previous_period_id: 'fp-1', + opening_balance_entry_id: 'ib-1', + }) + const updated = { ...period, is_closed: true, closed_externally: true } + results = [ + { data: period, error: null }, // fetch + { count: 0, data: null, error: null }, // imported-verifikat count + { data: [{ id: 'je-1' }], error: null }, // period entries (id-only page) + { count: 0, data: null, error: null }, // result-account lines in chunk 1: none + { data: period, error: null }, // findNextPeriod: fetch current + { data: next, error: null }, // findNextPeriod: chained lookup, has IB + { count: 0, data: null, error: null }, // guard leg 1: untriaged count + { data: [], error: null }, // guard leg 2: business-unbooked candidates + { data: updated, error: null }, // update + { data: null, error: null }, // audit_log insert + ] + + const supabase = makeClient() + const result = await markPeriodClosedExternally(supabase as never, 'company-1', 'user-1', 'fp-1') + expect(result.closed_externally).toBe(true) + + // The line check is a text comparison on the account number string: + // classes 3-8 sort at or above '3' and below '9'. + const lineBuilders = supabase.from.mock.results + .map( + (r) => + r.value as { + in: ReturnType + gte: ReturnType + lt: ReturnType + }, + ) + .filter((b) => b.in.mock.calls.some((c) => c[0] === 'journal_entry_id')) + expect(lineBuilders).toHaveLength(1) + expect(lineBuilders[0].in).toHaveBeenCalledWith('journal_entry_id', ['je-1']) + expect(lineBuilders[0].gte).toHaveBeenCalledWith('account_number', '3') + expect(lineBuilders[0].lt).toHaveBeenCalledWith('account_number', '9') + }) + + it('refuses a native balance-sheet-only year when the next year has no IB (normal year-end carries the balances)', async () => { + const period = makeFiscalPeriod({ id: 'fp-1', period_end: '2024-12-31' }) + const next = makeFiscalPeriod({ + id: 'fp-2', + period_start: '2025-01-01', + period_end: '2025-12-31', + previous_period_id: 'fp-1', + opening_balance_entry_id: null, + }) + results = [ + { data: period, error: null }, // fetch + { count: 0, data: null, error: null }, // imported-verifikat count + { data: [{ id: 'je-1' }], error: null }, // period entries: one native voucher + { count: 0, data: null, error: null }, // result-account lines: none + { data: period, error: null }, // findNextPeriod: fetch current + { data: next, error: null }, // findNextPeriod: chained lookup, no IB + ] + + const supabase = makeClient() + await expect( + markPeriodClosedExternally(supabase as never, 'company-1', 'user-1', 'fp-1') + ).rejects.toThrow('saknar ingående balanser') + }) + + it('refuses a native balance-sheet-only year when no next period exists at all', async () => { const period = makeFiscalPeriod({ id: 'fp-1', period_end: '2024-12-31' }) results = [ - { data: period, error: null }, // fetch - { count: 0, data: null, error: null }, // imported-verifikat count - { count: 7, data: null, error: null }, // total-verifikat count: native entries + { data: period, error: null }, // fetch + { count: 0, data: null, error: null }, // imported-verifikat count + { data: [{ id: 'je-1' }], error: null }, // period entries: one native voucher + { count: 0, data: null, error: null }, // result-account lines: none + { data: period, error: null }, // findNextPeriod: fetch current + { data: null, error: null }, // findNextPeriod: chained lookup misses + { data: null, error: null }, // findNextPeriod: date lookup misses ] const supabase = makeClient() @@ -717,6 +795,36 @@ describe('markPeriodClosedExternally', () => { ).rejects.toThrow('vanliga årsbokslutet') }) + it('refuses a period bookkept natively in Accounted (result accounts, no imported verifikat)', async () => { + const period = makeFiscalPeriod({ id: 'fp-1', period_end: '2024-12-31' }) + results = [ + { data: period, error: null }, // fetch + { count: 0, data: null, error: null }, // imported-verifikat count + { data: [{ id: 'je-1' }, { id: 'je-2' }], error: null }, // period entries: native + { count: 4, data: null, error: null }, // result-account lines in chunk 1 + ] + + const supabase = makeClient() + await expect( + markPeriodClosedExternally(supabase as never, 'company-1', 'user-1', 'fp-1') + ).rejects.toThrow('vanliga årsbokslutet') + }) + + it('fails closed when the result-account line check errors', async () => { + const period = makeFiscalPeriod({ id: 'fp-1', period_end: '2024-12-31' }) + results = [ + { data: period, error: null }, // fetch + { count: 0, data: null, error: null }, // imported-verifikat count + { data: [{ id: 'je-1' }], error: null }, // period entries + { count: null, data: null, error: { message: 'boom' } }, // line head-count fails + ] + + const supabase = makeClient() + await expect( + markPeriodClosedExternally(supabase as never, 'company-1', 'user-1', 'fp-1') + ).rejects.toThrow('Kunde inte kontrollera periodens verifikat') + }) + it('rejects an already-closed period', async () => { const period = makeFiscalPeriod({ id: 'fp-1', is_closed: true }) results = [{ data: period, error: null }] diff --git a/lib/core/bookkeeping/period-service.ts b/lib/core/bookkeeping/period-service.ts index 00b01d30..a30d5050 100644 --- a/lib/core/bookkeeping/period-service.ts +++ b/lib/core/bookkeeping/period-service.ts @@ -399,6 +399,61 @@ export async function closePeriod( return updated as FiscalPeriod } +/** Max journal_entry ids per `.in()` filter: keeps the request URL short. */ +const RESULT_LINE_CHECK_CHUNK_SIZE = 100 + +interface PeriodLedgerShape { + /** Number of journal entries dated inside the period, any source_type. */ + entryCount: number + /** Whether any of their lines sits on a result account (BAS class 3-8). */ + hasResultLines: boolean +} + +/** + * How much bookkeeping the period holds and whether any of it sits on a + * result account (BAS class 3-8), i.e. whether a bokslutsverifikat would + * have anything to transfer. An existence check, not a fetch: entries are + * read id-only, and lines are head-counted per id chunk with early exit, so + * a natively bookkept year answers on its first chunk. Two-step by design; + * see lib/bookkeeping/entry-lines.ts for why the `journal_entries!inner` + * embed is avoided. Throws on any query error so the caller fails closed. + */ +async function inspectPeriodLedger( + supabase: SupabaseClient, + companyId: string, + periodStart: string, + periodEnd: string, +): Promise { + const entries = await fetchAllRows<{ id: string }>( + ({ from, to }) => + supabase + .from('journal_entries') + .select('id') + .eq('company_id', companyId) + .gte('entry_date', periodStart) + .lte('entry_date', periodEnd) + .order('id', { ascending: true }) + .range(from, to), + { dedupeBy: (e) => e.id }, + ) + for (let i = 0; i < entries.length; i += RESULT_LINE_CHECK_CHUNK_SIZE) { + const chunk = entries.slice(i, i + RESULT_LINE_CHECK_CHUNK_SIZE).map((e) => e.id) + const { count, error } = await supabase + .from('journal_entry_lines') + .select('id', { count: 'exact', head: true }) + .in('journal_entry_id', chunk) + // Account numbers are strings, so these are text comparisons on the + // BAS class digit: classes 3-8 sort at or above '3' and below '9'; + // classes 1-2 (balance sheet) sort below '3', class 9 (interna + // poster, never transferred by a bokslut) at or above '9'. + .gte('account_number', '3') + .lt('account_number', '9') + if (error) throw error + if ((count ?? 0) > 0) return { entryCount: entries.length, hasResultLines: true } + } + return { entryCount: entries.length, hasResultLines: false } +} + /** * Mark a fiscal period as closed in a previous bookkeeping system * ("klarmarkera"). Imported historical years (SIE) arrive with @@ -454,10 +509,20 @@ export async function markPeriodClosedExternally( // Klarmarkera exists for MIGRATED years. A period bookkept natively in // Accounted must go through the real year-end: closing it without a - // bokslutsverifikat leaves 3xxx-8xxx untransferred (BFL 5-6 kap) with no - // clean way back once locked. "Migrated" is read from the ledger itself: - // the period either contains SIE-imported verifikat (source_type='import') - // or no verifikat at all (year closed elsewhere and never imported here). + // bokslutsverifikat leaves 3xxx-8xxx untransferred and the next year + // without IB (BFL 5-6 kap), with no clean way back once locked. + // "Migrated" is read from the ledger itself. The period passes when it + // contains SIE-imported verifikat (source_type='import'), or when it holds + // no verifikat at all (year closed elsewhere, never imported here), or + // when its native verifikat touch balance-sheet accounts only AND the + // next period already carries its IB. That third leg is the migrated + // first year whose SIE import failed and whose owner re-keyed the opening + // voucher (1930/2081 aktiekapital) by hand: nothing for a bokslut to + // transfer, the balances already continue into the next year, and the + // normal year-end refuses on exactly that IB (NEXT_PERIOD_HAS_IB). Until + // this leg existed the year could not be closed by any path. A native + // balance-sheet-only year whose next period lacks IB stays refused: there + // the normal year-end works and is what carries the balances forward. const { count: importedCount, error: importedError } = await supabase .from('journal_entries') .select('id', { count: 'exact', head: true }) @@ -469,20 +534,30 @@ export async function markPeriodClosedExternally( throw new Error('Kunde inte kontrollera periodens verifikat. Försök igen.') } if ((importedCount ?? 0) === 0) { - const { count: totalCount, error: totalError } = await supabase - .from('journal_entries') - .select('id', { count: 'exact', head: true }) - .eq('company_id', companyId) - .gte('entry_date', period.period_start) - .lte('entry_date', period.period_end) - if (totalError) { + let ledger: PeriodLedgerShape + try { + ledger = await inspectPeriodLedger( + supabase, + companyId, + period.period_start, + period.period_end, + ) + } catch { throw new Error('Kunde inte kontrollera periodens verifikat. Försök igen.') } - if ((totalCount ?? 0) > 0) { + if (ledger.hasResultLines) { throw new Error( - 'Perioden innehåller bokföring skapad i Accounted och inga importerade verifikat. Använd det vanliga årsbokslutet i stället.' + 'Perioden innehåller resultatkonton (3000-8999) bokförda i Accounted och inga importerade verifikat. Använd det vanliga årsbokslutet i stället, så att årets resultat förs över.' ) } + if (ledger.entryCount > 0) { + const nextPeriod = await findNextPeriod(supabase, companyId, fiscalPeriodId) + if (!nextPeriod?.opening_balance_entry_id) { + throw new Error( + 'Perioden innehåller bokföring skapad i Accounted och nästa räkenskapsår saknar ingående balanser. Använd det vanliga årsbokslutet i stället, så förs balanserna över.' + ) + } + } } // Same stranding guard as lockPeriod: closing makes unbooked