feat(reconciliation): manual adapter for the whole balance sheet (Reko bilagor, PR 1) (#1854)
* feat(reconciliation): manual adapter so the whole balance sheet is reconcilable and signable (Reko bilagor, PR 1) Every class 1-2 account the bank and skattekonto adapters do not own now appears on the Avstämning page under "Övriga balanskonton" with IB, movement and UB through the balansdag, a system specification where one exists (1510 kundreskontra, 2440 leverantörsreskontra, 2920/2940 semesterlöneskuld) and, for every other account, the balance the signer states from their underlag at sign-off. Same three doors as before: dashboard routes, v1 API and the MCP tools take manual:<BAS> keys and an external_balance. The ledger side is computed per fiscal period via generateTrialBalance, never as an all-history sum: year-end re-books every balance account in an opening_balance verifikat, so an all-history sum counts a closed year twice. A stated external_balance is refused (EXTERNAL_BALANCE_NOT_ALLOWED) wherever the system already has an outside truth, so it can never hide a difference. No migration: account_reconciliations already accepts manual:NNNN keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * chore(api-skill): regenerate banking reference for the sign-off external_balance field Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
1e6e19afe9
commit
ebbdf96b74
@@ -11,6 +11,8 @@ const SignoffBodySchema = z.object({
|
||||
through_date: z.string().regex(ISO_DATE_RE),
|
||||
note: z.string().max(2000).nullable().optional(),
|
||||
force: z.boolean().optional(),
|
||||
/** Manual accounts without a system specification: the balance per the signer's underlag, ledger sign. */
|
||||
external_balance: z.number().finite().nullable().optional(),
|
||||
dry_run: z.boolean().optional(),
|
||||
})
|
||||
|
||||
@@ -65,7 +67,12 @@ export const POST = withRouteContext<{ params: Promise<{ accountKey: string }> }
|
||||
companyId,
|
||||
user.id,
|
||||
accountKey,
|
||||
{ through_date: parsed.data.through_date, note: parsed.data.note ?? null, force: parsed.data.force },
|
||||
{
|
||||
through_date: parsed.data.through_date,
|
||||
note: parsed.data.note ?? null,
|
||||
force: parsed.data.force,
|
||||
external_balance: parsed.data.external_balance ?? null,
|
||||
},
|
||||
{ dryRun: parsed.data.dry_run === true },
|
||||
)
|
||||
if (!result) {
|
||||
|
||||
@@ -93,11 +93,39 @@ describe('dashboard sign-off routes', () => {
|
||||
'company-1',
|
||||
'user-1',
|
||||
'skattekonto',
|
||||
{ through_date: '2026-07-31', note: 'ok', force: undefined },
|
||||
{ through_date: '2026-07-31', note: 'ok', force: undefined, external_balance: null },
|
||||
{ dryRun: true },
|
||||
)
|
||||
})
|
||||
|
||||
it('POST forwards a stated external_balance for a manual account and 400s a non-numeric one', async () => {
|
||||
const res = await signPOST(
|
||||
createMockRequest('http://localhost/api/reconciliation/accounts/manual:2350/signoff', { method: 'POST', body: {
|
||||
through_date: '2026-07-31',
|
||||
external_balance: -250000,
|
||||
note: 'Enligt engagemangsbesked',
|
||||
} }),
|
||||
p({ accountKey: 'manual:2350' }),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(signMock).toHaveBeenCalledWith(
|
||||
supabase,
|
||||
'company-1',
|
||||
'user-1',
|
||||
'manual:2350',
|
||||
{ through_date: '2026-07-31', note: 'Enligt engagemangsbesked', force: undefined, external_balance: -250000 },
|
||||
{ dryRun: false },
|
||||
)
|
||||
const bad = await signPOST(
|
||||
createMockRequest('http://localhost/api/reconciliation/accounts/manual:2350/signoff', { method: 'POST', body: {
|
||||
through_date: '2026-07-31',
|
||||
external_balance: '250 000',
|
||||
} }),
|
||||
p({ accountKey: 'manual:2350' }),
|
||||
)
|
||||
expect(bad.status).toBe(400)
|
||||
})
|
||||
|
||||
it('POST 400s a missing or malformed through_date', async () => {
|
||||
const res = await signPOST(
|
||||
createMockRequest('http://localhost/api/reconciliation/accounts/skattekonto/signoff', { method: 'POST', body: { through_date: '31/07/2026' } }),
|
||||
|
||||
+9
-3
@@ -24,6 +24,7 @@ const SignoffRequest = z.object({
|
||||
through_date: z.string().regex(ISO_DATE_RE),
|
||||
note: z.string().max(2000).nullable().optional(),
|
||||
force: z.boolean().optional(),
|
||||
external_balance: z.number().finite().nullable().optional(),
|
||||
})
|
||||
|
||||
const SignoffResponse = z.object({
|
||||
@@ -89,11 +90,11 @@ registerEndpoint({
|
||||
path: '/api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff',
|
||||
summary: 'Mark an account reconciled through a date (sign-off).',
|
||||
description:
|
||||
'Body: { through_date: "YYYY-MM-DD", note?, force? }. Recomputes the bridge through the date and refuses unless unexplained_difference is zero; with force: true and a note it signs anyway and records the difference. Refuses dates in the future, dates past the skattekonto snapshot (NOT_FETCHED_THROUGH), and dates at or before an existing active sign-off (ALREADY_SIGNED_OFF: reopen that one first). ?dry_run=true returns would_sign without writing. Undo with POST .../signoff/{signoffId}/reopen.',
|
||||
'Body: { through_date: "YYYY-MM-DD", note?, force?, external_balance? }. Recomputes the bridge through the date and refuses unless unexplained_difference is zero; with force: true and a note it signs anyway and records the difference. Refuses dates in the future, dates past the skattekonto snapshot (NOT_FETCHED_THROUGH), and dates at or before an existing active sign-off (ALREADY_SIGNED_OFF: reopen that one first). For a manual:NNNN account without a system specification (anything but 1510/2440/2920/2940), external_balance is the balance per the signer\'s underlag in ledger sign (liabilities negative); the difference against the booked balance is recorded, and a non-zero one still needs force + note. On bank, skattekonto and specification accounts external_balance is refused (EXTERNAL_BALANCE_NOT_ALLOWED). ?dry_run=true returns would_sign without writing. Undo with POST .../signoff/{signoffId}/reopen.',
|
||||
useWhen: 'The month (or period) is explained and you want the account marked as reconciled through its last day, as a human would in the Avstämning page.',
|
||||
doNotUseFor: 'Linking rows or booking anything: a sign-off changes no data in the ledger. Use .../links and the booking endpoints first.',
|
||||
pitfalls: [
|
||||
'Refusal codes come back as VALIDATION_ERROR with details.code: INVALID_DATE, DATE_IN_FUTURE, NOT_FETCHED_THROUGH, OUTSIDE_UNKNOWN, NOT_RECONCILED, NOTE_REQUIRED; ALREADY_SIGNED_OFF and SIGNOFF_RACE come back as CONFLICT.',
|
||||
'Refusal codes come back as VALIDATION_ERROR with details.code: INVALID_DATE, DATE_IN_FUTURE, NOT_FETCHED_THROUGH, OUTSIDE_UNKNOWN, NOT_RECONCILED, NOTE_REQUIRED, EXTERNAL_BALANCE_NOT_ALLOWED; ALREADY_SIGNED_OFF and SIGNOFF_RACE come back as CONFLICT.',
|
||||
'force: true without a note is NOTE_REQUIRED: the note is what the next reader sees next to the non-zero difference.',
|
||||
'Idempotency-Key is required; repeating the same key replays the first response.',
|
||||
],
|
||||
@@ -169,7 +170,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; accountKey:
|
||||
ctx.companyId!,
|
||||
ctx.userId,
|
||||
accountKey,
|
||||
{ through_date: parsed.data.through_date, note: parsed.data.note ?? null, force: parsed.data.force },
|
||||
{
|
||||
through_date: parsed.data.through_date,
|
||||
note: parsed.data.note ?? null,
|
||||
force: parsed.data.force,
|
||||
external_balance: parsed.data.external_balance ?? null,
|
||||
},
|
||||
{ dryRun: ctx.dryRun },
|
||||
)
|
||||
if (!result) {
|
||||
|
||||
+1
-1
@@ -153,7 +153,7 @@ describe('v1 reconciliation sign-off', () => {
|
||||
COMPANY_ID,
|
||||
'user-1',
|
||||
'skattekonto',
|
||||
{ through_date: '2026-07-31', note: 'ok', force: undefined },
|
||||
{ through_date: '2026-07-31', note: 'ok', force: undefined, external_balance: null },
|
||||
{ dryRun: false },
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user