fix: harden kontantmetod year-end cutoff (#1592)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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/,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user