feat: stage kontantmetod year-end cutoff (#1586)
* feat: stage kontantmetod year-end cutoff * fix: keep cutoff tool payload searchable * fix: trim year-end tool metadata
This commit is contained in:
@@ -900,6 +900,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-13] Underlag failures resolve the response where it fails (getResponseErrorMessage + status) and an expired session announces itself on the existing session-timeout BroadcastChannel, instead of a global authenticated-fetch wrapper: `throw new Error(json.error)` printed "[object Object]" for the structured envelope, so the middleware 401 on a backgrounded mobile tab surfaced as the generic "Något gick fel" with no way back; a wrapper would have to be threaded through every call site to buy the same thing here. Upload failures also post metadata (status, size, mime, resolved reason) to /api/log, the one API path exempt from the timeout gate, because a request answered before the route runs leaves nothing in the function logs and the user-reported failures were invisible there.
|
||||
[2026-08-13] Oversized phone photos are re-encoded in the browser (2400px long edge, JPEG q0.85 stepping down) rather than raising a platform limit or streaming straight to storage: hosted rejects any request body over 4.5 MB itself (measured against prod: 4.4 MB reaches the route, 4.6 MB returns a plain-text FUNCTION_PAYLOAD_TOO_LARGE), before the route runs and therefore invisibly in the function logs, while the route advertises 10 MB it can never receive. A downscaled photo is still a faithful, durably readable reproduction (BFL 7 kap), which a refusal is not. What cannot be shrunk (PDF, or HEIC where the browser will not decode it) is refused client-side with its actual size named, and 413 was added to the HTTP status map so a rejection in transit still says what happened. Direct-to-storage upload, which would remove the ceiling for PDFs too, is the follow-up, not this fix: it moves sha256/WORM integrity off the server.
|
||||
[2026-08-13] The book-route underlag fix landed as a pinned-document leg inside propagateUnderlagForBookedTransaction rather than the planned "extract categorize-core's propagation block into a shared helper": PR #1547 had already done that extraction overnight and wired /book and bulk-book to the shared helper, but the helper only walked matched inbox items, so a document pinned via transactions.document_id with no unconsumed inbox item (direct upload, or item consumed elsewhere) still booked to "Underlag saknas". Anchoring the pin inside the helper fixes /book, categorize, bulk-book and attach-after-book in one place; the pin is read fresh (not from the caller's pre-booking snapshot) so a concurrent attach still anchors, and the bulk-book RPC's own atomic doc-linking makes the leg a no-op there.
|
||||
[2026-08-13] Kontantmetoden cut-off is a year-end readiness blocker with a staged MCP remedy, not a warning or a lock-time gate: BFL 5 kap 2 § requires all unpaid receivables and liabilities at fiscal year end, and a lock-time failure would occur after executeYearEndClosing has already posted its immutable closing entry. The staged preview freezes all cut-off and day-one reversal lines; approval re-collects the reskontra and refuses drift or any existing full or partial marker before posting through the bookkeeping engine.
|
||||
[2026-08-13] v1 categorize/batch-categorize wire the shared underlag propagation after the CAS write rather than inlining anchoring logic, and the route tests mock the helper to assert wiring only (called once per booking the request owns; skipped on partial success and lost CAS races): the helper's own semantics (pin anchoring, never-steal, failure isolation) are unit-tested where they live, and duplicating them at route level is what let the v1 surface drift out of the #1560 fix in the first place. Salvaged from the closed duplicate PR #1559: the attach-after-bulk-book samlingsverifikat test.
|
||||
[2026-08-13] Bank import: an explicit format choice that parses 0 rows falls back to auto-detect (info issue names both formats) instead of failing: an explicit bank pick must never underperform Automatisk identifiering, and the fallback result carries the detected format so external_ids equal the auto path. generic_csv is exempt: it is the manual column-mapping escape hatch whose default mapping legitimately parses 0 rows.
|
||||
[2026-08-13] BOM'd bank files decode with utf-8-sig semantics (BOM stripped at byte level, never re-included in the windows-1252 fallback), plus UTF-16 BOM support: kills the mojibake-prefix class that broke exact-match header detectors.
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { 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')>(
|
||||
'@/lib/core/bookkeeping/period-service',
|
||||
)
|
||||
return { ...actual, findNextPeriod: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/core/bookkeeping/kontantmetod-cutoff', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('@/lib/core/bookkeeping/kontantmetod-cutoff')
|
||||
>('@/lib/core/bookkeeping/kontantmetod-cutoff')
|
||||
return { ...actual, assessKontantmetodCutoff: vi.fn() }
|
||||
})
|
||||
|
||||
import { tools, deriveToolMeta } from '../server'
|
||||
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
||||
import { findNextPeriod } from '@/lib/core/bookkeeping/period-service'
|
||||
import {
|
||||
assessKontantmetodCutoff,
|
||||
buildCutoffLines,
|
||||
} from '@/lib/core/bookkeeping/kontantmetod-cutoff'
|
||||
|
||||
const tool = tools.find((candidate) => candidate.name === 'gnubok_post_kontantmetod_cutoff')!
|
||||
|
||||
function makeSupabase(settings: Record<string, unknown> = {
|
||||
accounting_method: 'cash', entity_type: 'aktiebolag',
|
||||
}) {
|
||||
const inserts: unknown[] = []
|
||||
const rows: Record<string, unknown> = {
|
||||
fiscal_periods: {
|
||||
id: 'fp-1', name: '2026', period_start: '2026-01-01', period_end: '2026-12-31',
|
||||
is_closed: false, locked_at: null,
|
||||
},
|
||||
company_settings: settings,
|
||||
pending_operations: { id: 'op-1' },
|
||||
}
|
||||
const from = vi.fn((table: string) => {
|
||||
const chain: Record<string, unknown> = {}
|
||||
for (const name of ['select', 'eq', 'in', 'gte', 'lte', 'order', 'limit']) {
|
||||
chain[name] = () => chain
|
||||
}
|
||||
chain.insert = (value: unknown) => {
|
||||
inserts.push(value)
|
||||
return chain
|
||||
}
|
||||
chain.maybeSingle = async () => ({ data: rows[table] ?? null, error: null })
|
||||
chain.single = async () => ({ data: rows[table] ?? null, error: null })
|
||||
chain.then = (resolve: (value: unknown) => unknown) =>
|
||||
resolve({ data: rows[table] ?? null, error: null })
|
||||
return chain
|
||||
})
|
||||
return { auth: {}, from, inserts }
|
||||
}
|
||||
|
||||
const collection = {
|
||||
receivables: [{
|
||||
id: 'inv-1', reference: 'F-1', vatTreatment: 'standard_25' as const,
|
||||
outstanding: 1250, vat: 250,
|
||||
}],
|
||||
payables: [{
|
||||
id: 'si-1', reference: 'L-1', outstanding: 625, vat: 125,
|
||||
netByAccount: [{ account: '5410', amount: 500 }],
|
||||
}],
|
||||
unknownVatTreatment: [],
|
||||
strayVatOnZeroRate: [],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(findNextPeriod).mockResolvedValue({
|
||||
id: 'fp-2', period_start: '2027-01-01', period_end: '2027-12-31',
|
||||
is_closed: false, locked_at: null,
|
||||
} as never)
|
||||
vi.mocked(assessKontantmetodCutoff).mockResolvedValue({
|
||||
collection,
|
||||
lines: buildCutoffLines(collection.receivables, collection.payables),
|
||||
postings: {
|
||||
complete: false, hasAny: false, receivableEntryId: null,
|
||||
receivableReversalId: null, payableEntryId: null, payableReversalId: null,
|
||||
missing: ['receivable', 'receivable_reversal', 'payable', 'payable_reversal'],
|
||||
duplicates: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_post_kontantmetod_cutoff', () => {
|
||||
it('is a discoverable high-risk staged bookkeeping write with readiness preflight', () => {
|
||||
expect(tool).toBeDefined()
|
||||
expect(tool.catalogVisibility).toBe('search')
|
||||
expect(tool.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true })
|
||||
expect(TOOL_SCOPE_MAP.gnubok_post_kontantmetod_cutoff).toBe('bookkeeping:write')
|
||||
expect(deriveToolMeta(tool)).toMatchObject({
|
||||
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(
|
||||
{ fiscal_period_id: 'fp-1' },
|
||||
'company-1', 'user-1', supabase as never, { type: 'api_key' },
|
||||
)) as { staged: boolean; risk_level: string; preview: { entries: Array<Record<string, unknown>> } }
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.risk_level).toBe('high')
|
||||
expect(result.preview.entries).toHaveLength(4)
|
||||
expect(result.preview.entries.map((entry) => entry.entry_date)).toEqual([
|
||||
'2026-12-31', '2027-01-01', '2026-12-31', '2027-01-01',
|
||||
])
|
||||
expect(result.preview.entries[0]?.lines).toEqual(buildCutoffLines(collection.receivables, []).receivableLines)
|
||||
expect(supabase.inserts).toHaveLength(1)
|
||||
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 },
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses accrual companies, missing next periods, invalid VAT, duplicates, and empty previews', async () => {
|
||||
const accrual = makeSupabase({ accounting_method: 'accrual', entity_type: 'aktiebolag' })
|
||||
await expect(tool.execute(
|
||||
{ fiscal_period_id: 'fp-1' }, 'company-1', 'user-1', accrual as never,
|
||||
)).rejects.toThrow(/inte kontantmetoden/i)
|
||||
|
||||
vi.mocked(findNextPeriod).mockResolvedValueOnce(null)
|
||||
await expect(tool.execute(
|
||||
{ fiscal_period_id: 'fp-1' }, 'company-1', 'user-1', makeSupabase() as never,
|
||||
)).rejects.toThrow(/nästa räkenskapsår/i)
|
||||
|
||||
vi.mocked(assessKontantmetodCutoff).mockResolvedValueOnce({
|
||||
collection: { ...collection, unknownVatTreatment: ['F-9'] },
|
||||
lines: buildCutoffLines([], []),
|
||||
postings: { complete: false, hasAny: false, receivableEntryId: null, receivableReversalId: null, payableEntryId: null, payableReversalId: null, missing: [], duplicates: [] },
|
||||
})
|
||||
await expect(tool.execute(
|
||||
{ fiscal_period_id: 'fp-1' }, 'company-1', 'user-1', makeSupabase() as never,
|
||||
)).rejects.toThrow(/saknar momsinställning/i)
|
||||
|
||||
vi.mocked(assessKontantmetodCutoff).mockResolvedValueOnce({
|
||||
collection,
|
||||
lines: buildCutoffLines(collection.receivables, collection.payables),
|
||||
postings: { complete: true, hasAny: true, receivableEntryId: 'je-1', receivableReversalId: 'je-2', payableEntryId: 'je-3', payableReversalId: 'je-4', missing: [], duplicates: [] },
|
||||
})
|
||||
await expect(tool.execute(
|
||||
{ fiscal_period_id: 'fp-1' }, 'company-1', 'user-1', makeSupabase() as never,
|
||||
)).rejects.toThrow(/redan bokförd/i)
|
||||
|
||||
vi.mocked(assessKontantmetodCutoff).mockResolvedValueOnce({
|
||||
collection: { receivables: [], payables: [], unknownVatTreatment: [], strayVatOnZeroRate: [] },
|
||||
lines: buildCutoffLines([], []),
|
||||
postings: { complete: true, hasAny: false, receivableEntryId: null, receivableReversalId: null, payableEntryId: null, payableReversalId: null, missing: [], duplicates: [] },
|
||||
})
|
||||
await expect(tool.execute(
|
||||
{ fiscal_period_id: 'fp-1' }, 'company-1', 'user-1', makeSupabase() as never,
|
||||
)).rejects.toThrow(/Inga obetalda/i)
|
||||
})
|
||||
})
|
||||
@@ -67,7 +67,7 @@ describe('gnubok_year_end_readiness: registration', () => {
|
||||
)
|
||||
// Guards the summarizing itself: if a kind stops being period-state, or a
|
||||
// new one appears, it has to show up in the description.
|
||||
expect(actionable.length).toBe(7)
|
||||
expect(actionable.length).toBe(8)
|
||||
for (const kind of actionable) {
|
||||
expect(tool.description, `blocker kind ${kind} missing from description`).toContain(kind)
|
||||
}
|
||||
|
||||
@@ -156,8 +156,15 @@ import {
|
||||
validateVoucherForSupplierInvoiceLink,
|
||||
} from '@/lib/invoices/supplier-voucher-matching'
|
||||
import { findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine'
|
||||
import { closePeriod, countUnbookedInPeriod, lockPeriod, resolvePeriodStatusForDate, type PeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
|
||||
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,
|
||||
hasIncompleteKontantmetodCutoffPair,
|
||||
KONTANTMETOD_CUTOFF_DESCRIPTIONS,
|
||||
nextDay,
|
||||
reverseLines,
|
||||
} from '@/lib/core/bookkeeping/kontantmetod-cutoff'
|
||||
import { generateSIEExport } from '@/lib/reports/sie-export'
|
||||
import { generateFullArchive, estimateArchiveSize } from '@/lib/reports/full-archive-export'
|
||||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||||
@@ -1144,6 +1151,7 @@ const STAGED_OPERATION_SCHEMA = {
|
||||
*/
|
||||
const TOOL_PREFLIGHT_MAP: Record<string, string> = {
|
||||
gnubok_run_year_end: 'gnubok_year_end_readiness',
|
||||
gnubok_post_kontantmetod_cutoff: 'gnubok_year_end_readiness',
|
||||
gnubok_vat_declaration_submit: 'gnubok_vat_declaration_validate',
|
||||
gnubok_post_annual_depreciation: 'gnubok_propose_annual_depreciation',
|
||||
gnubok_book_salary_run: 'gnubok_get_salary_run',
|
||||
@@ -1761,6 +1769,8 @@ export const YEAR_END_BLOCKER_KIND: Record<YearEndBlockerCode, string> = {
|
||||
TRIAL_BALANCE_UNBALANCED: 'trial_balance_unbalanced',
|
||||
CONTINUITY_MISMATCH: 'opening_balance_continuity',
|
||||
NEXT_PERIOD_HAS_IB: 'next_period_ib_posted',
|
||||
KONTANTMETOD_CUTOFF_REQUIRED: 'kontantmetod_cutoff_required',
|
||||
KONTANTMETOD_CUTOFF_CHECK_FAILED: 'kontantmetod_cutoff_required',
|
||||
UNBOOKED_TRANSACTIONS: 'unbooked_transactions',
|
||||
UNBOOKED_CHECK_FAILED: 'unbooked_transactions',
|
||||
}
|
||||
@@ -13132,7 +13142,7 @@ export const tools: McpTool[] = [
|
||||
// there, the period either is closable or is not. Open items in foreign
|
||||
// currency are warnings, never blockers, because executeYearEndClosing
|
||||
// revalues them in step 2 (lib/core/bookkeeping/year-end-service.ts).
|
||||
description: "Pre-flight for irreversible gnubok_run_year_end. Blockers: unbooked_transactions (most common), draft_entries, unexplained_voucher_gap, sequence_mismatch, trial_balance_unbalanced, opening_balance_continuity, next_period_ib_posted, period-state. FX = warning, never blocker.",
|
||||
description: 'Year-end check. Blockers: kontantmetod_cutoff_required, unbooked_transactions (most common), draft_entries, unexplained_voucher_gap, sequence_mismatch, trial_balance_unbalanced, opening_balance_continuity, next_period_ib_posted, period-state. FX = warning, never blocker.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -13236,6 +13246,189 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_post_kontantmetod_cutoff',
|
||||
title: 'Post Cash-Method Year-End Cut-Off',
|
||||
description: 'Stage the exact year-end receivable/payable cut-off and next-period reversals required for kontantmetoden. Review all proposed lines, then approve with confirmed=true.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
fiscal_period_id: {
|
||||
type: 'string',
|
||||
description: 'UUID of the cash-method fiscal period being closed',
|
||||
},
|
||||
idempotency_key: {
|
||||
type: 'string',
|
||||
description: 'Optional UUID to dedupe retries of the same preview and staged operation',
|
||||
},
|
||||
},
|
||||
required: ['fiscal_period_id'],
|
||||
},
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
// Specialized cash-method year-end step. The year-end readiness blocker
|
||||
// and year-end skill name it exactly, while search-only visibility avoids
|
||||
// charging every MCP session for a schema most companies never need.
|
||||
catalogVisibility: 'search',
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const fiscalPeriodId = args.fiscal_period_id as string
|
||||
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
|
||||
|
||||
const [{ data: period }, { data: settings }] = await Promise.all([
|
||||
supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end, is_closed, locked_at')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, entity_type')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle(),
|
||||
])
|
||||
|
||||
if (!period) throw new Error('Fiscal period not found')
|
||||
if (period.is_closed || period.locked_at) {
|
||||
throw new Error('Räkenskapsperioden är stängd eller låst')
|
||||
}
|
||||
if (settings?.accounting_method !== 'cash') {
|
||||
throw new Error('Företaget använder inte kontantmetoden')
|
||||
}
|
||||
|
||||
const nextPeriod = await findNextPeriod(supabase, companyId, fiscalPeriodId)
|
||||
if (!nextPeriod) {
|
||||
throw new Error(
|
||||
'Kontantmetodens bokslutsavgränsning kräver att nästa räkenskapsår är upplagt: vändningarna bokas första dagen på det nya året.',
|
||||
)
|
||||
}
|
||||
if (nextPeriod.is_closed || nextPeriod.locked_at) {
|
||||
throw new Error(
|
||||
'Nästa räkenskapsår är stängt eller låst: vändningarna kan inte bokföras. Lås upp perioden och försök igen.',
|
||||
)
|
||||
}
|
||||
|
||||
const assessment = await assessKontantmetodCutoff(
|
||||
supabase,
|
||||
companyId,
|
||||
period,
|
||||
nextPeriod.id,
|
||||
(settings.entity_type ?? 'aktiebolag') as EntityType,
|
||||
)
|
||||
if (assessment.collection.unknownVatTreatment.length > 0) {
|
||||
throw new Error(
|
||||
`${assessment.collection.unknownVatTreatment.length} fakturor saknar momsinställning: ` +
|
||||
`${assessment.collection.unknownVatTreatment.slice(0, 10).join(', ')}. Komplettera dem och försök igen.`,
|
||||
)
|
||||
}
|
||||
if (assessment.collection.strayVatOnZeroRate.length > 0) {
|
||||
throw new Error(
|
||||
`${assessment.collection.strayVatOnZeroRate.length} fakturor har moms trots en momsfri momsinställning: ` +
|
||||
`${assessment.collection.strayVatOnZeroRate.slice(0, 10).join(', ')}. Rätta dem och försök igen.`,
|
||||
)
|
||||
}
|
||||
if (
|
||||
assessment.lines.receivableLines.length === 0 &&
|
||||
assessment.lines.payableLines.length === 0
|
||||
) {
|
||||
throw new Error('Inga obetalda kund- eller leverantörsfakturor finns vid periodens slut')
|
||||
}
|
||||
if (
|
||||
assessment.postings.complete ||
|
||||
hasIncompleteKontantmetodCutoffPair(assessment.postings, assessment.lines)
|
||||
) {
|
||||
throw new Error(
|
||||
'Kontantmetodens bokslutsavgränsning är redan bokförd eller delvis bokförd för perioden. Kontrollera verifikaten innan du försöker igen.',
|
||||
)
|
||||
}
|
||||
|
||||
const reversalDate = nextDay(period.period_end)
|
||||
const entries = [
|
||||
...(assessment.lines.receivableLines.length > 0 &&
|
||||
!assessment.postings.receivableEntryId &&
|
||||
!assessment.postings.receivableReversalId
|
||||
? [
|
||||
{
|
||||
kind: 'receivable_cutoff',
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: period.period_end,
|
||||
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable,
|
||||
total: assessment.lines.receivableTotal,
|
||||
lines: assessment.lines.receivableLines,
|
||||
},
|
||||
{
|
||||
kind: 'receivable_reversal',
|
||||
fiscal_period_id: nextPeriod.id,
|
||||
entry_date: reversalDate,
|
||||
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivableReversal,
|
||||
total: assessment.lines.receivableTotal,
|
||||
lines: reverseLines(assessment.lines.receivableLines),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(assessment.lines.payableLines.length > 0 &&
|
||||
!assessment.postings.payableEntryId &&
|
||||
!assessment.postings.payableReversalId
|
||||
? [
|
||||
{
|
||||
kind: 'payable_cutoff',
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: period.period_end,
|
||||
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.payable,
|
||||
total: assessment.lines.payableTotal,
|
||||
lines: assessment.lines.payableLines,
|
||||
},
|
||||
{
|
||||
kind: 'payable_reversal',
|
||||
fiscal_period_id: nextPeriod.id,
|
||||
entry_date: reversalDate,
|
||||
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.payableReversal,
|
||||
total: assessment.lines.payableTotal,
|
||||
lines: reverseLines(assessment.lines.payableLines),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
return stagePendingOperation(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
'post_kontantmetod_cutoff',
|
||||
`Kontantmetodens bokslutsavgränsning: ${period.name}`,
|
||||
{
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
next_fiscal_period_id: nextPeriod.id,
|
||||
collection: assessment.collection,
|
||||
},
|
||||
{
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
next_fiscal_period_id: nextPeriod.id,
|
||||
receivable_count: assessment.collection.receivables.length,
|
||||
payable_count: assessment.collection.payables.length,
|
||||
entries,
|
||||
},
|
||||
actor,
|
||||
{
|
||||
description: 'Re-run year-end readiness after the cut-off and all reversals are posted.',
|
||||
tool: 'gnubok_year_end_readiness',
|
||||
args: { fiscal_period_id: fiscalPeriodId },
|
||||
},
|
||||
{
|
||||
idempotencyKey:
|
||||
typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
|
||||
dateForPeriodCheck: period.period_end,
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_run_year_end',
|
||||
title: 'Run Year-End Closing (Bokslut)',
|
||||
@@ -16800,7 +16993,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
'• VAT: gnubok_get_vat_report(period_type, year, period). Ruta49 = VAT to pay (positive) or refund (negative). Pass render_ui=true to open the momsdeklaration review widget (claude.ai / Desktop). gnubok_vat_close_check reports filing-readiness blockers.',
|
||||
'• Reporting: gnubok_get_trial_balance / _income_statement / _balance_sheet / _kpi_report / _ar_ledger / _supplier_ledger: all default to the most recent fiscal period. For account roll-ups use gnubok_get_general_ledger; for ad-hoc line queries (free-text, amount/date/source filters) use gnubok_query_journal.',
|
||||
'• Interactive review UIs (claude.ai / Claude Desktop only): gnubok_get_vat_report(render_ui=true) renders the VAT widget, gnubok_receipt_matcher opens the receipt↔transaction matcher, and gnubok_list_pending_operations(render_ui=true) opens the approval queue where the user approves/rejects with a click. All also return structured data; other clients ignore the UI and use the data.',
|
||||
'• Year-end: gnubok_lock_period → gnubok_run_year_end → gnubok_set_opening_balances → gnubok_close_period. Each stages for human approval; closing is irreversible per BFL.',
|
||||
'• Year-end: run gnubok_year_end_readiness first. For kontantmetoden, resolve kontantmetod_cutoff_required with the searchable gnubok_post_kontantmetod_cutoff tool. Then gnubok_run_year_end → gnubok_set_opening_balances → gnubok_close_period. Each write stages for human approval; closing is irreversible per BFL.',
|
||||
'• Payroll: gnubok_create_salary_run → gnubok_calculate_salary_run → gnubok_book_salary_run → gnubok_generate_agi.',
|
||||
'• Reviewing & approving staged operations: gnubok_list_pending_operations shows the queue. When the user explicitly authorises a specific operation_id in chat, call gnubok_approve_pending_operation to commit. Use gnubok_reject_pending_operation to discard.',
|
||||
'',
|
||||
|
||||
@@ -28,15 +28,23 @@ Before running year-end, post any year-end adjusting entries via the web app:
|
||||
|
||||
These are not staged via MCP today: direct in web UI. The skill is to remind the user.
|
||||
|
||||
### Step 2: Currency revaluation (if multi-currency)
|
||||
### Step 2: Cash-method cut-off (kontantmetoden only)
|
||||
|
||||
For a company using kontantmetoden, first run
|
||||
|
||||
\`gnubok_post_kontantmetod_cutoff({ fiscal_period_id })\`.
|
||||
|
||||
It stages the exact customer-receivable and supplier-payable entries dated on the fiscal year end, plus their reversals on day one of the next period. Review every proposed line and approve with \`confirmed=true\`. The next fiscal period must already exist and be open. Re-run \`gnubok_year_end_readiness\` after approval: BFL 5 kap 2 § makes this a blocker, not an optional reminder.
|
||||
|
||||
### Step 3: Currency revaluation (if multi-currency)
|
||||
|
||||
If the company has open foreign-currency receivables/payables (1510/2440 in EUR/USD/etc.), revalue to closing-date FX rate via \`gnubok_run_currency_revaluation({ fiscal_period_id, closing_date })\`. Posts to **3960** (kursvinster) and **7960** (kursförluster). One revaluation per period.
|
||||
|
||||
### Step 3: Lock the period
|
||||
### Step 4: Lock the period
|
||||
|
||||
\`gnubok_lock_period(fiscal_period_id)\`. Required before year-end. Refuses if business transactions are unbooked.
|
||||
|
||||
### Step 4: Run year-end
|
||||
### Step 5: Run year-end
|
||||
|
||||
\`gnubok_run_year_end(fiscal_period_id)\`: stages a high-risk operation. After approval:
|
||||
|
||||
@@ -44,11 +52,11 @@ If the company has open foreign-currency receivables/payables (1510/2440 in EUR/
|
||||
- Period flagged \`is_year_end_complete\`
|
||||
- Next period created automatically
|
||||
|
||||
### Step 5: Set opening balances
|
||||
### Step 6: Set opening balances
|
||||
|
||||
\`gnubok_set_opening_balances({ closed_period_id, next_period_id })\`. Copies class 1-2 closing balances into the next period as opening balances. Stage → approve.
|
||||
|
||||
### Step 6: Close (final, irreversible)
|
||||
### Step 7: Close (final, irreversible)
|
||||
|
||||
\`gnubok_close_period(fiscal_period_id)\`. Once approved, the period is sealed forever. **No more entries possible, not even via storno.**
|
||||
|
||||
@@ -85,6 +93,7 @@ These compute with \`gnubok_get_kpi_report\` for inputs but the actual tax JE is
|
||||
## Tools
|
||||
|
||||
- \`gnubok_lock_period\`: pre-flight before year-end
|
||||
- \`gnubok_post_kontantmetod_cutoff\`: stage the mandatory cash-method cut-off and reversals
|
||||
- \`gnubok_run_year_end\`: zero result accounts
|
||||
- \`gnubok_set_opening_balances\`: seed next period
|
||||
- \`gnubok_run_currency_revaluation\`: FX revaluation
|
||||
|
||||
@@ -67,6 +67,7 @@ export const bokslutStep = defineAgentIntent<BokslutStepArgs, CapturedBokslutSte
|
||||
|
||||
tools: [
|
||||
'gnubok_year_end_readiness',
|
||||
'gnubok_post_kontantmetod_cutoff',
|
||||
'gnubok_list_fiscal_periods',
|
||||
'gnubok_propose_accruals',
|
||||
'gnubok_propose_annual_depreciation',
|
||||
@@ -173,11 +174,12 @@ export const bokslutStep = defineAgentIntent<BokslutStepArgs, CapturedBokslutSte
|
||||
lines.push('')
|
||||
lines.push('Arbetssätt: hjälp användaren genom STEGET de står i:')
|
||||
lines.push('1. Kör gnubok_year_end_readiness för att se vad som saknas.')
|
||||
lines.push('2. Om steget är "accruals": använd gnubok_propose_accruals för periodiseringar och förklara varje förslag (när påverkar det BR/RR, varför detta belopp?).')
|
||||
lines.push('3. Om steget är "depreciation": gnubok_propose_annual_depreciation. Förklara planenlig vs. överavskrivning, K2 schablonregler vs. K3 individual.')
|
||||
lines.push('4. Om steget är "dispositioner": gnubok_propose_dispositioner. Periodiseringsfond, koncernbidrag (om holding), årets skatt.')
|
||||
lines.push('5. Om steget är "arsredovisning": preview via gnubok_preview_arsredovisning, granska noter, förvaltningsberättelse, underskrifter, deadline.')
|
||||
lines.push('6. Om EF: använd gnubok_preview_ef_declaration. Räntefördelning, expansionsfond, NE-bilaga.')
|
||||
lines.push('2. Om kontantmetodens bokslutsavgränsning blockerar: använd gnubok_post_kontantmetod_cutoff, visa alla föreslagna verifikat och vändningar, och inhämta uttryckligt godkännande före bokföring.')
|
||||
lines.push('3. Om steget är "accruals": använd gnubok_propose_accruals för periodiseringar och förklara varje förslag (när påverkar det BR/RR, varför detta belopp?).')
|
||||
lines.push('4. Om steget är "depreciation": gnubok_propose_annual_depreciation. Förklara planenlig vs. överavskrivning, K2 schablonregler vs. K3 individual.')
|
||||
lines.push('5. Om steget är "dispositioner": gnubok_propose_dispositioner. Periodiseringsfond, koncernbidrag (om holding), årets skatt.')
|
||||
lines.push('6. Om steget är "arsredovisning": preview via gnubok_preview_arsredovisning, granska noter, förvaltningsberättelse, underskrifter, deadline.')
|
||||
lines.push('7. Om EF: använd gnubok_preview_ef_declaration. Räntefördelning, expansionsfond, NE-bilaga.')
|
||||
lines.push('')
|
||||
lines.push('Var BFL-rigorös: bokslut är irreversibelt när det låses. Peka på risker innan du föreslår staging av en operation.')
|
||||
lines.push('Svara på svenska. Ditt första svar är det första användaren ser: gå rakt på sak.')
|
||||
|
||||
@@ -271,6 +271,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
gnubok_lock_period: 'bookkeeping:write',
|
||||
gnubok_unlock_period: 'bookkeeping:write',
|
||||
gnubok_run_year_end: 'bookkeeping:write',
|
||||
gnubok_post_kontantmetod_cutoff: 'bookkeeping:write',
|
||||
gnubok_year_end_readiness: 'reports:read',
|
||||
gnubok_set_opening_balances: 'bookkeeping:write',
|
||||
gnubok_run_currency_revaluation: 'bookkeeping:write',
|
||||
|
||||
@@ -20,16 +20,11 @@ vi.mock('@/lib/reports/supplier-reconciliation', () => ({
|
||||
generateReconciliation: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/bookkeeping/kontantmetod-cutoff', () => ({
|
||||
collectKontantmetodCutoff: vi.fn(),
|
||||
}))
|
||||
|
||||
import { buildBokslutReadinessReport } from '../readiness-aggregator'
|
||||
import { validateYearEndReadiness } from '@/lib/core/bookkeeping/year-end-service'
|
||||
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import { generateARReconciliation } from '@/lib/reports/ar-reconciliation'
|
||||
import { generateReconciliation as generateAPReconciliation } from '@/lib/reports/supplier-reconciliation'
|
||||
import { collectKontantmetodCutoff } from '@/lib/core/bookkeeping/kontantmetod-cutoff'
|
||||
|
||||
const CASH_ACCOUNT_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
|
||||
|
||||
@@ -350,7 +345,6 @@ describe('buildBokslutReadinessReport', () => {
|
||||
// only mislead.
|
||||
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
|
||||
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN as never)
|
||||
vi.mocked(collectKontantmetodCutoff).mockResolvedValue({ receivables: [], payables: [], unknownVatTreatment: [], strayVatOnZeroRate: [] })
|
||||
const supabase = makeSupabase({
|
||||
period: { data: PERIOD, error: null },
|
||||
settings: { data: { entity_type: 'enskild_firma', accounting_method: 'cash' }, error: null },
|
||||
@@ -380,17 +374,16 @@ describe('buildBokslutReadinessReport', () => {
|
||||
// The AP side still ran and reported clean independently of the AR failure.
|
||||
expect(vi.mocked(generateAPReconciliation)).toHaveBeenCalled()
|
||||
})
|
||||
it('reminds kontantmetoden companies to book the year-end cut-off', async () => {
|
||||
// BFL 5 kap 2 §: fordringar och skulder must be booked at räkenskapsårets
|
||||
// utgång even though the year is otherwise kept on a cash basis.
|
||||
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
|
||||
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN as never)
|
||||
vi.mocked(collectKontantmetodCutoff).mockResolvedValue({
|
||||
receivables: [{ id: 'i1', reference: 'F-1', vatTreatment: 'standard_25', outstanding: 1250, vat: 250 }],
|
||||
payables: [{ id: 's1', reference: 'L-1', outstanding: 500, vat: 100, netByAccount: [] }],
|
||||
unknownVatTreatment: [],
|
||||
strayVatOnZeroRate: [],
|
||||
it('passes through the legal kontantmetoden cut-off blocker from core readiness', async () => {
|
||||
const message =
|
||||
'2 obetalda fakturor var utestående vid periodens slut. Förhandsgranska och bokför kontantmetodens bokslutsavgränsning.'
|
||||
vi.mocked(validateYearEndReadiness).mockResolvedValue({
|
||||
...baseValidation(),
|
||||
ready: false,
|
||||
blockers: [{ code: 'KONTANTMETOD_CUTOFF_REQUIRED', message }],
|
||||
errors: [message],
|
||||
})
|
||||
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN as never)
|
||||
const supabase = makeSupabase({
|
||||
period: { data: PERIOD, error: null },
|
||||
settings: { data: { entity_type: 'enskild_firma', accounting_method: 'cash' }, error: null },
|
||||
@@ -398,28 +391,13 @@ describe('buildBokslutReadinessReport', () => {
|
||||
|
||||
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
|
||||
|
||||
const cutoff = report.reminders.find((r) => r.code === 'kontantmetod_cutoff_required')
|
||||
expect(cutoff?.severity).toBe('warning')
|
||||
expect(cutoff?.message).toContain('2 obetalda fakturor')
|
||||
expect(cutoff?.message).toContain('vilande')
|
||||
// Advisory only: it must never flip readiness on its own.
|
||||
expect(report.ready).toBe(true)
|
||||
})
|
||||
|
||||
it('emits no cut-off reminder when nothing was outstanding at period end', async () => {
|
||||
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
|
||||
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN as never)
|
||||
vi.mocked(collectKontantmetodCutoff).mockResolvedValue({ receivables: [], payables: [], unknownVatTreatment: [], strayVatOnZeroRate: [] })
|
||||
const supabase = makeSupabase({
|
||||
period: { data: PERIOD, error: null },
|
||||
settings: { data: { entity_type: 'enskild_firma', accounting_method: 'cash' }, error: null },
|
||||
expect(report.ready).toBe(false)
|
||||
expect(report.blockerItems).toContainEqual({
|
||||
code: 'KONTANTMETOD_CUTOFF_REQUIRED', message,
|
||||
})
|
||||
|
||||
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
|
||||
expect(report.reminders.find((r) => r.code === 'kontantmetod_cutoff_required')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never runs the cut-off check for faktureringsmetoden companies', async () => {
|
||||
it('keeps faktureringsmetoden reconciliation behavior unchanged', async () => {
|
||||
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
|
||||
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN as never)
|
||||
vi.mocked(generateARReconciliation).mockResolvedValue({ is_reconciled: true, difference: 0, unconverted_fx_count: 0 } as never)
|
||||
@@ -430,20 +408,7 @@ describe('buildBokslutReadinessReport', () => {
|
||||
})
|
||||
|
||||
await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
|
||||
expect(vi.mocked(collectKontantmetodCutoff)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('degrades gracefully when the cut-off check fails', async () => {
|
||||
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
|
||||
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN as never)
|
||||
vi.mocked(collectKontantmetodCutoff).mockRejectedValue(new Error('boom'))
|
||||
const supabase = makeSupabase({
|
||||
period: { data: PERIOD, error: null },
|
||||
settings: { data: { entity_type: 'enskild_firma', accounting_method: 'cash' }, error: null },
|
||||
})
|
||||
|
||||
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
|
||||
expect(report.ready).toBe(true)
|
||||
expect(report.reminders.find((r) => r.code === 'kontantmetod_cutoff_required')).toBeUndefined()
|
||||
expect(vi.mocked(generateARReconciliation)).toHaveBeenCalled()
|
||||
expect(vi.mocked(generateAPReconciliation)).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliatio
|
||||
import { resolveCashAccountScope } from '@/lib/reconciliation/cash-account-scope'
|
||||
import { generateARReconciliation } from '@/lib/reports/ar-reconciliation'
|
||||
import { generateReconciliation as generateAPReconciliation } from '@/lib/reports/supplier-reconciliation'
|
||||
import { collectKontantmetodCutoff } from '@/lib/core/bookkeeping/kontantmetod-cutoff'
|
||||
import { computeEfDeclarationPreview } from '@/lib/bokslut/enskild-firma/ef-declaration-preview'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type { YearEndBlocker, YearEndValidation } from '@/types'
|
||||
@@ -167,63 +166,6 @@ export async function buildBokslutReadinessReport(
|
||||
// construction for the whole year and would only mislead.
|
||||
// Warnings, never blockers: a difference can be legitimate (e.g. partial
|
||||
// payments settled at a different FX rate than the invoice-date rate).
|
||||
if (accountingMethod === 'cash') {
|
||||
// Kontantmetoden year-end cut-off (BFL 5 kap 2 §): fordringar och skulder
|
||||
// must be booked at räkenskapsårets utgång even though the year is kept on
|
||||
// a cash basis. Advisory here, not a blocker: promoting it would newly
|
||||
// block every cash company mid-bokslut, and the posting step is the
|
||||
// founder's call to gate on.
|
||||
try {
|
||||
const cutoff = await collectKontantmetodCutoff(
|
||||
supabase,
|
||||
companyId,
|
||||
period.period_start,
|
||||
period.period_end,
|
||||
)
|
||||
const openCount = cutoff.receivables.length + cutoff.payables.length
|
||||
if (openCount > 0) {
|
||||
reminders.push({
|
||||
code: 'kontantmetod_cutoff_required',
|
||||
severity: 'warning',
|
||||
message:
|
||||
`${openCount} obetalda fakturor var utestående vid periodens slut. ` +
|
||||
'Kontantmetoden kräver att fordringar och skulder bokförs vid ' +
|
||||
'räkenskapsårets utgång (BFL 5 kap 2 §). Momsen bokas som vilande ' +
|
||||
'och redovisas först vid betalning.',
|
||||
href: '/reports/kundreskontra',
|
||||
})
|
||||
}
|
||||
// Surfaced separately: these rows block the cut-off entirely, so the
|
||||
// user needs to see them even when nothing else is outstanding.
|
||||
if (cutoff.strayVatOnZeroRate.length > 0) {
|
||||
reminders.push({
|
||||
code: 'kontantmetod_cutoff_stray_vat',
|
||||
severity: 'warning',
|
||||
message:
|
||||
`${cutoff.strayVatOnZeroRate.length} fakturor har moms trots en momsfri ` +
|
||||
'momsinställning och kan inte tas med i bokslutsavgränsningen. Rätta dem innan bokslut: ' +
|
||||
`${cutoff.strayVatOnZeroRate.slice(0, 5).join(', ')}`,
|
||||
href: '/invoices',
|
||||
})
|
||||
}
|
||||
if (cutoff.unknownVatTreatment.length > 0) {
|
||||
reminders.push({
|
||||
code: 'kontantmetod_cutoff_missing_vat_treatment',
|
||||
severity: 'warning',
|
||||
message:
|
||||
`${cutoff.unknownVatTreatment.length} fakturor saknar momsinställning och kan ` +
|
||||
'inte tas med i bokslutsavgränsningen. Komplettera dem innan bokslut: ' +
|
||||
`${cutoff.unknownVatTreatment.slice(0, 5).join(', ')}`,
|
||||
href: '/invoices',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
// Advisory: never break the wizard on it, but keep the failure traceable
|
||||
// so a silently missing reminder is not mistaken for "nothing open".
|
||||
log.warn('kontantmetoden cut-off check failed; reminder omitted', err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
if (accountingMethod === 'accrual') {
|
||||
const [arResult, apResult] = await Promise.allSettled([
|
||||
generateARReconciliation(supabase, companyId, fiscalPeriodId),
|
||||
|
||||
@@ -8,7 +8,11 @@ vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
import {
|
||||
buildCutoffLines,
|
||||
buildCutoffNote,
|
||||
cutoffCollectionsEqual,
|
||||
collectKontantmetodCutoff,
|
||||
distributeOre,
|
||||
inspectKontantmetodCutoffPostings,
|
||||
KONTANTMETOD_CUTOFF_DESCRIPTIONS,
|
||||
postKontantmetodCutoff,
|
||||
nextDay,
|
||||
reverseLines,
|
||||
@@ -264,6 +268,184 @@ describe('buildCutoffNote (BFL 5 kap 6-7 §: traceability)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('cut-off snapshot and posting inspection', () => {
|
||||
const makeJournalSupabase = (rows: unknown[], error: { message: string } | null = null) => ({
|
||||
from: () => {
|
||||
const query: Record<string, unknown> = {}
|
||||
query.select = () => query
|
||||
query.eq = () => query
|
||||
query.in = () => query
|
||||
query.then = (resolve: (value: unknown) => unknown) => resolve({ data: rows, error })
|
||||
return query
|
||||
},
|
||||
}) as never
|
||||
|
||||
it('treats collection order as irrelevant but catches changed source data', () => {
|
||||
const first = {
|
||||
receivables: [receivable({ id: 'b' }), receivable({ id: 'a' })],
|
||||
payables: [payable({ id: 'p' })],
|
||||
unknownVatTreatment: [],
|
||||
strayVatOnZeroRate: [],
|
||||
}
|
||||
const reordered = {
|
||||
...first,
|
||||
receivables: [...first.receivables].reverse(),
|
||||
}
|
||||
expect(cutoffCollectionsEqual(first, reordered)).toBe(true)
|
||||
expect(
|
||||
cutoffCollectionsEqual(first, {
|
||||
...reordered,
|
||||
receivables: [receivable({ id: 'a', outstanding: 1300 }), receivable({ id: 'b' })],
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('requires exact cut-off lines and exact next-period reversals', async () => {
|
||||
const lines = buildCutoffLines([receivable()], [payable()])
|
||||
const rows = [
|
||||
{
|
||||
id: 'ar', fiscal_period_id: 'fp-1',
|
||||
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable,
|
||||
lines: lines.receivableLines,
|
||||
},
|
||||
{
|
||||
id: 'ar-rev', fiscal_period_id: 'fp-2',
|
||||
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivableReversal,
|
||||
lines: reverseLines(lines.receivableLines),
|
||||
},
|
||||
{
|
||||
id: 'ap', fiscal_period_id: 'fp-1',
|
||||
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.payable,
|
||||
lines: lines.payableLines,
|
||||
},
|
||||
{
|
||||
id: 'ap-rev', fiscal_period_id: 'fp-2',
|
||||
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.payableReversal,
|
||||
lines: reverseLines(lines.payableLines),
|
||||
},
|
||||
]
|
||||
|
||||
const status = await inspectKontantmetodCutoffPostings(
|
||||
makeJournalSupabase(rows), 'co-1', 'fp-1', 'fp-2', lines,
|
||||
)
|
||||
expect(status).toMatchObject({
|
||||
complete: true,
|
||||
hasAny: true,
|
||||
receivableEntryId: 'ar',
|
||||
receivableReversalId: 'ar-rev',
|
||||
payableEntryId: 'ap',
|
||||
payableReversalId: 'ap-rev',
|
||||
missing: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a single stale immutable marker as a conflict', async () => {
|
||||
const lines = buildCutoffLines([receivable()], [])
|
||||
const stale = lines.receivableLines.map((line) =>
|
||||
line.account_number === '1510' ? { ...line, debit_amount: 999 } : line,
|
||||
)
|
||||
const rows = [
|
||||
{
|
||||
id: 'ar', fiscal_period_id: 'fp-1',
|
||||
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable,
|
||||
lines: stale,
|
||||
},
|
||||
{
|
||||
id: 'ar-rev', fiscal_period_id: 'fp-2',
|
||||
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivableReversal,
|
||||
lines: reverseLines(lines.receivableLines),
|
||||
},
|
||||
]
|
||||
|
||||
const status = await inspectKontantmetodCutoffPostings(
|
||||
makeJournalSupabase(rows), 'co-1', 'fp-1', 'fp-2', lines,
|
||||
)
|
||||
expect(status.complete).toBe(false)
|
||||
expect(status.missing).toContain('receivable')
|
||||
expect(status.duplicates).toContain(KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable)
|
||||
})
|
||||
|
||||
it('treats multiple exact markers as a duplicate conflict', async () => {
|
||||
const lines = buildCutoffLines([receivable()], [])
|
||||
const rows = [
|
||||
{
|
||||
id: 'ar', fiscal_period_id: 'fp-1',
|
||||
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable,
|
||||
lines: lines.receivableLines,
|
||||
},
|
||||
{
|
||||
id: 'ar-duplicate', fiscal_period_id: 'fp-1',
|
||||
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable,
|
||||
lines: lines.receivableLines,
|
||||
},
|
||||
{
|
||||
id: 'ar-rev', fiscal_period_id: 'fp-2',
|
||||
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivableReversal,
|
||||
lines: reverseLines(lines.receivableLines),
|
||||
},
|
||||
]
|
||||
|
||||
const status = await inspectKontantmetodCutoffPostings(
|
||||
makeJournalSupabase(rows), 'co-1', 'fp-1', 'fp-2', lines,
|
||||
)
|
||||
expect(status.complete).toBe(false)
|
||||
expect(status.missing).toContain('receivable')
|
||||
expect(status.duplicates).toContain(KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable)
|
||||
})
|
||||
|
||||
it('fails closed when the immutable journal cannot be inspected', async () => {
|
||||
await expect(
|
||||
inspectKontantmetodCutoffPostings(
|
||||
makeJournalSupabase([], { message: 'connection lost' }),
|
||||
'co-1', 'fp-1', 'fp-2', buildCutoffLines([], []),
|
||||
),
|
||||
).rejects.toThrow(/kunde inte kontrolleras/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectKontantmetodCutoff', () => {
|
||||
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
|
||||
query.then = (resolve: (value: unknown) => unknown) => resolve({
|
||||
data: table === 'supplier_invoices' ? [] : null,
|
||||
error: table === 'invoices' ? { message: 'read failed' } : null,
|
||||
})
|
||||
return query
|
||||
},
|
||||
}
|
||||
await expect(
|
||||
collectKontantmetodCutoff(supabase as never, 'co-1', '2026-01-01', '2026-12-31'),
|
||||
).rejects.toThrow(/kunde inte läsa reskontran/i)
|
||||
})
|
||||
|
||||
it('fails closed when payment history cannot be reconstructed', async () => {
|
||||
const rows: Record<string, unknown[]> = {
|
||||
invoices: [{
|
||||
id: 'inv-1', invoice_number: 'F-1', invoice_date: '2026-12-01', status: 'sent',
|
||||
total: 1250, vat_amount: 250, vat_treatment: 'standard_25', document_type: 'invoice',
|
||||
}],
|
||||
supplier_invoices: [],
|
||||
}
|
||||
const supabase = {
|
||||
from: (table: string) => {
|
||||
const query: Record<string, unknown> = {}
|
||||
for (const name of ['select', 'eq', 'lte', 'in']) query[name] = () => query
|
||||
query.then = (resolve: (value: unknown) => unknown) => resolve({
|
||||
data: rows[table] ?? [],
|
||||
error: table === 'invoice_payments' ? { message: 'payment read failed' } : null,
|
||||
})
|
||||
return query
|
||||
},
|
||||
}
|
||||
await expect(
|
||||
collectKontantmetodCutoff(supabase as never, 'co-1', '2026-01-01', '2026-12-31'),
|
||||
).rejects.toThrow(/kunde inte läsa betalningar/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('postKontantmetodCutoff', () => {
|
||||
const OPEN_NEXT = {
|
||||
id: 'fp-next',
|
||||
@@ -273,14 +455,20 @@ describe('postKontantmetodCutoff', () => {
|
||||
locked_at: null,
|
||||
}
|
||||
|
||||
const makeSupabase = (next: Record<string, unknown> | null) => ({
|
||||
from: () => ({
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
eq: () => ({ maybeSingle: async () => ({ data: next, error: next ? null : { message: 'x' } }) }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
const makeSupabase = (next: Record<string, unknown> | null, journalRows: unknown[] = []) => ({
|
||||
from: (table: string) => {
|
||||
const query: Record<string, unknown> = {}
|
||||
query.select = () => query
|
||||
query.eq = () => query
|
||||
query.in = () => query
|
||||
query.maybeSingle = async () => ({
|
||||
data: table === 'fiscal_periods' ? next : null,
|
||||
error: table === 'fiscal_periods' && !next ? { message: 'x' } : null,
|
||||
})
|
||||
query.then = (resolve: (value: unknown) => unknown) =>
|
||||
resolve({ data: table === 'journal_entries' ? journalRows : null, error: null })
|
||||
return query
|
||||
},
|
||||
}) as never
|
||||
|
||||
const baseOpts = {
|
||||
@@ -366,6 +554,58 @@ describe('postKontantmetodCutoff', () => {
|
||||
expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to post a second cut-off when an earlier marker exists', async () => {
|
||||
await expect(
|
||||
postKontantmetodCutoff(
|
||||
makeSupabase(OPEN_NEXT, [{
|
||||
id: 'existing',
|
||||
fiscal_period_id: 'fp-1',
|
||||
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable,
|
||||
lines: buildCutoffLines([receivable()], []).receivableLines,
|
||||
}]),
|
||||
'co-1',
|
||||
'user-1',
|
||||
baseOpts,
|
||||
),
|
||||
).rejects.toThrow(/delvis eller dubbelt bokförd/i)
|
||||
expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resumes with the missing payable pair after a prior receivable pair succeeded', async () => {
|
||||
const receivableLines = buildCutoffLines([receivable()], []).receivableLines
|
||||
const existingRows = [
|
||||
{
|
||||
id: 'ar', fiscal_period_id: 'fp-1',
|
||||
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable,
|
||||
lines: receivableLines,
|
||||
},
|
||||
{
|
||||
id: 'ar-rev', fiscal_period_id: 'fp-next',
|
||||
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivableReversal,
|
||||
lines: reverseLines(receivableLines),
|
||||
},
|
||||
]
|
||||
vi.mocked(createJournalEntry)
|
||||
.mockResolvedValueOnce({ id: 'ap' } as never)
|
||||
.mockResolvedValueOnce({ id: 'ap-rev' } as never)
|
||||
|
||||
const result = await postKontantmetodCutoff(
|
||||
makeSupabase(OPEN_NEXT, existingRows),
|
||||
'co-1',
|
||||
'user-1',
|
||||
{ ...baseOpts, payables: [payable()] },
|
||||
)
|
||||
|
||||
expect(result.receivableEntry?.id).toBe('ar')
|
||||
expect(result.receivableReversal?.id).toBe('ar-rev')
|
||||
expect(result.payableEntry?.id).toBe('ap')
|
||||
expect(result.payableReversal?.id).toBe('ap-rev')
|
||||
expect(createJournalEntry).toHaveBeenCalledTimes(2)
|
||||
expect(vi.mocked(createJournalEntry).mock.calls[0]?.[3].description).toBe(
|
||||
KONTANTMETOD_CUTOFF_DESCRIPTIONS.payable,
|
||||
)
|
||||
})
|
||||
|
||||
it('stornoes the cut-off when its vändning fails, leaving no inflated 1510', async () => {
|
||||
// The failure mode the module exists to prevent: a committed cut-off with
|
||||
// no vändning inflates 1510/2440 permanently and double-books every
|
||||
|
||||
@@ -34,6 +34,7 @@ interface SeededTables {
|
||||
supplier_invoices?: Row[]
|
||||
invoice_payments?: Row[]
|
||||
supplier_invoice_payments?: Row[]
|
||||
company_settings?: Row[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,6 +237,11 @@ import { validateYearEndReadiness, previewYearEndClosing } from '../year-end-ser
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
import { countUnbookedInPeriod, findNextPeriod } from '../period-service'
|
||||
import {
|
||||
buildCutoffLines,
|
||||
KONTANTMETOD_CUTOFF_DESCRIPTIONS,
|
||||
reverseLines,
|
||||
} from '../kontantmetod-cutoff'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -662,6 +668,91 @@ describe('validateYearEndReadiness', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateYearEndReadiness: kontantmetoden cut-off gate', () => {
|
||||
const nextPeriod = {
|
||||
id: 'fp-2', period_start: '2025-01-01', period_end: '2025-12-31',
|
||||
is_closed: false, locked_at: null, opening_balance_entry_id: null,
|
||||
}
|
||||
const openInvoice = {
|
||||
id: 'inv-1', company_id: 'company-1', invoice_number: 'F-1',
|
||||
invoice_date: '2024-12-15', status: 'sent', total: 1250, total_sek: 1250,
|
||||
vat_amount: 250, vat_amount_sek: 250, vat_treatment: 'standard_25',
|
||||
credited_invoice_id: null, document_type: 'invoice',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
rows: [], isBalanced: true, totalDebit: 10000, totalCredit: 10000,
|
||||
} as never)
|
||||
vi.mocked(findNextPeriod).mockResolvedValue(nextPeriod as never)
|
||||
})
|
||||
|
||||
function cashTables(journalEntries: Row[] = []): SeededTables {
|
||||
return {
|
||||
...fxBaseTables({ journal_entries: journalEntries }),
|
||||
company_settings: [{
|
||||
company_id: 'company-1', accounting_method: 'cash', entity_type: 'aktiebolag',
|
||||
}],
|
||||
invoices: [openInvoice],
|
||||
}
|
||||
}
|
||||
|
||||
it('blocks year-end when an outstanding invoice has no matching cut-off pair', async () => {
|
||||
const result = await validateYearEndReadiness(
|
||||
makeFilteringClient(cashTables()) as never,
|
||||
'company-1', 'user-1', 'fp-1',
|
||||
)
|
||||
|
||||
expect(result.ready).toBe(false)
|
||||
expect(result.blockers).toContainEqual(expect.objectContaining({
|
||||
code: 'KONTANTMETOD_CUTOFF_REQUIRED',
|
||||
}))
|
||||
})
|
||||
|
||||
it('clears only after the exact cut-off and next-period reversal are posted', async () => {
|
||||
const expected = buildCutoffLines([{
|
||||
id: 'inv-1', reference: 'F-1', vatTreatment: 'standard_25',
|
||||
outstanding: 1250, vat: 250,
|
||||
}], [])
|
||||
const markers = [
|
||||
{
|
||||
id: 'cutoff', company_id: 'company-1', fiscal_period_id: 'fp-1',
|
||||
voucher_series: 'A', voucher_number: 10, status: 'posted', source_type: 'year_end',
|
||||
source_id: 'fp-1', entry_date: '2024-12-31',
|
||||
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable,
|
||||
lines: expected.receivableLines,
|
||||
},
|
||||
{
|
||||
id: 'reversal', company_id: 'company-1', fiscal_period_id: 'fp-2',
|
||||
voucher_series: 'A', voucher_number: 1, status: 'posted', source_type: 'year_end',
|
||||
source_id: 'fp-1', entry_date: '2025-01-01',
|
||||
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivableReversal,
|
||||
lines: reverseLines(expected.receivableLines),
|
||||
},
|
||||
]
|
||||
|
||||
const result = await validateYearEndReadiness(
|
||||
makeFilteringClient(cashTables(markers)) as never,
|
||||
'company-1', 'user-1', 'fp-1',
|
||||
)
|
||||
expect(result.blockers.some((blocker) =>
|
||||
blocker.code === 'KONTANTMETOD_CUTOFF_REQUIRED' ||
|
||||
blocker.code === 'KONTANTMETOD_CUTOFF_CHECK_FAILED',
|
||||
)).toBe(false)
|
||||
expect(result.ready).toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed when the cut-off query cannot run', async () => {
|
||||
const result = await validateYearEndReadiness(
|
||||
makeFilteringClient(cashTables(), 'company_settings') as never,
|
||||
'company-1', 'user-1', 'fp-1',
|
||||
)
|
||||
expect(result.blockers).toContainEqual(expect.objectContaining({
|
||||
code: 'KONTANTMETOD_CUTOFF_CHECK_FAILED',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateYearEndReadiness: open FX items at balansdagen (ÅRL 4 kap. 13 §)', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
|
||||
@@ -63,6 +63,13 @@ export const VILANDE_INPUT_VAT_ACCOUNT = '2648'
|
||||
export const RECEIVABLES_ACCOUNT = '1510'
|
||||
export const PAYABLES_ACCOUNT = '2440'
|
||||
|
||||
export const KONTANTMETOD_CUTOFF_DESCRIPTIONS = {
|
||||
receivable: 'Kundfordringar vid bokslut (kontantmetoden)',
|
||||
receivableReversal: 'Vändning kundfordringar bokslut (kontantmetoden)',
|
||||
payable: 'Leverantörsskulder vid bokslut (kontantmetoden)',
|
||||
payableReversal: 'Vändning leverantörsskulder bokslut (kontantmetoden)',
|
||||
} as const
|
||||
|
||||
/** A customer invoice still outstanding at period end. Amounts are SEK. */
|
||||
export interface CutoffReceivable {
|
||||
id: string
|
||||
@@ -109,6 +116,39 @@ export interface CutoffLines {
|
||||
payableTotal: number
|
||||
}
|
||||
|
||||
interface PostedCutoffEntry {
|
||||
id: string
|
||||
fiscal_period_id: string
|
||||
description: string
|
||||
lines: Array<{
|
||||
account_number: string
|
||||
debit_amount: number | string | null
|
||||
credit_amount: number | string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export interface KontantmetodCutoffPostingStatus {
|
||||
complete: boolean
|
||||
hasAny: boolean
|
||||
receivableEntryId: string | null
|
||||
receivableReversalId: string | null
|
||||
payableEntryId: string | null
|
||||
payableReversalId: string | null
|
||||
missing: Array<'receivable' | 'receivable_reversal' | 'payable' | 'payable_reversal'>
|
||||
duplicates: string[]
|
||||
}
|
||||
|
||||
export function hasIncompleteKontantmetodCutoffPair(
|
||||
status: KontantmetodCutoffPostingStatus,
|
||||
lines: CutoffLines,
|
||||
): boolean {
|
||||
const receivablePartial = lines.receivableLines.length > 0 &&
|
||||
Boolean(status.receivableEntryId) !== Boolean(status.receivableReversalId)
|
||||
const payablePartial = lines.payableLines.length > 0 &&
|
||||
Boolean(status.payableEntryId) !== Boolean(status.payableReversalId)
|
||||
return status.duplicates.length > 0 || receivablePartial || payablePartial
|
||||
}
|
||||
|
||||
// Go through roundOre first: Math.round(x * 100) alone mis-rounds exact-half
|
||||
// values that arrive with float drift (lib/money.ts).
|
||||
const toOre = (amount: number): number => Math.round(roundOre(amount) * 100)
|
||||
@@ -303,6 +343,133 @@ export function buildCutoffLines(
|
||||
}
|
||||
}
|
||||
|
||||
function comparableLines(lines: Array<{
|
||||
account_number: string
|
||||
debit_amount: number | string | null
|
||||
credit_amount: number | string | null
|
||||
}>): string[] {
|
||||
return lines
|
||||
.map((line) =>
|
||||
[
|
||||
line.account_number,
|
||||
roundOre(Number(line.debit_amount ?? 0)).toString(),
|
||||
roundOre(Number(line.credit_amount ?? 0)).toString(),
|
||||
].join(':'),
|
||||
)
|
||||
.sort()
|
||||
}
|
||||
|
||||
export function cutoffLinesEqual(
|
||||
left: CreateJournalEntryLineInput[],
|
||||
right: Array<{
|
||||
account_number: string
|
||||
debit_amount: number | string | null
|
||||
credit_amount: number | string | null
|
||||
}>,
|
||||
): boolean {
|
||||
const normalizedLeft = comparableLines(left)
|
||||
const normalizedRight = comparableLines(right)
|
||||
return normalizedLeft.length === normalizedRight.length &&
|
||||
normalizedLeft.every((line, index) => line === normalizedRight[index])
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect the immutable journal for a complete cut-off and its day-one
|
||||
* reversals. Matching exact account totals, rather than only a description,
|
||||
* makes a late invoice or payment reopen the blocker until a fresh cut-off is
|
||||
* posted. `source_id` anchors all four entries to the year being closed.
|
||||
*/
|
||||
export async function inspectKontantmetodCutoffPostings(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
fiscalPeriodId: string,
|
||||
nextFiscalPeriodId: 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)',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.eq('source_type', 'year_end')
|
||||
.eq('source_id', fiscalPeriodId)
|
||||
.eq('status', 'posted')
|
||||
.in('fiscal_period_id', [fiscalPeriodId, nextFiscalPeriodId])
|
||||
.in('description', Object.values(KONTANTMETOD_CUTOFF_DESCRIPTIONS))
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Kontantmetodens bokslutsavgränsning kunde inte kontrolleras: ${error.message}`)
|
||||
}
|
||||
|
||||
const rows = (data ?? []) as PostedCutoffEntry[]
|
||||
const missing: KontantmetodCutoffPostingStatus['missing'] = []
|
||||
const duplicates: string[] = []
|
||||
|
||||
const matchOne = (
|
||||
description: string,
|
||||
periodId: string,
|
||||
lines: CreateJournalEntryLineInput[],
|
||||
missingKind: KontantmetodCutoffPostingStatus['missing'][number],
|
||||
): string | null => {
|
||||
const candidates = rows.filter(
|
||||
(row) => row.description === description && row.fiscal_period_id === periodId,
|
||||
)
|
||||
|
||||
if (lines.length === 0) {
|
||||
if (candidates.length > 0) duplicates.push(description)
|
||||
return null
|
||||
}
|
||||
|
||||
const exact = candidates.filter((row) => 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
|
||||
// top of an immutable entry after the source reskontra changed.
|
||||
if (candidates.length > 0) duplicates.push(description)
|
||||
missing.push(missingKind)
|
||||
return null
|
||||
}
|
||||
return exact[0]!.id
|
||||
}
|
||||
|
||||
const receivableEntryId = matchOne(
|
||||
KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable,
|
||||
fiscalPeriodId,
|
||||
expected.receivableLines,
|
||||
'receivable',
|
||||
)
|
||||
const receivableReversalId = matchOne(
|
||||
KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivableReversal,
|
||||
nextFiscalPeriodId,
|
||||
reverseLines(expected.receivableLines),
|
||||
'receivable_reversal',
|
||||
)
|
||||
const payableEntryId = matchOne(
|
||||
KONTANTMETOD_CUTOFF_DESCRIPTIONS.payable,
|
||||
fiscalPeriodId,
|
||||
expected.payableLines,
|
||||
'payable',
|
||||
)
|
||||
const payableReversalId = matchOne(
|
||||
KONTANTMETOD_CUTOFF_DESCRIPTIONS.payableReversal,
|
||||
nextFiscalPeriodId,
|
||||
reverseLines(expected.payableLines),
|
||||
'payable_reversal',
|
||||
)
|
||||
|
||||
return {
|
||||
complete: missing.length === 0 && duplicates.length === 0,
|
||||
hasAny: rows.length > 0,
|
||||
receivableEntryId,
|
||||
receivableReversalId,
|
||||
payableEntryId,
|
||||
payableReversalId,
|
||||
missing,
|
||||
duplicates,
|
||||
}
|
||||
}
|
||||
|
||||
/** Swap every debit and credit: the vändning posted on day 1 of the new year. */
|
||||
export function reverseLines(
|
||||
lines: CreateJournalEntryLineInput[],
|
||||
@@ -342,6 +509,33 @@ export interface CutoffCollection {
|
||||
strayVatOnZeroRate: string[]
|
||||
}
|
||||
|
||||
export interface KontantmetodCutoffAssessment {
|
||||
collection: CutoffCollection
|
||||
lines: CutoffLines
|
||||
postings: KontantmetodCutoffPostingStatus
|
||||
}
|
||||
|
||||
function sortedCollection(collection: CutoffCollection): CutoffCollection {
|
||||
return {
|
||||
receivables: [...collection.receivables].sort((a, b) => a.id.localeCompare(b.id)),
|
||||
payables: [...collection.payables]
|
||||
.map((row) => ({
|
||||
...row,
|
||||
netByAccount: [...row.netByAccount].sort((a, b) => a.account.localeCompare(b.account)),
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
unknownVatTreatment: [...collection.unknownVatTreatment].sort(),
|
||||
strayVatOnZeroRate: [...collection.strayVatOnZeroRate].sort(),
|
||||
}
|
||||
}
|
||||
|
||||
export function cutoffCollectionsEqual(
|
||||
left: CutoffCollection,
|
||||
right: CutoffCollection,
|
||||
): boolean {
|
||||
return JSON.stringify(sortedCollection(left)) === JSON.stringify(sortedCollection(right))
|
||||
}
|
||||
|
||||
/**
|
||||
* An aggregate verifikat still has to say which affärshändelser it covers
|
||||
* (BFL 5 kap 6-7 §: motpart and underlag must be traceable). The lines are
|
||||
@@ -394,6 +588,13 @@ export async function collectKontantmetodCutoff(
|
||||
.in('status', ['registered', 'approved', 'partially_paid', 'paid']),
|
||||
])
|
||||
|
||||
if (invoicesResult.error || supplierResult.error) {
|
||||
throw new Error(
|
||||
'Kontantmetodens bokslutsavgränsning kunde inte läsa reskontran: ' +
|
||||
(invoicesResult.error?.message ?? supplierResult.error?.message ?? 'okänt fel'),
|
||||
)
|
||||
}
|
||||
|
||||
const invoices = (invoicesResult.data ?? []) as Array<Record<string, unknown>>
|
||||
const supplierInvoices = (supplierResult.data ?? []) as Array<Record<string, unknown>>
|
||||
|
||||
@@ -410,7 +611,7 @@ export async function collectKontantmetodCutoff(
|
||||
.eq('company_id', companyId)
|
||||
.lte('payment_date', periodEnd)
|
||||
.in('invoice_id', invoiceIds)
|
||||
: Promise.resolve({ data: [] as Array<Record<string, unknown>> }),
|
||||
: Promise.resolve({ data: [] as Array<Record<string, unknown>>, error: null }),
|
||||
supplierIds.length > 0
|
||||
? supabase
|
||||
.from('supplier_invoice_payments')
|
||||
@@ -418,9 +619,16 @@ export async function collectKontantmetodCutoff(
|
||||
.eq('company_id', companyId)
|
||||
.lte('payment_date', periodEnd)
|
||||
.in('supplier_invoice_id', supplierIds)
|
||||
: Promise.resolve({ data: [] as Array<Record<string, unknown>> }),
|
||||
: Promise.resolve({ data: [] as Array<Record<string, unknown>>, error: null }),
|
||||
])
|
||||
|
||||
if (invoicePayments.error || supplierPayments.error) {
|
||||
throw new Error(
|
||||
'Kontantmetodens bokslutsavgränsning kunde inte läsa betalningar: ' +
|
||||
(invoicePayments.error?.message ?? supplierPayments.error?.message ?? 'okänt fel'),
|
||||
)
|
||||
}
|
||||
|
||||
const paidByInvoice = new Map<string, number>()
|
||||
for (const row of (invoicePayments.data ?? []) as Array<Record<string, unknown>>) {
|
||||
const id = row.invoice_id as string
|
||||
@@ -529,6 +737,31 @@ export async function collectKontantmetodCutoff(
|
||||
return { receivables, payables, unknownVatTreatment, strayVatOnZeroRate }
|
||||
}
|
||||
|
||||
export async function assessKontantmetodCutoff(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
period: { id: string; period_start: string; period_end: string },
|
||||
nextFiscalPeriodId: string,
|
||||
entityType: EntityType = 'aktiebolag',
|
||||
): Promise<KontantmetodCutoffAssessment> {
|
||||
const collection = 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,
|
||||
lines,
|
||||
)
|
||||
|
||||
return { collection, lines, postings }
|
||||
}
|
||||
|
||||
export interface PostCutoffResult {
|
||||
receivableEntry: JournalEntry | null
|
||||
receivableReversal: JournalEntry | null
|
||||
@@ -645,6 +878,38 @@ export async function postKontantmetodCutoff(
|
||||
|
||||
await assertReversalPeriodPostable(supabase, companyId, opts.nextFiscalPeriodId, reversalDate)
|
||||
|
||||
const existing = await inspectKontantmetodCutoffPostings(
|
||||
supabase,
|
||||
companyId,
|
||||
opts.fiscalPeriodId,
|
||||
opts.nextFiscalPeriodId,
|
||||
{
|
||||
receivableLines,
|
||||
payableLines,
|
||||
receivableTotal: receivableLines.reduce((sum, line) => sum + line.debit_amount, 0),
|
||||
payableTotal: payableLines.reduce((sum, line) => sum + line.credit_amount, 0),
|
||||
},
|
||||
)
|
||||
if (hasIncompleteKontantmetodCutoffPair(existing, {
|
||||
receivableLines,
|
||||
payableLines,
|
||||
receivableTotal: receivableLines.reduce((sum, line) => sum + line.debit_amount, 0),
|
||||
payableTotal: payableLines.reduce((sum, line) => sum + line.credit_amount, 0),
|
||||
})) {
|
||||
throw new Error(
|
||||
'Kontantmetodens bokslutsavgränsning är delvis eller dubbelt bokförd för perioden. Kontrollera och rätta verifikaten innan du försöker igen.',
|
||||
)
|
||||
}
|
||||
|
||||
if (existing.receivableEntryId && existing.receivableReversalId) {
|
||||
result.receivableEntry = { id: existing.receivableEntryId } as JournalEntry
|
||||
result.receivableReversal = { id: existing.receivableReversalId } as JournalEntry
|
||||
}
|
||||
if (existing.payableEntryId && existing.payableReversalId) {
|
||||
result.payableEntry = { id: existing.payableEntryId } as JournalEntry
|
||||
result.payableReversal = { id: existing.payableReversalId } as JournalEntry
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a cut-off/vändning pair. On reversal failure the cut-off is stornoed
|
||||
* so the pair is all-or-nothing from the ledger's point of view.
|
||||
@@ -654,11 +919,19 @@ export async function postKontantmetodCutoff(
|
||||
label: string,
|
||||
references: string[],
|
||||
): Promise<[JournalEntry, JournalEntry]> => {
|
||||
const description = label === 'Kundfordringar'
|
||||
? KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable
|
||||
: KONTANTMETOD_CUTOFF_DESCRIPTIONS.payable
|
||||
const reversalDescription = label === 'Kundfordringar'
|
||||
? KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivableReversal
|
||||
: KONTANTMETOD_CUTOFF_DESCRIPTIONS.payableReversal
|
||||
|
||||
const entry = await createJournalEntry(supabase, companyId, userId, {
|
||||
fiscal_period_id: opts.fiscalPeriodId,
|
||||
entry_date: opts.periodEnd,
|
||||
description: `${label} vid bokslut (kontantmetoden)`,
|
||||
description,
|
||||
source_type: 'year_end',
|
||||
source_id: opts.fiscalPeriodId,
|
||||
notes: buildCutoffNote(label, references),
|
||||
lines,
|
||||
})
|
||||
@@ -667,8 +940,9 @@ export async function postKontantmetodCutoff(
|
||||
const reversal = await createJournalEntry(supabase, companyId, userId, {
|
||||
fiscal_period_id: opts.nextFiscalPeriodId,
|
||||
entry_date: reversalDate,
|
||||
description: `Vändning ${label.toLowerCase()} bokslut (kontantmetoden)`,
|
||||
description: reversalDescription,
|
||||
source_type: 'year_end',
|
||||
source_id: opts.fiscalPeriodId,
|
||||
notes: buildCutoffNote(`Vändning ${label.toLowerCase()}`, references),
|
||||
lines: reverseLines(lines),
|
||||
})
|
||||
@@ -690,7 +964,7 @@ export async function postKontantmetodCutoff(
|
||||
}
|
||||
}
|
||||
|
||||
if (receivableLines.length > 0) {
|
||||
if (receivableLines.length > 0 && !result.receivableEntry) {
|
||||
const [entry, reversal] = await postPair(
|
||||
receivableLines,
|
||||
'Kundfordringar',
|
||||
@@ -700,7 +974,7 @@ export async function postKontantmetodCutoff(
|
||||
result.receivableReversal = reversal
|
||||
}
|
||||
|
||||
if (payableLines.length > 0) {
|
||||
if (payableLines.length > 0 && !result.payableEntry) {
|
||||
const [entry, reversal] = await postPair(
|
||||
payableLines,
|
||||
'Leverantörsskulder',
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
executeCurrencyRevaluation,
|
||||
} from '@/lib/bookkeeping/currency-revaluation'
|
||||
import { validateBalanceContinuity } from '@/lib/reports/continuity-check'
|
||||
import { assessKontantmetodCutoff } from './kontantmetod-cutoff'
|
||||
import type {
|
||||
YearEndValidation,
|
||||
YearEndBlocker,
|
||||
@@ -341,6 +342,66 @@ export async function validateYearEndReadiness(
|
||||
}
|
||||
}
|
||||
|
||||
// Kontantmetoden is a legal hard gate, not an advisory: BFL 5 kap 2 §
|
||||
// requires every unpaid receivable and liability to be booked at the fiscal
|
||||
// year end. Gate before executeYearEndClosing posts its closing entry. A
|
||||
// later lock-time check would leave a partial close behind on failure.
|
||||
try {
|
||||
const { data: settings, error: settingsError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, entity_type')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (settingsError) throw settingsError
|
||||
|
||||
if (settings?.accounting_method === 'cash') {
|
||||
if (!nextPeriod) {
|
||||
blockers.push({
|
||||
code: 'KONTANTMETOD_CUTOFF_REQUIRED',
|
||||
message:
|
||||
'Kontantmetodens bokslutsavgränsning kan inte bokföras förrän nästa räkenskapsår är upplagt. Skapa nästa period, förhandsgranska och bokför avgränsningen innan bokslut.',
|
||||
})
|
||||
} else {
|
||||
const assessment = await assessKontantmetodCutoff(
|
||||
supabase,
|
||||
companyId,
|
||||
period,
|
||||
nextPeriod.id,
|
||||
settings.entity_type ?? 'aktiebolag',
|
||||
)
|
||||
const invalidCount =
|
||||
assessment.collection.unknownVatTreatment.length +
|
||||
assessment.collection.strayVatOnZeroRate.length
|
||||
const outstandingCount =
|
||||
assessment.collection.receivables.length + assessment.collection.payables.length
|
||||
|
||||
if (invalidCount > 0) {
|
||||
blockers.push({
|
||||
code: 'KONTANTMETOD_CUTOFF_REQUIRED',
|
||||
message:
|
||||
`${invalidCount} fakturor kan inte tas med i kontantmetodens bokslutsavgränsning på grund av saknad eller oförenlig momsinställning. Rätta fakturorna och bokför avgränsningen innan bokslut.`,
|
||||
})
|
||||
} else if (!assessment.postings.complete) {
|
||||
blockers.push({
|
||||
code: 'KONTANTMETOD_CUTOFF_REQUIRED',
|
||||
message:
|
||||
outstandingCount > 0
|
||||
? `${outstandingCount} obetalda fakturor var utestående vid periodens slut. Förhandsgranska och bokför kontantmetodens bokslutsavgränsning med vändningar innan bokslut (BFL 5 kap 2 §).`
|
||||
: 'En tidigare kontantmetodavgränsning stämmer inte längre med reskontran. Kontrollera och rätta verifikaten innan bokslut.',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('kontantmetoden cut-off readiness check failed', err as Error)
|
||||
blockers.push({
|
||||
code: 'KONTANTMETOD_CUTOFF_CHECK_FAILED',
|
||||
message:
|
||||
'Kontrollen av kontantmetodens bokslutsavgränsning kunde inte genomföras: försök igen innan bokslut',
|
||||
})
|
||||
}
|
||||
|
||||
// Check: unbooked bank transactions in the period. lockPeriod enforces this
|
||||
// at step 7 of executeYearEndClosing, AFTER the closing entry has already
|
||||
// posted at step 4: without this readiness check a period with unbooked
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PendingOperation } from '@/types'
|
||||
|
||||
vi.mock('@/lib/core/bookkeeping/kontantmetod-cutoff', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('@/lib/core/bookkeeping/kontantmetod-cutoff')
|
||||
>('@/lib/core/bookkeeping/kontantmetod-cutoff')
|
||||
return {
|
||||
...actual,
|
||||
assessKontantmetodCutoff: vi.fn(),
|
||||
postKontantmetodCutoff: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
import { commitPendingOperation } from '../commit'
|
||||
import {
|
||||
assessKontantmetodCutoff,
|
||||
buildCutoffLines,
|
||||
postKontantmetodCutoff,
|
||||
} from '@/lib/core/bookkeeping/kontantmetod-cutoff'
|
||||
|
||||
const collection = {
|
||||
receivables: [{
|
||||
id: 'inv-1', reference: 'F-1', vatTreatment: 'standard_25' as const,
|
||||
outstanding: 1250, vat: 250,
|
||||
}],
|
||||
payables: [],
|
||||
unknownVatTreatment: [],
|
||||
strayVatOnZeroRate: [],
|
||||
}
|
||||
|
||||
function makePendingOp(overrides: Partial<PendingOperation> = {}): PendingOperation {
|
||||
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 },
|
||||
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',
|
||||
...overrides,
|
||||
} as PendingOperation
|
||||
}
|
||||
|
||||
function makeSupabase(options: {
|
||||
period?: unknown
|
||||
settings?: unknown
|
||||
nextPeriod?: unknown
|
||||
} = {}) {
|
||||
const period = options.period ?? {
|
||||
id: 'fp-1', period_start: '2026-01-01', period_end: '2026-12-31',
|
||||
is_closed: false, locked_at: null,
|
||||
}
|
||||
const settings = options.settings ?? { accounting_method: 'cash', entity_type: 'aktiebolag' }
|
||||
const nextPeriod = Object.prototype.hasOwnProperty.call(options, 'nextPeriod')
|
||||
? options.nextPeriod
|
||||
: {
|
||||
id: 'fp-2', period_start: '2027-01-01', period_end: '2027-12-31',
|
||||
is_closed: false, locked_at: null,
|
||||
}
|
||||
let fiscalReads = 0
|
||||
const updates: Array<{ table: string; value: unknown }> = []
|
||||
const from = vi.fn((table: string) => {
|
||||
const chain: Record<string, unknown> = {}
|
||||
for (const name of ['select', 'eq', 'in', 'order', 'limit']) chain[name] = () => chain
|
||||
chain.update = (value: unknown) => {
|
||||
updates.push({ table, value })
|
||||
return chain
|
||||
}
|
||||
const response = () => {
|
||||
if (table === 'pending_operations') return { data: { id: 'op-1' }, error: null }
|
||||
if (table === 'company_settings') return { data: settings, error: null }
|
||||
if (table === 'fiscal_periods') {
|
||||
fiscalReads++
|
||||
return { data: fiscalReads === 1 ? period : nextPeriod, error: null }
|
||||
}
|
||||
return { data: null, error: null }
|
||||
}
|
||||
chain.maybeSingle = async () => response()
|
||||
chain.single = async () => response()
|
||||
chain.then = (resolve: (value: unknown) => unknown) => resolve(response())
|
||||
return chain
|
||||
})
|
||||
return { auth: {}, from, updates }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(assessKontantmetodCutoff).mockResolvedValue({
|
||||
collection,
|
||||
lines: buildCutoffLines(collection.receivables, collection.payables),
|
||||
postings: {
|
||||
complete: false, hasAny: false, receivableEntryId: null,
|
||||
receivableReversalId: null, payableEntryId: null, payableReversalId: null,
|
||||
missing: ['receivable', 'receivable_reversal'], duplicates: [],
|
||||
},
|
||||
})
|
||||
vi.mocked(postKontantmetodCutoff).mockResolvedValue({
|
||||
receivableEntry: { id: 'je-1' } as never,
|
||||
receivableReversal: { id: 'je-2' } as never,
|
||||
payableEntry: null,
|
||||
payableReversal: null,
|
||||
})
|
||||
})
|
||||
|
||||
describe('commitPendingOperation: post_kontantmetod_cutoff', () => {
|
||||
it('revalidates the frozen preview and posts through the cut-off service', async () => {
|
||||
const supabase = makeSupabase()
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never, 'user-1', 'company-1', makePendingOp(),
|
||||
)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(result.data).toEqual({
|
||||
receivable_entry_id: 'je-1',
|
||||
receivable_reversal_entry_id: 'je-2',
|
||||
payable_entry_id: null,
|
||||
payable_reversal_entry_id: null,
|
||||
})
|
||||
expect(postKontantmetodCutoff).toHaveBeenCalledWith(
|
||||
expect.anything(), 'company-1', 'user-1',
|
||||
expect.objectContaining({
|
||||
fiscalPeriodId: 'fp-1', nextFiscalPeriodId: 'fp-2',
|
||||
receivables: collection.receivables, payables: [],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects when the reskontra changed after staging', async () => {
|
||||
vi.mocked(assessKontantmetodCutoff).mockResolvedValueOnce({
|
||||
collection: {
|
||||
...collection,
|
||||
receivables: [{ ...collection.receivables[0]!, outstanding: 1300 }],
|
||||
},
|
||||
lines: buildCutoffLines([], []),
|
||||
postings: { complete: false, hasAny: false, receivableEntryId: null, receivableReversalId: null, payableEntryId: null, payableReversalId: null, missing: [], duplicates: [] },
|
||||
})
|
||||
const result = await commitPendingOperation(
|
||||
makeSupabase() as never, 'user-1', 'company-1', makePendingOp(),
|
||||
)
|
||||
expect(result).toMatchObject({ status: 'rejected', http_status: 409 })
|
||||
expect(result.error).toMatch(/ändrats sedan förhandsgranskningen/i)
|
||||
expect(postKontantmetodCutoff).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a duplicate, locked period, wrong accounting method, and missing next period', async () => {
|
||||
vi.mocked(assessKontantmetodCutoff).mockResolvedValueOnce({
|
||||
collection,
|
||||
lines: buildCutoffLines(collection.receivables, []),
|
||||
postings: { complete: true, hasAny: true, receivableEntryId: 'je-1', receivableReversalId: 'je-2', payableEntryId: null, payableReversalId: null, missing: [], duplicates: [] },
|
||||
})
|
||||
await expect(commitPendingOperation(
|
||||
makeSupabase() as never, 'user-1', 'company-1', makePendingOp(),
|
||||
)).resolves.toMatchObject({ status: 'rejected', http_status: 409 })
|
||||
|
||||
await expect(commitPendingOperation(
|
||||
makeSupabase({ period: { id: 'fp-1', period_end: '2026-12-31', locked_at: 'x', is_closed: false } }) as never,
|
||||
'user-1', 'company-1', makePendingOp(),
|
||||
)).resolves.toMatchObject({ status: 'rejected', http_status: 409 })
|
||||
|
||||
await expect(commitPendingOperation(
|
||||
makeSupabase({ settings: { accounting_method: 'accrual' } }) as never,
|
||||
'user-1', 'company-1', makePendingOp(),
|
||||
)).resolves.toMatchObject({ status: 'rejected', http_status: 409 })
|
||||
|
||||
await expect(commitPendingOperation(
|
||||
makeSupabase({ nextPeriod: null }) as never,
|
||||
'user-1', 'company-1', makePendingOp(),
|
||||
)).resolves.toMatchObject({ status: 'rejected', http_status: 409 })
|
||||
})
|
||||
})
|
||||
@@ -53,6 +53,13 @@ import {
|
||||
executeYearEndClosing,
|
||||
generateOpeningBalances,
|
||||
} from '@/lib/core/bookkeeping/year-end-service'
|
||||
import {
|
||||
assessKontantmetodCutoff,
|
||||
cutoffCollectionsEqual,
|
||||
hasIncompleteKontantmetodCutoffPair,
|
||||
postKontantmetodCutoff,
|
||||
type CutoffCollection,
|
||||
} from '@/lib/core/bookkeeping/kontantmetod-cutoff'
|
||||
import { executeCurrencyRevaluation } from '@/lib/bookkeeping/currency-revaluation'
|
||||
import {
|
||||
createSupplierCreditNoteEntry,
|
||||
@@ -3191,6 +3198,104 @@ async function commitRunYearEnd(
|
||||
}
|
||||
}
|
||||
|
||||
async function commitPostKontantmetodCutoff(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>,
|
||||
): 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) {
|
||||
return { error: 'Invalid staged kontantmetod cut-off parameters', status: 400 }
|
||||
}
|
||||
|
||||
const [{ data: period }, { data: settings }] = await Promise.all([
|
||||
supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, period_start, period_end, is_closed, locked_at')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, entity_type')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle(),
|
||||
])
|
||||
|
||||
if (!period) return { error: 'Fiscal period not found', status: 404 }
|
||||
if (period.is_closed || period.locked_at) {
|
||||
return { error: 'Räkenskapsperioden är stängd eller låst', status: 409 }
|
||||
}
|
||||
if (settings?.accounting_method !== 'cash') {
|
||||
return { error: 'Företaget använder inte kontantmetoden', status: 409 }
|
||||
}
|
||||
|
||||
const { data: nextPeriod } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, period_start, period_end, is_closed, locked_at')
|
||||
.eq('id', nextFiscalPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (!nextPeriod) return { error: 'Nästa räkenskapsår hittades inte', status: 409 }
|
||||
|
||||
try {
|
||||
const assessment = await assessKontantmetodCutoff(
|
||||
supabase,
|
||||
companyId,
|
||||
period,
|
||||
nextFiscalPeriodId,
|
||||
settings.entity_type ?? 'aktiebolag',
|
||||
)
|
||||
|
||||
if (assessment.postings.complete || hasIncompleteKontantmetodCutoffPair(
|
||||
assessment.postings,
|
||||
assessment.lines,
|
||||
)) {
|
||||
return {
|
||||
error:
|
||||
'Kontantmetodens bokslutsavgränsning är redan bokförd eller delvis bokförd för perioden',
|
||||
status: 409,
|
||||
}
|
||||
}
|
||||
if (!cutoffCollectionsEqual(stagedCollection, assessment.collection)) {
|
||||
return {
|
||||
error:
|
||||
'Reskontran har ändrats sedan förhandsgranskningen. Skapa en ny förhandsgranskning innan du bokför.',
|
||||
status: 409,
|
||||
}
|
||||
}
|
||||
|
||||
const result = await postKontantmetodCutoff(supabase, companyId, userId, {
|
||||
fiscalPeriodId,
|
||||
nextFiscalPeriodId,
|
||||
periodEnd: period.period_end,
|
||||
receivables: assessment.collection.receivables,
|
||||
payables: assessment.collection.payables,
|
||||
entityType: settings.entity_type ?? 'aktiebolag',
|
||||
unknownVatTreatment: assessment.collection.unknownVatTreatment,
|
||||
strayVatOnZeroRate: assessment.collection.strayVatOnZeroRate,
|
||||
})
|
||||
|
||||
return {
|
||||
data: {
|
||||
receivable_entry_id: result.receivableEntry?.id ?? null,
|
||||
receivable_reversal_entry_id: result.receivableReversal?.id ?? null,
|
||||
payable_entry_id: result.payableEntry?.id ?? null,
|
||||
payable_reversal_entry_id: result.payableReversal?.id ?? null,
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
if (isBookkeepingError(err)) throw err
|
||||
return {
|
||||
error: err instanceof Error ? err.message : 'Kontantmetodens bokslutsavgränsning misslyckades',
|
||||
status: 400,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function commitSetOpeningBalances(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
@@ -5693,6 +5798,14 @@ async function commitPendingOperationInner(
|
||||
case 'run_year_end':
|
||||
result = await commitRunYearEnd(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
case 'post_kontantmetod_cutoff':
|
||||
result = await commitPostKontantmetodCutoff(
|
||||
supabase,
|
||||
userId,
|
||||
companyId,
|
||||
pendingOp.params,
|
||||
)
|
||||
break
|
||||
case 'set_opening_balances':
|
||||
result = await commitSetOpeningBalances(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
|
||||
@@ -110,6 +110,7 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
|
||||
unlock_period: 'high',
|
||||
set_opening_balances: 'high',
|
||||
run_year_end: 'high',
|
||||
post_kontantmetod_cutoff: 'high',
|
||||
run_currency_revaluation: 'high',
|
||||
// Planenlig avskrivning: one journal entry per asset, each independently
|
||||
// reversible (storno). Mid-stakes bokslut posting: staged and human-reviewed,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
-- Add post_kontantmetod_cutoff to the pending_operations operation type
|
||||
-- CHECK for the staged cash-method year-end cut-off MCP flow.
|
||||
--
|
||||
-- The value list is the complete list from 20260807093856 plus the new type.
|
||||
|
||||
ALTER TABLE public.pending_operations
|
||||
DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check;
|
||||
|
||||
ALTER TABLE public.pending_operations
|
||||
ADD CONSTRAINT pending_operations_operation_type_check
|
||||
CHECK (operation_type IN (
|
||||
'categorize_transaction',
|
||||
'create_customer',
|
||||
'create_invoice',
|
||||
'mark_invoice_paid',
|
||||
'send_invoice',
|
||||
'mark_invoice_sent',
|
||||
'match_transaction_invoice',
|
||||
'close_period',
|
||||
'lock_period',
|
||||
'unlock_period',
|
||||
'set_opening_balances',
|
||||
'run_year_end',
|
||||
'post_kontantmetod_cutoff',
|
||||
'run_currency_revaluation',
|
||||
'import_sie',
|
||||
'explain_voucher_gap',
|
||||
'uncategorize_transaction',
|
||||
'approve_supplier_invoice',
|
||||
'credit_supplier_invoice',
|
||||
'credit_invoice',
|
||||
'convert_invoice',
|
||||
'create_transaction',
|
||||
'attach_document_to_transaction',
|
||||
'create_voucher',
|
||||
'correct_entry',
|
||||
'reverse_entry',
|
||||
'create_supplier',
|
||||
'create_supplier_invoice_from_inbox',
|
||||
'post_annual_depreciation',
|
||||
'link_invoice_voucher',
|
||||
'undo_sie_import',
|
||||
'match_batch_allocate',
|
||||
'bulk_book_transactions',
|
||||
'create_salary_run',
|
||||
'generate_agi',
|
||||
'link_transaction_journal_entry',
|
||||
'link_supplier_invoice_voucher',
|
||||
'submit_vat_declaration',
|
||||
'submit_agi',
|
||||
'create_article',
|
||||
'update_article',
|
||||
'bulk_book_inbox_items',
|
||||
'create_dimension_value',
|
||||
'retag_line_dimensions',
|
||||
'link_document_to_voucher',
|
||||
'update_payslip_line',
|
||||
'register_absence',
|
||||
'create_employee',
|
||||
'update_employee',
|
||||
'set_employee_opening_balances',
|
||||
'vacation_year_close',
|
||||
'create_account',
|
||||
'update_account',
|
||||
'set_voucher_note',
|
||||
'book_salary_run',
|
||||
'delete_absence',
|
||||
'update_company_settings',
|
||||
'update_customer',
|
||||
'update_invoice',
|
||||
'create_recurring_schedule',
|
||||
'update_recurring_schedule',
|
||||
'log_mileage_trip',
|
||||
'book_mileage_period'
|
||||
)) NOT VALID;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Validate the operation type CHECK re-added in 20260813124507.
|
||||
-- Kept separate to avoid a full-table scan under the stronger DDL lock.
|
||||
|
||||
ALTER TABLE public.pending_operations
|
||||
VALIDATE CONSTRAINT pending_operations_operation_type_check;
|
||||
@@ -106,4 +106,19 @@ describe('pending_operations operation_type CHECK audit', () => {
|
||||
client.release()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts post_kontantmetod_cutoff for the staged year-end posting flow', async () => {
|
||||
const client = await getPool().connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query(
|
||||
`INSERT INTO public.pending_operations (user_id, company_id, operation_type, title)
|
||||
VALUES ($1, $2, 'post_kontantmetod_cutoff', 'kontantmetod cut-off')`,
|
||||
[userId, companyId],
|
||||
)
|
||||
await client.query('ROLLBACK')
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2302,6 +2302,7 @@ export type PendingOperationType =
|
||||
| 'unlock_period'
|
||||
| 'set_opening_balances'
|
||||
| 'run_year_end'
|
||||
| 'post_kontantmetod_cutoff'
|
||||
| 'run_currency_revaluation'
|
||||
// Stream 1 Phase 1: SIE import (export is read-only)
|
||||
| 'import_sie'
|
||||
@@ -3584,6 +3585,8 @@ export type YearEndBlockerCode =
|
||||
| 'TRIAL_BALANCE_UNBALANCED'
|
||||
| 'CONTINUITY_MISMATCH'
|
||||
| 'NEXT_PERIOD_HAS_IB'
|
||||
| 'KONTANTMETOD_CUTOFF_REQUIRED'
|
||||
| 'KONTANTMETOD_CUTOFF_CHECK_FAILED'
|
||||
| 'UNBOOKED_TRANSACTIONS'
|
||||
| 'UNBOOKED_CHECK_FAILED'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user