From 857dd575d03a69943b12ece3ce21a6e3d86c30e0 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:53:19 +0200 Subject: [PATCH] fix: harden kontantmetod year-end cutoff (#1592) --- DECISIONS.md | 1 + .../kontantmetod-cutoff-tool.test.ts | 46 +- extensions/general/mcp-server/server.ts | 16 +- .../intents/__tests__/bokslut-step.test.ts | 1 + .../__tests__/kontantmetod-cutoff.test.ts | 322 +++++++++++- lib/core/bookkeeping/kontantmetod-cutoff.ts | 487 +++++++++++++----- .../kontantmetod-cutoff-executor.test.ts | 86 +++- lib/pending-operations/commit.ts | 30 +- lib/reports/__tests__/kpi.test.ts | 2 +- lib/reports/__tests__/vat-declaration.test.ts | 8 + lib/reports/vat-declaration.ts | 1 + lib/vat/__tests__/moms-box-mapping.test.ts | 1 + lib/vat/moms-box-mapping.ts | 1 + ...kontantmetod_cutoff_live_marker_unique.sql | 28 + ...xclude_kontantmetod_reversals_from_vat.sql | 111 ++++ .../pg/kontantmetod-cutoff-unique.pg.test.ts | 47 ++ .../pg/vat-declaration-totals-rpc.pg.test.ts | 34 +- 17 files changed, 1062 insertions(+), 160 deletions(-) create mode 100644 supabase/migrations/20260813124509_kontantmetod_cutoff_live_marker_unique.sql create mode 100644 supabase/migrations/20260813124510_exclude_kontantmetod_reversals_from_vat.sql create mode 100644 tests/pg/kontantmetod-cutoff-unique.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 11a43c8f..37b0fb81 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -931,3 +931,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-13] Skeptic refutation fix on the renewal guard: the picker's gap-fill probe keys its state by connectionId and lists `accounts` in its deps, so the pre-existing reset effect (re-runs on every accounts identity change, e.g. the panel's visibility refetch after a BankID app switch) can never wipe the renewal default without a matching re-probe. Backfill sweep hardening from the same pass: accounts whose cash_accounts row did not resolve (found: false) are skipped instead of degrading to the pooled currency-only form, and the sweep window opens at the oldest returned booking date when the bank over-returns history. resolveGapFillStart clamps to the backend's 365-day floor so the shown date always matches the actual backfill start. [2026-08-13] WhatsApp decline observability (#1552) reuses whatsapp_messages with content-free rows for unknown-sender declines instead of a new table or aggregate RPC: no migration (no orphan risk), the wamid unique index gives redelivery dedupe for free (a redelivered bad-code or greeted message no longer earns a second reply), and the existing 30-day unknown-sender retention pass already deletes the rows. Write amplification from an over-quota flood is bounded by a 20-rows-per-hash-per-day trace cap, not by dropping the trail entirely. The settings panel gets a closed event enum derived server-side (lib/last-event.ts), never raw error_message text, so internal errors cannot leak to the client. [2026-08-13] Issue #546 ships a provider-agnostic Peppol BIS Billing 3 XML export with strict Swedish preflight, not a fake send path: certified access-point delivery, SMP lookup, receipts, inbound handling, credentials, and commercial terms depend on Emil selecting and contracting a multitenant provider, and the existing email delivery state cannot truthfully model those guarantees. +[2026-08-13] Kontantmetoden year-end VAT supersedes the 2026-08-06 VAT-reporting premise: BAS 2618/2628/2638 and 2648 feed the final declaration, reverse-charge purchases include both VAT sides and their basis, and only the mechanical day-one reversal is excluded from later VAT periods. Skatteverket requires unpaid invoice VAT in the final period and warns against reporting it twice after year end. diff --git a/extensions/general/mcp-server/__tests__/kontantmetod-cutoff-tool.test.ts b/extensions/general/mcp-server/__tests__/kontantmetod-cutoff-tool.test.ts index e980d428..facee462 100644 --- a/extensions/general/mcp-server/__tests__/kontantmetod-cutoff-tool.test.ts +++ b/extensions/general/mcp-server/__tests__/kontantmetod-cutoff-tool.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/bookkeeping/period-service', async () => { const actual = await vi.importActual( @@ -23,6 +23,7 @@ import { } from '@/lib/core/bookkeeping/kontantmetod-cutoff' const tool = tools.find((candidate) => candidate.name === 'gnubok_post_kontantmetod_cutoff')! +const searchTool = tools.find((candidate) => candidate.name === 'gnubok_search_tools')! function makeSupabase(settings: Record = { accounting_method: 'cash', entity_type: 'aktiebolag', @@ -69,6 +70,8 @@ const collection = { beforeEach(() => { vi.clearAllMocks() + vi.useFakeTimers() + vi.setSystemTime(new Date('2027-02-01T12:00:00Z')) vi.mocked(findNextPeriod).mockResolvedValue({ id: 'fp-2', period_start: '2027-01-01', period_end: '2027-12-31', is_closed: false, locked_at: null, @@ -85,6 +88,10 @@ beforeEach(() => { }) }) +afterEach(() => { + vi.useRealTimers() +}) + describe('gnubok_post_kontantmetod_cutoff', () => { it('is a discoverable high-risk staged bookkeeping write with readiness preflight', () => { expect(tool).toBeDefined() @@ -98,6 +105,28 @@ describe('gnubok_post_kontantmetod_cutoff', () => { }) }) + it('is returned by full catalog search with its approval metadata', async () => { + const result = await searchTool.execute( + { + query: 'kontantmetod cutoff', + detail: 'full', + __keyScopes: ['bookkeeping:write'], + }, + 'company-1', + 'user-1', + makeSupabase() as never, + ) as { tools: Array> } + expect(result.tools).toContainEqual(expect.objectContaining({ + name: 'gnubok_post_kontantmetod_cutoff', + scope: 'bookkeeping:write', + _meta: expect.objectContaining({ + requires_approval: true, + approve_tool: 'gnubok_approve_pending_operation', + preflight: 'gnubok_year_end_readiness', + }), + })) + }) + it('stages the exact two cut-offs and two day-one reversals without posting', async () => { const supabase = makeSupabase() const result = (await tool.execute( @@ -116,7 +145,13 @@ describe('gnubok_post_kontantmetod_cutoff', () => { expect(supabase.inserts[0]).toMatchObject({ operation_type: 'post_kontantmetod_cutoff', risk_level: 'high', - params: { fiscal_period_id: 'fp-1', next_fiscal_period_id: 'fp-2', collection }, + params: { + fiscal_period_id: 'fp-1', + next_fiscal_period_id: 'fp-2', + period_end: '2026-12-31', + entity_type: 'aktiebolag', + preview_fingerprint: expect.stringMatching(/^[a-f0-9]{64}$/), + }, }) }) @@ -158,4 +193,11 @@ describe('gnubok_post_kontantmetod_cutoff', () => { { fiscal_period_id: 'fp-1' }, 'company-1', 'user-1', makeSupabase() as never, )).rejects.toThrow(/Inga obetalda/i) }) + + it('refuses to stage before the fiscal period has ended', async () => { + vi.setSystemTime(new Date('2026-12-01T12:00:00Z')) + await expect(tool.execute( + { fiscal_period_id: 'fp-1' }, 'company-1', 'user-1', makeSupabase() as never, + )).rejects.toThrow(/först efter periodens slut/i) + }) }) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 064707bc..08682e62 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -155,11 +155,12 @@ import { findMatchingVouchersForSupplierInvoice, validateVoucherForSupplierInvoiceLink, } from '@/lib/invoices/supplier-voucher-matching' -import { findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine' +import { findFiscalPeriod, getSwedishLocalDate, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine' import { closePeriod, countUnbookedInPeriod, findNextPeriod, lockPeriod, resolvePeriodStatusForDate, type PeriodStatusForDate } from '@/lib/core/bookkeeping/period-service' import { validateYearEndReadiness, previewYearEndClosing } from '@/lib/core/bookkeeping/year-end-service' import { assessKontantmetodCutoff, + cutoffPreviewFingerprint, hasIncompleteKontantmetodCutoffPair, KONTANTMETOD_CUTOFF_DESCRIPTIONS, nextDay, @@ -13298,6 +13299,9 @@ export const tools: McpTool[] = [ if (period.is_closed || period.locked_at) { throw new Error('Räkenskapsperioden är stängd eller låst') } + if (period.period_end >= getSwedishLocalDate()) { + throw new Error('Kontantmetodens bokslutsavgränsning kan bokföras först efter periodens slut') + } if (settings?.accounting_method !== 'cash') { throw new Error('Företaget använder inte kontantmetoden') } @@ -13349,6 +13353,7 @@ export const tools: McpTool[] = [ } const reversalDate = nextDay(period.period_end) + const entityType = (settings.entity_type ?? 'aktiebolag') as EntityType const entries = [ ...(assessment.lines.receivableLines.length > 0 && !assessment.postings.receivableEntryId && @@ -13405,7 +13410,14 @@ export const tools: McpTool[] = [ { fiscal_period_id: fiscalPeriodId, next_fiscal_period_id: nextPeriod.id, - collection: assessment.collection, + period_end: period.period_end, + entity_type: entityType, + preview_fingerprint: cutoffPreviewFingerprint({ + collection: assessment.collection, + lines: assessment.lines, + entityType, + periodEnd: period.period_end, + }), }, { fiscal_period_id: fiscalPeriodId, diff --git a/lib/agent/intents/__tests__/bokslut-step.test.ts b/lib/agent/intents/__tests__/bokslut-step.test.ts index f35c3804..1a9f5b2a 100644 --- a/lib/agent/intents/__tests__/bokslut-step.test.ts +++ b/lib/agent/intents/__tests__/bokslut-step.test.ts @@ -195,5 +195,6 @@ describe('bokslut.step prompt template', () => { it('exposes gnubok_list_fiscal_periods so the fail-closed instruction is actionable', () => { expect(bokslutStep.tools).toContain('gnubok_list_fiscal_periods') expect(bokslutStep.tools).toContain('gnubok_year_end_readiness') + expect(bokslutStep.tools).toContain('gnubok_post_kontantmetod_cutoff') }) }) diff --git a/lib/core/bookkeeping/__tests__/kontantmetod-cutoff.test.ts b/lib/core/bookkeeping/__tests__/kontantmetod-cutoff.test.ts index ebd52337..efb4eb69 100644 --- a/lib/core/bookkeeping/__tests__/kontantmetod-cutoff.test.ts +++ b/lib/core/bookkeeping/__tests__/kontantmetod-cutoff.test.ts @@ -9,6 +9,7 @@ import { buildCutoffLines, buildCutoffNote, cutoffCollectionsEqual, + cutoffPreviewFingerprint, collectKontantmetodCutoff, distributeOre, inspectKontantmetodCutoffPostings, @@ -64,6 +65,10 @@ describe('distributeOre', () => { expect(distributeOre(500, [])).toEqual([]) expect(distributeOre(500, [7])).toEqual([500]) }) + + it('preserves the sign of a credit amount', () => { + expect(distributeOre(-100, [1, 1, 1])).toEqual([-34, -33, -33]) + }) }) describe('buildCutoffLines: fordringar', () => { @@ -74,8 +79,8 @@ describe('buildCutoffLines: fordringar', () => { expect(debit?.account_number).toBe('1510') expect(debit?.debit_amount).toBe(1250) - // The whole point: moms parks on 2618, NOT 2611, so it stays out of the - // momsdeklaration until the invoice is actually paid. + // Year-end output VAT uses the dedicated BAS account. The final-period + // declaration maps 2618, while its day-one reversal is excluded. const vatLine = receivableLines.find((l) => l.account_number === '2618') expect(vatLine?.credit_amount).toBe(250) expect(receivableLines.some((l) => l.account_number === '2611')).toBe(false) @@ -160,7 +165,8 @@ describe('buildCutoffLines: skulder', () => { expect(credit?.account_number).toBe('2440') expect(credit?.credit_amount).toBe(1250) - // 2648, not 2641: the deduction is not claimable until payment. + // Year-end input VAT uses the dedicated BAS account and is claimed in the + // final VAT period under bokslutsmetoden. expect(payableLines.find((l) => l.account_number === VILANDE_INPUT_VAT_ACCOUNT)?.debit_amount).toBe(250) expect(payableLines.some((l) => l.account_number === '2641')).toBe(false) @@ -213,6 +219,31 @@ describe('buildCutoffLines: skulder', () => { const totals = sum(payableLines) expect(totals.debit).toBe(totals.credit) }) + + it('books customer and supplier credit notes with opposite polarity', () => { + const lines = buildCutoffLines( + [receivable({ outstanding: -1250, vat: -250 })], + [payable({ outstanding: -1250, vat: -250 })], + ) + expect(lines.receivableLines.find((line) => line.account_number === '1510')).toMatchObject({ + debit_amount: 0, + credit_amount: 1250, + }) + expect(lines.receivableLines.find((line) => line.account_number === '3001')).toMatchObject({ + debit_amount: 1000, + credit_amount: 0, + }) + expect(lines.payableLines.find((line) => line.account_number === '2440')).toMatchObject({ + debit_amount: 1250, + credit_amount: 0, + }) + expect(lines.payableLines.find((line) => line.account_number === '5410')).toMatchObject({ + debit_amount: 0, + credit_amount: 1000, + }) + expect(sum(lines.receivableLines)).toEqual({ debit: 1250, credit: 1250 }) + expect(sum(lines.payableLines)).toEqual({ debit: 1250, credit: 1250 }) + }) }) describe('reverseLines', () => { @@ -275,7 +306,13 @@ describe('cut-off snapshot and posting inspection', () => { query.select = () => query query.eq = () => query query.in = () => query - query.then = (resolve: (value: unknown) => unknown) => resolve({ data: rows, error }) + query.then = (resolve: (value: unknown) => unknown) => resolve({ + data: (rows as Array>).map((row) => ({ + entry_date: row.fiscal_period_id === 'fp-2' ? '2027-01-01' : '2026-12-31', + ...row, + })), + error, + }) return query }, }) as never @@ -300,6 +337,40 @@ describe('cut-off snapshot and posting inspection', () => { ).toBe(false) }) + it('fingerprints the exact preview and all derivation inputs canonically', () => { + const first = { + receivables: [receivable({ id: 'b' }), receivable({ id: 'a' })], + payables: [payable({ id: 'p' })], + unknownVatTreatment: [], + strayVatOnZeroRate: [], + } + const reordered = { ...first, receivables: [...first.receivables].reverse() } + const fingerprint = cutoffPreviewFingerprint({ + collection: first, + lines: buildCutoffLines(first.receivables, first.payables, 'aktiebolag'), + entityType: 'aktiebolag', + periodEnd: '2026-12-31', + }) + expect(cutoffPreviewFingerprint({ + collection: reordered, + lines: buildCutoffLines(reordered.receivables, reordered.payables, 'aktiebolag'), + entityType: 'aktiebolag', + periodEnd: '2026-12-31', + })).toBe(fingerprint) + expect(cutoffPreviewFingerprint({ + collection: first, + lines: buildCutoffLines(first.receivables, first.payables, 'enskild_firma'), + entityType: 'enskild_firma', + periodEnd: '2026-12-31', + })).not.toBe(fingerprint) + expect(cutoffPreviewFingerprint({ + collection: first, + lines: buildCutoffLines(first.receivables, first.payables, 'aktiebolag'), + entityType: 'aktiebolag', + periodEnd: '2027-06-30', + })).not.toBe(fingerprint) + }) + it('requires exact cut-off lines and exact next-period reversals', async () => { const lines = buildCutoffLines([receivable()], [payable()]) const rows = [ @@ -326,7 +397,7 @@ describe('cut-off snapshot and posting inspection', () => { ] const status = await inspectKontantmetodCutoffPostings( - makeJournalSupabase(rows), 'co-1', 'fp-1', 'fp-2', lines, + makeJournalSupabase(rows), 'co-1', 'fp-1', 'fp-2', '2026-12-31', lines, ) expect(status).toMatchObject({ complete: true, @@ -358,7 +429,24 @@ describe('cut-off snapshot and posting inspection', () => { ] const status = await inspectKontantmetodCutoffPostings( - makeJournalSupabase(rows), 'co-1', 'fp-1', 'fp-2', lines, + makeJournalSupabase(rows), 'co-1', 'fp-1', 'fp-2', '2026-12-31', lines, + ) + expect(status.complete).toBe(false) + expect(status.missing).toContain('receivable') + expect(status.duplicates).toContain(KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable) + }) + + it('treats an otherwise exact marker on the wrong date as a conflict', async () => { + const lines = buildCutoffLines([receivable()], []) + const status = await inspectKontantmetodCutoffPostings( + makeJournalSupabase([{ + id: 'ar', + fiscal_period_id: 'fp-1', + entry_date: '2026-12-30', + description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable, + lines: lines.receivableLines, + }]), + 'co-1', 'fp-1', 'fp-2', '2026-12-31', lines, ) expect(status.complete).toBe(false) expect(status.missing).toContain('receivable') @@ -386,7 +474,7 @@ describe('cut-off snapshot and posting inspection', () => { ] const status = await inspectKontantmetodCutoffPostings( - makeJournalSupabase(rows), 'co-1', 'fp-1', 'fp-2', lines, + makeJournalSupabase(rows), 'co-1', 'fp-1', 'fp-2', '2026-12-31', lines, ) expect(status.complete).toBe(false) expect(status.missing).toContain('receivable') @@ -397,18 +485,37 @@ describe('cut-off snapshot and posting inspection', () => { await expect( inspectKontantmetodCutoffPostings( makeJournalSupabase([], { message: 'connection lost' }), - 'co-1', 'fp-1', 'fp-2', buildCutoffLines([], []), + 'co-1', 'fp-1', 'fp-2', '2026-12-31', buildCutoffLines([], []), ), ).rejects.toThrow(/kunde inte kontrolleras/i) }) }) describe('collectKontantmetodCutoff', () => { + function makePagedSupabase(rows: Record>>) { + return { + from: (table: string) => { + let range = { from: 0, to: 999 } + const query: Record = {} + for (const name of ['select', 'eq', 'lte', 'in', 'order']) query[name] = () => query + query.range = (from: number, to: number) => { + range = { from, to } + return query + } + query.then = (resolve: (value: unknown) => unknown) => resolve({ + data: (rows[table] ?? []).slice(range.from, range.to + 1), + error: null, + }) + return query + }, + } + } + it('fails closed when either reskontra query fails', async () => { const supabase = { from: (table: string) => { const query: Record = {} - for (const name of ['select', 'eq', 'lte', 'in']) query[name] = () => query + for (const name of ['select', 'eq', 'lte', 'in', 'order', 'range']) query[name] = () => query query.then = (resolve: (value: unknown) => unknown) => resolve({ data: table === 'supplier_invoices' ? [] : null, error: table === 'invoices' ? { message: 'read failed' } : null, @@ -432,7 +539,7 @@ describe('collectKontantmetodCutoff', () => { const supabase = { from: (table: string) => { const query: Record = {} - for (const name of ['select', 'eq', 'lte', 'in']) query[name] = () => query + for (const name of ['select', 'eq', 'lte', 'in', 'order', 'range']) query[name] = () => query query.then = (resolve: (value: unknown) => unknown) => resolve({ data: rows[table] ?? [], error: table === 'invoice_payments' ? { message: 'payment read failed' } : null, @@ -444,6 +551,124 @@ describe('collectKontantmetodCutoff', () => { collectKontantmetodCutoff(supabase as never, 'co-1', '2026-01-01', '2026-12-31'), ).rejects.toThrow(/kunde inte läsa betalningar/i) }) + + it('keeps payments in invoice currency until the SEK balance is scaled', async () => { + const supabase = makePagedSupabase({ + invoices: [{ + id: 'inv-eur', invoice_number: 'F-EUR', invoice_date: '2026-12-01', status: 'partially_paid', + total: 1000, total_sek: 11500, vat_amount: 200, vat_amount_sek: 2300, + vat_treatment: 'standard_25', document_type: 'invoice', currency: 'EUR', exchange_rate: 11.5, + }], + invoice_payments: [{ id: 'ip-1', invoice_id: 'inv-eur', amount: 200, payment_date: '2026-12-15' }], + supplier_invoices: [{ + id: 'si-eur', supplier_invoice_number: 'L-EUR', invoice_date: '2026-12-01', status: 'partially_paid', + total: 1000, total_sek: 11500, vat_amount: 200, vat_amount_sek: 2300, + reverse_charge: false, is_credit_note: false, currency: 'EUR', exchange_rate: 11.5, + items: [{ account_number: '5410', line_total: 800 }], + }], + supplier_invoice_payments: [{ id: 'sp-1', supplier_invoice_id: 'si-eur', amount: 200, payment_date: '2026-12-15' }], + }) + const result = await collectKontantmetodCutoff( + supabase as never, 'co-1', '2026-01-01', '2026-12-31', + ) + expect(result.receivables[0]).toMatchObject({ outstanding: 9200, vat: 1840 }) + expect(result.payables[0]).toMatchObject({ outstanding: 9200, vat: 1840 }) + }) + + it('collects reverse-charge rate, supplier type, and scaled declaration basis', async () => { + const result = await collectKontantmetodCutoff(makePagedSupabase({ + supplier_invoices: [{ + id: 'si-rc', supplier_invoice_number: 'L-RC', invoice_date: '2026-12-01', + status: 'partially_paid', total: 1000, total_sek: 11500, + vat_amount: 0, vat_amount_sek: 0, reverse_charge: true, + is_credit_note: false, currency: 'EUR', exchange_rate: 11.5, + supplier: { supplier_type: 'eu_business' }, + items: [{ + account_number: '6540', line_total: 1000, vat_rate: 0, + reverse_charge_rate: 0.12, + }], + }], + supplier_invoice_payments: [{ + id: 'sp-rc', supplier_invoice_id: 'si-rc', amount: 200, payment_date: '2026-12-15', + }], + }) as never, 'co-1', '2026-01-01', '2026-12-31') + expect(result.payables[0]).toMatchObject({ + outstanding: 9200, + vat: 0, + reverseCharge: true, + reverseChargeGroups: [{ + rate: 0.12, + base: 9200, + nonBasisBase: 9200, + supplierType: 'eu_business', + }], + }) + const lines = buildCutoffLines([], result.payables).payableLines + expect(lines.find((line) => line.account_number === '2624')?.credit_amount).toBe(1104) + expect(lines.find((line) => line.account_number === '2645')?.debit_amount).toBe(1104) + expect(lines.find((line) => line.account_number === '4536')?.debit_amount).toBe(9200) + }) + + it('reads every PostgREST page instead of silently stopping at 1000 rows', async () => { + const invoices = Array.from({ length: 1001 }, (_, index) => ({ + id: `inv-${String(index).padStart(4, '0')}`, + invoice_number: `F-${index}`, + invoice_date: '2026-12-01', + status: 'sent', + total: 100, + total_sek: 100, + vat_amount: 0, + vat_amount_sek: 0, + vat_treatment: 'exempt', + document_type: 'invoice', + currency: 'SEK', + exchange_rate: 1, + })) + const result = await collectKontantmetodCutoff( + makePagedSupabase({ invoices }) as never, + 'co-1', '2026-01-01', '2026-12-31', + ) + expect(result.receivables).toHaveLength(1001) + expect(result.receivables.at(-1)?.id).toBe('inv-1000') + }) + + it('reconstructs customer and supplier credits as of period end', async () => { + const result = await collectKontantmetodCutoff(makePagedSupabase({ + invoices: [ + { + id: 'inv-original', invoice_number: 'F-1', invoice_date: '2026-11-01', status: 'credited', + total: 1250, total_sek: 1250, vat_amount: 250, vat_amount_sek: 250, + vat_treatment: 'standard_25', document_type: 'invoice', currency: 'SEK', + }, + { + id: 'inv-credit', invoice_number: 'K-1', invoice_date: '2026-12-01', status: 'sent', + total: -1250, total_sek: -1250, vat_amount: -250, vat_amount_sek: -250, + vat_treatment: 'standard_25', document_type: 'invoice', currency: 'SEK', + credited_invoice_id: 'inv-original', + }, + ], + supplier_invoices: [ + { + id: 'si-original', supplier_invoice_number: 'L-1', invoice_date: '2026-11-01', status: 'credited', + total: 1250, total_sek: 1250, vat_amount: 250, vat_amount_sek: 250, + reverse_charge: false, is_credit_note: false, currency: 'SEK', + items: [{ account_number: '5410', line_total: 1000 }], + }, + { + id: 'si-credit', supplier_invoice_number: 'LK-1', invoice_date: '2026-12-01', status: 'registered', + total: 1250, total_sek: 1250, vat_amount: 250, vat_amount_sek: 250, + reverse_charge: false, is_credit_note: true, currency: 'SEK', + credited_invoice_id: 'si-original', + items: [{ account_number: '5410', line_total: 1000 }], + }, + ], + }) as never, 'co-1', '2026-01-01', '2026-12-31') + expect(result.receivables.map((item) => item.outstanding)).toEqual([1250, -1250]) + expect(result.payables.map((item) => item.outstanding)).toEqual([1250, -1250]) + const lines = buildCutoffLines(result.receivables, result.payables) + expect(lines.receivableLines).toEqual([]) + expect(lines.payableLines).toEqual([]) + }) }) describe('postKontantmetodCutoff', () => { @@ -466,7 +691,20 @@ describe('postKontantmetodCutoff', () => { error: table === 'fiscal_periods' && !next ? { message: 'x' } : null, }) query.then = (resolve: (value: unknown) => unknown) => - resolve({ data: table === 'journal_entries' ? journalRows : null, error: null }) + resolve({ + data: table === 'journal_entries' + ? journalRows.map((row) => { + const entry = row as Record + return { + ...entry, + entry_date: entry.entry_date ?? ( + entry.fiscal_period_id === 'fp-next' ? '2027-01-01' : '2026-12-31' + ), + } + }) + : null, + error: null, + }) return query }, }) as never @@ -617,14 +855,21 @@ describe('postKontantmetodCutoff', () => { await expect( postKontantmetodCutoff(makeSupabase(OPEN_NEXT), 'co-1', 'user-1', baseOpts), - ).rejects.toThrow('period locked') + ).rejects.toMatchObject({ + name: 'KontantmetodCutoffPartialError', + postedIds: { + receivable_entry_id: 'je-cutoff', + receivable_storno_entry_id: 'je-storno', + }, + cause: expect.objectContaining({ message: 'period locked' }), + }) expect(vi.mocked(reverseEntry)).toHaveBeenCalledWith( expect.anything(), 'co-1', 'user-1', 'je-cutoff', '2026-12-31', ) }) - it('still rethrows the original error when the compensating storno also fails', async () => { + it('reports the immutable cut-off id when the compensating storno also fails', async () => { vi.mocked(createJournalEntry) .mockResolvedValueOnce({ id: 'je-cutoff' } as never) .mockRejectedValueOnce(new Error('period locked')) @@ -632,7 +877,32 @@ describe('postKontantmetodCutoff', () => { await expect( postKontantmetodCutoff(makeSupabase(OPEN_NEXT), 'co-1', 'user-1', baseOpts), - ).rejects.toThrow('period locked') + ).rejects.toMatchObject({ + name: 'KontantmetodCutoffPartialError', + postedIds: { receivable_entry_id: 'je-cutoff' }, + cause: expect.objectContaining({ message: 'period locked' }), + }) + }) + + it('reports the completed receivable pair when the payable phase fails', async () => { + vi.mocked(createJournalEntry) + .mockResolvedValueOnce({ id: 'ar' } as never) + .mockResolvedValueOnce({ id: 'ar-rev' } as never) + .mockRejectedValueOnce(new Error('payable failed')) + + await expect(postKontantmetodCutoff( + makeSupabase(OPEN_NEXT), + 'co-1', + 'user-1', + { ...baseOpts, payables: [payable()] }, + )).rejects.toMatchObject({ + name: 'KontantmetodCutoffPartialError', + postedIds: { + receivable_entry_id: 'ar', + receivable_reversal_entry_id: 'ar-rev', + }, + cause: expect.objectContaining({ message: 'payable failed' }), + }) }) it('posts nothing at all when there is nothing outstanding', async () => { @@ -647,16 +917,28 @@ describe('postKontantmetodCutoff', () => { }) describe('buildCutoffLines: omvänd betalningsskyldighet', () => { - it('never routes reverse-charge moms into the single vilande bucket', () => { - // A one-sided reverse charge is prohibited: the self-assessed output/input - // pair belongs to the payment entry, not to a deferred 2648 balance. + it('books the complete self-assessed pair and declaration basis at year end', () => { const { payableLines } = buildCutoffLines( [], - [payable({ outstanding: 1000, vat: 250, reverseCharge: true, netByAccount: [{ account: '4056', amount: 1000 }] })], + [payable({ + outstanding: 1000, + vat: 0, + reverseCharge: true, + reverseChargeGroups: [{ + rate: 0.25, + base: 1000, + nonBasisBase: 1000, + supplierType: 'eu_business', + }], + netByAccount: [{ account: '6540', amount: 1000 }], + })], ) expect(payableLines.some((l) => l.account_number === VILANDE_INPUT_VAT_ACCOUNT)).toBe(false) - // The full outstanding is expense against 2440. - expect(payableLines.find((l) => l.account_number === '4056')?.debit_amount).toBe(1000) + expect(payableLines.find((l) => l.account_number === '2645')?.debit_amount).toBe(250) + expect(payableLines.find((l) => l.account_number === '2614')?.credit_amount).toBe(250) + expect(payableLines.find((l) => l.account_number === '4535')?.debit_amount).toBe(1000) + expect(payableLines.find((l) => l.account_number === '4598')?.credit_amount).toBe(1000) + expect(payableLines.find((l) => l.account_number === '6540')?.debit_amount).toBe(1000) expect(payableLines.find((l) => l.account_number === '2440')?.credit_amount).toBe(1000) const totals = sum(payableLines) expect(totals.debit).toBe(totals.credit) diff --git a/lib/core/bookkeeping/kontantmetod-cutoff.ts b/lib/core/bookkeeping/kontantmetod-cutoff.ts index 6bae91cf..2f6e4506 100644 --- a/lib/core/bookkeeping/kontantmetod-cutoff.ts +++ b/lib/core/bookkeeping/kontantmetod-cutoff.ts @@ -7,14 +7,11 @@ * räkenskapsårets utgång, so the year-end needs a cut-off entry that puts every * still-outstanding invoice onto the balance sheet. * - * Moms is the part that is easy to get wrong. Under bokslutsmetoden moms is - * reported at payment, so the cut-off must NOT push moms into the current - * momsdeklaration. BAS provides "vilande" (dormant) moms accounts for exactly - * this: 2618/2628/2638 for utgående and 2648 for ingående. They are absent from - * ACCOUNT_RUTA / ACCOUNT_TO_BOX by design, so anything parked there stays out - * of the declaration until the invoice is actually paid. Booking cut-off moms - * to 2611/2641 instead would claim it a period early, which is the real error - * this module exists to avoid. + * Moms is the part that is easy to get wrong. Under bokslutsmetoden, unpaid + * invoice moms must be included in the final VAT period of the year. BAS + * provides 2618/2628/2638 for year-end output VAT and 2648 for year-end input + * VAT. The VAT report maps those accounts for the cut-off date, while excluding + * the mechanical day-one reversal so it cannot undo the final declaration. * * Shape: two aggregate verifikat (one for fordringar, one for skulder), each * reversed on the first day of the following period. Deliberately NOT @@ -33,6 +30,7 @@ * basis before any new-year payment is booked. */ import type { SupabaseClient } from '@supabase/supabase-js' +import { createHash } from 'node:crypto' import type { CreateJournalEntryLineInput, EntityType, @@ -40,9 +38,16 @@ import type { VatTreatment, } from '@/types' import { getRevenueAccount } from '@/lib/bookkeeping/invoice-entries' +import { + generateReverseChargeBasisLines, + generateReverseChargeLines, + isReverseChargeBasisAccount, + resolveReverseChargeRate, +} from '@/lib/bookkeeping/vat-entries' import { createJournalEntry, reverseEntry } from '@/lib/bookkeeping/engine' import { createLogger } from '@/lib/logger' import { ORE_TOLERANCE, roundOre } from '@/lib/money' +import { fetchAllRows } from '@/lib/supabase/fetch-all' const log = createLogger('kontantmetod-cutoff') @@ -95,12 +100,17 @@ export interface CutoffPayable { * self-assesses output AND input moms on 2614/2624/2634 + 2645/2647, which * is a symmetric pair that must never be split. `vat` is 0 on every such row * by construction, and this flag forces it to 0 anyway: routing a stray - * amount into the single 2648 bucket would post a one-sided reverse charge, - * the exact error the swedish-vat reference calls out as prohibited. - * The self-assessed pair is handled by the payment entry after the vändning, - * unchanged by the cut-off. + * amount into the single 2648 bucket would post a one-sided reverse charge. + * The complete output/input pair and its declaration basis are included in + * the final VAT period through `reverseChargeGroups` below. */ reverseCharge?: boolean + reverseChargeGroups?: Array<{ + rate: number + base: number + nonBasisBase: number + supplierType: 'eu_business' | 'non_eu_business' | 'swedish_business' + }> /** * Net expense split across BAS accounts, as weights. Only the ratios matter: * the net total is always derived as `outstanding - vat` so the verifikat @@ -119,6 +129,7 @@ export interface CutoffLines { interface PostedCutoffEntry { id: string fiscal_period_id: string + entry_date: string description: string lines: Array<{ account_number: string @@ -166,14 +177,17 @@ export function distributeOre(totalOre: number, weights: number[]): number[] { if (weights.length === 0) return [] if (weights.length === 1) return [totalOre] + const sign = totalOre < 0 ? -1 : 1 + const absoluteTotal = Math.abs(totalOre) + const weightSum = weights.reduce((sum, w) => sum + Math.abs(w), 0) // Degenerate input (all-zero weights): put everything on the first bucket // rather than emitting NaN. if (weightSum === 0) return weights.map((_, i) => (i === 0 ? totalOre : 0)) - const exact = weights.map((w) => (Math.abs(w) / weightSum) * totalOre) + const exact = weights.map((w) => (Math.abs(w) / weightSum) * absoluteTotal) const floors = exact.map((value) => Math.floor(value)) - let remainder = totalOre - floors.reduce((sum, value) => sum + value, 0) + let remainder = absoluteTotal - floors.reduce((sum, value) => sum + value, 0) // Hand the leftover öre to the largest fractional parts first. const order = exact @@ -186,7 +200,24 @@ export function distributeOre(totalOre: number, weights: number[]): number[] { result[index] += 1 remainder -= 1 } - return result + return result.map((value) => value * sign) +} + +function signedLine( + accountNumber: string, + normalSide: 'debit' | 'credit', + signedOre: number, + lineDescription: string, +): CreateJournalEntryLineInput { + const normal = signedOre >= 0 + const amount = toKronor(Math.abs(signedOre)) + const debit = (normalSide === 'debit') === normal + return { + account_number: accountNumber, + debit_amount: debit ? amount : 0, + credit_amount: debit ? 0 : amount, + line_description: lineDescription, + } } /** @@ -230,21 +261,21 @@ export function buildCutoffLines( } if (receivableOre !== 0) { - receivableLines.push({ - account_number: RECEIVABLES_ACCOUNT, - debit_amount: toKronor(receivableOre), - credit_amount: 0, - line_description: 'Kundfordringar vid räkenskapsårets utgång (kontantmetoden)', - }) + receivableLines.push(signedLine( + RECEIVABLES_ACCOUNT, + 'debit', + receivableOre, + 'Kundfordringar vid räkenskapsårets utgång (kontantmetoden)', + )) for (const [treatment, netOre] of revenueByTreatment) { if (netOre === 0) continue - receivableLines.push({ - account_number: getRevenueAccount(treatment, entityType), - debit_amount: 0, - credit_amount: toKronor(netOre), - line_description: 'Obetalda kundfakturor vid bokslut', - }) + receivableLines.push(signedLine( + getRevenueAccount(treatment, entityType), + 'credit', + netOre, + 'Obetalda kundfakturor vid bokslut', + )) } for (const [treatment, vatOre] of outputVatByTreatment) { @@ -259,34 +290,40 @@ export function buildCutoffLines( treatment, ore: vatOre, }) - receivableLines.push({ - account_number: getRevenueAccount(treatment, entityType), - debit_amount: 0, - credit_amount: toKronor(vatOre), - line_description: 'Obetalda kundfakturor vid bokslut', - }) + receivableLines.push(signedLine( + getRevenueAccount(treatment, entityType), + 'credit', + vatOre, + 'Obetalda kundfakturor vid bokslut', + )) continue } - receivableLines.push({ - account_number: account, - debit_amount: 0, - credit_amount: toKronor(vatOre), - line_description: 'Vilande utgående moms, redovisas vid betalning', - }) + receivableLines.push(signedLine( + account, + 'credit', + vatOre, + 'Utgående moms på obetald faktura vid bokslut', + )) } } // ---- Skulder ---------------------------------------------------------- const expenseByAccount = new Map() + const reverseChargeByGroup = new Map() let payableOre = 0 let inputVatOre = 0 for (const row of payables) { const outstandingOre = toOre(row.outstanding) if (outstandingOre === 0) continue - // Reverse charge carries no deductible moms on the invoice itself: the - // self-assessed pair is booked by the payment entry, never split into the - // single vilande bucket. Forced to 0 rather than trusted from the row. + // Reverse charge carries no charged moms on the invoice itself. The + // self-assessed pair is built from its basis groups below, never split into + // the single vilande bucket. Force the invoice moms field to zero. const vatOre = row.reverseCharge ? 0 : toOre(row.vat) const netOre = outstandingOre - vatOre @@ -305,41 +342,87 @@ export function buildCutoffLines( if (share === 0) return expenseByAccount.set(bucket.account, (expenseByAccount.get(bucket.account) ?? 0) + share) }) + for (const group of row.reverseChargeGroups ?? []) { + const key = `${group.supplierType}:${group.rate}` + const current = reverseChargeByGroup.get(key) ?? { + rate: group.rate, + baseOre: 0, + nonBasisBaseOre: 0, + supplierType: group.supplierType, + } + current.baseOre += toOre(group.base) + current.nonBasisBaseOre += toOre(group.nonBasisBase) + reverseChargeByGroup.set(key, current) + } } if (payableOre !== 0) { for (const [account, netOre] of expenseByAccount) { if (netOre === 0) continue - payableLines.push({ - account_number: account, - debit_amount: toKronor(netOre), - credit_amount: 0, - line_description: 'Obetalda leverantörsfakturor vid bokslut', - }) + payableLines.push(signedLine( + account, + 'debit', + netOre, + 'Obetalda leverantörsfakturor vid bokslut', + )) } if (inputVatOre !== 0) { - payableLines.push({ - account_number: VILANDE_INPUT_VAT_ACCOUNT, - debit_amount: toKronor(inputVatOre), - credit_amount: 0, - line_description: 'Vilande ingående moms, dras av vid betalning', - }) + payableLines.push(signedLine( + VILANDE_INPUT_VAT_ACCOUNT, + 'debit', + inputVatOre, + 'Ingående moms på obetald faktura vid bokslut', + )) } - payableLines.push({ - account_number: PAYABLES_ACCOUNT, - debit_amount: 0, - credit_amount: toKronor(payableOre), - line_description: 'Leverantörsskulder vid räkenskapsårets utgång (kontantmetoden)', - }) + const appendReverseChargeLines = ( + generated: CreateJournalEntryLineInput[], + sign: number, + ) => { + for (const line of generated) { + const normalSide = line.debit_amount > 0 ? 'debit' : 'credit' + const amount = line.debit_amount || line.credit_amount + payableLines.push(signedLine( + line.account_number, + normalSide, + toOre(amount) * sign, + line.line_description ?? 'Omvänd skattskyldighet vid bokslut', + )) + } + } + for (const group of reverseChargeByGroup.values()) { + if (group.baseOre === 0) continue + const sign = group.baseOre < 0 ? -1 : 1 + const base = toKronor(Math.abs(group.baseOre)) + const nonBasisBase = toKronor(Math.abs(group.nonBasisBaseOre)) + appendReverseChargeLines( + generateReverseChargeLines( + base, + group.rate, + group.supplierType === 'swedish_business', + ), + sign, + ) + appendReverseChargeLines( + generateReverseChargeBasisLines(nonBasisBase, group.rate, group.supplierType), + sign, + ) + } + + payableLines.push(signedLine( + PAYABLES_ACCOUNT, + 'credit', + payableOre, + 'Leverantörsskulder vid räkenskapsårets utgång (kontantmetoden)', + )) } return { receivableLines, payableLines, - receivableTotal: toKronor(receivableOre), - payableTotal: toKronor(payableOre), + receivableTotal: toKronor(Math.abs(receivableOre)), + payableTotal: toKronor(Math.abs(payableOre)), } } @@ -384,12 +467,13 @@ export async function inspectKontantmetodCutoffPostings( companyId: string, fiscalPeriodId: string, nextFiscalPeriodId: string, + periodEnd: string, expected: CutoffLines, ): Promise { const { data, error } = await supabase .from('journal_entries') .select( - 'id, fiscal_period_id, description, lines:journal_entry_lines(account_number, debit_amount, credit_amount)', + 'id, fiscal_period_id, entry_date, description, lines:journal_entry_lines(account_number, debit_amount, credit_amount)', ) .eq('company_id', companyId) .eq('source_type', 'year_end') @@ -411,6 +495,7 @@ export async function inspectKontantmetodCutoffPostings( periodId: string, lines: CreateJournalEntryLineInput[], missingKind: KontantmetodCutoffPostingStatus['missing'][number], + expectedDate: string, ): string | null => { const candidates = rows.filter( (row) => row.description === description && row.fiscal_period_id === periodId, @@ -421,7 +506,9 @@ export async function inspectKontantmetodCutoffPostings( return null } - const exact = candidates.filter((row) => cutoffLinesEqual(lines, row.lines ?? [])) + const exact = candidates.filter( + (row) => row.entry_date === expectedDate && cutoffLinesEqual(lines, row.lines ?? []), + ) if (candidates.length !== 1 || exact.length !== 1) { // Any marker with non-matching lines is a conflict, even when only one // exists. Treating it as merely missing could stage a second cut-off on @@ -438,24 +525,28 @@ export async function inspectKontantmetodCutoffPostings( fiscalPeriodId, expected.receivableLines, 'receivable', + periodEnd, ) const receivableReversalId = matchOne( KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivableReversal, nextFiscalPeriodId, reverseLines(expected.receivableLines), 'receivable_reversal', + nextDay(periodEnd), ) const payableEntryId = matchOne( KONTANTMETOD_CUTOFF_DESCRIPTIONS.payable, fiscalPeriodId, expected.payableLines, 'payable', + periodEnd, ) const payableReversalId = matchOne( KONTANTMETOD_CUTOFF_DESCRIPTIONS.payableReversal, nextFiscalPeriodId, reverseLines(expected.payableLines), 'payable_reversal', + nextDay(periodEnd), ) return { @@ -515,7 +606,7 @@ export interface KontantmetodCutoffAssessment { postings: KontantmetodCutoffPostingStatus } -function sortedCollection(collection: CutoffCollection): CutoffCollection { +export function sortedCutoffCollection(collection: CutoffCollection): CutoffCollection { return { receivables: [...collection.receivables].sort((a, b) => a.id.localeCompare(b.id)), payables: [...collection.payables] @@ -533,7 +624,35 @@ export function cutoffCollectionsEqual( left: CutoffCollection, right: CutoffCollection, ): boolean { - return JSON.stringify(sortedCollection(left)) === JSON.stringify(sortedCollection(right)) + return JSON.stringify(sortedCutoffCollection(left)) === JSON.stringify(sortedCutoffCollection(right)) +} + +function canonicalLines(lines: CreateJournalEntryLineInput[]): string[] { + return lines + .map((line) => JSON.stringify({ + account_number: line.account_number, + debit_amount: roundOre(line.debit_amount), + credit_amount: roundOre(line.credit_amount), + line_description: line.line_description ?? null, + })) + .sort() +} + +export function cutoffPreviewFingerprint(args: { + collection: CutoffCollection + lines: CutoffLines + entityType: EntityType + periodEnd: string +}): string { + const payload = JSON.stringify({ + collection: sortedCutoffCollection(args.collection), + entity_type: args.entityType, + period_end: args.periodEnd, + reversal_date: nextDay(args.periodEnd), + receivable_lines: canonicalLines(args.lines.receivableLines), + payable_lines: canonicalLines(args.lines.payableLines), + }) + return createHash('sha256').update(payload).digest('hex') } /** @@ -558,6 +677,24 @@ export function buildCutoffNote(label: string, references: string[]): string { : `${label} (${named.length} st): ${shown}` } +function resolveHeaderSek( + row: Record, + amountKey: string, + sekKey: string, +): number { + const amount = Number(row[amountKey] ?? 0) + const sekValue = row[sekKey] + const sek = sekValue == null ? null : Number(sekValue) + if (sek != null && Number.isFinite(sek) && (amount === 0 || sek !== 0)) return sek + const currency = String(row.currency ?? 'SEK').toUpperCase() + if (currency === 'SEK') return amount + const rate = Number(row.exchange_rate ?? 0) + if (Number.isFinite(rate) && rate > 0) return roundOre(amount * rate) + throw new Error( + `Faktura ${String(row.id ?? '')} i ${currency} saknar användbart SEK-belopp eller valutakurs`, + ) +} + /** * Fetch every invoice still outstanding at `periodEnd`. * @@ -573,69 +710,81 @@ export async function collectKontantmetodCutoff( periodStart: string, periodEnd: string, ): Promise { - const [invoicesResult, supplierResult] = await Promise.all([ - supabase - .from('invoices') - .select('id, invoice_number, invoice_date, status, total, total_sek, vat_amount, vat_amount_sek, vat_treatment, credited_invoice_id, document_type') - .eq('company_id', companyId) - .lte('invoice_date', periodEnd) - .in('status', ['sent', 'overdue', 'partially_paid', 'paid']), - supabase - .from('supplier_invoices') - .select('id, supplier_invoice_number, invoice_date, status, total, total_sek, vat_amount, vat_amount_sek, reverse_charge, is_credit_note, items:supplier_invoice_items(account_number, line_total)') - .eq('company_id', companyId) - .lte('invoice_date', periodEnd) - .in('status', ['registered', 'approved', 'partially_paid', 'paid']), - ]) - - if (invoicesResult.error || supplierResult.error) { + let invoices: Array> + let supplierInvoices: Array> + try { + [invoices, supplierInvoices] = await Promise.all([ + fetchAllRows>( + ({ from, to }) => supabase + .from('invoices') + .select('id, invoice_number, invoice_date, status, total, total_sek, vat_amount, vat_amount_sek, vat_treatment, credited_invoice_id, document_type, currency, exchange_rate') + .eq('company_id', companyId) + .lte('invoice_date', periodEnd) + .in('status', ['sent', 'overdue', 'partially_paid', 'paid', 'credited']) + .order('id', { ascending: true }) + .range(from, to), + { dedupeBy: (row) => row.id as string }, + ), + fetchAllRows>( + ({ from, to }) => supabase + .from('supplier_invoices') + .select('id, supplier_invoice_number, invoice_date, status, total, total_sek, vat_amount, vat_amount_sek, reverse_charge, is_credit_note, credited_invoice_id, currency, exchange_rate, supplier:suppliers(supplier_type), items:supplier_invoice_items(account_number, line_total, vat_rate, reverse_charge_rate)') + .eq('company_id', companyId) + .lte('invoice_date', periodEnd) + .in('status', ['registered', 'approved', 'partially_paid', 'paid', 'credited']) + .order('id', { ascending: true }) + .range(from, to), + { dedupeBy: (row) => row.id as string }, + ), + ]) + } catch (err) { throw new Error( 'Kontantmetodens bokslutsavgränsning kunde inte läsa reskontran: ' + - (invoicesResult.error?.message ?? supplierResult.error?.message ?? 'okänt fel'), + (err instanceof Error ? err.message : 'okänt fel'), ) } - const invoices = (invoicesResult.data ?? []) as Array> - const supplierInvoices = (supplierResult.data ?? []) as Array> - - const invoiceIds = invoices.map((row) => row.id as string) - const supplierIds = supplierInvoices.map((row) => row.id as string) - // Payments ON OR BEFORE period end reduce the outstanding balance; later - // ones must not. - const [invoicePayments, supplierPayments] = await Promise.all([ - invoiceIds.length > 0 - ? supabase + // ones must not. Amount is stored in the invoice's own currency. + let invoicePayments: Array> + let supplierPayments: Array> + try { + [invoicePayments, supplierPayments] = await Promise.all([ + fetchAllRows>( + ({ from, to }) => supabase .from('invoice_payments') - .select('invoice_id, amount, payment_date') + .select('id, invoice_id, amount, payment_date') .eq('company_id', companyId) .lte('payment_date', periodEnd) - .in('invoice_id', invoiceIds) - : Promise.resolve({ data: [] as Array>, error: null }), - supplierIds.length > 0 - ? supabase + .order('id', { ascending: true }) + .range(from, to), + { dedupeBy: (row) => row.id as string }, + ), + fetchAllRows>( + ({ from, to }) => supabase .from('supplier_invoice_payments') - .select('supplier_invoice_id, amount, payment_date') + .select('id, supplier_invoice_id, amount, payment_date') .eq('company_id', companyId) .lte('payment_date', periodEnd) - .in('supplier_invoice_id', supplierIds) - : Promise.resolve({ data: [] as Array>, error: null }), - ]) - - if (invoicePayments.error || supplierPayments.error) { + .order('id', { ascending: true }) + .range(from, to), + { dedupeBy: (row) => row.id as string }, + ), + ]) + } catch (err) { throw new Error( 'Kontantmetodens bokslutsavgränsning kunde inte läsa betalningar: ' + - (invoicePayments.error?.message ?? supplierPayments.error?.message ?? 'okänt fel'), + (err instanceof Error ? err.message : 'okänt fel'), ) } const paidByInvoice = new Map() - for (const row of (invoicePayments.data ?? []) as Array>) { + for (const row of invoicePayments) { const id = row.invoice_id as string paidByInvoice.set(id, (paidByInvoice.get(id) ?? 0) + Number(row.amount ?? 0)) } const paidBySupplierInvoice = new Map() - for (const row of (supplierPayments.data ?? []) as Array>) { + for (const row of supplierPayments) { const id = row.supplier_invoice_id as string paidBySupplierInvoice.set(id, (paidBySupplierInvoice.get(id) ?? 0) + Number(row.amount ?? 0)) } @@ -650,10 +799,12 @@ export async function collectKontantmetodCutoff( const documentType = row.document_type as string | null if (documentType && documentType !== 'invoice') continue - const total = Number(row.total_sek ?? row.total ?? 0) - const vat = Number(row.vat_amount_sek ?? row.vat_amount ?? 0) + const totalOwn = Number(row.total ?? 0) + const total = resolveHeaderSek(row, 'total', 'total_sek') + const vat = resolveHeaderSek(row, 'vat_amount', 'vat_amount_sek') const paid = paidByInvoice.get(row.id as string) ?? 0 - const outstanding = roundOre(total - paid) + const outstandingOwn = roundOre(totalOwn - paid) + const outstanding = totalOwn === 0 ? 0 : roundOre(total * (outstandingOwn / totalOwn)) if (Math.abs(outstanding) < ORE_TOLERANCE) continue // Never guess the treatment. Defaulting a 12 %/6 %/undantagen invoice to @@ -669,7 +820,7 @@ export async function collectKontantmetodCutoff( // Scale the moms share to the part still outstanding: a half-paid invoice // carries half its moms into the cut-off. - const ratio = total === 0 ? 0 : outstanding / total + const ratio = totalOwn === 0 ? 0 : outstandingOwn / totalOwn const scaledVat = roundOre(vat * ratio) // Moms on a treatment that cannot carry Swedish output moms is a real @@ -690,20 +841,58 @@ export async function collectKontantmetodCutoff( const payables: CutoffPayable[] = [] for (const row of supplierInvoices) { - const total = Number(row.total_sek ?? row.total ?? 0) - const vat = Number(row.vat_amount_sek ?? row.vat_amount ?? 0) + const sign = row.is_credit_note ? -1 : 1 + const totalOwn = Math.abs(Number(row.total ?? 0)) * sign + const total = Math.abs(resolveHeaderSek(row, 'total', 'total_sek')) * sign + const vat = Math.abs(resolveHeaderSek(row, 'vat_amount', 'vat_amount_sek')) * sign const paid = paidBySupplierInvoice.get(row.id as string) ?? 0 - const outstanding = roundOre(total - paid) + const outstandingOwn = roundOre(totalOwn - (paid * sign)) + const outstanding = totalOwn === 0 ? 0 : roundOre(total * (outstandingOwn / totalOwn)) if (Math.abs(outstanding) < ORE_TOLERANCE) continue - const ratio = total === 0 ? 0 : outstanding / total + const ratio = totalOwn === 0 ? 0 : outstandingOwn / totalOwn const items = (row.items ?? []) as Array> + const supplierValue = Array.isArray(row.supplier) ? row.supplier[0] : row.supplier + const supplierType = (supplierValue as Record | null)?.supplier_type + let reverseChargeGroups: CutoffPayable['reverseChargeGroups'] + if (row.reverse_charge) { + if (!['eu_business', 'non_eu_business', 'swedish_business'].includes(String(supplierType))) { + throw new Error( + `Leverantörsfaktura ${String(row.supplier_invoice_number ?? row.id)} med omvänd skattskyldighet saknar giltig leverantörstyp`, + ) + } + const groups = new Map() + for (const item of items) { + const rate = resolveReverseChargeRate({ + vat_rate: item.vat_rate == null ? null : Number(item.vat_rate), + reverse_charge_rate: item.reverse_charge_rate == null + ? null + : Number(item.reverse_charge_rate), + }) + const itemBase = totalOwn === 0 + ? 0 + : roundOre(Math.abs(Number(item.line_total ?? 0)) * Math.abs(total / totalOwn) * ratio) + const current = groups.get(rate) ?? { base: 0, nonBasisBase: 0 } + current.base = roundOre(current.base + itemBase) + if (!isReverseChargeBasisAccount(String(item.account_number ?? ''))) { + current.nonBasisBase = roundOre(current.nonBasisBase + itemBase) + } + groups.set(rate, current) + } + reverseChargeGroups = [...groups.entries()].map(([rate, group]) => ({ + rate, + base: group.base * sign, + nonBasisBase: group.nonBasisBase * sign, + supplierType: supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business', + })) + } payables.push({ id: row.id as string, reference: (row.supplier_invoice_number as string) ?? '', outstanding, vat: roundOre(vat * ratio), reverseCharge: Boolean(row.reverse_charge), + reverseChargeGroups, netByAccount: items .filter((item) => item.account_number) .map((item) => ({ @@ -744,18 +933,19 @@ export async function assessKontantmetodCutoff( nextFiscalPeriodId: string, entityType: EntityType = 'aktiebolag', ): Promise { - const collection = await collectKontantmetodCutoff( + const collection = sortedCutoffCollection(await collectKontantmetodCutoff( supabase, companyId, period.period_start, period.period_end, - ) + )) const lines = buildCutoffLines(collection.receivables, collection.payables, entityType) const postings = await inspectKontantmetodCutoffPostings( supabase, companyId, period.id, nextFiscalPeriodId, + period.period_end, lines, ) @@ -769,6 +959,18 @@ export interface PostCutoffResult { payableReversal: JournalEntry | null } +export class KontantmetodCutoffPartialError extends Error { + readonly postedIds: Record + readonly cause: unknown + + constructor(message: string, postedIds: Record, cause: unknown) { + super(message) + this.name = 'KontantmetodCutoffPartialError' + this.postedIds = postedIds + this.cause = cause + } +} + /** * Assert the vändning can actually be posted BEFORE any cut-off entry exists. * @@ -883,6 +1085,7 @@ export async function postKontantmetodCutoff( companyId, opts.fiscalPeriodId, opts.nextFiscalPeriodId, + opts.periodEnd, { receivableLines, payableLines, @@ -949,10 +1152,12 @@ export async function postKontantmetodCutoff( return [entry, reversal] } catch (reversalError) { // Compensate: an un-reversed cut-off is worse than no cut-off at all. + let stornoId: string | null = null try { // Storno in the same period as the cut-off so the pair nets to zero // inside the year being closed. - await reverseEntry(supabase, companyId, userId, entry.id, opts.periodEnd) + const storno = await reverseEntry(supabase, companyId, userId, entry.id, opts.periodEnd) + stornoId = storno.id } catch (stornoError) { log.error( 'cut-off reversal failed AND the compensating storno failed: 1510/2440 left inflated, manual correction required', @@ -960,7 +1165,15 @@ export async function postKontantmetodCutoff( { companyId, entryId: entry.id }, ) } - throw reversalError + const key = label === 'Kundfordringar' ? 'receivable' : 'payable' + throw new KontantmetodCutoffPartialError( + `Vändningen för ${label.toLowerCase()} kunde inte bokföras`, + { + [`${key}_entry_id`]: entry.id, + ...(stornoId ? { [`${key}_storno_entry_id`]: stornoId } : {}), + }, + reversalError, + ) } } @@ -975,13 +1188,35 @@ export async function postKontantmetodCutoff( } if (payableLines.length > 0 && !result.payableEntry) { - const [entry, reversal] = await postPair( - payableLines, - 'Leverantörsskulder', - opts.payables.map((p) => p.reference), - ) - result.payableEntry = entry - result.payableReversal = reversal + try { + const [entry, reversal] = await postPair( + payableLines, + 'Leverantörsskulder', + opts.payables.map((p) => p.reference), + ) + result.payableEntry = entry + result.payableReversal = reversal + } catch (err) { + const completedIds = { + ...(result.receivableEntry ? { receivable_entry_id: result.receivableEntry.id } : {}), + ...(result.receivableReversal + ? { receivable_reversal_entry_id: result.receivableReversal.id } + : {}), + } + if (Object.keys(completedIds).length === 0) throw err + if (err instanceof KontantmetodCutoffPartialError) { + throw new KontantmetodCutoffPartialError( + err.message, + { ...completedIds, ...err.postedIds }, + err.cause, + ) + } + throw new KontantmetodCutoffPartialError( + 'Leverantörsskuldernas bokslutsavgränsning kunde inte slutföras', + completedIds, + err, + ) + } } return result diff --git a/lib/pending-operations/__tests__/kontantmetod-cutoff-executor.test.ts b/lib/pending-operations/__tests__/kontantmetod-cutoff-executor.test.ts index 356982f2..24d3f70f 100644 --- a/lib/pending-operations/__tests__/kontantmetod-cutoff-executor.test.ts +++ b/lib/pending-operations/__tests__/kontantmetod-cutoff-executor.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PendingOperation } from '@/types' vi.mock('@/lib/core/bookkeeping/kontantmetod-cutoff', async () => { @@ -16,6 +16,8 @@ import { commitPendingOperation } from '../commit' import { assessKontantmetodCutoff, buildCutoffLines, + cutoffPreviewFingerprint, + KontantmetodCutoffPartialError, postKontantmetodCutoff, } from '@/lib/core/bookkeeping/kontantmetod-cutoff' @@ -30,10 +32,22 @@ const collection = { } function makePendingOp(overrides: Partial = {}): PendingOperation { + const lines = buildCutoffLines(collection.receivables, collection.payables) return { id: 'op-1', user_id: 'user-1', company_id: 'company-1', operation_type: 'post_kontantmetod_cutoff', status: 'pending', title: 'cut-off', - params: { fiscal_period_id: 'fp-1', next_fiscal_period_id: 'fp-2', collection }, + params: { + fiscal_period_id: 'fp-1', + next_fiscal_period_id: 'fp-2', + period_end: '2026-12-31', + entity_type: 'aktiebolag', + preview_fingerprint: cutoffPreviewFingerprint({ + collection, + lines, + entityType: 'aktiebolag', + periodEnd: '2026-12-31', + }), + }, preview_data: {}, result_data: null, actor_type: 'api_key', actor_id: null, actor_label: null, risk_level: 'high', created_at: '2026-08-13T00:00:00Z', resolved_at: null, updated_at: '2026-08-13T00:00:00Z', @@ -85,6 +99,8 @@ function makeSupabase(options: { beforeEach(() => { vi.clearAllMocks() + vi.useFakeTimers() + vi.setSystemTime(new Date('2027-02-01T12:00:00Z')) vi.mocked(assessKontantmetodCutoff).mockResolvedValue({ collection, lines: buildCutoffLines(collection.receivables, collection.payables), @@ -102,6 +118,10 @@ beforeEach(() => { }) }) +afterEach(() => { + vi.useRealTimers() +}) + describe('commitPendingOperation: post_kontantmetod_cutoff', () => { it('revalidates the frozen preview and posts through the cut-off service', async () => { const supabase = makeSupabase() @@ -167,4 +187,66 @@ describe('commitPendingOperation: post_kontantmetod_cutoff', () => { 'user-1', 'company-1', makePendingOp(), )).resolves.toMatchObject({ status: 'rejected', http_status: 409 }) }) + + it('rejects preview-affecting settings or period drift', async () => { + await expect(commitPendingOperation( + makeSupabase({ settings: { accounting_method: 'cash', entity_type: 'enskild_firma' } }) as never, + 'user-1', 'company-1', makePendingOp(), + )).resolves.toMatchObject({ status: 'rejected', http_status: 409 }) + await expect(commitPendingOperation( + makeSupabase({ + period: { + id: 'fp-1', period_start: '2026-01-01', period_end: '2026-11-30', + is_closed: false, locked_at: null, + }, + }) as never, + 'user-1', 'company-1', makePendingOp(), + )).resolves.toMatchObject({ status: 'rejected', http_status: 409 }) + expect(postKontantmetodCutoff).not.toHaveBeenCalled() + }) + + it('refuses a future-dated cut-off even when it was staged earlier', async () => { + vi.setSystemTime(new Date('2026-12-01T12:00:00Z')) + await expect(commitPendingOperation( + makeSupabase() as never, 'user-1', 'company-1', makePendingOp(), + )).resolves.toMatchObject({ status: 'rejected', http_status: 409 }) + expect(postKontantmetodCutoff).not.toHaveBeenCalled() + }) + + it('marks immutable partial work as failed_partial with posted ids', async () => { + vi.mocked(postKontantmetodCutoff).mockRejectedValueOnce( + new KontantmetodCutoffPartialError( + 'payable reversal failed', + { receivable_entry_id: 'ar', receivable_reversal_entry_id: 'ar-rev' }, + new Error('period locked'), + ), + ) + const supabase = makeSupabase() + const result = await commitPendingOperation( + supabase as never, 'user-1', 'company-1', makePendingOp(), + ) + expect(result).toMatchObject({ + status: 'failed', + http_status: 500, + code: 'partial_commit', + data: { + posted_ids: { + receivable_entry_id: 'ar', + receivable_reversal_entry_id: 'ar-rev', + }, + }, + }) + expect(supabase.updates).toContainEqual({ + table: 'pending_operations', + value: expect.objectContaining({ + status: 'failed_partial', + result_data: expect.objectContaining({ + posted_ids: { + receivable_entry_id: 'ar', + receivable_reversal_entry_id: 'ar-rev', + }, + }), + }), + }) + }) }) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 2455c061..771bf513 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -55,10 +55,10 @@ import { } from '@/lib/core/bookkeeping/year-end-service' import { assessKontantmetodCutoff, - cutoffCollectionsEqual, + cutoffPreviewFingerprint, hasIncompleteKontantmetodCutoffPair, + KontantmetodCutoffPartialError, postKontantmetodCutoff, - type CutoffCollection, } from '@/lib/core/bookkeeping/kontantmetod-cutoff' import { executeCurrencyRevaluation } from '@/lib/bookkeeping/currency-revaluation' import { @@ -3206,8 +3206,13 @@ async function commitPostKontantmetodCutoff( ): Promise { const fiscalPeriodId = params.fiscal_period_id as string const nextFiscalPeriodId = params.next_fiscal_period_id as string - const stagedCollection = params.collection as CutoffCollection | undefined - if (!fiscalPeriodId || !nextFiscalPeriodId || !stagedCollection) { + const stagedPeriodEnd = params.period_end as string + const stagedEntityType = params.entity_type as EntityType + const stagedFingerprint = params.preview_fingerprint as string + if ( + !fiscalPeriodId || !nextFiscalPeriodId || + !stagedPeriodEnd || !stagedEntityType || !stagedFingerprint + ) { return { error: 'Invalid staged kontantmetod cut-off parameters', status: 400 } } @@ -3229,6 +3234,12 @@ async function commitPostKontantmetodCutoff( if (period.is_closed || period.locked_at) { return { error: 'Räkenskapsperioden är stängd eller låst', status: 409 } } + if (period.period_end >= getSwedishLocalDate()) { + return { error: 'Bokslutsavgränsningen kan bokföras först efter periodens slut', status: 409 } + } + if (period.period_end !== stagedPeriodEnd || settings?.entity_type !== stagedEntityType) { + return { error: 'Period- eller företagsuppgifter har ändrats sedan förhandsgranskningen', status: 409 } + } if (settings?.accounting_method !== 'cash') { return { error: 'Företaget använder inte kontantmetoden', status: 409 } } @@ -3260,7 +3271,13 @@ async function commitPostKontantmetodCutoff( status: 409, } } - if (!cutoffCollectionsEqual(stagedCollection, assessment.collection)) { + const currentFingerprint = cutoffPreviewFingerprint({ + collection: assessment.collection, + lines: assessment.lines, + entityType: settings.entity_type ?? 'aktiebolag', + periodEnd: period.period_end, + }) + if (currentFingerprint !== stagedFingerprint) { return { error: 'Reskontran har ändrats sedan förhandsgranskningen. Skapa en ny förhandsgranskning innan du bokför.', @@ -3288,6 +3305,9 @@ async function commitPostKontantmetodCutoff( }, } } catch (err) { + if (err instanceof KontantmetodCutoffPartialError) { + throw new PartialCommitError(err.message, err.postedIds, err.cause) + } if (isBookkeepingError(err)) throw err return { error: err instanceof Error ? err.message : 'Kontantmetodens bokslutsavgränsning misslyckades', diff --git a/lib/reports/__tests__/kpi.test.ts b/lib/reports/__tests__/kpi.test.ts index b50a2e14..3f6a2c62 100644 --- a/lib/reports/__tests__/kpi.test.ts +++ b/lib/reports/__tests__/kpi.test.ts @@ -210,7 +210,7 @@ describe('VAT widget account lists (derived from ACCOUNT_RUTA)', () => { it('input accounts cover ruta 48', () => { expect([...VAT_INPUT_ACCOUNTS].sort()).toEqual([ - '2640', '2641', '2642', '2645', '2646', '2647', '2649', + '2640', '2641', '2642', '2645', '2646', '2647', '2648', '2649', ]) }) diff --git a/lib/reports/__tests__/vat-declaration.test.ts b/lib/reports/__tests__/vat-declaration.test.ts index eae78ed7..db18b66d 100644 --- a/lib/reports/__tests__/vat-declaration.test.ts +++ b/lib/reports/__tests__/vat-declaration.test.ts @@ -932,6 +932,14 @@ describe('calculateVatDeclaration: parent/summary accounts', () => { expect(result.rutor.ruta49).toBe(-200) // refund }) + it('maps year-end input VAT on 2648 to ruta48', async () => { + seedLedger([{ account_number: '2648', debit_amount: 250, credit_amount: 0 }]) + + const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1) + + expect(result.rutor.ruta48).toBe(250) + }) + it('reproduces the user-reported bug: 2610 balance now reaches ruta10', async () => { // Customer screenshot scenario (simplified): 3001 + 2610 booked with the // correct VAT amount on the parent account. Before the fix, ruta10 read 0 diff --git a/lib/reports/vat-declaration.ts b/lib/reports/vat-declaration.ts index 0b7ed72a..bee15b8f 100644 --- a/lib/reports/vat-declaration.ts +++ b/lib/reports/vat-declaration.ts @@ -84,6 +84,7 @@ export const ACCOUNT_RUTA: Record { expect(ACCOUNT_TO_BOX['2641']).toBe('48') expect(ACCOUNT_TO_BOX['2645']).toBe('48') expect(ACCOUNT_TO_BOX['2647']).toBe('48') + expect(ACCOUNT_TO_BOX['2648']).toBe('48') expect(ACCOUNT_TO_BOX['2649']).toBe('48') }) diff --git a/lib/vat/moms-box-mapping.ts b/lib/vat/moms-box-mapping.ts index 41627443..406c1ee2 100644 --- a/lib/vat/moms-box-mapping.ts +++ b/lib/vat/moms-box-mapping.ts @@ -133,6 +133,7 @@ export const ACCOUNT_TO_BOX: Record = { '2645': '48', // Beräknad ingående moms (EU/non-EU förvärv) '2646': '48', // Uthyrning '2647': '48', // Omvänd skattskyldighet i Sverige + '2648': '48', // Vilande ingående moms vid bokslut '2649': '48', // Blandad verksamhet // Reverse-charge purchase bases (debit on cost accounts) → Boxes 20-24 diff --git a/supabase/migrations/20260813124509_kontantmetod_cutoff_live_marker_unique.sql b/supabase/migrations/20260813124509_kontantmetod_cutoff_live_marker_unique.sql new file mode 100644 index 00000000..d75a2689 --- /dev/null +++ b/supabase/migrations/20260813124509_kontantmetod_cutoff_live_marker_unique.sql @@ -0,0 +1,28 @@ +-- Serialize cash-method year-end cut-off approvals at the immutable journal. +-- Two separately staged operations can race past the application preflight; +-- the live marker index makes the second commit fail before a duplicate +-- posted entry can exist. +-- pg-test: tests/pg/kontantmetod-cutoff-unique.pg.test.ts + +-- The existing corporate-tax index was broader than its comment: it reserved +-- every year_end entry with a fiscal-period source_id, so a cut-off could not +-- post its second immutable voucher. Keep the tax race guard on tax entries. +DROP INDEX IF EXISTS public.uq_year_end_corporate_tax_per_period; + +CREATE UNIQUE INDEX uq_year_end_corporate_tax_per_period + ON public.journal_entries (company_id, source_id) + WHERE source_type = 'year_end' + AND source_id IS NOT NULL + AND status IN ('draft', 'posted') + AND description LIKE 'Bokslutsdisposition: Bolagsskatt %'; + +CREATE UNIQUE INDEX IF NOT EXISTS journal_entries_kontantmetod_cutoff_live_marker_unique + ON public.journal_entries (company_id, source_id, description) + WHERE status = 'posted' + AND source_type = 'year_end' + AND description IN ( + 'Kundfordringar vid bokslut (kontantmetoden)', + 'Vändning kundfordringar bokslut (kontantmetoden)', + 'Leverantörsskulder vid bokslut (kontantmetoden)', + 'Vändning leverantörsskulder bokslut (kontantmetoden)' + ); diff --git a/supabase/migrations/20260813124510_exclude_kontantmetod_reversals_from_vat.sql b/supabase/migrations/20260813124510_exclude_kontantmetod_reversals_from_vat.sql new file mode 100644 index 00000000..a6435b15 --- /dev/null +++ b/supabase/migrations/20260813124510_exclude_kontantmetod_reversals_from_vat.sql @@ -0,0 +1,111 @@ +-- Keep the cash-method year-end cut-off in the final VAT declaration while +-- excluding its day-one reversal from the following period. The reversal is +-- mechanical balance-sheet cleanup; counting it as new VAT activity would +-- undo the legally required final-period reporting before the invoice is paid. +-- pg-test: tests/pg/vat-declaration-totals-rpc.pg.test.ts + +CREATE OR REPLACE FUNCTION public.get_vat_declaration_totals( + p_company_id uuid, + p_start date, + p_end date, + p_accounts text[], + p_ruta_accounts text[], + p_net_accounts text[] +) +RETURNS jsonb +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path TO 'public' +AS $$ +WITH closing_entries AS ( + SELECT fp.closing_entry_id AS id + FROM public.fiscal_periods fp + WHERE fp.company_id = p_company_id + AND fp.closing_entry_id IS NOT NULL +), +scoped_entries AS ( + SELECT e.id, e.status, e.entry_date, e.source_type, e.description, + e.voucher_series, e.voucher_number + FROM public.journal_entries e + WHERE e.company_id = p_company_id + AND e.status IN ('posted', 'reversed') + AND e.entry_date >= p_start + AND e.entry_date <= p_end + AND NOT ( + e.status = 'posted' + AND EXISTS (SELECT 1 FROM closing_entries c WHERE c.id = e.id) + ) +), +non_settlement_entries AS ( + SELECT * FROM scoped_entries + WHERE source_type IS DISTINCT FROM 'vat_settlement' + AND NOT ( + source_type = 'year_end' + AND description IN ( + 'Vändning kundfordringar bokslut (kontantmetoden)', + 'Vändning leverantörsskulder bokslut (kontantmetoden)' + ) + ) +), +vat_lines AS ( + SELECT l.journal_entry_id, l.account_number, l.debit_amount, l.credit_amount + FROM public.journal_entry_lines l + JOIN non_settlement_entries e ON e.id = l.journal_entry_id + WHERE l.account_number = ANY (p_accounts) +), +shaped AS ( + SELECT e.id, e.status, e.entry_date, e.source_type, e.voucher_series, e.voucher_number + FROM non_settlement_entries e + WHERE e.source_type IS DISTINCT FROM 'opening_balance' + AND EXISTS ( + SELECT 1 FROM vat_lines l + WHERE l.journal_entry_id = e.id AND l.account_number = ANY (p_ruta_accounts) + ) + AND EXISTS ( + SELECT 1 FROM vat_lines l + WHERE l.journal_entry_id = e.id AND l.account_number = ANY (p_net_accounts) + ) +) +SELECT jsonb_build_object( + 'totals', 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 vat_lines l + WHERE NOT EXISTS (SELECT 1 FROM shaped s WHERE s.id = l.journal_entry_id) + GROUP BY l.account_number + ) t + ), '[]'::jsonb), + 'settlement_shaped_entries', COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'id', s.id, + 'status', s.status, + 'entry_date', s.entry_date, + 'source_type', s.source_type, + 'voucher_series', s.voucher_series, + 'voucher_number', s.voucher_number + ) ORDER BY s.entry_date, s.id) + FROM shaped s + ), '[]'::jsonb), + 'source_type_counts', COALESCE(( + SELECT jsonb_object_agg(COALESCE(c.source_type, ''), c.n) + FROM ( + SELECT source_type, count(*)::int AS n + FROM scoped_entries + GROUP BY source_type + ) c + ), '{}'::jsonb) +) +$$; + +REVOKE ALL ON FUNCTION public.get_vat_declaration_totals(uuid, date, date, text[], text[], text[]) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.get_vat_declaration_totals(uuid, date, date, text[], text[], text[]) TO authenticated, service_role; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/kontantmetod-cutoff-unique.pg.test.ts b/tests/pg/kontantmetod-cutoff-unique.pg.test.ts new file mode 100644 index 00000000..4c423d45 --- /dev/null +++ b/tests/pg/kontantmetod-cutoff-unique.pg.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { insertPostedJournalEntry, seedCompany } from './fixtures' + +describe('kontantmetod cut-off live marker uniqueness', () => { + it('allows exactly one of two concurrent live markers', async () => { + const seeded = await seedCompany() + const description = 'Kundfordringar vid bokslut (kontantmetoden)' + const common = { + userId: seeded.userId, + companyId: seeded.companyId, + fiscalPeriodId: seeded.fiscalPeriodId, + entryDate: '2026-12-31', + description, + sourceType: 'year_end', + sourceId: seeded.fiscalPeriodId, + } + + const results = await Promise.allSettled([ + insertPostedJournalEntry({ ...common, voucherNumber: 11 }), + insertPostedJournalEntry({ ...common, voucherNumber: 12 }), + ]) + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + const rejected = results.find((result) => result.status === 'rejected') + expect(rejected).toMatchObject({ status: 'rejected' }) + expect(String((rejected as PromiseRejectedResult).reason)).toMatch( + /journal_entries_kontantmetod_cutoff_live_marker_unique/, + ) + }) + + it('preserves the corporate-tax race guard without blocking other year-end entries', async () => { + const seeded = await seedCompany() + const common = { + userId: seeded.userId, + companyId: seeded.companyId, + fiscalPeriodId: seeded.fiscalPeriodId, + entryDate: '2026-12-31', + description: 'Bokslutsdisposition: Bolagsskatt 20,6 %', + sourceType: 'year_end', + sourceId: seeded.fiscalPeriodId, + } + + await insertPostedJournalEntry({ ...common, voucherNumber: 21 }) + await expect(insertPostedJournalEntry({ ...common, voucherNumber: 22 })).rejects.toThrow( + /uq_year_end_corporate_tax_per_period/, + ) + }) +}) diff --git a/tests/pg/vat-declaration-totals-rpc.pg.test.ts b/tests/pg/vat-declaration-totals-rpc.pg.test.ts index 59a8a830..29f5ce5d 100644 --- a/tests/pg/vat-declaration-totals-rpc.pg.test.ts +++ b/tests/pg/vat-declaration-totals-rpc.pg.test.ts @@ -30,7 +30,7 @@ import { // Mirrors the TS call site (lib/reports/vat-declaration.ts): a small // representative slice of ACCOUNT_RUTA is enough since the full list is a // parameter, not baked into the SQL. -const RUTA_ACCOUNTS = ['2611', '2621', '2641', '2645', '3001'] +const RUTA_ACCOUNTS = ['2611', '2618', '2621', '2641', '2645', '2648', '3001'] const NET_ACCOUNTS = ['2650', '1650'] const ALL_ACCOUNTS = [...RUTA_ACCOUNTS, ...NET_ACCOUNTS] // This account keeps intentionally narrow VAT fixtures balanced without @@ -74,6 +74,7 @@ async function insertJournalEntry(params: { status?: 'draft' | 'posted' | 'reversed' sourceType?: string entryDate?: string + description?: string lines: Array<{ account: string; debit: number; credit: number }> }): Promise { if ((params.status ?? 'posted') === 'posted') { @@ -83,7 +84,7 @@ async function insertJournalEntry(params: { fiscalPeriodId: params.fiscalPeriodId, voucherNumber: params.voucherNumber, entryDate: params.entryDate ?? '2026-03-15', - description: 'VAT RPC test', + description: params.description ?? 'VAT RPC test', sourceType: params.sourceType ?? 'manual', lines: params.lines.map((line) => ({ accountNumber: line.account, @@ -340,6 +341,35 @@ describe('get_vat_declaration_totals RPC', () => { expect(payload.settlement_shaped_entries).toEqual([]) }) + it('includes the cash-method cut-off but excludes its day-one reversal', async () => { + const ctx = await seedCompany() + + await insertJournalEntry({ + ...ctx, + voucherNumber: 1, + sourceType: 'year_end', + description: 'Kundfordringar vid bokslut (kontantmetoden)', + lines: [ + { account: '2618', debit: 0, credit: 250 }, + { account: VAT_FIXTURE_BALANCING_ACCOUNT, debit: 250, credit: 0 }, + ], + }) + await insertJournalEntry({ + ...ctx, + voucherNumber: 2, + sourceType: 'year_end', + description: 'Vändning kundfordringar bokslut (kontantmetoden)', + lines: [ + { account: '2618', debit: 250, credit: 0 }, + { account: VAT_FIXTURE_BALANCING_ACCOUNT, debit: 0, credit: 250 }, + ], + }) + + const payload = await callRpc(ctx.companyId) + expect(totalsByAccount(payload).get('2618')).toMatchObject({ debit: 0, credit: 250 }) + expect(payload.source_type_counts).toEqual({ year_end: 2 }) + }) + it('scopes everything to the requested company', async () => { const a = await seedCompany() const b = await seedCompany()