From 9a8291f4549abf84259f58901f220e2490aabe50 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:57:38 +0200 Subject: [PATCH] feat(reports): list a booked 8999 in Resultatrapport instead of hiding it (#2457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(reports): list a booked 8999 in Resultatrapport instead of hiding it Resultatrapport and dimension-pnl filtered account 8999 out and printed a computed result row, so a user who books or imports the omföring of årets resultat by hand saw huvudboken and the account-level report disagree. Why it occurred: the filter was copied from the formal Resultaträkning, where it is right (ÅRL's uppställningsform has no 8999 line). In the operational report it hid a real balance. Our own bokslut verifikat never posts 8999 (it zeroes each P&L account straight against 2099), so the only 8999 balances that exist are manual or SIE-imported ones, exactly the case the report suppressed. What was removed: the exclusion itself, in both operational reports, so they keep reconciling. The XLSX bottom row is renamed to "Beräknat resultat" to match the UI and PDF. Beräknat resultat now reads zero after such an omföring, the Fortnox/Visma resultatrapport convention. Why this and not the proposed shape: the user asked about Resultaträkning, which stays as is on purpose. The bigger version (Stage 2 of #1051, showing the bokslut verifikat via exclude-final) would zero every row of a closed year given our closing-entry shape and is a separate decision; recorded in DECISIONS.md. Fixes #2455 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0131jmfXGzSdyjaQoGiCoo1t * test(reports): pin the deliberate 8999 gap between Resultatrapport and Resultaträkning The cross-surface agreement test claimed the two operational reports are identical; after #2455 they differ by exactly a booked 8999 omföring, and the fixture had no such row so the invariant went silently false. Pin the gap explicitly, and note in DECISIONS.md that this supersedes the 2026-07-29 same-profit line. Refs #2455 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0131jmfXGzSdyjaQoGiCoo1t --------- Co-authored-by: Claude Fable 5.1 --- DECISIONS.md | 1 + app/api/reports/resultatrapport/xlsx/route.ts | 2 +- .../__tests__/cross-surface-agreement.test.ts | 36 +++++++++++++++++++ lib/reports/__tests__/dimension-pnl.test.ts | 36 +++++++++++++++++-- lib/reports/__tests__/resultatrapport.test.ts | 9 +++-- lib/reports/dimension-pnl.ts | 8 ++--- lib/reports/resultatrapport.ts | 18 +++++----- 7 files changed, 90 insertions(+), 20 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index ff81863d..712e0cb9 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1693,4 +1693,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-08] Zettle orders cron pages candidates (range) and caps entitled syncs at 50 instead of limit(50) before hasCapability: entitlement skips must not consume the batch or advance last_order_synced_at (purchase recovery cursor). Declined a separate cron_checked_at column for now; revisit if scanned non-entitled volume becomes a time-budget problem. [2026-09-09] PR #2416 (Zettle, community-authored) adopted on the contributor's branch instead of re-implemented: kept the Shopify-shaped feed-only design, added migration 20260909100400 for the sites that enumerate platforms/connection tables (platform CHECKs on webshop_orders and webshop_store_settings, writer-role gate trigger, migration-reset snapshot and lock via the 20260826150000 wrapper pattern rather than re-issuing the 400-line reset body), renamed the four PR migrations past prod's 20260908143051, froze the validated connect origin on zettle_connections.return_origin so white-label users return to their brand domain (Zettle has one registered callback URL), and derive per-rate VAT net from Zettle's own product rows (tax / rate drifted from what was charged) with a one-öre-per-row line-sum tolerance instead of 0.005 kr (silently dropped multi-row 12%/6% underlag). Per-purchase rows kept for v1; daily kassarapport aggregation and Finance API fees/payouts are the follow-up. [2026-09-09] Zettle v1 imports split-tender, gift-card (sale or tender) and tip-carrying purchases as is_paid = false rows titled 'bokför manuellt' and skips their refunds, instead of booking them through the one-account / revenue-per-rate model: the skeptic showed a card+cash split would put the whole gross on 1686, a card+invoice split would count as paid, a 0 % gift-card row would land on 3004 / ruta 42 (it is a 2421 liability), and tips would book as momsfri sale. Proper support needs per-payment amounts and a voucher liability on webshop_orders (follow-up). Sync runs claim the connection (sync_lock_until) before refreshing the rotating token: a concurrent cron + manual sync otherwise reuses a refresh token, Zettle answers 400, and the connection flips to revoked. The cron snapshots its candidate list before syncing because each sync moves the row to the tail of the last_order_synced_at ordering, so live offset paging re-fetched synced rows and skipped unseen ones. +[2026-09-09] Resultatrapport and dimension-pnl list account 8999 as a normal class-8 row instead of filtering it out (#2455): our own bokslut verifikat never posts 8999 (it zeroes each P&L account straight against 2099), so the only 8999 balances are manual or SIE-imported omföringar, and hiding those made an account-level report disagree with huvudboken. "Beräknat resultat" now reads zero after such an omföring, the Fortnox/Visma convention. Resultaträkning keeps excluding 8999 because ÅRL's uppställningsform has no such line. This supersedes the 2026-07-29 line that kept Resultatrapport on the same profit as Resultaträkning: they still share 'exclude-all-year-end', and now differ by exactly a booked 8999, pinned in cross-surface-agreement.test.ts. No MCP or v1 surface is built on generateResultatrapport (the income-statement tool and v1 route carry the word "resultatrapport" but serve Resultaträkning), so an agent asked for resultatrapporten still answers the computed figure: follow-up issue filed. Rejected the bigger version (Stage 2 of #1051, 'exclude-final'): with our closing entry shape it would zero every row of a closed year, and it is a separate decision. [2026-09-09] SIE account creation writes each chunk as an ignore-duplicates upsert (ON CONFLICT (company_id, account_number) DO NOTHING, returning the landed rows) instead of a plain INSERT whose duplicate-key error was swallowed: under PostgREST one request is one transaction, so a single concurrent duplicate rolled back the whole statement while the caller counted it as created and moved on with accounts missing. The conflict clause makes the race a skipped row, `created` counts exactly what landed, and the "duplicate" string special-case is gone. Raised by CodeRabbit and the compliance review on PR #2451 (count accuracy under BFNAR 2013:2 p. 9.16); the audit_log trigger stays the per-row record. diff --git a/app/api/reports/resultatrapport/xlsx/route.ts b/app/api/reports/resultatrapport/xlsx/route.ts index 5ff599f5..166e7c7f 100644 --- a/app/api/reports/resultatrapport/xlsx/route.ts +++ b/app/api/reports/resultatrapport/xlsx/route.ts @@ -84,7 +84,7 @@ export const GET = withRouteContext('report.resultatrapport.xlsx', async (reques rows.push({ group: 'Resultat', account_number: '', - account_name: 'Årets resultat', + account_name: 'Beräknat resultat', current_period: report.net_result_current, prior_period: report.net_result_prior, }) diff --git a/lib/reports/__tests__/cross-surface-agreement.test.ts b/lib/reports/__tests__/cross-surface-agreement.test.ts index cbb5fe67..8e7c313f 100644 --- a/lib/reports/__tests__/cross-surface-agreement.test.ts +++ b/lib/reports/__tests__/cross-surface-agreement.test.ts @@ -19,6 +19,12 @@ * is asserted explicitly, so when Stage 2 moves generateIncomeStatement to * 'exclude-final' this test says exactly which expectations must change instead * of failing vaguely. + * + * One deliberate exception inside the operational family (#2455): a booked or + * imported 8999 omföring is listed by Resultatrapport (account-level, reads 0 + * after the omföring like a Fortnox/Visma resultatrapport) but excluded by + * Resultaträkning (ÅRL uppställningsform, årets resultat is always computed). + * That gap is pinned below too. */ import { describe, it, expect, vi, beforeEach } from 'vitest' @@ -40,6 +46,7 @@ import { EXPECTED, PRE_CLOSING_ROWS, rowsForMode, + tbRow, } from './closed-year-fixture' const COMPANY_ID = 'company-1' @@ -155,6 +162,35 @@ describe('operational surfaces agree with each other', () => { expect(is.net_result).toBe(rr.net_result_current) }) + it('differ by exactly a booked 8999 omföring, the one deliberate gap (#2455)', async () => { + // Same closed year, plus a manual/imported omföring: D 8999 / K 2099. + const omforing = EXPECTED.resultAfterFinancial + vi.mocked(generateTrialBalance).mockImplementation(async (_s, _c, _p, opts) => ({ + rows: + opts.closingEntry === 'exclude-all-year-end' + ? [ + ...rowsForMode(opts.closingEntry), + tbRow('8999', 'Årets resultat', omforing), + tbRow('2099', 'Årets resultat', -omforing), + ] + : rowsForMode(opts.closingEntry), + totalDebit: 0, + totalCredit: 0, + isBalanced: true, + })) + + const is = await generateIncomeStatement(makeSupabase(), COMPANY_ID, PERIOD_ID) + const rr = await generateResultatrapport(makeSupabase(), COMPANY_ID, PERIOD_ID) + + // Resultaträkning ignores 8999 and still reports the computed result. + expect(is.net_result).toBe(EXPECTED.resultAfterFinancial) + // Resultatrapport lists the row and its beräknat resultat reads zero. + const row8999 = rr.groups.flatMap((g) => g.rows).find((r) => r.account_number === '8999') + expect(row8999?.current_period).toBe(-omforing) + expect(rr.net_result_current).toBe(0) + expect(is.net_result - rr.net_result_current).toBe(omforing) + }) + it('and the same revenue as the statutory family', async () => { const is = await generateIncomeStatement(makeSupabase(), COMPANY_ID, PERIOD_ID) const ink2 = await generateINK2Declaration(makeSupabase(), COMPANY_ID, PERIOD_ID) diff --git a/lib/reports/__tests__/dimension-pnl.test.ts b/lib/reports/__tests__/dimension-pnl.test.ts index 90796226..8b82c929 100644 --- a/lib/reports/__tests__/dimension-pnl.test.ts +++ b/lib/reports/__tests__/dimension-pnl.test.ts @@ -116,7 +116,6 @@ describe('generateDimensionPnl', () => { tbRow({ account_number: '3001', account_class: 3, closing_credit: 1000 }), tbRow({ account_number: '4010', account_name: 'Inköp', account_class: 4, closing_debit: 400 }), tbRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 900 }), - tbRow({ account_number: '8999', account_name: 'Årets resultat', account_class: 8, closing_debit: 600 }), ]), ) @@ -146,12 +145,43 @@ describe('generateDimensionPnl', () => { } expect(report.net_per_column).toEqual([200, 300, 100]) - // net_total = resultatrapport semantics over classes 3-8 excl 8999: - // +1000 (3001) − 400 (4010) = 600. 1930 (class 1) and 8999 excluded. + // net_total = resultatrapport semantics over classes 3-8: + // +1000 (3001) − 400 (4010) = 600. 1930 (class 1) excluded. expect(report.net_total).toBe(600) expect(report.period).toEqual({ start: '2026-01-01', end: '2026-12-31' }) }) + it('lists a booked 8999 in the untagged bucket and nets it into net_total (#2455)', async () => { + mockResults = { + fiscal_periods: [{ data: PERIOD, error: null }], + dimensions: [{ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt' }, error: null }], + dimension_values: [{ data: [{ code: 'P001', name: 'Villa Almgren' }], error: null }], + journal_entry_lines: [ + { + data: [ + { id: 'l1', account_number: '3001', debit_amount: 0, credit_amount: 1000, dimensions: { '6': 'P001' } }, + ], + error: null, + }, + ], + } + mockTrialBalance.mockResolvedValue( + tb([ + tbRow({ account_number: '3001', account_class: 3, closing_credit: 1000 }), + // Manual omföring of årets resultat: 8999 debit, never dimension-tagged. + tbRow({ account_number: '8999', account_name: 'Årets resultat', account_class: 8, closing_debit: 1000 }), + ]), + ) + + const report = await generateDimensionPnl(supabase, 'company-1', 'period-1', '6') + + const row8999 = report.groups.flatMap((g) => g.rows).find((r) => r.account_number === '8999') + expect(row8999?.total).toBe(-1000) + // Same scope as resultatrapport: the omföring zeroes the result. + expect(report.net_total).toBe(0) + expect(report.net_per_column).toEqual([1000, -1000]) + }) + it('drops the untagged column when every krona is tagged', async () => { mockResults = { fiscal_periods: [{ data: PERIOD, error: null }], diff --git a/lib/reports/__tests__/resultatrapport.test.ts b/lib/reports/__tests__/resultatrapport.test.ts index 9d395782..2a9193fd 100644 --- a/lib/reports/__tests__/resultatrapport.test.ts +++ b/lib/reports/__tests__/resultatrapport.test.ts @@ -172,7 +172,7 @@ describe('generateResultatrapport', () => { expect(discontinued.prior_period).toBe(5000) }) - it('excludes account 8999 (year-end closing account)', async () => { + it('lists a booked 8999 as a class-8 row and nets it into beräknat resultat (#2455)', async () => { const q = createQueuedMockSupabase() q.enqueue({ data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: null }, @@ -190,8 +190,11 @@ describe('generateResultatrapport', () => { const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1') const class8 = report.groups.find((g) => g.class === 8) - expect(class8).toBeUndefined() - expect(report.net_result_current).toBe(100000) + const row8999 = class8?.rows.find((r) => r.account_number === '8999') + expect(row8999?.current_period).toBe(-100000) + // The omföring of årets resultat zeroes the operational result, exactly + // as a Fortnox/Visma resultatrapport reads after bokslut. + expect(report.net_result_current).toBe(0) }) it('ignores balance accounts (class 1-2)', async () => { diff --git a/lib/reports/dimension-pnl.ts b/lib/reports/dimension-pnl.ts index 06254127..e423e4d0 100644 --- a/lib/reports/dimension-pnl.ts +++ b/lib/reports/dimension-pnl.ts @@ -131,7 +131,7 @@ export async function generateDimensionPnl( }) // Bucket raw amounts per (account, code). Only accounts present in the P&L - // trial-balance scope count: anything else (balance accounts, 8999) is out. + // trial-balance scope count: anything else (balance accounts) is out. const buckets = new Map>() const codesSeen = new Set() for (const line of taggedLines) { @@ -235,10 +235,10 @@ export async function generateDimensionPnl( } } +// Same scope as resultatrapport's filterPnl, 8999 included (#2455): the +// Totalt column must reconcile with that report's "Beräknat resultat". function filterPnl(rows: TrialBalanceRow[]): TrialBalanceRow[] { - return rows.filter( - (r) => r.account_class >= 3 && r.account_class <= 8 && r.account_number !== '8999' - ) + return rows.filter((r) => r.account_class >= 3 && r.account_class <= 8) } // credit − debit: revenue positive, expenses negative: resultatrapport's diff --git a/lib/reports/resultatrapport.ts b/lib/reports/resultatrapport.ts index c63517ed..65a386ac 100644 --- a/lib/reports/resultatrapport.ts +++ b/lib/reports/resultatrapport.ts @@ -26,9 +26,14 @@ const CLASS_LABELS: Record = { * keeps account numbers and is meant for ongoing reconciliation, not for * årsbokslut/årsredovisning. * - * Account 8999 is excluded: it's the year-end closing account that moves - * årets resultat into equity (2099). Including its balance would double-count - * the result. Same exclusion as generateIncomeStatement. + * Account 8999 is listed like any other class-8 account (#2455). It only + * carries a balance when the user books or imports the omföring of årets + * resultat by hand (our own bokslut verifikat zeroes each P&L account straight + * against 2099 and never touches 8999). Listing it makes "Beräknat resultat" + * read zero after that omföring, the Fortnox/Visma resultatrapport + * convention, and keeps this report in agreement with huvudboken. The formal + * Resultaträkning (generateIncomeStatement) still excludes 8999: ÅRL's + * uppställningsform has no such line, årets resultat is always computed there. */ export async function generateResultatrapport( supabase: SupabaseClient, @@ -204,12 +209,7 @@ export async function generateResultatrapport( } function filterPnl(rows: TrialBalanceRow[]): TrialBalanceRow[] { - return rows.filter( - (r) => - r.account_class >= 3 && - r.account_class <= 8 && - r.account_number !== '8999' - ) + return rows.filter((r) => r.account_class >= 3 && r.account_class <= 8) } /**