feat(reconciliation): sign-off, period picker, Hem row and the three doors for it (#1835)

* feat(reconciliation): skattekonto bridge engine, sync-time twin proposals, account-keyed facade

The engine half of the reconciliation page (design: Avstämningsmotorn).

- lib/reconciliation/skattekonto-reconciliation.ts: getSkattekontoReconciliationStatus
  anchors at the saldo snapshot and returns the bridge (saldo hos Skatteverket,
  händelser som saknas, 1630-rader utan händelse, ignorerade, ingående skillnad,
  bokfört), the item buckets the page shows (proposed, unmatched external,
  unmatched ledger, matched, ignored, upcoming), opening_difference,
  unexplained_difference (0,00 by construction when data is consistent),
  dead-link handling (a link to a reversed/draft entry counts as unlinked and is
  flagged), awaiting_external for ledger lines within 5 days of the snapshot,
  staleness, and a window that scopes item lists without hiding older rows.
  Core reads skattekonto_transactions and the extension's snapshot row directly;
  no @/extensions import.
- lib/reconciliation/gl-balance.ts: one ledger-balance helper with the
  trial-balance predicate status IN (posted, reversed). The drift check summed
  posted only, which misstated 1630 for any company with a storno on the account;
  skattekonto-drift.ts now delegates to the helper.
- Proposals at sync: migration 20260823120000 adds suggested_journal_entry_id /
  suggested_at (ON DELETE SET NULL, partial index on open rows); the sync calls
  refreshSkattekontoProposals after the upsert. findMatchSuggestionsBulk now
  assigns one-to-one across rows (AGI period first, then nearest date) and falls
  back to an entry whose 1630 lines net to the amount (split lines); a proposal
  is never a link.
- lib/reconciliation/service.ts + schemas.ts: the account-keyed facade
  (bank:<cash_account_id> | skattekonto | manual:NNNN) with listReconciliationAccounts
  (enabled cash accounts folded per IBAN, skattekonto when configured) and
  getAccountStatus dispatching to the bank engine or the new one; shared Zod
  shapes for the v1 registry, MCP schemas and the UI (PR 2).

Tests: identity on a mixed fixture, storno pair, stale snapshot, awaiting window,
window scoping, failed ledger read, live-linked entries never proposed; matcher
one-to-one and split-line cases; proposal refresh writes/clears; service
dedupe and dispatch. No UI in this PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): roundOre instead of inline öre rounding (guard ratchet)

The antipattern ratchet counts Math.round(x*100)/100; the new engine used it in
five places. Switch to roundOre from @/lib/money and ratchet the baseline down
by the three occurrences this removes net of the matcher rewrite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reconciliation): three doors over one engine: dashboard routes, v1 API and MCP tools for account-keyed reconciliation

PR 2 of the Avstämning build (design: Avstämning via API och MCP). Every door
calls lib/reconciliation/{service,items,actions}.ts; none re-implements a link.

- lib/reconciliation/items.ts: listAccountItems per account_key, the page's
  buckets (proposed, unmatched_external, unmatched_ledger, matched, ignored,
  upcoming), limit/offset; skattekonto from the engine, bank from the scoped
  transactions + unlinked GL lines (netted per entry).
- lib/reconciliation/actions.ts: matchPairs (pairs or use_proposals, dry run,
  partial success with codes), unmatchLink, setItemIgnored; emits
  reconciliation.matched / reconciliation.unmatched.
- lib/skatteverket/skattekonto-link.ts: canonical core link semantics for a
  skattekonto row (single line or entry net on 1630, live-link guard, race-safe
  update, unlink, ignore); the extension keeps its own matchSkattekontoToEntry
  until its tests are ported.
- Dashboard routes /api/reconciliation/accounts[...]: list, status, items,
  links (POST), links/{linkId} (DELETE), items/{itemId}/ignore (POST); apply
  directly (a human clicked).
- v1 routes /api/v1/companies/{id}/reconciliation/accounts[...]: same six,
  withApiV1, new scopes reconciliation:read / reconciliation:write (write is a
  staging scope for SoD), Idempotency-Key + dry_run on writes, registered for
  OpenAPI, load-routes, skills/accounted-api regenerated. Legacy bank routes
  and their transactions:* scopes unchanged.
- MCP: gnubok_get_reconciliation_status takes account_key (legacy bank path
  untouched), new gnubok_list_reconciliation_items (default catalog),
  gnubok_reconcile_match (stages reconciliation_match, preflight = status) and
  gnubok_reconcile_unmatch (stages reconciliation_unmatch), both search-only to
  stay under the tools/list payload ceiling; gnubok_link_transaction_to_journal_entry
  moved to search. Executors in commit.ts; risk tiers medium/low; migration pair
  20260823130000/130001 adds the two op types to the CHECK constraint (value
  list = live prod as of 2026-08-23 + the two); close_period loadout updated.

Tests: service/actions/items/link unit tests, v1 route tests (401/403/400/404/
happy, idempotency, dry run), dashboard route tests, MCP tool tests + the guard
suite (payload ceiling, descriptions, staging meta, qualified ids). Guards and
apiskill:check green; no type errors in changed files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): refresh the v1 spec snapshot and keep the ignore update readable by the phantom-column guard

The six new v1 reconciliation endpoints and the two new scopes were not
recorded in the spec snapshot, and setSkattekontoRowIgnored updated
through one conditional payload, which the phantom-column scanner cannot
read (ceiling 380 -> 381). Two literal payloads instead; snapshot updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reconciliation): the Avstämning page, one body for every account with an outside truth

/reconciliation in Arbeta (after Transaktioner), on the approved layout:
an account rail on the left (bank accounts and the skattekonto, logo or
monogram, last fetch, status dot, URL-owned selection), and for the
selected account four tiles (outside, ledger, difference, unexplained),
the bridge that explains the difference, an actions row (link the
proposed pairs, book the unbooked skattekonto events, run the bank
matcher) and a full-width table banded by bucket with proposal rows
linkable one by one. Every read and write goes through the PR 2
dashboard routes, so the page shows exactly what the v1 API and the MCP
tools see.

Also: nav item, command palette entry, sv/en strings. Period picker,
manual match mode and sign-off are deliberately not here (PR 4/5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reconciliation): sign-off, period picker, Hem row and the three doors for it

"Markera som avstämd t.o.m. <datum>" as an append-only attestation:
account_reconciliations (who signed which account through which date,
with the numbers as they stood; reopen stamps instead of deletes; RLS
members write as themselves, viewers read). Policy in one place
(lib/reconciliation/signoff.ts): refused with an unexplained difference
unless forced with a note, refused past today or past the skattekonto
snapshot, refused at or before an active sign-off; reopen is the undo.
Every status read now carries the latest active sign-off and the rail
shows "avstämt t.o.m.".

Three doors: dashboard routes (GET/POST .../signoff, POST .../reopen),
v1 (same, scope reconciliation:signoff, Idempotency-Key, dry-run,
registry + regenerated API skill), MCP gnubok_reconcile_signoff (search
catalog, stages reconciliation_signoff after a policy dry run; executor
+ risk tier + op-type CHECK migration pair). Events
reconciliation.signed_off / reconciliation.reopened, and the four
reconciliation events join the public webhook set (additive; API version
unchanged, changelog section added).

Page: räkenskapsår + range picker in the header (own preset memory,
opens on this month) scoping the bridge, the items and the default
sign-off date; sign-off dialog with the forced-with-note path; reopen
on hover. Hem: worklist category reconciliation_due ("Konton att stämma
av"), zero until the company has signed anything off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): classify reconciliation:signoff as a tenant write for the MCP role guard

gnubok_reconcile_signoff carries the deliberately separate
reconciliation:signoff scope; the central viewer guard keys on the
:write/:approve/:manage suffixes, so a viewer could reach the tool (RLS
would still refuse the row, but the guard is the intended layer). Add
:signoff to the classifier; the strictness test that caught it now passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(providers): serve local rate-limiter waiters in arrival order

Two callers that both found the in-memory bucket empty each set their own
timeout; the timeouts expired at the same instant from different timer
lists and which woke first was platform-dependent. hydrateInvoices relies
on "started first, requested first" to serve open invoices before paid
ones, so lib/providers/__tests__/hydrate-invoices.test.ts flipped on CI
(twice on #1817) while holding locally. A promise queue makes the local
waiters FIFO without changing the rate; the Upstash path is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 14a7599bf2c6fa7f97de6ffab3dc4cf4d0e1827d)

---------

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:
Jakob Wennberg
2026-08-24 14:07:58 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent c609dbb228
commit f40795896f
50 changed files with 2973 additions and 32 deletions
@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { AccountKeySchema } from '@/lib/reconciliation/schemas'
import { ReconciliationSignoffError, reopenSignoff } from '@/lib/reconciliation/signoff'
import { getErrorMessage } from '@/lib/errors/get-error-message'
const ReopenBodySchema = z.object({ reason: z.string().max(2000).nullable().optional() })
/**
* POST /api/reconciliation/accounts/{accountKey}/signoff/{signoffId}/reopen
*
* The undo of a sign-off: stamps it reopened (the row stays as history).
* Body { reason? }; an empty body is fine.
*/
export const POST = withRouteContext<{ params: Promise<{ accountKey: string; signoffId: string }> }>(
'reconciliation.accounts.signoff.reopen',
async (request, { supabase, user, companyId }, { params }) => {
const { accountKey, signoffId } = await params
if (!AccountKeySchema.safeParse(accountKey).success || !z.string().uuid().safeParse(signoffId).success) {
return NextResponse.json({ error: 'Okänd signering' }, { status: 404 })
}
let body: unknown = {}
try {
const text = await request.text()
body = text ? JSON.parse(text) : {}
} catch {
return NextResponse.json({ error: 'Ogiltig JSON' }, { status: 400 })
}
const parsed = ReopenBodySchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Ogiltig body' }, { status: 400 })
}
try {
const result = await reopenSignoff(supabase, companyId, user.id, accountKey, signoffId, {
reason: parsed.data.reason ?? null,
})
if (!result) {
return NextResponse.json({ error: 'Okänt konto för det här företaget' }, { status: 404 })
}
return NextResponse.json({ data: { signoff: result } })
} catch (err) {
if (err instanceof ReconciliationSignoffError) {
const status = err.code === 'SIGNOFF_NOT_FOUND' ? 404 : err.code === 'SIGNOFF_RACE' ? 409 : 400
return NextResponse.json({ error: getErrorMessage(err), code: err.code }, { status })
}
throw err
}
},
{ requireWrite: true },
)
@@ -0,0 +1,84 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { AccountKeySchema } from '@/lib/reconciliation/schemas'
import { listSignoffs } from '@/lib/reconciliation/signoff-store'
import { ReconciliationSignoffError, signOffAccount } from '@/lib/reconciliation/signoff'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { ISO_DATE_RE } from '@/lib/invariants'
const SignoffBodySchema = z.object({
through_date: z.string().regex(ISO_DATE_RE),
note: z.string().max(2000).nullable().optional(),
force: z.boolean().optional(),
dry_run: z.boolean().optional(),
})
/**
* GET /api/reconciliation/accounts/{accountKey}/signoff
*
* Sign-off history for one account, newest first (?include_reopened=1 to
* see reopened ones too, ?limit).
*/
export const GET = withRouteContext<{ params: Promise<{ accountKey: string }> }>(
'reconciliation.accounts.signoff.list',
async (request, { supabase, companyId }, { params }) => {
const { accountKey } = await params
if (!AccountKeySchema.safeParse(accountKey).success) {
return NextResponse.json({ error: 'Okänt konto' }, { status: 404 })
}
const { searchParams } = new URL(request.url)
const limit = Math.min(Number(searchParams.get('limit') ?? 50) || 50, 200)
const includeReopened = searchParams.get('include_reopened') === '1'
const signoffs = await listSignoffs(supabase, companyId, accountKey, { limit, includeReopened })
return NextResponse.json({ data: { signoffs } })
},
)
/**
* POST /api/reconciliation/accounts/{accountKey}/signoff
*
* "Markera som avstämd t.o.m. <datum>". Body { through_date, note?, force?,
* dry_run? }. Refused (400 + code) unless the account is reconciled through
* the date, or force + note is given.
*/
export const POST = withRouteContext<{ params: Promise<{ accountKey: string }> }>(
'reconciliation.accounts.signoff.create',
async (request, { supabase, user, companyId }, { params }) => {
const { accountKey } = await params
if (!AccountKeySchema.safeParse(accountKey).success) {
return NextResponse.json({ error: 'Okänt konto' }, { status: 404 })
}
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Ogiltig JSON' }, { status: 400 })
}
const parsed = SignoffBodySchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Ogiltig body: through_date (ÅÅÅÅ-MM-DD) krävs' }, { status: 400 })
}
try {
const result = await signOffAccount(
supabase,
companyId,
user.id,
accountKey,
{ through_date: parsed.data.through_date, note: parsed.data.note ?? null, force: parsed.data.force },
{ dryRun: parsed.data.dry_run === true },
)
if (!result) {
return NextResponse.json({ error: 'Okänt konto för det här företaget' }, { status: 404 })
}
return NextResponse.json({ data: result })
} catch (err) {
if (err instanceof ReconciliationSignoffError) {
const status = err.code === 'SIGNOFF_NOT_FOUND' ? 404 : err.code === 'SIGNOFF_RACE' ? 409 : 400
return NextResponse.json({ error: getErrorMessage(err), code: err.code }, { status })
}
throw err
}
},
{ requireWrite: true },
)
@@ -0,0 +1,166 @@
/**
* Tests for the dashboard sign-off routes (cookie session, withRouteContext):
* GET/POST /api/reconciliation/accounts/{accountKey}/signoff and
* POST .../signoff/{signoffId}/reopen. The policy layer is mocked; the wrapper is real.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const signMock = vi.fn()
const reopenMock = vi.fn()
const listMock = vi.fn()
vi.mock('@/lib/reconciliation/signoff', async () => {
const actual = await vi.importActual<typeof import('@/lib/reconciliation/signoff')>('@/lib/reconciliation/signoff')
return {
...actual,
signOffAccount: (...args: unknown[]) => signMock(...args),
reopenSignoff: (...args: unknown[]) => reopenMock(...args),
}
})
vi.mock('@/lib/reconciliation/signoff-store', () => ({
listSignoffs: (...args: unknown[]) => listMock(...args),
}))
import { ReconciliationSignoffError } from '@/lib/reconciliation/signoff'
import { GET as listGET, POST as signPOST } from '../[accountKey]/signoff/route'
import { POST as reopenPOST } from '../[accountKey]/signoff/[signoffId]/reopen/route'
const SIGNOFF_ID = '77777777-7777-4777-8777-777777777777'
const p = (obj: Record<string, string>) => ({ params: Promise.resolve(obj) }) as never
describe('dashboard sign-off routes', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
listMock.mockResolvedValue([{ id: SIGNOFF_ID, through_date: '2026-07-31' }])
signMock.mockResolvedValue({ dry_run: false, signoff: { id: SIGNOFF_ID, through_date: '2026-07-31' } })
reopenMock.mockResolvedValue({ id: SIGNOFF_ID, reopened_at: '2026-08-24T08:00:00Z' })
})
it('401 without a session', async () => {
requireAuthMock.mockResolvedValue({ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) })
const res = await listGET(createMockRequest('http://localhost/api/reconciliation/accounts/skattekonto/signoff'), p({ accountKey: 'skattekonto' }))
expect(res.status).toBe(401)
})
it('GET lists the history and passes include_reopened / limit through', async () => {
const res = await listGET(
createMockRequest('http://localhost/api/reconciliation/accounts/skattekonto/signoff?include_reopened=1&limit=5'),
p({ accountKey: 'skattekonto' }),
)
expect(res.status).toBe(200)
const { body } = await parseJsonResponse<{ data: { signoffs: Array<{ id: string }> } }>(res)
expect(body.data.signoffs[0].id).toBe(SIGNOFF_ID)
expect(listMock).toHaveBeenCalledWith(supabase, 'company-1', 'skattekonto', { limit: 5, includeReopened: true })
})
it('GET 404s a malformed account key', async () => {
const res = await listGET(createMockRequest('http://localhost/api/reconciliation/accounts/1630/signoff'), p({ accountKey: '1630' }))
expect(res.status).toBe(404)
})
it('POST signs off with the validated body and forwards dry_run', async () => {
const res = await signPOST(
createMockRequest('http://localhost/api/reconciliation/accounts/skattekonto/signoff', { method: 'POST', body: {
through_date: '2026-07-31',
note: 'ok',
dry_run: true,
} }),
p({ accountKey: 'skattekonto' }),
)
expect(res.status).toBe(200)
expect(signMock).toHaveBeenCalledWith(
supabase,
'company-1',
'user-1',
'skattekonto',
{ through_date: '2026-07-31', note: 'ok', force: undefined },
{ dryRun: true },
)
})
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' } }),
p({ accountKey: 'skattekonto' }),
)
expect(res.status).toBe(400)
expect(signMock).not.toHaveBeenCalled()
})
it('POST maps policy refusals to 400 + code, races to 409, and a null result to 404', async () => {
signMock.mockRejectedValueOnce(new ReconciliationSignoffError('oförklarat', 'NOT_RECONCILED'))
const refused = await signPOST(
createMockRequest('http://localhost/api/reconciliation/accounts/skattekonto/signoff', { method: 'POST', body: { through_date: '2026-07-31' } }),
p({ accountKey: 'skattekonto' }),
)
expect(refused.status).toBe(400)
const refusedBody = (await parseJsonResponse<{ code: string }>(refused)).body
expect(refusedBody.code).toBe('NOT_RECONCILED')
signMock.mockRejectedValueOnce(new ReconciliationSignoffError('race', 'SIGNOFF_RACE'))
const raced = await signPOST(
createMockRequest('http://localhost/api/reconciliation/accounts/skattekonto/signoff', { method: 'POST', body: { through_date: '2026-07-31' } }),
p({ accountKey: 'skattekonto' }),
)
expect(raced.status).toBe(409)
signMock.mockResolvedValueOnce(null)
const missing = await signPOST(
createMockRequest('http://localhost/api/reconciliation/accounts/skattekonto/signoff', { method: 'POST', body: { through_date: '2026-07-31' } }),
p({ accountKey: 'skattekonto' }),
)
expect(missing.status).toBe(404)
})
it('POST requires write permission', async () => {
requireWriteMock.mockResolvedValue({ ok: false, response: NextResponse.json({ error: 'Läsbehörighet' }, { status: 403 }) })
const res = await signPOST(
createMockRequest('http://localhost/api/reconciliation/accounts/skattekonto/signoff', { method: 'POST', body: { through_date: '2026-07-31' } }),
p({ accountKey: 'skattekonto' }),
)
expect(res.status).toBe(403)
expect(signMock).not.toHaveBeenCalled()
})
it('reopen stamps the sign-off, accepts an empty body, and 404s a malformed id', async () => {
const res = await reopenPOST(
createMockRequest(`http://localhost/api/reconciliation/accounts/skattekonto/signoff/${SIGNOFF_ID}/reopen`, { method: 'POST' }),
p({ accountKey: 'skattekonto', signoffId: SIGNOFF_ID }),
)
expect(res.status).toBe(200)
expect(reopenMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', 'skattekonto', SIGNOFF_ID, { reason: null })
const bad = await reopenPOST(
createMockRequest('http://localhost/api/reconciliation/accounts/skattekonto/signoff/not-a-uuid/reopen', { method: 'POST' }),
p({ accountKey: 'skattekonto', signoffId: 'not-a-uuid' }),
)
expect(bad.status).toBe(404)
reopenMock.mockRejectedValueOnce(new ReconciliationSignoffError('redan', 'ALREADY_REOPENED'))
const already = await reopenPOST(
createMockRequest(`http://localhost/api/reconciliation/accounts/skattekonto/signoff/${SIGNOFF_ID}/reopen`, { method: 'POST', body: { reason: 'x' } }),
p({ accountKey: 'skattekonto', signoffId: SIGNOFF_ID }),
)
expect(already.status).toBe(400)
})
})
@@ -0,0 +1,127 @@
/**
* POST /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/signoff/{signoffId}/reopen
*
* Undo a sign-off. The row stays as history with a reopen stamp (who, when,
* why); the account then shows its previous active sign-off, if any.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { AccountKeySchema, ReconciliationSignoffSchema } from '@/lib/reconciliation/schemas'
import { ReconciliationSignoffError, reopenSignoff } from '@/lib/reconciliation/signoff'
import { getErrorMessage } from '@/lib/errors/get-error-message'
const ReopenRequest = z.object({ reason: z.string().max(2000).nullable().optional() })
const ReopenResponse = z.object({ signoff: ReconciliationSignoffSchema })
registerEndpoint({
operation: 'reconciliation.accounts.signoff.reopen',
method: 'POST',
path: '/api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff/:signoffId/reopen',
summary: 'Reopen (undo) a reconciliation sign-off.',
description:
'Body: { reason? }. Stamps the sign-off reopened_at/by/reason; nothing is deleted and the ledger is untouched. After this the account can be signed off again for the same or an earlier date. A sign-off that is already reopened is ALREADY_REOPENED (CONFLICT).',
useWhen: 'A signed-off period turns out to need more work (a late bank row, a corrected verifikat) and the attestation must be withdrawn before it is redone.',
doNotUseFor: 'Removing a link or un-booking anything: those are separate operations; reopening only withdraws the attestation.',
pitfalls: [
'Reopening is recorded, not erased: the history endpoint (?include_reopened=true) keeps showing the row with its reopen stamp.',
'Idempotency-Key is required; repeating the same key replays the first response.',
],
example: {
request: { reason: 'Sen bankrad 31 juli kom in 3 augusti.' },
response: {
data: {
signoff: {
id: '77777777-7777-4777-8777-777777777777',
account_key: 'skattekonto',
through_date: '2026-07-31',
external_balance: 12450.0,
ledger_balance: 12450.0,
unexplained_difference: 0,
note: null,
signed_by: '88888888-8888-4888-8888-888888888888',
signed_at: '2026-08-03T09:12:00Z',
reopened_at: '2026-08-04T07:30:00Z',
reopened_by: '88888888-8888-4888-8888-888888888888',
reopen_reason: 'Sen bankrad 31 juli kom in 3 augusti.',
},
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'reconciliation:signoff',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: ReopenRequest },
response: { success: dataEnvelope(ReopenResponse) },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string; accountKey: string; signoffId: string }> }>(
'reconciliation.accounts.signoff.reopen',
async (request, ctx, params) => {
const { accountKey, signoffId } = await params.params
if (!AccountKeySchema.safeParse(accountKey).success || !z.string().uuid().safeParse(signoffId).success) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { field: 'signoffId', message: 'Okänd signering.' },
})
}
let rawBody: unknown = {}
try {
const text = await request.text()
rawBody = text ? JSON.parse(text) : {}
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = ReopenRequest.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })),
},
})
}
try {
if (ctx.dryRun) {
return dryRunPreview(
{ signoff_id: signoffId, would_reopen: true },
{ requestId: ctx.requestId, log: ctx.log },
)
}
const signoff = await reopenSignoff(ctx.supabase, ctx.companyId!, ctx.userId, accountKey, signoffId, {
reason: parsed.data.reason ?? null,
})
if (!signoff) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { field: 'accountKey', message: 'Okänt konto för det här företaget.' },
})
}
return ok({ signoff }, { requestId: ctx.requestId })
} catch (err) {
if (err instanceof ReconciliationSignoffError) {
const v1Code =
err.code === 'SIGNOFF_NOT_FOUND'
? 'NOT_FOUND'
: err.code === 'ALREADY_REOPENED' || err.code === 'SIGNOFF_RACE'
? 'CONFLICT'
: 'VALIDATION_ERROR'
return v1ErrorResponseFromCode(v1Code, ctx.log, {
requestId: ctx.requestId,
details: { code: err.code, message: getErrorMessage(err) },
})
}
return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
}
},
{ requireIdempotencyKey: true },
)
@@ -0,0 +1,202 @@
/**
* GET /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/signoff
* POST /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/signoff
*
* Sign-off history, and the sign-off itself ("markera som avstämd t.o.m.
* <datum>"). A sign-off is the attestation on top of the engine's bridge:
* refused unless the account is reconciled through the date, or the caller
* forces it with a note. Writes nothing to the ledger. Dry-runnable;
* Idempotency-Key required on POST.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { AccountKeySchema, ReconciliationSignoffSchema } from '@/lib/reconciliation/schemas'
import { listSignoffs } from '@/lib/reconciliation/signoff-store'
import { ReconciliationSignoffError, signOffAccount } from '@/lib/reconciliation/signoff'
import { ISO_DATE_RE } from '@/lib/invariants'
import { getErrorMessage } from '@/lib/errors/get-error-message'
const SignoffRequest = z.object({
through_date: z.string().regex(ISO_DATE_RE),
note: z.string().max(2000).nullable().optional(),
force: z.boolean().optional(),
})
const SignoffResponse = z.object({
dry_run: z.boolean(),
signoff: ReconciliationSignoffSchema.optional(),
would_sign: z
.object({
account_key: z.string(),
through_date: z.string(),
external_balance: z.number().nullable(),
ledger_balance: z.number().nullable(),
unexplained_difference: z.number().nullable(),
is_reconciled: z.boolean(),
forced: z.boolean(),
previous_through_date: z.string().nullable(),
})
.optional(),
})
const SignoffListResponse = z.object({ signoffs: z.array(ReconciliationSignoffSchema) })
const EXAMPLE_SIGNOFF = {
id: '77777777-7777-4777-8777-777777777777',
account_key: 'skattekonto',
through_date: '2026-07-31',
external_balance: 12450.0,
ledger_balance: 12450.0,
unexplained_difference: 0,
note: null,
signed_by: '88888888-8888-4888-8888-888888888888',
signed_at: '2026-08-03T09:12:00Z',
reopened_at: null,
reopened_by: null,
reopen_reason: null,
}
registerEndpoint({
operation: 'reconciliation.accounts.signoff.list',
method: 'GET',
path: '/api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff',
summary: 'Sign-off history for one reconcilable account.',
description:
'Every "avstämt t.o.m." sign-off on the account, newest first. Active ones by default; ?include_reopened=true adds the reopened (undone) ones with their reopen stamp. The latest active sign-off also rides along on GET .../accounts/{accountKey} as `signoff`.',
useWhen: 'You need the attestation trail (who signed what through which date) for an account, e.g. for a close checklist or an audit question.',
doNotUseFor: 'Deciding whether the account is reconciled today: read unexplained_difference on the account status for that.',
pitfalls: [
'A sign-off is an assertion made at a point in time; rows or links added later can make the live bridge differ from the signed numbers. Compare signoff.unexplained_difference with the current status when that matters.',
],
example: {
response: { data: { signoffs: [EXAMPLE_SIGNOFF] }, meta: { request_id: 'req_…', api_version: '2026-05-12' } },
},
scope: 'reconciliation:read',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: false,
response: { success: dataEnvelope(SignoffListResponse) },
})
registerEndpoint({
operation: 'reconciliation.accounts.signoff.create',
method: 'POST',
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.',
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.',
'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.',
],
example: {
request: { through_date: '2026-07-31' },
response: {
data: { dry_run: false, signoff: EXAMPLE_SIGNOFF },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'reconciliation:signoff',
risk: 'medium',
idempotent: false,
reversible: true,
dryRunSupported: true,
request: { body: SignoffRequest },
response: { success: dataEnvelope(SignoffResponse) },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string; accountKey: string }> }>(
'reconciliation.accounts.signoff.list',
async (request, ctx, params) => {
const { accountKey } = await params.params
if (!AccountKeySchema.safeParse(accountKey).success) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { field: 'accountKey', message: 'Okänt konto.' },
})
}
const { searchParams } = new URL(request.url)
const limit = Math.min(Number(searchParams.get('limit') ?? 50) || 50, 200)
const includeReopened = searchParams.get('include_reopened') === 'true' || searchParams.get('include_reopened') === '1'
try {
const signoffs = await listSignoffs(ctx.supabase, ctx.companyId!, accountKey, { limit, includeReopened })
return ok({ signoffs }, { requestId: ctx.requestId })
} catch (err) {
return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
}
},
)
export const POST = withApiV1<{ params: Promise<{ companyId: string; accountKey: string }> }>(
'reconciliation.accounts.signoff.create',
async (request, ctx, params) => {
const { accountKey } = await params.params
if (!AccountKeySchema.safeParse(accountKey).success) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { field: 'accountKey', message: 'Okänt konto.' },
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = SignoffRequest.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })),
},
})
}
try {
const result = await signOffAccount(
ctx.supabase,
ctx.companyId!,
ctx.userId,
accountKey,
{ through_date: parsed.data.through_date, note: parsed.data.note ?? null, force: parsed.data.force },
{ dryRun: ctx.dryRun },
)
if (!result) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { field: 'accountKey', message: 'Okänt konto för det här företaget.' },
})
}
if (ctx.dryRun) {
return dryRunPreview(result, { requestId: ctx.requestId, log: ctx.log })
}
return ok(result, { requestId: ctx.requestId })
} catch (err) {
if (err instanceof ReconciliationSignoffError) {
const v1Code =
err.code === 'SIGNOFF_NOT_FOUND'
? 'NOT_FOUND'
: err.code === 'ALREADY_SIGNED_OFF' || err.code === 'SIGNOFF_RACE'
? 'CONFLICT'
: 'VALIDATION_ERROR'
return v1ErrorResponseFromCode(v1Code, ctx.log, {
requestId: ctx.requestId,
details: { code: err.code, message: getErrorMessage(err) },
})
}
return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
}
},
{ requireIdempotencyKey: true },
)
@@ -0,0 +1,210 @@
/**
* Tests for the v1 sign-off routes:
* GET .../reconciliation/accounts/{accountKey}/signoff
* POST .../reconciliation/accounts/{accountKey}/signoff
* POST .../reconciliation/accounts/{accountKey}/signoff/{signoffId}/reopen
*
* Real withApiV1 wrapper (auth, scope, membership, idempotency, dry-run);
* the policy layer is mocked.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required')
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
const { signMock, reopenMock, listMock } = vi.hoisted(() => ({
signMock: vi.fn(),
reopenMock: vi.fn(),
listMock: vi.fn(),
}))
vi.mock('@/lib/reconciliation/signoff', async () => {
const actual = await vi.importActual<typeof import('@/lib/reconciliation/signoff')>('@/lib/reconciliation/signoff')
return { ...actual, signOffAccount: signMock, reopenSignoff: reopenMock }
})
vi.mock('@/lib/reconciliation/signoff-store', () => ({ listSignoffs: listMock }))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { ReconciliationSignoffError } from '@/lib/reconciliation/signoff'
import { GET as listGET, POST as signPOST } from '../[accountKey]/signoff/route'
import { POST as reopenPOST } from '../[accountKey]/signoff/[signoffId]/reopen/route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
type MockResult = { data?: unknown; error?: unknown }
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
const queues = new Map<string, MockResult[]>()
for (const [t, val] of Object.entries(byTable)) queues.set(t, Array.isArray(val) ? [...val] : [val])
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve(next)
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const SIGNOFF_ID = '77777777-7777-4777-8777-777777777777'
const BASE = `http://localhost/api/v1/companies/${COMPANY_ID}/reconciliation/accounts`
function req(url: string, init: { method?: string; body?: unknown; idem?: boolean; dryRun?: boolean } = {}): Request {
const headers: Record<string, string> = {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Content-Type': 'application/json',
}
if (init.idem !== false && init.method && init.method !== 'GET') headers['Idempotency-Key'] = `idem-${Math.random().toString(36).slice(2)}-aaaa-4abc-8def-1234567890ab`
if (init.dryRun) headers['X-Dry-Run'] = 'true'
return new Request(url, {
method: init.method ?? 'GET',
headers,
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
})
}
function authOk(scopes: string[]) {
mockValidate.mockResolvedValue({
valid: true,
userId: 'user-1',
keyId: 'key-1',
keyName: 'Test key',
scopes,
mode: 'live',
})
}
const params = (extra: Record<string, string> = {}) =>
({ params: Promise.resolve({ companyId: COMPANY_ID, ...extra }) }) as never
describe('v1 reconciliation sign-off', () => {
beforeEach(() => {
vi.clearAllMocks()
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { role: 'owner' } },
idempotency_keys: { data: null },
}),
)
listMock.mockResolvedValue([{ id: SIGNOFF_ID, account_key: 'skattekonto', through_date: '2026-07-31' }])
signMock.mockResolvedValue({ dry_run: false, signoff: { id: SIGNOFF_ID, account_key: 'skattekonto', through_date: '2026-07-31' } })
reopenMock.mockResolvedValue({ id: SIGNOFF_ID, account_key: 'skattekonto', through_date: '2026-07-31', reopened_at: '2026-08-24T08:00:00Z' })
})
it('401 without a valid key', async () => {
mockValidate.mockResolvedValue({ valid: false, error: 'invalid' })
const res = await listGET(req(`${BASE}/skattekonto/signoff`), params({ accountKey: 'skattekonto' }))
expect(res.status).toBe(401)
})
it('GET history needs reconciliation:read and passes include_reopened through', async () => {
authOk(['reconciliation:signoff'])
const forbidden = await listGET(req(`${BASE}/skattekonto/signoff`), params({ accountKey: 'skattekonto' }))
expect(forbidden.status).toBe(403)
authOk(['reconciliation:read'])
const res = await listGET(req(`${BASE}/skattekonto/signoff?include_reopened=true&limit=10`), params({ accountKey: 'skattekonto' }))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.signoffs[0].id).toBe(SIGNOFF_ID)
expect(listMock).toHaveBeenCalledWith(expect.anything(), COMPANY_ID, 'skattekonto', { limit: 10, includeReopened: true })
})
it('POST signoff needs reconciliation:signoff (write alone is not enough) and an Idempotency-Key', async () => {
authOk(['reconciliation:write'])
const forbidden = await signPOST(req(`${BASE}/skattekonto/signoff`, { method: 'POST', body: { through_date: '2026-07-31' } }), params({ accountKey: 'skattekonto' }))
expect(forbidden.status).toBe(403)
authOk(['reconciliation:signoff'])
const noIdem = await signPOST(req(`${BASE}/skattekonto/signoff`, { method: 'POST', body: { through_date: '2026-07-31' }, idem: false }), params({ accountKey: 'skattekonto' }))
expect(noIdem.status).toBe(400)
const invalid = await signPOST(req(`${BASE}/skattekonto/signoff`, { method: 'POST', body: { through_date: '2026-7-1' } }), params({ accountKey: 'skattekonto' }))
expect(invalid.status).toBe(400)
expect(signMock).not.toHaveBeenCalled()
const ok = await signPOST(req(`${BASE}/skattekonto/signoff`, { method: 'POST', body: { through_date: '2026-07-31', note: 'ok' } }), params({ accountKey: 'skattekonto' }))
expect(ok.status).toBe(200)
const body = await ok.json()
expect(body.data.signoff.id).toBe(SIGNOFF_ID)
expect(signMock).toHaveBeenCalledWith(
expect.anything(),
COMPANY_ID,
'user-1',
'skattekonto',
{ through_date: '2026-07-31', note: 'ok', force: undefined },
{ dryRun: false },
)
})
it('POST signoff dry-runs through the X-Dry-Run header', async () => {
authOk(['reconciliation:signoff'])
signMock.mockResolvedValue({ dry_run: true, would_sign: { account_key: 'skattekonto', through_date: '2026-07-31', is_reconciled: true } })
const res = await signPOST(req(`${BASE}/skattekonto/signoff`, { method: 'POST', body: { through_date: '2026-07-31' }, dryRun: true }), params({ accountKey: 'skattekonto' }))
expect(res.status).toBe(200)
expect(signMock).toHaveBeenCalledWith(expect.anything(), COMPANY_ID, 'user-1', 'skattekonto', expect.anything(), { dryRun: true })
const body = await res.json()
expect(body.data.dry_run).toBe(true)
})
it('POST signoff maps refusals: NOT_RECONCILED -> 400 VALIDATION_ERROR, ALREADY_SIGNED_OFF -> 409 CONFLICT, null -> 404', async () => {
authOk(['reconciliation:signoff'])
signMock.mockRejectedValueOnce(new ReconciliationSignoffError('oförklarat', 'NOT_RECONCILED'))
const refused = await signPOST(req(`${BASE}/skattekonto/signoff`, { method: 'POST', body: { through_date: '2026-07-31' } }), params({ accountKey: 'skattekonto' }))
expect(refused.status).toBe(400)
const refusedBody = await refused.json()
expect(refusedBody.error.code).toBe('VALIDATION_ERROR')
expect(refusedBody.error.details.code).toBe('NOT_RECONCILED')
signMock.mockRejectedValueOnce(new ReconciliationSignoffError('redan', 'ALREADY_SIGNED_OFF'))
const conflict = await signPOST(req(`${BASE}/skattekonto/signoff`, { method: 'POST', body: { through_date: '2026-07-31' } }), params({ accountKey: 'skattekonto' }))
expect(conflict.status).toBe(409)
signMock.mockResolvedValueOnce(null)
const missing = await signPOST(req(`${BASE}/skattekonto/signoff`, { method: 'POST', body: { through_date: '2026-07-31' } }), params({ accountKey: 'skattekonto' }))
expect(missing.status).toBe(404)
})
it('POST reopen needs reconciliation:signoff, accepts an empty body, previews on dry run, 404s a bad id', async () => {
authOk(['reconciliation:write'])
const forbidden = await reopenPOST(req(`${BASE}/skattekonto/signoff/${SIGNOFF_ID}/reopen`, { method: 'POST' }), params({ accountKey: 'skattekonto', signoffId: SIGNOFF_ID }))
expect(forbidden.status).toBe(403)
authOk(['reconciliation:signoff'])
const ok = await reopenPOST(req(`${BASE}/skattekonto/signoff/${SIGNOFF_ID}/reopen`, { method: 'POST' }), params({ accountKey: 'skattekonto', signoffId: SIGNOFF_ID }))
expect(ok.status).toBe(200)
expect(reopenMock).toHaveBeenCalledWith(expect.anything(), COMPANY_ID, 'user-1', 'skattekonto', SIGNOFF_ID, { reason: null })
const dry = await reopenPOST(req(`${BASE}/skattekonto/signoff/${SIGNOFF_ID}/reopen`, { method: 'POST', body: { reason: 'x' }, dryRun: true }), params({ accountKey: 'skattekonto', signoffId: SIGNOFF_ID }))
expect(dry.status).toBe(200)
expect(reopenMock).toHaveBeenCalledTimes(1)
const bad = await reopenPOST(req(`${BASE}/skattekonto/signoff/nope/reopen`, { method: 'POST' }), params({ accountKey: 'skattekonto', signoffId: 'nope' }))
expect(bad.status).toBe(404)
reopenMock.mockRejectedValueOnce(new ReconciliationSignoffError('redan', 'ALREADY_REOPENED'))
const already = await reopenPOST(req(`${BASE}/skattekonto/signoff/${SIGNOFF_ID}/reopen`, { method: 'POST' }), params({ accountKey: 'skattekonto', signoffId: SIGNOFF_ID }))
expect(already.status).toBe(409)
})
})