fix: harden kontantmetod year-end cutoff (#1592)

This commit is contained in:
Mattsson
2026-08-13 16:53:19 +02:00
committed by GitHub
parent a47ba9fede
commit 857dd575d0
17 changed files with 1062 additions and 160 deletions
+1
View File
@@ -931,3 +931,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
@@ -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<typeof import('@/lib/core/bookkeeping/period-service')>(
@@ -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<string, unknown> = {
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<Record<string, unknown>> }
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)
})
})
+14 -2
View File
@@ -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,
@@ -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')
})
})
@@ -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<Record<string, unknown>>).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<string, Array<Record<string, unknown>>>) {
return {
from: (table: string) => {
let range = { from: 0, to: 999 }
const query: Record<string, unknown> = {}
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<string, unknown> = {}
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<string, unknown> = {}
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<string, unknown>
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)
+361 -126
View File
@@ -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<string, number>()
const reverseChargeByGroup = new Map<string, {
rate: number
baseOre: number
nonBasisBaseOre: number
supplierType: 'eu_business' | 'non_eu_business' | 'swedish_business'
}>()
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<KontantmetodCutoffPostingStatus> {
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<string, unknown>,
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<CutoffCollection> {
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<Record<string, unknown>>
let supplierInvoices: Array<Record<string, unknown>>
try {
[invoices, supplierInvoices] = await Promise.all([
fetchAllRows<Record<string, unknown>>(
({ 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<Record<string, unknown>>(
({ 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<Record<string, unknown>>
const supplierInvoices = (supplierResult.data ?? []) as Array<Record<string, unknown>>
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<Record<string, unknown>>
let supplierPayments: Array<Record<string, unknown>>
try {
[invoicePayments, supplierPayments] = await Promise.all([
fetchAllRows<Record<string, unknown>>(
({ 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<Record<string, unknown>>, error: null }),
supplierIds.length > 0
? supabase
.order('id', { ascending: true })
.range(from, to),
{ dedupeBy: (row) => row.id as string },
),
fetchAllRows<Record<string, unknown>>(
({ 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<Record<string, unknown>>, 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<string, number>()
for (const row of (invoicePayments.data ?? []) as Array<Record<string, unknown>>) {
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<string, number>()
for (const row of (supplierPayments.data ?? []) as Array<Record<string, unknown>>) {
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<Record<string, unknown>>
const supplierValue = Array.isArray(row.supplier) ? row.supplier[0] : row.supplier
const supplierType = (supplierValue as Record<string, unknown> | 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<number, { base: number; nonBasisBase: number }>()
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<KontantmetodCutoffAssessment> {
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<string, string>
readonly cause: unknown
constructor(message: string, postedIds: Record<string, string>, 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
@@ -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> = {}): 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',
},
}),
}),
})
})
})
+25 -5
View File
@@ -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<ExecutorResult> {
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',
+1 -1
View File
@@ -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',
])
})
@@ -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
+1
View File
@@ -84,6 +84,7 @@ export const ACCOUNT_RUTA: Record<string, { box: keyof VatDeclarationRutor; side
'2645': { box: 'ruta48', side: 'debit' }, // Förvärv utlandet (EU/non-EU RC)
'2646': { box: 'ruta48', side: 'debit' }, // Uthyrning
'2647': { box: 'ruta48', side: 'debit' }, // Omvänd skattskyldighet i Sverige
'2648': { box: 'ruta48', side: 'debit' }, // Vilande ingående moms vid bokslut
'2649': { box: 'ruta48', side: 'debit' }, // Blandad verksamhet
// Import VAT (since 2015, via momsdeklaration) → ruta 60/61/62
'2615': { box: 'ruta60', side: 'credit' }, // Import 25%
@@ -48,6 +48,7 @@ describe('ACCOUNT_TO_BOX', () => {
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')
})
+1
View File
@@ -133,6 +133,7 @@ export const ACCOUNT_TO_BOX: Record<string, MomsBox> = {
'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
@@ -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)'
);
@@ -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';
@@ -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/,
)
})
})
+32 -2
View File
@@ -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<string> {
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()