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:
@@ -1179,3 +1179,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-23] Reconciliation engine (PR 1): the skattekonto status engine lives in core lib/reconciliation (reads the core table + the extension snapshot row in extension_data directly) instead of in the skatteverket extension: core must never import @/extensions/*, and the reconciliation facade must work with zero extensions; the matcher stays in the extension and writes its proposals to the row at sync time. Proposals are propose-only (suggested_journal_entry_id is never a link); the same per-entry one-to-one assignment replaces per-row "exactly one candidate". Ledger balances everywhere in reconciliation use the trial-balance predicate status IN (posted, reversed): the drift check summed posted only, which misstated 1630 for every company with a storno on the account.
|
||||
[2026-08-23] Reconciliation doors (PR 2): dashboard routes, the v1 API and the MCP tools all call lib/reconciliation/{service,items,actions}.ts; no door re-implements a link. Policy lives in the door: page + REST apply directly, MCP stages (reconciliation_match / reconciliation_unmatch pending operations, executors in commit.ts). The MCP write tools are catalogVisibility search (and gnubok_link_transaction_to_journal_entry moved to search) because the tools/list payload ceiling (59 900 tokens) left no room for them in the default catalog; the reads (status with account_key, items) stay default and the items description points at the write. The skattekonto link now has its canonical implementation in core lib/skatteverket/skattekonto-link.ts (needed by core doors; core must not import the extension); the extension route still uses its own matchSkattekontoToEntry until its queued-mock tests are ported, then it delegates. New scopes reconciliation:read/write; gnubok_get_reconciliation_status keeps reports:read and the legacy bank routes keep transactions:* so no existing key is cut off.
|
||||
[2026-08-23] Avstämning page (PR 3) ships without the period picker, the manual two-pane match mode and the sign-off button: the page renders the approved 'Vald riktning' layout (rail + tiles + bridge + actions + banded table) over the PR 2 dashboard routes only, so that it is verifiable on its own; period + sign-off arrive together in PR 4 (both are period-bound), manual N:M matching with residual booking in PR 5. Bank accounts get the same generic body plus links to the existing bank view for the matcher run rather than embedding the 1900-line BankReconciliationView: one body for every account kind is the point of the page, and embedding would have doubled the header.
|
||||
[2026-08-23] Reconciliation sign-off (PR 4) is an append-only attestation table (account_reconciliations) with a reopen stamp, not a flag on the account: who signed what through which date, with the numbers as they stood, is the thing an auditor and the Hem row read, so it must survive a later change of mind. Sign-off is refused with an unexplained difference unless forced with a note (the note is what the next reader sees). Separate scope reconciliation:signoff (write is not enough): an integration that links rows should not be able to attest. The worklist category reconciliation_due is gated on adoption (zero until the company has signed anything off) so the nudge reaches the people who reconcile monthly without becoming a new chore for everyone. Webhook events added additively without bumping API_V1_VERSION: the dated version is reserved for breaking changes; a new event type breaks no existing subscriber.
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import { Suspense } from 'react'
|
||||
import { ReconciliationWorkspace } from '@/components/reconciliation/ReconciliationWorkspace'
|
||||
import { getDashboardAuthContext, getDashboardCompanyId } from '../request-context'
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
|
||||
/**
|
||||
* /reconciliation. Fiscal periods are loaded here so the period picker has
|
||||
* them on first paint (same as the focused reports); the page itself is a
|
||||
* client workspace over the reconciliation API.
|
||||
*/
|
||||
export default async function ReconciliationPage() {
|
||||
const [{ supabase }, companyId] = await Promise.all([getDashboardAuthContext(), getDashboardCompanyId()])
|
||||
const { data: periods } = companyId
|
||||
? await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.order('period_start', { ascending: false })
|
||||
: { data: [] }
|
||||
|
||||
// useSearchParams in the workspace needs a Suspense boundary above it.
|
||||
export default function ReconciliationPage() {
|
||||
return (
|
||||
// useSearchParams in the workspace needs a Suspense boundary above it.
|
||||
<Suspense fallback={null}>
|
||||
<ReconciliationWorkspace />
|
||||
<ReconciliationWorkspace initialPeriods={(periods ?? []) as FiscalPeriod[]} initialCompanyId={companyId} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
+127
@@ -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 },
|
||||
)
|
||||
+202
@@ -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 },
|
||||
)
|
||||
+210
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Landmark,
|
||||
Loader2,
|
||||
ReceiptText,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
Stamp,
|
||||
} from 'lucide-react'
|
||||
@@ -198,6 +199,7 @@ export default function AttGoraSection({
|
||||
const bevakaRows =
|
||||
counts.overdue_invoice > 0 ||
|
||||
counts.deadline_action > 0 ||
|
||||
counts.reconciliation_due > 0 ||
|
||||
expiringBankConnections.length > 0
|
||||
const allClear = !bokforRows && !granskaRows && !bevakaRows
|
||||
|
||||
@@ -394,6 +396,15 @@ export default function AttGoraSection({
|
||||
count={counts.deadline_action}
|
||||
/>
|
||||
)}
|
||||
{counts.reconciliation_due > 0 && (
|
||||
<WorklistRow
|
||||
href="/reconciliation"
|
||||
icon={Scale}
|
||||
label={t('row_reconciliation_due')}
|
||||
detail={t('row_reconciliation_due_detail')}
|
||||
count={counts.reconciliation_due}
|
||||
/>
|
||||
)}
|
||||
{expiringBankConnections.length > 0 && (
|
||||
<WorklistRow
|
||||
href="/settings/banking"
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
ReconciliationStatus,
|
||||
} from '@/lib/reconciliation/schemas'
|
||||
import type { SkattekontoBatchRowResult, SkattekontoTransactionWithSuggestion } from '@/types/skatteverket'
|
||||
import { SignoffDialog } from './SignoffDialog'
|
||||
|
||||
const SkattekontoBookDialog = dynamic(
|
||||
() => import('@/components/skattekonto/SkattekontoBookDialog'),
|
||||
@@ -57,13 +58,20 @@ const FOLDED_BY_DEFAULT: ReadonlySet<ReconciliationItemBucket> = new Set(['match
|
||||
|
||||
const ITEMS_LIMIT = 200
|
||||
|
||||
export interface ReconciliationWindow {
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
|
||||
interface AccountOverviewProps {
|
||||
account: ReconciliationAccount
|
||||
/** The selected period: scopes the bank bridge and the item windows; its end is the default sign-off date. */
|
||||
window: ReconciliationWindow
|
||||
/** Called after any write so the rail can refresh its status dots. */
|
||||
onChanged: () => void
|
||||
}
|
||||
|
||||
export function AccountOverview({ account, onChanged }: AccountOverviewProps) {
|
||||
export function AccountOverview({ account, window, onChanged }: AccountOverviewProps) {
|
||||
const t = useTranslations('reconciliation')
|
||||
const locale = useLocale()
|
||||
const { toast } = useToast()
|
||||
@@ -73,15 +81,17 @@ export function AccountOverview({ account, onChanged }: AccountOverviewProps) {
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [unfolded, setUnfolded] = useState<Set<ReconciliationItemBucket>>(new Set())
|
||||
const [bookRow, setBookRow] = useState<ReconciliationItem | null>(null)
|
||||
const [signoffOpen, setSignoffOpen] = useState(false)
|
||||
|
||||
const isSkv = account.kind === 'skattekonto'
|
||||
const base = `/api/reconciliation/accounts/${encodeURIComponent(account.account_key)}`
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const qs = new URLSearchParams({ date_from: window.from, date_to: window.to })
|
||||
const [statusRes, itemsRes] = await Promise.all([
|
||||
fetch(base),
|
||||
fetch(`${base}/items?limit=${ITEMS_LIMIT}`),
|
||||
fetch(`${base}?${qs.toString()}`),
|
||||
fetch(`${base}/items?limit=${ITEMS_LIMIT}&${qs.toString()}`),
|
||||
])
|
||||
setLoadError(false)
|
||||
if (!statusRes.ok || !itemsRes.ok) {
|
||||
@@ -95,7 +105,7 @@ export function AccountOverview({ account, onChanged }: AccountOverviewProps) {
|
||||
} catch {
|
||||
setLoadError(true)
|
||||
}
|
||||
}, [base])
|
||||
}, [base, window.from, window.to])
|
||||
|
||||
// The workspace keys this component on account_key, so a new account is a
|
||||
// fresh mount: no state to reset here.
|
||||
@@ -230,6 +240,33 @@ export function AccountOverview({ account, onChanged }: AccountOverviewProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSignoff(input: { through_date: string; note: string | null; force: boolean }) {
|
||||
const res = await fetch(`${base}/signoff`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
const json = await res.json().catch(() => ({}))
|
||||
if (!res.ok) return getUserErrorMessage(json, { statusCode: res.status })
|
||||
setSignoffOpen(false)
|
||||
toast({ title: t('toast_signed_off', { date: formatDate(input.through_date) }) })
|
||||
await refresh()
|
||||
return null
|
||||
}
|
||||
|
||||
async function reopen(signoffId: string) {
|
||||
setBusy('reopen')
|
||||
try {
|
||||
const data = await postJson(`${base}/signoff/${signoffId}/reopen`, {})
|
||||
if (data) {
|
||||
toast({ title: t('toast_reopened') })
|
||||
await refresh()
|
||||
}
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- render -------------------------------------------------------------
|
||||
|
||||
if (loadError) {
|
||||
@@ -320,6 +357,13 @@ export function AccountOverview({ account, onChanged }: AccountOverviewProps) {
|
||||
const bankRunHref = '/reports/bank-reconciliation?autorun=1'
|
||||
const bankViewHref = '/reports/bank-reconciliation'
|
||||
|
||||
// Default sign-off date: the window end, never past today nor past the
|
||||
// skattekonto snapshot. The button hides when that date is already signed.
|
||||
const todayIso = new Date().toISOString().slice(0, 10)
|
||||
const signoffMaxDate = isSkv ? (asOfDate < todayIso ? asOfDate : todayIso) : todayIso
|
||||
const signoffDefaultDate = window.to < signoffMaxDate ? window.to : signoffMaxDate
|
||||
const signoffEnabled = !status.signoff || status.signoff.through_date < signoffDefaultDate
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Tiles: label + number, nothing else. */}
|
||||
@@ -348,6 +392,23 @@ export function AccountOverview({ account, onChanged }: AccountOverviewProps) {
|
||||
<p className="text-[13px] text-muted-foreground">{t('reconciled_line')}</p>
|
||||
) : null}
|
||||
|
||||
{status.signoff && (
|
||||
<p className="group flex items-center gap-2 text-[13px] text-muted-foreground">
|
||||
<span>
|
||||
{t('signed_off_line', { date: formatDate(status.signoff.through_date), when: formatDate(status.signoff.signed_at) })}
|
||||
{status.signoff.note && <span className="ml-1">· {t('signed_off_forced')}</span>}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void reopen(status.signoff!.id)}
|
||||
disabled={busy !== null}
|
||||
className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}
|
||||
>
|
||||
{t('reopen')}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Bridge: how the difference is explained. */}
|
||||
{status.bridge.length > 0 && (
|
||||
<dl className="max-w-[520px] text-[13px]">
|
||||
@@ -389,6 +450,11 @@ export function AccountOverview({ account, onChanged }: AccountOverviewProps) {
|
||||
<Link href={bankRunHref}>{t('action_run_bank_matcher')}</Link>
|
||||
</Button>
|
||||
)}
|
||||
{signoffEnabled && (
|
||||
<Button size="sm" variant={status.is_reconciled ? 'default' : 'outline'} onClick={() => setSignoffOpen(true)} disabled={busy !== null}>
|
||||
{t('signoff_button', { date: formatDate(signoffDefaultDate) })}
|
||||
</Button>
|
||||
)}
|
||||
<span className="ml-auto">
|
||||
<Link href={isSkv ? '/skattekonto' : bankViewHref} className={QUIET_LINK_CLASS}>
|
||||
{isSkv ? t('action_open_skattekonto') : t('action_open_bank_view')}
|
||||
@@ -504,6 +570,17 @@ export function AccountOverview({ account, onChanged }: AccountOverviewProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SignoffDialog
|
||||
open={signoffOpen}
|
||||
onOpenChange={setSignoffOpen}
|
||||
accountName={account.name}
|
||||
defaultDate={signoffDefaultDate}
|
||||
maxDate={signoffMaxDate}
|
||||
unexplained={status.unexplained_difference}
|
||||
currency={currency}
|
||||
onSubmit={submitSignoff}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,7 +100,11 @@ export function ReconciliationRail({ accounts, selectedKey, onSelect }: Reconcil
|
||||
<span className="block truncate text-[11.5px] text-muted-foreground tabular-nums">
|
||||
<span data-ph-mask>{account.account_number}</span>
|
||||
{' · '}
|
||||
{synced ? t('rail_synced', { date: formatDate(synced) }) : t('rail_never_synced')}
|
||||
{account.signed_off_through
|
||||
? t('rail_signed_off', { date: formatDate(account.signed_off_through) })
|
||||
: synced
|
||||
? t('rail_synced', { date: formatDate(synced) })
|
||||
: t('rail_never_synced')}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
|
||||
@@ -9,9 +9,12 @@ import { HelpPopover } from '@/components/ui/help-popover'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { FyPicker } from '@/components/common/FyPicker'
|
||||
import { ReportDateRange, type DateRangeValue } from '@/components/common/ReportDateRange'
|
||||
import type { ReconciliationAccount } from '@/lib/reconciliation/schemas'
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
import { ReconciliationRail } from './ReconciliationRail'
|
||||
import { AccountOverview } from './AccountOverview'
|
||||
import { AccountOverview, type ReconciliationWindow } from './AccountOverview'
|
||||
|
||||
/**
|
||||
* /reconciliation: one page for every account with an outside truth. The
|
||||
@@ -19,18 +22,47 @@ import { AccountOverview } from './AccountOverview'
|
||||
* their status; the body shows the selected account's bridge and the rows
|
||||
* behind it. Selection lives in the URL (?account=) so a link lands on the
|
||||
* right account and a reload keeps it.
|
||||
*
|
||||
* The period (räkenskapsår + range within it) scopes the bank bridge and the
|
||||
* item windows and sets the default sign-off date. It keeps its own preset
|
||||
* memory, separate from the reports: reconciling is a monthly ritual, so it
|
||||
* opens on this month rather than on whatever range a report left behind.
|
||||
*/
|
||||
export function ReconciliationWorkspace() {
|
||||
|
||||
const FY_STORAGE_KEY_PREFIX = 'Accounted:recon-fy:'
|
||||
const RANGE_STORAGE_KEY_PREFIX = 'Accounted:recon-page-range-preset:'
|
||||
|
||||
interface ReconciliationWorkspaceProps {
|
||||
initialPeriods: FiscalPeriod[]
|
||||
initialCompanyId: string | null
|
||||
}
|
||||
|
||||
export function ReconciliationWorkspace({ initialPeriods, initialCompanyId }: ReconciliationWorkspaceProps) {
|
||||
const t = useTranslations('reconciliation')
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const [accounts, setAccounts] = useState<ReconciliationAccount[] | null>(null)
|
||||
const [loadError, setLoadError] = useState(false)
|
||||
const [periodId, setPeriodId] = useState<string | null>(null)
|
||||
const [periodBounds, setPeriodBounds] = useState<{ start: string; end: string } | null>(null)
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({})
|
||||
|
||||
// The effective window: the range within the period, defaulting to the
|
||||
// period bounds. Null until the period picker has resolved.
|
||||
const window = useMemo<ReconciliationWindow | null>(() => {
|
||||
if (!periodBounds) return null
|
||||
return {
|
||||
from: dateRange.fromDate ?? periodBounds.start,
|
||||
to: dateRange.toDate ?? periodBounds.end,
|
||||
}
|
||||
}, [periodBounds, dateRange])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!window) return
|
||||
try {
|
||||
const res = await fetch('/api/reconciliation/accounts')
|
||||
const qs = new URLSearchParams({ date_from: window.from, date_to: window.to })
|
||||
const res = await fetch(`/api/reconciliation/accounts?${qs.toString()}`)
|
||||
setLoadError(false)
|
||||
if (!res.ok) {
|
||||
setLoadError(true)
|
||||
@@ -41,7 +73,7 @@ export function ReconciliationWorkspace() {
|
||||
} catch {
|
||||
setLoadError(true)
|
||||
}
|
||||
}, [])
|
||||
}, [window])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
@@ -74,6 +106,33 @@ export function ReconciliationWorkspace() {
|
||||
<p>{t('help_text')}</p>
|
||||
</HelpPopover>
|
||||
}
|
||||
action={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<FyPicker
|
||||
value={periodId}
|
||||
onChange={(id, period) => {
|
||||
setPeriodId(id)
|
||||
setPeriodBounds(period ? { start: period.period_start, end: period.period_end } : null)
|
||||
setDateRange({})
|
||||
}}
|
||||
includeAllOption={false}
|
||||
hideFuturePeriods
|
||||
initialPeriods={initialPeriods}
|
||||
initialCompanyId={initialCompanyId}
|
||||
storageKeyPrefix={FY_STORAGE_KEY_PREFIX}
|
||||
/>
|
||||
{periodBounds && (
|
||||
<ReportDateRange
|
||||
periodStart={periodBounds.start}
|
||||
periodEnd={periodBounds.end}
|
||||
value={dateRange}
|
||||
onChange={setDateRange}
|
||||
defaultPreset="this_month"
|
||||
storageKeyPrefix={RANGE_STORAGE_KEY_PREFIX}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -86,7 +145,7 @@ export function ReconciliationWorkspace() {
|
||||
)
|
||||
}
|
||||
|
||||
if (accounts === null) {
|
||||
if (accounts === null || !window) {
|
||||
return (
|
||||
<div className="space-y-6" aria-busy>
|
||||
{header}
|
||||
@@ -124,7 +183,14 @@ export function ReconciliationWorkspace() {
|
||||
<div className="grid gap-8 lg:grid-cols-[220px_1fr]">
|
||||
<ReconciliationRail accounts={accounts} selectedKey={selected?.account_key ?? null} onSelect={select} />
|
||||
<div className="min-w-0">
|
||||
{selected && <AccountOverview key={selected.account_key} account={selected} onChanged={() => void load()} />}
|
||||
{selected && (
|
||||
<AccountOverview
|
||||
key={selected.account_key}
|
||||
account={selected}
|
||||
window={window}
|
||||
onChanged={() => void load()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* "Markera som avstämd": date, optional note, and (only when the engine
|
||||
* reports an unexplained difference) the explicit "sign anyway" choice that
|
||||
* makes the note mandatory. The policy lives in lib/reconciliation/signoff.ts;
|
||||
* this dialog only collects the input and shows the server's refusal verbatim.
|
||||
*/
|
||||
interface SignoffDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
accountName: string
|
||||
/** Default through-date (the window end, clamped to today). */
|
||||
defaultDate: string
|
||||
/** Latest possible date (today, or the skattekonto snapshot date). */
|
||||
maxDate: string
|
||||
unexplained: number | null
|
||||
currency: string
|
||||
/** Returns an error message to show inline, or null on success. */
|
||||
onSubmit: (input: { through_date: string; note: string | null; force: boolean }) => Promise<string | null>
|
||||
}
|
||||
|
||||
export function SignoffDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
accountName,
|
||||
defaultDate,
|
||||
maxDate,
|
||||
unexplained,
|
||||
currency,
|
||||
onSubmit,
|
||||
}: SignoffDialogProps) {
|
||||
const t = useTranslations('reconciliation')
|
||||
const [date, setDate] = useState(defaultDate)
|
||||
const [note, setNote] = useState('')
|
||||
const [force, setForce] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const needsForce = unexplained == null || Math.abs(unexplained) >= 0.005
|
||||
|
||||
// Reset per opening so a second sign-off does not inherit the last one's
|
||||
// note or override choice.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDate(defaultDate)
|
||||
setNote('')
|
||||
setForce(false)
|
||||
setError(null)
|
||||
}
|
||||
}, [open, defaultDate])
|
||||
|
||||
const canSubmit = !busy && date.length === 10 && (!needsForce || (force && note.trim().length > 0))
|
||||
|
||||
async function submit() {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const message = await onSubmit({ through_date: date, note: note.trim() || null, force: needsForce && force })
|
||||
if (message) setError(message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('signoff_title')}</DialogTitle>
|
||||
<DialogDescription>{t('signoff_body', { account: accountName })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="signoff-date">{t('signoff_date')}</Label>
|
||||
<Input
|
||||
id="signoff-date"
|
||||
type="date"
|
||||
value={date}
|
||||
max={maxDate}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
{needsForce && (
|
||||
<div className="space-y-2 rounded-lg bg-warning/10 px-3 py-2.5 text-[13px] text-foreground">
|
||||
<p>
|
||||
{unexplained == null
|
||||
? t('tile_unknown')
|
||||
: t('signoff_unexplained_warning', { amount: formatCurrency(unexplained, currency) })}
|
||||
</p>
|
||||
<label className="flex items-center gap-2 text-[13px]">
|
||||
<Checkbox checked={force} onCheckedChange={(v) => setForce(v === true)} />
|
||||
{t('signoff_force')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="signoff-note">{t('signoff_note')}</Label>
|
||||
<Textarea
|
||||
id="signoff-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t('signoff_note_placeholder')}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-[13px] text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||
{t('signoff_cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => void submit()} disabled={!canSubmit} aria-busy={busy}>
|
||||
{t('signoff_confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
const statusMock = vi.fn()
|
||||
const itemsMock = vi.fn()
|
||||
const matchMock = vi.fn()
|
||||
const signoffMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/reconciliation/service', () => ({
|
||||
getAccountStatus: (...args: unknown[]) => statusMock(...args),
|
||||
@@ -24,6 +25,9 @@ vi.mock('@/lib/reconciliation/actions', () => ({
|
||||
unmatchLink: vi.fn(),
|
||||
setItemIgnored: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/reconciliation/signoff', () => ({
|
||||
signOffAccount: (...args: unknown[]) => signoffMock(...args),
|
||||
}))
|
||||
|
||||
import { tools, isDefaultCatalogTool, deriveToolMeta } from '../server'
|
||||
|
||||
@@ -44,6 +48,7 @@ describe('reconciliation MCP tools', () => {
|
||||
statusMock.mockReset()
|
||||
itemsMock.mockReset()
|
||||
matchMock.mockReset()
|
||||
signoffMock.mockReset()
|
||||
})
|
||||
|
||||
it('registers the four tools with the intended catalog visibility and staging contract', () => {
|
||||
@@ -160,3 +165,63 @@ describe('reconciliation MCP tools', () => {
|
||||
expect(out.preview).toEqual({ account_key: 'skattekonto', external_id: ROW })
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_reconcile_signoff', () => {
|
||||
beforeEach(() => {
|
||||
signoffMock.mockReset()
|
||||
})
|
||||
|
||||
it('is search-only, requires approval, and preflights on the status tool', () => {
|
||||
expect(isDefaultCatalogTool(tool('gnubok_reconcile_signoff'))).toBe(false)
|
||||
expect(deriveToolMeta(tool('gnubok_reconcile_signoff'))).toMatchObject({
|
||||
requires_approval: true,
|
||||
preflight: 'gnubok_get_reconciliation_status',
|
||||
})
|
||||
})
|
||||
|
||||
it('dry-runs the policy first and stages reconciliation_signoff with the preview as the operation preview', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
signoffMock.mockResolvedValue({
|
||||
dry_run: true,
|
||||
would_sign: { account_key: 'skattekonto', through_date: '2026-07-31', unexplained_difference: 0, is_reconciled: true, forced: false, previous_through_date: null },
|
||||
})
|
||||
const out = (await tool('gnubok_reconcile_signoff').execute(
|
||||
{ account_key: 'skattekonto', through_date: '2026-07-31', dry_run: true },
|
||||
COMPANY,
|
||||
USER,
|
||||
supabase as never,
|
||||
)) as Record<string, unknown>
|
||||
expect(signoffMock).toHaveBeenCalledWith(
|
||||
supabase,
|
||||
COMPANY,
|
||||
USER,
|
||||
'skattekonto',
|
||||
{ through_date: '2026-07-31', note: null, force: false },
|
||||
{ dryRun: true },
|
||||
)
|
||||
expect(out).toMatchObject({ staged: false, dry_run: true, risk_level: 'medium' })
|
||||
expect(out.next).toMatchObject({ tool: 'gnubok_get_reconciliation_status' })
|
||||
expect(out.preview).toMatchObject({ through_date: '2026-07-31', is_reconciled: true })
|
||||
})
|
||||
|
||||
it('surfaces a policy refusal instead of staging', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
signoffMock.mockRejectedValue(new Error('Kontot har en oförklarad differens.'))
|
||||
await expect(
|
||||
tool('gnubok_reconcile_signoff').execute(
|
||||
{ account_key: 'skattekonto', through_date: '2026-07-31' },
|
||||
COMPANY,
|
||||
USER,
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/oförklarad/)
|
||||
})
|
||||
|
||||
it('rejects a malformed account_key before touching anything', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
tool('gnubok_reconcile_signoff').execute({ account_key: '1630', through_date: '2026-07-31' }, COMPANY, USER, supabase as never),
|
||||
).rejects.toThrow(/Invalid account_key/)
|
||||
expect(signoffMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,7 +47,11 @@ export function isTenantWriteScope(scope: ApiKeyScope | undefined): boolean {
|
||||
return (
|
||||
scope?.endsWith(':write') === true ||
|
||||
scope?.endsWith(':approve') === true ||
|
||||
scope?.endsWith(':manage') === true
|
||||
scope?.endsWith(':manage') === true ||
|
||||
// Sign-off attests on the company's behalf: a write for the role guard
|
||||
// even though the scope is deliberately not `:write` (linking rows must
|
||||
// not imply the right to attest).
|
||||
scope?.endsWith(':signoff') === true
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ export const RECOMMENDED_WORKFLOW_LOADOUTS: readonly WorkflowLoadout[] = [
|
||||
// staged link (bank accounts and skattekonto alike).
|
||||
'gnubok_list_reconciliation_items',
|
||||
'gnubok_reconcile_match',
|
||||
'gnubok_reconcile_signoff',
|
||||
'gnubok_list_voucher_gaps',
|
||||
'gnubok_explain_voucher_gap',
|
||||
'gnubok_lock_period',
|
||||
|
||||
@@ -172,6 +172,7 @@ import { generateGeneralLedger } from '@/lib/reports/general-ledger'
|
||||
import { getAccountStatus } from '@/lib/reconciliation/service'
|
||||
import { listAccountItems } from '@/lib/reconciliation/items'
|
||||
import { matchPairs } from '@/lib/reconciliation/actions'
|
||||
import { signOffAccount } from '@/lib/reconciliation/signoff'
|
||||
import { parseAccountKey, type ReconciliationItemBucket } from '@/lib/reconciliation/schemas'
|
||||
import { decryptPersonnummer, maskEmployeeForResponse, maskPersonnummer } from '@/lib/salary/personnummer'
|
||||
import {
|
||||
@@ -1365,6 +1366,7 @@ const TOOL_PREFLIGHT_MAP: Record<string, string> = {
|
||||
gnubok_post_annual_depreciation: 'gnubok_propose_annual_depreciation',
|
||||
gnubok_book_salary_run: 'gnubok_get_salary_run',
|
||||
gnubok_reconcile_match: 'gnubok_get_reconciliation_status',
|
||||
gnubok_reconcile_signoff: 'gnubok_get_reconciliation_status',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -10064,6 +10066,76 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
name: 'gnubok_reconcile_signoff',
|
||||
title: 'Reconcile: Sign off',
|
||||
description: 'Mark one account (skattekonto or bank:<cash_account_id>) as reconciled through a date ("avstämt t.o.m."). Refused unless unexplained_difference is 0 through that date, or force + note. Writes nothing to the ledger. Stages (medium risk); dry_run previews.',
|
||||
catalogVisibility: 'search',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
account_key: { type: 'string', description: '"skattekonto" or "bank:<cash_account_id>".' },
|
||||
through_date: { type: 'string', description: 'Inclusive YYYY-MM-DD the account is reconciled through (not in the future; not past the skattekonto snapshot).' },
|
||||
note: { type: 'string', description: 'Free text. Required with force.' },
|
||||
force: { type: 'boolean', description: 'Sign despite an unexplained difference or an unknown outside balance. Needs note.' },
|
||||
dry_run: { type: 'boolean' },
|
||||
idempotency_key: { type: 'string' },
|
||||
},
|
||||
required: ['account_key', 'through_date'],
|
||||
},
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const accountKey = args.account_key as string
|
||||
const throughDate = args.through_date as string
|
||||
if (!parseAccountKey(accountKey)) throw new Error(`Invalid account_key "${accountKey}"`)
|
||||
// Policy runs now (dry run of the sign-off) so a refusal surfaces here,
|
||||
// not at approval time; the executor re-runs it when the user approves.
|
||||
const preview = await signOffAccount(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
accountKey,
|
||||
{ through_date: throughDate, note: (args.note as string | undefined) ?? null, force: args.force === true },
|
||||
{ dryRun: true },
|
||||
)
|
||||
if (!preview) throw new Error(`Unknown account_key "${accountKey}" for this company`)
|
||||
const previewData: Record<string, unknown> = preview.dry_run
|
||||
? { ...preview.would_sign }
|
||||
: { account_key: accountKey, through_date: throughDate }
|
||||
return stagePendingOperation(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
'reconciliation_signoff',
|
||||
`Markera ${accountKey} som avstämt t.o.m. ${throughDate}`,
|
||||
{
|
||||
account_key: accountKey,
|
||||
through_date: throughDate,
|
||||
note: (args.note as string | undefined) ?? null,
|
||||
force: args.force === true,
|
||||
},
|
||||
previewData,
|
||||
actor,
|
||||
{
|
||||
description: 'After approval, the account shows "avstämt t.o.m." in the Avstämning page and on its status.',
|
||||
tool: 'gnubok_get_reconciliation_status',
|
||||
args: { account_key: accountKey },
|
||||
},
|
||||
{
|
||||
dryRun: args.dry_run === true,
|
||||
idempotencyKey: args.idempotency_key as string | undefined,
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_list_cash_accounts',
|
||||
title: 'List Cash Accounts',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `131`;
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `134`;
|
||||
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = `
|
||||
[
|
||||
@@ -36,6 +36,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
|
||||
"GET /api/v1/companies/:companyId/reconciliation/accounts",
|
||||
"GET /api/v1/companies/:companyId/reconciliation/accounts/:accountKey",
|
||||
"GET /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/items",
|
||||
"GET /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff",
|
||||
"GET /api/v1/companies/:companyId/reconciliation/bank/status",
|
||||
"GET /api/v1/companies/:companyId/reports/ar-ledger",
|
||||
"GET /api/v1/companies/:companyId/reports/avgifter-basis",
|
||||
@@ -105,6 +106,8 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
|
||||
"POST /api/v1/companies/:companyId/journal-entries/batch-create",
|
||||
"POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/items/:itemId/ignore",
|
||||
"POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/links",
|
||||
"POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff",
|
||||
"POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff/:signoffId/reopen",
|
||||
"POST /api/v1/companies/:companyId/reconciliation/bank/run",
|
||||
"POST /api/v1/companies/:companyId/salary-runs",
|
||||
"POST /api/v1/companies/:companyId/salary-runs/:id/approve",
|
||||
@@ -155,6 +158,7 @@ exports[`v1 spec snapshot > matches the recorded scope catalogue > endpoint-scop
|
||||
"payroll:write",
|
||||
"public",
|
||||
"reconciliation:read",
|
||||
"reconciliation:signoff",
|
||||
"reconciliation:write",
|
||||
"reports:read",
|
||||
"suppliers:read",
|
||||
|
||||
@@ -160,6 +160,8 @@ import '@/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/
|
||||
import '@/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/links/route'
|
||||
import '@/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/links/[linkId]/route'
|
||||
import '@/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/items/[itemId]/ignore/route'
|
||||
import '@/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/route'
|
||||
import '@/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/[signoffId]/reopen/route'
|
||||
|
||||
// Dimensions PR2: registry list + value creation (kostnadsställe/projekt).
|
||||
import '@/app/api/v1/companies/[companyId]/dimensions/route'
|
||||
|
||||
@@ -40,6 +40,7 @@ export const API_KEY_SCOPES = {
|
||||
// (match/unmatch) and ignore flags. Links never touch the ledger.
|
||||
'reconciliation:read': { label: 'Avstämning: läs', description: 'Konton att stämma av, bryggan per konto och raderna bakom den (bank + skattekonto)' },
|
||||
'reconciliation:write': { label: 'Avstämning: skriv', description: 'Koppla och koppla bort händelser mot verifikat, ignorera rader (MCP stagar; REST skriver direkt)' },
|
||||
'reconciliation:signoff': { label: 'Avstämning: signera', description: 'Markera ett konto som avstämt t.o.m. ett datum och öppna en signering igen (MCP stagar; REST skriver direkt)' },
|
||||
} as const
|
||||
|
||||
export type ApiKeyScope = keyof typeof API_KEY_SCOPES
|
||||
@@ -133,6 +134,8 @@ export const STAGING_SCOPES: ApiKeyScope[] = [
|
||||
// gnubok_reconcile_match / gnubok_reconcile_unmatch stage reconciliation_*
|
||||
// operations; same SoD reasoning.
|
||||
'reconciliation:write',
|
||||
// gnubok_reconcile_signoff stages reconciliation_signoff.
|
||||
'reconciliation:signoff',
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -190,6 +193,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
gnubok_list_reconciliation_items: 'reconciliation:read',
|
||||
gnubok_reconcile_match: 'reconciliation:write',
|
||||
gnubok_reconcile_unmatch: 'reconciliation:write',
|
||||
gnubok_reconcile_signoff: 'reconciliation:signoff',
|
||||
gnubok_bulk_book_transactions: 'transactions:write',
|
||||
gnubok_bulk_book_inbox_items: 'transactions:write',
|
||||
gnubok_auto_match_period: 'transactions:write',
|
||||
|
||||
@@ -142,6 +142,9 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
|
||||
'POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/links': 'reconciliation:write',
|
||||
'DELETE /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/links/:linkId': 'reconciliation:write',
|
||||
'POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/items/:itemId/ignore': 'reconciliation:write',
|
||||
'GET /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff': 'reconciliation:read',
|
||||
'POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff': 'reconciliation:signoff',
|
||||
'POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff/:signoffId/reopen': 'reconciliation:signoff',
|
||||
|
||||
// Phase 5 PR-3: Reports + import async. Reports are read-only over
|
||||
// existing lib/reports/* generators; imports are async over the Phase 4
|
||||
|
||||
@@ -35,6 +35,14 @@ The first stable release of the public REST API. Six phases of development cover
|
||||
- **Reads**: \`GET /accounts\`, \`GET /fiscal-periods\`.
|
||||
- All write surfaces honour strict-mode (commit fully or error with no side effects).
|
||||
|
||||
### Reconciliation, account-keyed (2026-08)
|
||||
|
||||
- **Accounts**: \`GET /reconciliation/accounts\` lists every account with an outside truth (bank accounts as \`bank:<cash_account_id>\`, the skattekonto as \`skattekonto\`) with status; \`GET .../accounts/{accountKey}\` is the bridge (outside balance, ledger, difference, unexplained, explanatory lines, counts, latest sign-off); \`GET .../accounts/{accountKey}/items\` the rows behind it, bucketed (proposed, unmatched_external, unmatched_ledger, matched, ignored, upcoming).
|
||||
- **Links**: \`POST .../accounts/{accountKey}/links\` (pairs or \`use_proposals\`), \`DELETE .../links/{linkId}\`, \`POST .../items/{itemId}/ignore\`. Links never touch the ledger.
|
||||
- **Sign-off**: \`POST .../accounts/{accountKey}/signoff\` ("avstämt t.o.m." a date; refused with an unexplained difference unless forced with a note), \`GET .../signoff\` history, \`POST .../signoff/{signoffId}/reopen\`.
|
||||
- New scopes \`reconciliation:read\`, \`reconciliation:write\`, \`reconciliation:signoff\`. The legacy \`/reconciliation/bank/*\` endpoints and their \`transactions:*\` scopes are unchanged.
|
||||
- **Webhooks**: new event types \`reconciliation.matched\`, \`reconciliation.unmatched\`, \`reconciliation.signed_off\`, \`reconciliation.reopened\`. Additive: existing subscriptions are unaffected and the API version date is unchanged.
|
||||
|
||||
### Bookkeeping primitives + AP + compliance (Phase 4)
|
||||
|
||||
- **Suppliers + supplier-invoices** vertical (mirror of Phase 2 invoices on the AP side).
|
||||
|
||||
@@ -76,6 +76,10 @@ export type CoreEvent =
|
||||
// signals the flows builder triggers on.
|
||||
| { type: 'reconciliation.matched'; payload: { accountKey: string; externalId: string; journalEntryId: string; method: 'manual' | 'proposal'; userId: string; companyId: string } }
|
||||
| { type: 'reconciliation.unmatched'; payload: { accountKey: string; externalId: string; previousJournalEntryId: string | null; userId: string; companyId: string } }
|
||||
// Sign-off: the human (or agent-staged, user-approved) assertion "reconciled
|
||||
// through this date" on one account (lib/reconciliation/signoff.ts), and its undo.
|
||||
| { type: 'reconciliation.signed_off'; payload: { accountKey: string; signoffId: string; throughDate: string; unexplainedDifference: number | null; userId: string; companyId: string } }
|
||||
| { type: 'reconciliation.reopened'; payload: { accountKey: string; signoffId: string; throughDate: string; reason: string | null; userId: string; companyId: string } }
|
||||
// Bank connection lifecycle: consent + account selection are the
|
||||
// GDPR/PSD2 audit points; emitted to event_log for compliance trail.
|
||||
| { type: 'bank_connection.consent_granted'; payload: { connectionId: string; bankName: string | null; accountCount: number; consentExpiresAt: string | null; userId: string; companyId: string } }
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '@/lib/currency/supplier-invoice-rate'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { matchPairs, unmatchLink } from '@/lib/reconciliation/actions'
|
||||
import { signOffAccount } from '@/lib/reconciliation/signoff'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { validateVatNumber } from '@/lib/vat/vies-client'
|
||||
import {
|
||||
@@ -6046,6 +6047,44 @@ async function commitReconciliationUnmatch(
|
||||
}
|
||||
}
|
||||
|
||||
/** reconciliation_signoff: "avstämt t.o.m." on one account; policy in lib/reconciliation/signoff.ts. */
|
||||
async function commitReconciliationSignoff(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<ExecutorResult> {
|
||||
const accountKey = params.account_key as string | undefined
|
||||
const throughDate = params.through_date as string | undefined
|
||||
if (!accountKey || !throughDate) {
|
||||
return { error: 'account_key and through_date are required', status: 400 }
|
||||
}
|
||||
try {
|
||||
const result = await signOffAccount(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
accountKey,
|
||||
{
|
||||
through_date: throughDate,
|
||||
note: (params.note as string | null | undefined) ?? null,
|
||||
force: params.force === true,
|
||||
},
|
||||
{ dryRun: false },
|
||||
)
|
||||
if (!result) return { error: `Unknown account_key ${accountKey}`, status: 404 }
|
||||
if (result.dry_run) return { error: 'Unexpected dry-run result', status: 500 }
|
||||
return { data: { account_key: accountKey, signoff: result.signoff } }
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code
|
||||
return {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
errorCode: code,
|
||||
status: code === 'SIGNOFF_NOT_FOUND' ? 404 : code === 'ALREADY_SIGNED_OFF' || code === 'SIGNOFF_RACE' ? 409 : 400,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function commitLinkTransactionJournalEntry(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
@@ -6387,6 +6426,9 @@ async function commitPendingOperationInner(
|
||||
case 'reconciliation_unmatch':
|
||||
result = await commitReconciliationUnmatch(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
case 'reconciliation_signoff':
|
||||
result = await commitReconciliationSignoff(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
case 'submit_vat_declaration':
|
||||
result = await commitSubmitVatDeclaration(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
|
||||
@@ -207,6 +207,9 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
|
||||
// sibling above. Unmatch only clears a pointer: 'low'.
|
||||
reconciliation_match: 'medium',
|
||||
reconciliation_unmatch: 'low',
|
||||
// Sign-off writes the attestation row others rely on (overview, Hem, auditor)
|
||||
// but nothing in the ledger, and reopen undoes it: 'medium'.
|
||||
reconciliation_signoff: 'medium',
|
||||
|
||||
// ── Körjournal (mileage) ───────────────────────────────────────────
|
||||
// A trip row is pure travel documentation: no booking impact until a
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TokenBucketRateLimiter } from '../rate-limiter';
|
||||
|
||||
/**
|
||||
* The local (no-Upstash) limiter must serve waiters in arrival order. Two
|
||||
* callers that both find the bucket empty used to set their own timeouts
|
||||
* that expired at the same instant; which woke first was platform-dependent,
|
||||
* which made "open invoices are requested before paid ones" (hydrateInvoices)
|
||||
* flip on CI while holding locally.
|
||||
*/
|
||||
describe('TokenBucketRateLimiter (local fallback)', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('UPSTASH_REDIS_REST_URL', '');
|
||||
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', '');
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('grants tokens immediately while the bucket has them', async () => {
|
||||
const limiter = new TokenBucketRateLimiter({ maxRequests: 2, windowMs: 1000 });
|
||||
await expect(limiter.acquire()).resolves.toBeUndefined();
|
||||
await expect(limiter.acquire()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('serves waiters in arrival order once the bucket is empty', async () => {
|
||||
const limiter = new TokenBucketRateLimiter({ maxRequests: 1, windowMs: 1000 });
|
||||
await limiter.acquire(); // bucket empty
|
||||
|
||||
const order: string[] = [];
|
||||
const a = limiter.acquire().then(() => order.push('a'));
|
||||
const b = limiter.acquire().then(() => order.push('b'));
|
||||
const c = limiter.acquire().then(() => order.push('c'));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await Promise.all([a, b, c]);
|
||||
expect(order).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,22 @@ export class TokenBucketRateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
private async acquireLocal(): Promise<void> {
|
||||
// Local waiters are served in arrival order. Two callers that both find the
|
||||
// bucket empty would otherwise race on timer tie-breaking: their timeouts
|
||||
// expire at the same instant from different timer lists, and which wakes
|
||||
// first is platform-dependent. Callers rely on "started first, requested
|
||||
// first" (hydrateInvoices serves open invoices before paid ones), so the
|
||||
// queue makes that guarantee hold without changing the rate.
|
||||
private localQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
private acquireLocal(): Promise<void> {
|
||||
const turn = this.localQueue.then(() => this.acquireLocalInOrder());
|
||||
// Keep the chain alive even if a turn rejects (nothing throws today).
|
||||
this.localQueue = turn.catch(() => undefined);
|
||||
return turn;
|
||||
}
|
||||
|
||||
private async acquireLocalInOrder(): Promise<void> {
|
||||
this.refill();
|
||||
if (this.tokens > 0) {
|
||||
this.tokens--;
|
||||
|
||||
@@ -86,6 +86,7 @@ describe('listReconciliationAccounts', () => {
|
||||
cashAccount(ID_C, { ledger_account: '1931', iban: 'SE2' }),
|
||||
],
|
||||
})
|
||||
enqueue({ data: [] }) // latest sign-offs (none)
|
||||
// latestBankSyncAt per account (withStatus=false skips bankStatus): three maybeSingle reads
|
||||
enqueue({ data: { created_at: '2026-08-19T06:00:00Z' } })
|
||||
enqueue({ data: { created_at: '2026-06-01T06:00:00Z' } })
|
||||
@@ -135,6 +136,7 @@ describe('listReconciliationAccounts', () => {
|
||||
it('omits the skattekonto when the company has neither snapshot nor rows', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [cashAccount(ID_A, { is_primary: true })] })
|
||||
enqueue({ data: [] }) // latest sign-offs (none)
|
||||
enqueue({ data: null })
|
||||
skattekontoStatusMock.mockResolvedValue(null)
|
||||
|
||||
@@ -148,6 +150,7 @@ describe('listReconciliationAccounts', () => {
|
||||
it('computes the bank status through the existing engine with the account scope and maps it to the common shape', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [cashAccount(ID_A, { is_primary: true, currency: 'SEK' })] })
|
||||
enqueue({ data: [] }) // latest sign-offs (none)
|
||||
bankStatusMock.mockResolvedValue(bankStatus({ unmatched_transaction_count: 2, unmatched_transaction_total: -1046, is_reconciled: false }))
|
||||
enqueue({ data: { created_at: '2026-08-20T06:00:00Z' } }) // latestBankSyncAt inside bankStatus
|
||||
enqueue({ data: { created_at: '2026-08-20T06:00:00Z' } }) // latestBankSyncAt for the account row
|
||||
@@ -199,7 +202,7 @@ describe('getAccountStatus', () => {
|
||||
windowFrom: '2026-07-01',
|
||||
windowTo: '2026-07-31',
|
||||
})
|
||||
expect(s).toEqual({ account_key: 'skattekonto' })
|
||||
expect(s).toEqual({ account_key: 'skattekonto', signoff: null })
|
||||
expect(skattekontoStatusMock).toHaveBeenCalledWith(supabase, COMPANY, {
|
||||
today: '2026-08-20',
|
||||
windowFrom: '2026-07-01',
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { getLatestSignoffs, insertSignoff, listSignoffs, mapSignoffRow, stampReopen } from '../signoff-store'
|
||||
|
||||
const { supabase, enqueue, reset, findCall } = createQueuedMockSupabase()
|
||||
const COMPANY = 'company-1'
|
||||
|
||||
function row(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'sig-1',
|
||||
account_key: 'skattekonto',
|
||||
through_date: '2026-07-31',
|
||||
external_balance: '1000.00',
|
||||
ledger_balance: 1000,
|
||||
unexplained_difference: '0.004',
|
||||
note: null,
|
||||
signed_by: 'user-1',
|
||||
signed_at: '2026-08-23T10:00:00Z',
|
||||
reopened_at: null,
|
||||
reopened_by: null,
|
||||
reopen_reason: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
})
|
||||
|
||||
describe('signoff-store', () => {
|
||||
it('maps numeric strings to öre-rounded numbers', () => {
|
||||
const mapped = mapSignoffRow(row())
|
||||
expect(mapped.external_balance).toBe(1000)
|
||||
expect(mapped.ledger_balance).toBe(1000)
|
||||
expect(mapped.unexplained_difference).toBe(0)
|
||||
})
|
||||
|
||||
it('getLatestSignoffs keeps the first (newest) active row per account', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
row({ id: 'a', account_key: 'skattekonto', through_date: '2026-07-31' }),
|
||||
row({ id: 'b', account_key: 'skattekonto', through_date: '2026-06-30' }),
|
||||
row({ id: 'c', account_key: 'bank:11111111-1111-4111-8111-111111111111', through_date: '2026-07-31' }),
|
||||
],
|
||||
})
|
||||
const latest = await getLatestSignoffs(supabase as never, COMPANY)
|
||||
expect(latest.get('skattekonto')?.id).toBe('a')
|
||||
expect(latest.get('bank:11111111-1111-4111-8111-111111111111')?.id).toBe('c')
|
||||
expect(supabase.from).toHaveBeenCalledWith('account_reconciliations')
|
||||
})
|
||||
|
||||
it('listSignoffs excludes reopened rows unless asked and clamps the limit', async () => {
|
||||
enqueue({ data: [row()] })
|
||||
await listSignoffs(supabase as never, COMPANY, 'skattekonto', { limit: 5000 })
|
||||
expect(findCall('account_reconciliations', 'is')).toEqual(['reopened_at', null])
|
||||
expect(findCall('account_reconciliations', 'limit')).toEqual([200])
|
||||
|
||||
reset()
|
||||
enqueue({ data: [row()] })
|
||||
await listSignoffs(supabase as never, COMPANY, 'skattekonto', { includeReopened: true })
|
||||
expect(findCall('account_reconciliations', 'is')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('insertSignoff returns the mapped row and throws on error', async () => {
|
||||
enqueue({ data: row() })
|
||||
const inserted = await insertSignoff(supabase as never, COMPANY, {
|
||||
account_key: 'skattekonto',
|
||||
through_date: '2026-07-31',
|
||||
external_balance: 1000,
|
||||
ledger_balance: 1000,
|
||||
unexplained_difference: 0,
|
||||
note: null,
|
||||
signed_by: 'user-1',
|
||||
})
|
||||
expect(inserted.id).toBe('sig-1')
|
||||
|
||||
enqueue({ data: null, error: { message: 'duplicate key value violates unique constraint "ux_account_reconciliations_active"' } })
|
||||
await expect(
|
||||
insertSignoff(supabase as never, COMPANY, {
|
||||
account_key: 'skattekonto',
|
||||
through_date: '2026-07-31',
|
||||
external_balance: 1000,
|
||||
ledger_balance: 1000,
|
||||
unexplained_difference: 0,
|
||||
note: null,
|
||||
signed_by: 'user-1',
|
||||
}),
|
||||
).rejects.toThrow(/ux_account_reconciliations_active/)
|
||||
})
|
||||
|
||||
it('stampReopen guards on reopened_at IS NULL and returns null when nothing matched', async () => {
|
||||
enqueue({ data: null })
|
||||
const result = await stampReopen(supabase as never, COMPANY, 'sig-1', { reopened_by: 'user-1', reason: null })
|
||||
expect(result).toBeNull()
|
||||
expect(findCall('account_reconciliations', 'is')).toEqual(['reopened_at', null])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,278 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const statusMock = vi.fn()
|
||||
const latestMock = vi.fn()
|
||||
const byIdMock = vi.fn()
|
||||
const insertMock = vi.fn()
|
||||
const stampMock = vi.fn()
|
||||
const emitMock = vi.fn()
|
||||
|
||||
vi.mock('../service', () => ({
|
||||
getAccountStatus: (...args: unknown[]) => statusMock(...args),
|
||||
}))
|
||||
vi.mock('../signoff-store', () => ({
|
||||
getLatestSignoff: (...args: unknown[]) => latestMock(...args),
|
||||
getSignoffById: (...args: unknown[]) => byIdMock(...args),
|
||||
insertSignoff: (...args: unknown[]) => insertMock(...args),
|
||||
stampReopen: (...args: unknown[]) => stampMock(...args),
|
||||
}))
|
||||
vi.mock('@/lib/events/bus', () => ({ eventBus: { emit: (...args: unknown[]) => emitMock(...args) } }))
|
||||
|
||||
import { ReconciliationSignoffError, reopenSignoff, signOffAccount } from '../signoff'
|
||||
|
||||
const COMPANY = 'company-1'
|
||||
const USER = 'user-1'
|
||||
const TODAY = '2026-08-23'
|
||||
const SIGNOFF_ID = '77777777-7777-4777-8777-777777777777'
|
||||
|
||||
function status(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
account_key: 'skattekonto',
|
||||
kind: 'skattekonto',
|
||||
account_number: '1630',
|
||||
currency: 'SEK',
|
||||
as_of: '2026-08-20T06:00:00Z',
|
||||
stale: false,
|
||||
external_balance: 1000,
|
||||
ledger_balance: 1000,
|
||||
difference: 0,
|
||||
unexplained_difference: 0,
|
||||
is_reconciled: true,
|
||||
bridge: [],
|
||||
counts: { proposed: 0, unmatched_external: 0, unmatched_ledger: 0, matched: 0, ignored: 0 },
|
||||
skattekonto: null,
|
||||
bank: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function row(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: SIGNOFF_ID,
|
||||
account_key: 'skattekonto',
|
||||
through_date: '2026-07-31',
|
||||
external_balance: 1000,
|
||||
ledger_balance: 1000,
|
||||
unexplained_difference: 0,
|
||||
note: null,
|
||||
signed_by: USER,
|
||||
signed_at: '2026-08-23T10:00:00Z',
|
||||
reopened_at: null,
|
||||
reopened_by: null,
|
||||
reopen_reason: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('signOffAccount', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
statusMock.mockReset()
|
||||
latestMock.mockReset()
|
||||
insertMock.mockReset()
|
||||
emitMock.mockResolvedValue(undefined)
|
||||
statusMock.mockResolvedValue(status())
|
||||
latestMock.mockResolvedValue(null)
|
||||
insertMock.mockImplementation(async (_s: unknown, _c: unknown, input: Record<string, unknown>) =>
|
||||
row({ through_date: input.through_date, note: input.note, unexplained_difference: input.unexplained_difference }),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for an unknown or manual account key', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
expect(await signOffAccount(supabase as never, COMPANY, USER, 'nope', { through_date: '2026-07-31' })).toBeNull()
|
||||
expect(await signOffAccount(supabase as never, COMPANY, USER, 'manual:1910', { through_date: '2026-07-31' })).toBeNull()
|
||||
expect(statusMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a malformed date and a date in the future', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
signOffAccount(supabase as never, COMPANY, USER, 'skattekonto', { through_date: '31/07/2026' }, { today: TODAY }),
|
||||
).rejects.toMatchObject({ code: 'INVALID_DATE' })
|
||||
await expect(
|
||||
signOffAccount(supabase as never, COMPANY, USER, 'skattekonto', { through_date: '2026-09-01' }, { today: TODAY }),
|
||||
).rejects.toMatchObject({ code: 'DATE_IN_FUTURE' })
|
||||
expect(insertMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('signs a reconciled account, records the numbers, and emits reconciliation.signed_off', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const result = await signOffAccount(
|
||||
supabase as never,
|
||||
COMPANY,
|
||||
USER,
|
||||
'skattekonto',
|
||||
{ through_date: '2026-07-31' },
|
||||
{ today: TODAY },
|
||||
)
|
||||
expect(result).toMatchObject({ dry_run: false, signoff: { through_date: '2026-07-31', unexplained_difference: 0 } })
|
||||
// The bridge is asked for through the requested date.
|
||||
expect(statusMock).toHaveBeenCalledWith(supabase, COMPANY, 'skattekonto', { today: TODAY, windowTo: '2026-07-31' })
|
||||
expect(insertMock).toHaveBeenCalledWith(
|
||||
supabase,
|
||||
COMPANY,
|
||||
expect.objectContaining({ account_key: 'skattekonto', through_date: '2026-07-31', signed_by: USER, note: null }),
|
||||
)
|
||||
expect(emitMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'reconciliation.signed_off',
|
||||
payload: expect.objectContaining({ accountKey: 'skattekonto', throughDate: '2026-07-31', signoffId: SIGNOFF_ID }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('dry run previews without writing', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const result = await signOffAccount(
|
||||
supabase as never,
|
||||
COMPANY,
|
||||
USER,
|
||||
'skattekonto',
|
||||
{ through_date: '2026-07-31' },
|
||||
{ today: TODAY, dryRun: true },
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
dry_run: true,
|
||||
would_sign: { through_date: '2026-07-31', is_reconciled: true, forced: false, previous_through_date: null },
|
||||
})
|
||||
expect(insertMock).not.toHaveBeenCalled()
|
||||
expect(emitMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses an unexplained difference unless forced with a note, then records the difference', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
statusMock.mockResolvedValue(status({ unexplained_difference: 12.5, is_reconciled: false }))
|
||||
await expect(
|
||||
signOffAccount(supabase as never, COMPANY, USER, 'skattekonto', { through_date: '2026-07-31' }, { today: TODAY }),
|
||||
).rejects.toMatchObject({ code: 'NOT_RECONCILED' })
|
||||
await expect(
|
||||
signOffAccount(
|
||||
supabase as never,
|
||||
COMPANY,
|
||||
USER,
|
||||
'skattekonto',
|
||||
{ through_date: '2026-07-31', force: true },
|
||||
{ today: TODAY },
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'NOTE_REQUIRED' })
|
||||
const forced = await signOffAccount(
|
||||
supabase as never,
|
||||
COMPANY,
|
||||
USER,
|
||||
'skattekonto',
|
||||
{ through_date: '2026-07-31', force: true, note: 'Bankavgift 12,50 bokförs i augusti.' },
|
||||
{ today: TODAY },
|
||||
)
|
||||
expect(forced).toMatchObject({ dry_run: false })
|
||||
expect(insertMock).toHaveBeenCalledWith(
|
||||
supabase,
|
||||
COMPANY,
|
||||
expect.objectContaining({ unexplained_difference: 12.5, note: 'Bankavgift 12,50 bokförs i augusti.' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses when the outside balance is unknown', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
statusMock.mockResolvedValue(status({ external_balance: null, unexplained_difference: null, is_reconciled: false }))
|
||||
await expect(
|
||||
signOffAccount(supabase as never, COMPANY, USER, 'skattekonto', { through_date: '2026-07-31' }, { today: TODAY }),
|
||||
).rejects.toMatchObject({ code: 'OUTSIDE_UNKNOWN' })
|
||||
})
|
||||
|
||||
it('refuses a skattekonto date past the snapshot', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
statusMock.mockResolvedValue(status({ as_of: '2026-07-15T06:00:00Z' }))
|
||||
await expect(
|
||||
signOffAccount(supabase as never, COMPANY, USER, 'skattekonto', { through_date: '2026-07-31' }, { today: TODAY }),
|
||||
).rejects.toMatchObject({ code: 'NOT_FETCHED_THROUGH' })
|
||||
})
|
||||
|
||||
it('refuses a date at or before the latest active sign-off', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
latestMock.mockResolvedValue(row({ through_date: '2026-07-31' }))
|
||||
await expect(
|
||||
signOffAccount(supabase as never, COMPANY, USER, 'skattekonto', { through_date: '2026-07-31' }, { today: TODAY }),
|
||||
).rejects.toMatchObject({ code: 'ALREADY_SIGNED_OFF' })
|
||||
await expect(
|
||||
signOffAccount(supabase as never, COMPANY, USER, 'skattekonto', { through_date: '2026-06-30' }, { today: TODAY }),
|
||||
).rejects.toMatchObject({ code: 'ALREADY_SIGNED_OFF' })
|
||||
// A later date is fine and the preview names what it supersedes.
|
||||
const later = await signOffAccount(
|
||||
supabase as never,
|
||||
COMPANY,
|
||||
USER,
|
||||
'skattekonto',
|
||||
{ through_date: '2026-08-15' },
|
||||
{ today: TODAY, dryRun: true },
|
||||
)
|
||||
expect(later).toMatchObject({ dry_run: true, would_sign: { previous_through_date: '2026-07-31' } })
|
||||
})
|
||||
|
||||
it('maps a unique-index collision to SIGNOFF_RACE', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
insertMock.mockRejectedValue(new Error('duplicate key value violates unique constraint "ux_account_reconciliations_active"'))
|
||||
await expect(
|
||||
signOffAccount(supabase as never, COMPANY, USER, 'skattekonto', { through_date: '2026-07-31' }, { today: TODAY }),
|
||||
).rejects.toMatchObject({ code: 'SIGNOFF_RACE' })
|
||||
})
|
||||
|
||||
it('404s (null) when the status says the account does not exist for the company', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
statusMock.mockResolvedValue(null)
|
||||
expect(
|
||||
await signOffAccount(
|
||||
supabase as never,
|
||||
COMPANY,
|
||||
USER,
|
||||
'bank:11111111-1111-4111-8111-111111111111',
|
||||
{ through_date: '2026-07-31' },
|
||||
{ today: TODAY },
|
||||
),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reopenSignoff', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
byIdMock.mockReset()
|
||||
stampMock.mockReset()
|
||||
emitMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('stamps the row, emits reconciliation.reopened, and returns the updated row', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
byIdMock.mockResolvedValue(row())
|
||||
stampMock.mockResolvedValue(row({ reopened_at: '2026-08-24T08:00:00Z', reopened_by: USER, reopen_reason: 'sen rad' }))
|
||||
const result = await reopenSignoff(supabase as never, COMPANY, USER, 'skattekonto', SIGNOFF_ID, { reason: ' sen rad ' })
|
||||
expect(result).toMatchObject({ reopened_by: USER, reopen_reason: 'sen rad' })
|
||||
expect(stampMock).toHaveBeenCalledWith(supabase, COMPANY, SIGNOFF_ID, { reopened_by: USER, reason: 'sen rad' })
|
||||
expect(emitMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'reconciliation.reopened', payload: expect.objectContaining({ signoffId: SIGNOFF_ID, reason: 'sen rad' }) }),
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a missing or already reopened sign-off, and reports a lost race', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
byIdMock.mockResolvedValue(null)
|
||||
await expect(reopenSignoff(supabase as never, COMPANY, USER, 'skattekonto', SIGNOFF_ID)).rejects.toMatchObject({
|
||||
code: 'SIGNOFF_NOT_FOUND',
|
||||
})
|
||||
byIdMock.mockResolvedValue(row({ reopened_at: '2026-08-24T08:00:00Z', reopened_by: USER }))
|
||||
await expect(reopenSignoff(supabase as never, COMPANY, USER, 'skattekonto', SIGNOFF_ID)).rejects.toMatchObject({
|
||||
code: 'ALREADY_REOPENED',
|
||||
})
|
||||
byIdMock.mockResolvedValue(row())
|
||||
stampMock.mockResolvedValue(null)
|
||||
await expect(reopenSignoff(supabase as never, COMPANY, USER, 'skattekonto', SIGNOFF_ID)).rejects.toBeInstanceOf(
|
||||
ReconciliationSignoffError,
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for an unknown account key', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
expect(await reopenSignoff(supabase as never, COMPANY, USER, 'nope', SIGNOFF_ID)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -57,6 +57,23 @@ export const ReconciliationStateSchema = z.enum([
|
||||
'not_configured',
|
||||
])
|
||||
|
||||
/** The latest active sign-off on an account: "avstämt t.o.m. through_date" with the numbers as they stood. */
|
||||
export const ReconciliationSignoffSchema = z.object({
|
||||
id: z.string(),
|
||||
account_key: AccountKeySchema,
|
||||
through_date: z.string(),
|
||||
external_balance: z.number().nullable(),
|
||||
ledger_balance: z.number().nullable(),
|
||||
unexplained_difference: z.number().nullable(),
|
||||
note: z.string().nullable(),
|
||||
signed_by: z.string(),
|
||||
signed_at: z.string(),
|
||||
reopened_at: z.string().nullable(),
|
||||
reopened_by: z.string().nullable(),
|
||||
reopen_reason: z.string().nullable(),
|
||||
})
|
||||
export type ReconciliationSignoff = z.infer<typeof ReconciliationSignoffSchema>
|
||||
|
||||
export const ReconciliationAccountSchema = z.object({
|
||||
account_key: AccountKeySchema,
|
||||
kind: ReconciliationKindSchema,
|
||||
@@ -80,6 +97,8 @@ export const ReconciliationAccountSchema = z.object({
|
||||
.nullable(),
|
||||
/** Another enabled cash account shares this IBAN and currency (reconnect duplicate). */
|
||||
superseded_by: AccountKeySchema.nullable(),
|
||||
/** through_date of the latest active sign-off, null when the account was never signed off. */
|
||||
signed_off_through: z.string().nullable().optional(),
|
||||
})
|
||||
export type ReconciliationAccount = z.infer<typeof ReconciliationAccountSchema>
|
||||
|
||||
@@ -190,6 +209,8 @@ export const ReconciliationStatusSchema = z.object({
|
||||
skattekonto: SkattekontoStatusBlockSchema.nullable(),
|
||||
/** Today's bank status fields, unchanged, for the bank kind (see bank-reconciliation.ts). */
|
||||
bank: z.record(z.string(), z.unknown()).nullable(),
|
||||
/** Latest active sign-off on the account (lib/reconciliation/signoff.ts); null when none. */
|
||||
signoff: ReconciliationSignoffSchema.nullable().optional(),
|
||||
})
|
||||
export type ReconciliationStatus = z.infer<typeof ReconciliationStatusSchema>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ReconciliationAccount,
|
||||
type ReconciliationStatus,
|
||||
} from './schemas'
|
||||
import { getLatestSignoff, getLatestSignoffs } from './signoff-store'
|
||||
|
||||
const log = createLogger('reconciliation/service')
|
||||
|
||||
@@ -242,6 +243,15 @@ export async function listReconciliationAccounts(
|
||||
for (const other of sorted.slice(1)) supersededBy.set(other.id, bankAccountKey(keep.id))
|
||||
}
|
||||
|
||||
// Latest active sign-off per account, one query; the rail shows "avstämt
|
||||
// t.o.m." next to the live status. A failed read must not hide the accounts.
|
||||
let signoffs = new Map<string, Awaited<ReturnType<typeof getLatestSignoff>>>()
|
||||
try {
|
||||
signoffs = await getLatestSignoffs(supabase, companyId)
|
||||
} catch (err) {
|
||||
log.warn('sign-off read failed', { companyId, error: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
|
||||
const bankAccounts = await Promise.all(
|
||||
cashAccounts.map(async (a): Promise<ReconciliationAccount> => {
|
||||
let status: ReconciliationStatus | null = null
|
||||
@@ -277,6 +287,7 @@ export async function listReconciliationAccounts(
|
||||
},
|
||||
status: stateOf(status),
|
||||
superseded_by: supersededBy.get(a.id) ?? null,
|
||||
signed_off_through: signoffs.get(bankAccountKey(a.id))?.through_date ?? null,
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -299,6 +310,7 @@ export async function listReconciliationAccounts(
|
||||
},
|
||||
status: s.skattekonto?.fetched_at ? stateOf(s) : { ...stateOf(s)!, state: 'not_configured' },
|
||||
superseded_by: null,
|
||||
signed_off_through: signoffs.get(SKATTEKONTO_ACCOUNT_KEY)?.through_date ?? null,
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -331,8 +343,9 @@ export async function getAccountStatus(
|
||||
if (!parsed) return null
|
||||
const today = options.today ?? isoDate(new Date())
|
||||
|
||||
let status: ReconciliationStatus | null = null
|
||||
if (parsed.kind === 'skattekonto') {
|
||||
return getSkattekontoReconciliationStatus(supabase, companyId, {
|
||||
status = await getSkattekontoReconciliationStatus(supabase, companyId, {
|
||||
today,
|
||||
windowFrom: options.windowFrom ?? null,
|
||||
windowTo: options.windowTo ?? null,
|
||||
@@ -352,9 +365,18 @@ export async function getAccountStatus(
|
||||
from: options.windowFrom ?? defaultWindow(today).from,
|
||||
to: options.windowTo ?? defaultWindow(today).to,
|
||||
}
|
||||
return bankStatus(supabase, companyId, data as CashAccountRow, window, today)
|
||||
status = await bankStatus(supabase, companyId, data as CashAccountRow, window, today)
|
||||
}
|
||||
|
||||
// manual accounts: later adapter
|
||||
return null
|
||||
if (!status) return null
|
||||
|
||||
// The latest active sign-off rides along on every status read (page, v1,
|
||||
// MCP) so "avstämt t.o.m." never needs a second call.
|
||||
try {
|
||||
status.signoff = await getLatestSignoff(supabase, companyId, accountKey)
|
||||
} catch (err) {
|
||||
log.warn('sign-off read failed', { companyId, accountKey, error: err instanceof Error ? err.message : String(err) })
|
||||
status.signoff = null
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { ReconciliationSignoff } from './schemas'
|
||||
|
||||
/**
|
||||
* The account_reconciliations table, read and written in one place. Pure
|
||||
* storage: no policy (that is signoff.ts) and no status computation (that is
|
||||
* service.ts), so service.ts can read the latest sign-offs without a module
|
||||
* cycle.
|
||||
*/
|
||||
|
||||
interface SignoffRow {
|
||||
id: string
|
||||
account_key: string
|
||||
through_date: string
|
||||
external_balance: number | string | null
|
||||
ledger_balance: number | string | null
|
||||
unexplained_difference: number | string | null
|
||||
note: string | null
|
||||
signed_by: string
|
||||
signed_at: string
|
||||
reopened_at: string | null
|
||||
reopened_by: string | null
|
||||
reopen_reason: string | null
|
||||
}
|
||||
|
||||
function num(v: number | string | null): number | null {
|
||||
if (v == null) return null
|
||||
const n = typeof v === 'number' ? v : Number(v)
|
||||
return Number.isFinite(n) ? roundOre(n) : null
|
||||
}
|
||||
|
||||
export function mapSignoffRow(row: SignoffRow): ReconciliationSignoff {
|
||||
return {
|
||||
id: row.id,
|
||||
account_key: row.account_key,
|
||||
through_date: row.through_date,
|
||||
external_balance: num(row.external_balance),
|
||||
ledger_balance: num(row.ledger_balance),
|
||||
unexplained_difference: num(row.unexplained_difference),
|
||||
note: row.note,
|
||||
signed_by: row.signed_by,
|
||||
signed_at: row.signed_at,
|
||||
reopened_at: row.reopened_at,
|
||||
reopened_by: row.reopened_by,
|
||||
reopen_reason: row.reopen_reason,
|
||||
}
|
||||
}
|
||||
|
||||
/** Latest active sign-off per account key, one query for the whole company. */
|
||||
export async function getLatestSignoffs(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<Map<string, ReconciliationSignoff>> {
|
||||
const { data, error } = await supabase
|
||||
.from('account_reconciliations')
|
||||
.select(
|
||||
'id, account_key, through_date, external_balance, ledger_balance, unexplained_difference, note, signed_by, signed_at, reopened_at, reopened_by, reopen_reason',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.is('reopened_at', null)
|
||||
.order('through_date', { ascending: false })
|
||||
if (error) throw new Error(`Kunde inte hämta avstämningssigneringar: ${error.message}`)
|
||||
const out = new Map<string, ReconciliationSignoff>()
|
||||
for (const row of (data ?? []) as SignoffRow[]) {
|
||||
if (!out.has(row.account_key)) out.set(row.account_key, mapSignoffRow(row))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Latest active sign-off on one account, or null. */
|
||||
export async function getLatestSignoff(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
accountKey: string,
|
||||
): Promise<ReconciliationSignoff | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('account_reconciliations')
|
||||
.select(
|
||||
'id, account_key, through_date, external_balance, ledger_balance, unexplained_difference, note, signed_by, signed_at, reopened_at, reopened_by, reopen_reason',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.eq('account_key', accountKey)
|
||||
.is('reopened_at', null)
|
||||
.order('through_date', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`Kunde inte hämta avstämningssignering: ${error.message}`)
|
||||
return data ? mapSignoffRow(data as SignoffRow) : null
|
||||
}
|
||||
|
||||
/** One sign-off by id (any state), scoped to company + account. */
|
||||
export async function getSignoffById(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
accountKey: string,
|
||||
signoffId: string,
|
||||
): Promise<ReconciliationSignoff | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('account_reconciliations')
|
||||
.select(
|
||||
'id, account_key, through_date, external_balance, ledger_balance, unexplained_difference, note, signed_by, signed_at, reopened_at, reopened_by, reopen_reason',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.eq('account_key', accountKey)
|
||||
.eq('id', signoffId)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`Kunde inte hämta avstämningssignering: ${error.message}`)
|
||||
return data ? mapSignoffRow(data as SignoffRow) : null
|
||||
}
|
||||
|
||||
/** Sign-off history for one account, newest first; reopened ones included when asked. */
|
||||
export async function listSignoffs(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
accountKey: string,
|
||||
options: { limit?: number; includeReopened?: boolean } = {},
|
||||
): Promise<ReconciliationSignoff[]> {
|
||||
const limit = Math.min(Math.max(options.limit ?? 50, 1), 200)
|
||||
let query = supabase
|
||||
.from('account_reconciliations')
|
||||
.select(
|
||||
'id, account_key, through_date, external_balance, ledger_balance, unexplained_difference, note, signed_by, signed_at, reopened_at, reopened_by, reopen_reason',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.eq('account_key', accountKey)
|
||||
.order('through_date', { ascending: false })
|
||||
.order('signed_at', { ascending: false })
|
||||
.limit(limit)
|
||||
if (!options.includeReopened) query = query.is('reopened_at', null)
|
||||
const { data, error } = await query
|
||||
if (error) throw new Error(`Kunde inte hämta avstämningssigneringar: ${error.message}`)
|
||||
return ((data ?? []) as SignoffRow[]).map(mapSignoffRow)
|
||||
}
|
||||
|
||||
export interface InsertSignoffInput {
|
||||
account_key: string
|
||||
through_date: string
|
||||
external_balance: number | null
|
||||
ledger_balance: number | null
|
||||
unexplained_difference: number | null
|
||||
note: string | null
|
||||
signed_by: string
|
||||
}
|
||||
|
||||
export async function insertSignoff(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
input: InsertSignoffInput,
|
||||
): Promise<ReconciliationSignoff> {
|
||||
const { data, error } = await supabase
|
||||
.from('account_reconciliations')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
account_key: input.account_key,
|
||||
through_date: input.through_date,
|
||||
external_balance: input.external_balance,
|
||||
ledger_balance: input.ledger_balance,
|
||||
unexplained_difference: input.unexplained_difference,
|
||||
note: input.note,
|
||||
signed_by: input.signed_by,
|
||||
})
|
||||
.select(
|
||||
'id, account_key, through_date, external_balance, ledger_balance, unexplained_difference, note, signed_by, signed_at, reopened_at, reopened_by, reopen_reason',
|
||||
)
|
||||
.single()
|
||||
if (error || !data) {
|
||||
throw new Error(`Kunde inte spara signeringen: ${error?.message ?? 'okänt fel'}`)
|
||||
}
|
||||
return mapSignoffRow(data as SignoffRow)
|
||||
}
|
||||
|
||||
/** Stamp a sign-off as reopened; the guarded `.is('reopened_at', null)` makes it race-safe. */
|
||||
export async function stampReopen(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
signoffId: string,
|
||||
input: { reopened_by: string; reason: string | null },
|
||||
): Promise<ReconciliationSignoff | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('account_reconciliations')
|
||||
.update({ reopened_at: new Date().toISOString(), reopened_by: input.reopened_by, reopen_reason: input.reason })
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', signoffId)
|
||||
.is('reopened_at', null)
|
||||
.select(
|
||||
'id, account_key, through_date, external_balance, ledger_balance, unexplained_difference, note, signed_by, signed_at, reopened_at, reopened_by, reopen_reason',
|
||||
)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`Kunde inte öppna signeringen igen: ${error.message}`)
|
||||
return data ? mapSignoffRow(data as SignoffRow) : null
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { ISO_DATE_RE } from '@/lib/invariants'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { parseAccountKey, type ReconciliationSignoff, type ReconciliationStatus } from './schemas'
|
||||
import { getAccountStatus } from './service'
|
||||
import { getLatestSignoff, getSignoffById, insertSignoff, stampReopen } from './signoff-store'
|
||||
|
||||
const log = createLogger('reconciliation/signoff')
|
||||
|
||||
/**
|
||||
* Sign-off ("Markera som avstämd t.o.m. <datum>") and reopen on one
|
||||
* reconcilable account. The policy layer over signoff-store.ts: a sign-off
|
||||
* is refused unless the engine says the account is reconciled through that
|
||||
* date, or the signer explicitly overrides with a note. Writes nothing to
|
||||
* the ledger; the row is the attestation the overview, the Hem row and an
|
||||
* auditor read.
|
||||
*
|
||||
* Same function for the dashboard routes, the v1 API and the MCP executor,
|
||||
* so the rule is one rule.
|
||||
*/
|
||||
|
||||
export type SignoffErrorCode =
|
||||
| 'INVALID_DATE'
|
||||
| 'DATE_IN_FUTURE'
|
||||
| 'NOT_FETCHED_THROUGH'
|
||||
| 'OUTSIDE_UNKNOWN'
|
||||
| 'NOT_RECONCILED'
|
||||
| 'NOTE_REQUIRED'
|
||||
| 'ALREADY_SIGNED_OFF'
|
||||
| 'SIGNOFF_NOT_FOUND'
|
||||
| 'ALREADY_REOPENED'
|
||||
| 'SIGNOFF_RACE'
|
||||
|
||||
export class ReconciliationSignoffError extends Error {
|
||||
readonly code: SignoffErrorCode
|
||||
constructor(message: string, code: SignoffErrorCode) {
|
||||
super(message)
|
||||
this.name = 'ReconciliationSignoffError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export interface SignoffInput {
|
||||
/** Inclusive ISO date the account is asserted reconciled through. */
|
||||
through_date: string
|
||||
/** Free text; required when signing with an unexplained difference (force). */
|
||||
note?: string | null
|
||||
/** Sign even though the engine reports an unexplained difference or an unknown outside balance. Needs a note. */
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
export interface SignoffOptions {
|
||||
dryRun?: boolean
|
||||
/** ISO date for "today" (tests). */
|
||||
today?: string
|
||||
}
|
||||
|
||||
export interface SignoffPreview {
|
||||
account_key: string
|
||||
through_date: string
|
||||
external_balance: number | null
|
||||
ledger_balance: number | null
|
||||
unexplained_difference: number | null
|
||||
is_reconciled: boolean
|
||||
forced: boolean
|
||||
/** The active sign-off this one supersedes in the rail, when any (earlier through_date). */
|
||||
previous_through_date: string | null
|
||||
}
|
||||
|
||||
export type SignoffResult =
|
||||
| { dry_run: true; would_sign: SignoffPreview }
|
||||
| { dry_run: false; signoff: ReconciliationSignoff }
|
||||
|
||||
function isoToday(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign one account off through a date. Returns null when the account key does
|
||||
* not resolve for this company (callers map that to 404); throws
|
||||
* ReconciliationSignoffError for every policy refusal.
|
||||
*/
|
||||
export async function signOffAccount(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
accountKey: string,
|
||||
input: SignoffInput,
|
||||
options: SignoffOptions = {},
|
||||
): Promise<SignoffResult | null> {
|
||||
const parsed = parseAccountKey(accountKey)
|
||||
// Manual accounts get their adapter later; until then they are not reconcilable.
|
||||
if (!parsed || parsed.kind === 'manual') return null
|
||||
const today = options.today ?? isoToday()
|
||||
const throughDate = input.through_date
|
||||
if (!ISO_DATE_RE.test(throughDate) || Number.isNaN(Date.parse(throughDate))) {
|
||||
throw new ReconciliationSignoffError('Ogiltigt datum. Ange ÅÅÅÅ-MM-DD.', 'INVALID_DATE')
|
||||
}
|
||||
if (throughDate > today) {
|
||||
throw new ReconciliationSignoffError('Du kan inte stämma av framåt i tiden.', 'DATE_IN_FUTURE')
|
||||
}
|
||||
const note = input.note?.trim() ? input.note.trim() : null
|
||||
const force = input.force === true
|
||||
if (force && !note) {
|
||||
throw new ReconciliationSignoffError(
|
||||
'Skriv en rad om varför du signerar trots att allt inte är förklarat.',
|
||||
'NOTE_REQUIRED',
|
||||
)
|
||||
}
|
||||
|
||||
// The engine's view through the requested date. The skattekonto bridge is
|
||||
// anchored at the saldo snapshot, so the date cannot pass it; the bank
|
||||
// bridge is a period movement, so the window simply ends on the date.
|
||||
const status: ReconciliationStatus | null = await getAccountStatus(supabase, companyId, accountKey, {
|
||||
today,
|
||||
windowTo: throughDate,
|
||||
})
|
||||
if (!status) return null
|
||||
const asOfDate = status.as_of.slice(0, 10)
|
||||
if (status.kind === 'skattekonto' && throughDate > asOfDate) {
|
||||
throw new ReconciliationSignoffError(
|
||||
`Skattekontot är hämtat t.o.m. ${asOfDate}. Hämta igen innan du stämmer av ett senare datum.`,
|
||||
'NOT_FETCHED_THROUGH',
|
||||
)
|
||||
}
|
||||
|
||||
const unexplained = status.unexplained_difference
|
||||
const reconciled = unexplained != null && Math.abs(unexplained) < 0.005
|
||||
if (!reconciled && !force) {
|
||||
if (unexplained == null) {
|
||||
throw new ReconciliationSignoffError(
|
||||
'Saldot utanför bokföringen är okänt, så kontot kan inte stämmas av. Hämta det först, eller signera med en notering.',
|
||||
'OUTSIDE_UNKNOWN',
|
||||
)
|
||||
}
|
||||
throw new ReconciliationSignoffError(
|
||||
'Kontot har en oförklarad differens. Koppla eller bokför raderna först, eller signera med en notering.',
|
||||
'NOT_RECONCILED',
|
||||
)
|
||||
}
|
||||
|
||||
const latest = await getLatestSignoff(supabase, companyId, accountKey)
|
||||
if (latest && latest.through_date >= throughDate) {
|
||||
throw new ReconciliationSignoffError(
|
||||
`Kontot är redan avstämt t.o.m. ${latest.through_date}. Öppna den signeringen igen om du vill ändra.`,
|
||||
'ALREADY_SIGNED_OFF',
|
||||
)
|
||||
}
|
||||
|
||||
const preview: SignoffPreview = {
|
||||
account_key: accountKey,
|
||||
through_date: throughDate,
|
||||
external_balance: status.external_balance,
|
||||
ledger_balance: status.ledger_balance,
|
||||
unexplained_difference: unexplained,
|
||||
is_reconciled: reconciled,
|
||||
forced: !reconciled,
|
||||
previous_through_date: latest?.through_date ?? null,
|
||||
}
|
||||
if (options.dryRun) return { dry_run: true, would_sign: preview }
|
||||
|
||||
let signoff: ReconciliationSignoff
|
||||
try {
|
||||
signoff = await insertSignoff(supabase, companyId, {
|
||||
account_key: accountKey,
|
||||
through_date: throughDate,
|
||||
external_balance: status.external_balance,
|
||||
ledger_balance: status.ledger_balance,
|
||||
unexplained_difference: unexplained,
|
||||
note,
|
||||
signed_by: userId,
|
||||
})
|
||||
} catch (err) {
|
||||
// The partial unique index turns a concurrent identical sign-off into a
|
||||
// constraint error; surface it as a race rather than a 500.
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (/ux_account_reconciliations_active|duplicate key/i.test(message)) {
|
||||
throw new ReconciliationSignoffError('Kontot signerades precis av någon annan. Ladda om.', 'SIGNOFF_RACE')
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'reconciliation.signed_off',
|
||||
payload: {
|
||||
accountKey,
|
||||
signoffId: signoff.id,
|
||||
throughDate,
|
||||
unexplainedDifference: unexplained,
|
||||
userId,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('reconciliation.signed_off emit failed', { companyId, accountKey, error: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
return { dry_run: false, signoff }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopen a sign-off (the undo). The row stays as history with the reopen
|
||||
* stamp; the account shows its previous active sign-off, if any, afterwards.
|
||||
*/
|
||||
export async function reopenSignoff(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
accountKey: string,
|
||||
signoffId: string,
|
||||
input: { reason?: string | null } = {},
|
||||
): Promise<ReconciliationSignoff | null> {
|
||||
const parsed = parseAccountKey(accountKey)
|
||||
if (!parsed || parsed.kind === 'manual') return null
|
||||
const existing = await getSignoffById(supabase, companyId, accountKey, signoffId)
|
||||
if (!existing) {
|
||||
throw new ReconciliationSignoffError('Signeringen hittades inte.', 'SIGNOFF_NOT_FOUND')
|
||||
}
|
||||
if (existing.reopened_at) {
|
||||
throw new ReconciliationSignoffError('Signeringen är redan öppnad igen.', 'ALREADY_REOPENED')
|
||||
}
|
||||
const reason = input.reason?.trim() ? input.reason.trim() : null
|
||||
const updated = await stampReopen(supabase, companyId, signoffId, { reopened_by: userId, reason })
|
||||
if (!updated) {
|
||||
throw new ReconciliationSignoffError('Signeringen öppnades precis av någon annan.', 'SIGNOFF_RACE')
|
||||
}
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'reconciliation.reopened',
|
||||
payload: { accountKey, signoffId, throughDate: updated.through_date, reason, userId, companyId },
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('reconciliation.reopened emit failed', { companyId, accountKey, error: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
return updated
|
||||
}
|
||||
@@ -984,6 +984,10 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
|
||||
// lines and the old description/date, i.e. the preserved side of every
|
||||
// in-verifikat rättelse — räkenskapsinformation, not an operation log.
|
||||
{ name: 'journal_entry_rattelse_log', file: 'journal_entry_rattelse_log.json', orderBy: 'created_at' },
|
||||
// Reconciliation sign-offs ("avstämt t.o.m."): who attested which account
|
||||
// through which date with the numbers as they stood, plus reopen stamps.
|
||||
// Part of the avstämningsdokumentation an auditor asks for; kept.
|
||||
{ name: 'account_reconciliations', file: 'account_reconciliations.json', orderBy: 'signed_at' },
|
||||
{ name: 'journal_entry_no_doc_required', file: 'journal_entry_no_doc_required.json', pageKey: 'journal_entry_id' },
|
||||
{ name: 'rot_rut_payout_requests', file: 'rot_rut_payout_requests.json', orderBy: 'created_at' },
|
||||
// No `denormalize`: rot_rut_payout_requests has no currency column either.
|
||||
|
||||
@@ -54,6 +54,10 @@ const PUBLIC_WEBHOOK_EVENTS = new Set<CoreEventType>([
|
||||
'supplier_invoice.uncredited',
|
||||
'transaction.categorized',
|
||||
'transaction.reconciled',
|
||||
'reconciliation.matched',
|
||||
'reconciliation.unmatched',
|
||||
'reconciliation.signed_off',
|
||||
'reconciliation.reopened',
|
||||
'journal_entry.committed',
|
||||
'journal_entry.reversed',
|
||||
'journal_entry.corrected',
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock('../categories', () => ({
|
||||
countOverdueInvoices: vi.fn().mockResolvedValue(5),
|
||||
countDeadlinesNeedingAction: vi.fn().mockResolvedValue(1),
|
||||
countPendingOperations: vi.fn().mockResolvedValue(2),
|
||||
countReconciliationDue: vi.fn().mockResolvedValue(1),
|
||||
}))
|
||||
|
||||
import { getWorklistCounts } from '../aggregate'
|
||||
@@ -32,12 +33,13 @@ describe('getWorklistCounts', () => {
|
||||
overdue_invoice: 5,
|
||||
deadline_action: 1,
|
||||
pending_operations: 2,
|
||||
reconciliation_due: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('excludes suggested_match from the total (subset of book_transaction)', async () => {
|
||||
const { total } = await getWorklistCounts(supabase, 'company-1')
|
||||
// 4 + 6 + 1 + 3 + 5 + 1 + 2, without the 2 suggested matches.
|
||||
expect(total).toBe(22)
|
||||
// 4 + 6 + 1 + 3 + 5 + 1 + 2 + 1, without the 2 suggested matches.
|
||||
expect(total).toBe(23)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
countInboxDocuments,
|
||||
countOverdueInvoices,
|
||||
countPendingOperations,
|
||||
countReconciliationDue,
|
||||
countSuggestedMatches,
|
||||
countSupplierInvoicesAwaitingApproval,
|
||||
countUnbookedTransactions,
|
||||
@@ -413,3 +414,56 @@ describe('listSuggestedMatches', () => {
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('countReconciliationDue', () => {
|
||||
const CASH_A = '11111111-1111-4111-8111-111111111111'
|
||||
const CASH_B = '22222222-2222-4222-8222-222222222222'
|
||||
const CASH_B_DUP = '33333333-3333-4333-8333-333333333333'
|
||||
// Today 2026-08-23 → previous month end 2026-07-31.
|
||||
const TODAY = new Date('2026-08-23T10:00:00Z')
|
||||
|
||||
it('is zero for a company that never signed anything off (adoption gate)', async () => {
|
||||
enqueue({ data: [] })
|
||||
await expect(countReconciliationDue(supabase, COMPANY, TODAY)).resolves.toBe(0)
|
||||
expect(mockSupabase.from).toHaveBeenCalledTimes(1)
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('account_reconciliations')
|
||||
})
|
||||
|
||||
it('counts reconcilable accounts without an active sign-off through the previous month end', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
// bank A signed through July: covered.
|
||||
{ account_key: `bank:${CASH_A}`, through_date: '2026-07-31', reopened_at: null },
|
||||
// skattekonto signed through June only: due.
|
||||
{ account_key: 'skattekonto', through_date: '2026-06-30', reopened_at: null },
|
||||
// bank B signed through July but reopened: due.
|
||||
{ account_key: `bank:${CASH_B}`, through_date: '2026-07-31', reopened_at: '2026-08-02T08:00:00Z' },
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: CASH_A, iban: 'SE1', currency: 'SEK', updated_at: '2026-08-01' },
|
||||
{ id: CASH_B, iban: 'SE2', currency: 'SEK', updated_at: '2026-08-01' },
|
||||
// Reconnect duplicate of B (same IBAN + currency): counts once.
|
||||
{ id: CASH_B_DUP, iban: 'SE2', currency: 'SEK', updated_at: '2026-07-01' },
|
||||
],
|
||||
})
|
||||
enqueue({ count: 12 })
|
||||
// Due: skattekonto + bank B (deduplicated) = 2.
|
||||
await expect(countReconciliationDue(supabase, COMPANY, TODAY)).resolves.toBe(2)
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('cash_accounts')
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('skattekonto_transactions')
|
||||
})
|
||||
|
||||
it('ignores the skattekonto when it has no rows', async () => {
|
||||
enqueue({ data: [{ account_key: `bank:${CASH_A}`, through_date: '2026-05-31', reopened_at: null }] })
|
||||
enqueue({ data: [{ id: CASH_A, iban: null, currency: 'SEK', updated_at: null }] })
|
||||
enqueue({ count: 0 })
|
||||
await expect(countReconciliationDue(supabase, COMPANY, TODAY)).resolves.toBe(1)
|
||||
})
|
||||
|
||||
it('soft-fails to 0 on a query error', async () => {
|
||||
enqueue({ error: { message: 'boom' } })
|
||||
await expect(countReconciliationDue(supabase, COMPANY, TODAY)).resolves.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
countInboxDocuments,
|
||||
countOverdueInvoices,
|
||||
countPendingOperations,
|
||||
countReconciliationDue,
|
||||
countSuggestedMatches,
|
||||
countSupplierInvoicesAwaitingApproval,
|
||||
countUnbookedTransactions,
|
||||
@@ -34,6 +35,7 @@ export async function getWorklistCounts(
|
||||
overdueInvoice,
|
||||
deadlineAction,
|
||||
pendingOperations,
|
||||
reconciliationDue,
|
||||
] = await Promise.all([
|
||||
countUnbookedTransactions(supabase, companyId),
|
||||
countInboxDocuments(supabase, companyId),
|
||||
@@ -43,6 +45,7 @@ export async function getWorklistCounts(
|
||||
countOverdueInvoices(supabase, companyId),
|
||||
countDeadlinesNeedingAction(supabase, companyId),
|
||||
countPendingOperations(supabase, companyId),
|
||||
countReconciliationDue(supabase, companyId),
|
||||
])
|
||||
|
||||
return {
|
||||
@@ -55,6 +58,7 @@ export async function getWorklistCounts(
|
||||
overdue_invoice: overdueInvoice,
|
||||
deadline_action: deadlineAction,
|
||||
pending_operations: pendingOperations,
|
||||
reconciliation_due: reconciliationDue,
|
||||
},
|
||||
total:
|
||||
bookTransaction +
|
||||
@@ -63,6 +67,7 @@ export async function getWorklistCounts(
|
||||
verifikatMissingDocument +
|
||||
overdueInvoice +
|
||||
deadlineAction +
|
||||
pendingOperations,
|
||||
pendingOperations +
|
||||
reconciliationDue,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,3 +428,67 @@ export async function listSuggestedMatches(
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Accounts not signed off through the end of the previous month. Cheap by
|
||||
* construction (three small reads, no bridge computation) and zero for
|
||||
* companies that never signed off anything, so the Hem row only appears
|
||||
* once the ritual is adopted. Mirrors lib/reconciliation/service.ts's account
|
||||
* set: enabled cash accounts deduplicated per IBAN + currency, plus the
|
||||
* skattekonto when it has rows.
|
||||
*/
|
||||
export async function countReconciliationDue(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
today: Date = new Date(),
|
||||
): Promise<number> {
|
||||
// Last day of the previous month, as ISO date (UTC: the day boundary only
|
||||
// needs to be stable, not local).
|
||||
const prevMonthEnd = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 0))
|
||||
.toISOString()
|
||||
.slice(0, 10)
|
||||
|
||||
const { data: signoffRows, error: signoffError } = await supabase
|
||||
.from('account_reconciliations')
|
||||
.select('account_key, through_date, reopened_at')
|
||||
.eq('company_id', companyId)
|
||||
.order('through_date', { ascending: false })
|
||||
.limit(500)
|
||||
if (signoffError) return logAndZero('reconciliation_due', companyId, signoffError)
|
||||
const signoffs = (signoffRows ?? []) as Array<{ account_key: string; through_date: string; reopened_at: string | null }>
|
||||
// Adoption gate: no sign-off ever (active or reopened) means no nudge.
|
||||
if (signoffs.length === 0) return 0
|
||||
const coveredKeys = new Set(
|
||||
signoffs.filter((s) => s.reopened_at === null && s.through_date >= prevMonthEnd).map((s) => s.account_key),
|
||||
)
|
||||
|
||||
const { data: cashRows, error: cashError } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('id, iban, currency, updated_at')
|
||||
.eq('company_id', companyId)
|
||||
.eq('enabled', true)
|
||||
if (cashError) return logAndZero('reconciliation_due', companyId, cashError)
|
||||
const cash = (cashRows ?? []) as Array<{ id: string; iban: string | null; currency: string | null; updated_at: string | null }>
|
||||
// Reconnect duplicates (same IBAN + currency) count once: the newest row.
|
||||
const byIban = new Map<string, typeof cash[number]>()
|
||||
const keys: string[] = []
|
||||
for (const a of cash) {
|
||||
if (!a.iban) {
|
||||
keys.push(`bank:${a.id}`)
|
||||
continue
|
||||
}
|
||||
const k = `${a.iban}|${a.currency ?? 'SEK'}`
|
||||
const prev = byIban.get(k)
|
||||
if (!prev || (a.updated_at ?? '') > (prev.updated_at ?? '')) byIban.set(k, a)
|
||||
}
|
||||
for (const a of byIban.values()) keys.push(`bank:${a.id}`)
|
||||
|
||||
const { count: skvCount, error: skvError } = await supabase
|
||||
.from('skattekonto_transactions')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
if (skvError) return logAndZero('reconciliation_due', companyId, skvError)
|
||||
if ((skvCount ?? 0) > 0) keys.push('skattekonto')
|
||||
|
||||
return keys.filter((k) => !coveredKeys.has(k)).length
|
||||
}
|
||||
|
||||
@@ -77,6 +77,19 @@ export const WORKLIST_CATEGORIES = [
|
||||
* Done: committed or rejected.
|
||||
*/
|
||||
'pending_operations',
|
||||
/**
|
||||
* Accounts not signed off through the end of the previous month
|
||||
* ("N konton att stämma av"), only for companies that have adopted the
|
||||
* sign-off ritual (at least one account_reconciliations row ever).
|
||||
* Pending: an enabled cash account (deduplicated per IBAN + currency) or a
|
||||
* configured skattekonto whose latest ACTIVE sign-off has
|
||||
* through_date before the last day of the previous month.
|
||||
* Done: a sign-off through that date or later (POST .../signoff), or
|
||||
* the account stops being reconcilable (disabled cash account).
|
||||
* Zero for companies with no sign-off at all: the nudge is for those who
|
||||
* reconcile monthly, not a new chore for everyone.
|
||||
*/
|
||||
'reconciliation_due',
|
||||
] as const
|
||||
|
||||
export type WorklistCategory = (typeof WORKLIST_CATEGORIES)[number]
|
||||
|
||||
+20
-1
@@ -6618,6 +6618,8 @@
|
||||
"suggested_confirmed_toast": "Match recorded",
|
||||
"suggested_failed_toast": "Match failed",
|
||||
"row_deadlines": "VAT and tax deadlines",
|
||||
"row_reconciliation_due": "Accounts to reconcile",
|
||||
"row_reconciliation_due_detail": "Not signed off through the end of last month",
|
||||
"greeting_morning": "Good morning",
|
||||
"greeting_day": "Good day",
|
||||
"greeting_evening": "Good evening",
|
||||
@@ -7868,7 +7870,24 @@
|
||||
"toast_booked": "{count} booked",
|
||||
"toast_book_partial": "{ok} booked, {failed} failed",
|
||||
"toast_failed": "That did not work",
|
||||
"truncated": "Showing the first {count} rows per group."
|
||||
"truncated": "Showing the first {count} rows per group.",
|
||||
"rail_signed_off": "signed off through {date}",
|
||||
"period_label": "Period",
|
||||
"signoff_button": "Mark as reconciled through {date}",
|
||||
"signoff_title": "Mark as reconciled",
|
||||
"signoff_body": "You attest that {account} agrees with the ledger through this date. Nothing in the ledger changes; the sign-off shows in the overview and can be reopened.",
|
||||
"signoff_date": "Reconciled through",
|
||||
"signoff_note": "Note",
|
||||
"signoff_note_placeholder": "Optional. Required when something is unexplained.",
|
||||
"signoff_unexplained_warning": "{amount} is not explained. You can sign anyway with a note, but it shows in the history.",
|
||||
"signoff_force": "Sign despite the unexplained difference",
|
||||
"signoff_confirm": "Sign",
|
||||
"signoff_cancel": "Cancel",
|
||||
"signed_off_line": "Reconciled through {date} ({when})",
|
||||
"signed_off_forced": "with a note",
|
||||
"reopen": "Reopen",
|
||||
"toast_signed_off": "Marked as reconciled through {date}",
|
||||
"toast_reopened": "The sign-off is reopened"
|
||||
},
|
||||
"skattekonto": {
|
||||
"help_text": "The balance and events are fetched from Skatteverket and synced automatically every night. Completed events are booked against 1630 Skattekonto, usually automatically; anything that cannot be matched is flagged in the list. Pay in via bankgiro 5050-1055 with your OCR number.",
|
||||
|
||||
+20
-1
@@ -6618,6 +6618,8 @@
|
||||
"suggested_confirmed_toast": "Matchning bokförd",
|
||||
"suggested_failed_toast": "Matchningen misslyckades",
|
||||
"row_deadlines": "Moms- och skattedeadlines",
|
||||
"row_reconciliation_due": "Konton att stämma av",
|
||||
"row_reconciliation_due_detail": "Inte avstämda t.o.m. förra månadsskiftet",
|
||||
"greeting_morning": "God morgon",
|
||||
"greeting_day": "God dag",
|
||||
"greeting_evening": "God kväll",
|
||||
@@ -7868,7 +7870,24 @@
|
||||
"toast_booked": "{count} bokförda",
|
||||
"toast_book_partial": "{ok} bokförda, {failed} misslyckades",
|
||||
"toast_failed": "Det gick inte",
|
||||
"truncated": "Visar de första {count} raderna per grupp."
|
||||
"truncated": "Visar de första {count} raderna per grupp.",
|
||||
"rail_signed_off": "avstämt t.o.m. {date}",
|
||||
"period_label": "Period",
|
||||
"signoff_button": "Markera som avstämd t.o.m. {date}",
|
||||
"signoff_title": "Markera som avstämd",
|
||||
"signoff_body": "Du intygar att {account} stämmer mot bokföringen t.o.m. det här datumet. Inget i bokföringen ändras; signeringen syns i översikten och kan öppnas igen.",
|
||||
"signoff_date": "Avstämd t.o.m.",
|
||||
"signoff_note": "Notering",
|
||||
"signoff_note_placeholder": "Valfritt. Obligatoriskt om något är oförklarat.",
|
||||
"signoff_unexplained_warning": "{amount} är inte förklarat. Du kan signera ändå med en notering, men det syns i historiken.",
|
||||
"signoff_force": "Signera trots oförklarad differens",
|
||||
"signoff_confirm": "Signera",
|
||||
"signoff_cancel": "Avbryt",
|
||||
"signed_off_line": "Avstämt t.o.m. {date} ({when})",
|
||||
"signed_off_forced": "med notering",
|
||||
"reopen": "Öppna igen",
|
||||
"toast_signed_off": "Markerat som avstämt t.o.m. {date}",
|
||||
"toast_reopened": "Signeringen är öppnad igen"
|
||||
},
|
||||
"skattekonto": {
|
||||
"help_text": "Saldot och händelserna hämtas från Skatteverket och synkas automatiskt varje natt. Genomförda händelser bokförs mot 1630 Skattekonto, oftast automatiskt; det som inte kan matchas flaggas i listan. Betala in via bankgiro 5050-1055 med ditt OCR-nummer.",
|
||||
|
||||
@@ -8,7 +8,7 @@ description: >-
|
||||
transactions and reconciliation, payroll (lön), VAT/moms and financial
|
||||
reports, SIE import/export, documents, webhooks. Covers auth with
|
||||
gnubok_sk_ API keys, conventions (dry-run, idempotency, cursor
|
||||
pagination, scopes), and all 131 endpoints.
|
||||
pagination, scopes), and all 134 endpoints.
|
||||
---
|
||||
|
||||
<!-- GENERATED FILE, do not edit. Source: lib/api/v1 registry + scripts/api-skill/overlays. Regenerate with `npm run apiskill:generate`. -->
|
||||
@@ -140,7 +140,7 @@ call can undo it, e.g. invoice credit).
|
||||
|
||||
## Endpoint index
|
||||
|
||||
API version `2026-05-12`, 131 operations. Paths are shown without
|
||||
API version `2026-05-12`, 134 operations. Paths are shown without
|
||||
their `/api/v1` prefix (full base URL: `https://app.gnubok.se/api/v1`).
|
||||
|
||||
### Core (4)
|
||||
@@ -251,7 +251,7 @@ POST /companies/{companyId}/documents/{id}/link : Link a document to a journal e
|
||||
POST /companies/{companyId}/inbox-items/{id}/stamp : Mark an inbox item as consumed by a journal entry [scope:documents:write risk:low idempotent]
|
||||
```
|
||||
|
||||
### Banking (18)
|
||||
### Banking (21)
|
||||
|
||||
Full detail: [references/banking.md](references/banking.md)
|
||||
|
||||
@@ -264,6 +264,9 @@ GET /companies/{companyId}/reconciliation/accounts/{accountKey}/items : List the
|
||||
POST /companies/{companyId}/reconciliation/accounts/{accountKey}/items/{itemId}/ignore : Ignore or restore one outside row [scope:reconciliation:write risk:low idempotent dry-run reversible]
|
||||
POST /companies/{companyId}/reconciliation/accounts/{accountKey}/links : Link outside rows to existing verifikat (pairs or proposals) [scope:reconciliation:write risk:medium dry-run reversible]
|
||||
DELETE /companies/{companyId}/reconciliation/accounts/{accountKey}/links/{linkId} : Remove a link between an outside row and a verifikat [scope:reconciliation:write risk:low idempotent dry-run reversible]
|
||||
GET /companies/{companyId}/reconciliation/accounts/{accountKey}/signoff : Sign-off history for one reconcilable account [scope:reconciliation:read risk:low idempotent reversible]
|
||||
POST /companies/{companyId}/reconciliation/accounts/{accountKey}/signoff : Mark an account reconciled through a date (sign-off) [scope:reconciliation:signoff risk:medium dry-run reversible]
|
||||
POST /companies/{companyId}/reconciliation/accounts/{accountKey}/signoff/{signoffId}/reopen : Reopen (undo) a reconciliation sign-off [scope:reconciliation:signoff risk:low idempotent dry-run reversible]
|
||||
POST /companies/{companyId}/reconciliation/bank/run : Run the bank-reconciliation matcher [scope:transactions:write risk:medium idempotent dry-run]
|
||||
GET /companies/{companyId}/reconciliation/bank/status : Bank-reconciliation health snapshot [scope:transactions:read risk:low idempotent]
|
||||
GET /companies/{companyId}/transactions : List transactions for a company [scope:transactions:read risk:low idempotent]
|
||||
|
||||
@@ -107,7 +107,7 @@ Response `200`:
|
||||
```ts
|
||||
{
|
||||
data: {
|
||||
accounts: { account_key: string, kind: "bank" | "skattekonto" | "manual", account_number: string, name: string, currency: string, logo_url: string, source: { type: "psd2" | "bank_file" | "skatteverket_api" | "skatteverket_file" | "manual", synced_at: string, stale: boolean }, status: { state: "reconciled" | "open" | "stale" | "not_configured", as_of: string, unexplained_difference: number, open_counts: { proposed: number, unmatched_external: number, unmatched_ledger: number } }, superseded_by: string }[]
|
||||
accounts: { account_key: string, kind: "bank" | "skattekonto" | "manual", account_number: string, name: string, currency: string, logo_url: string, source: { type: "psd2" | "bank_file" | "skatteverket_api" | "skatteverket_file" | "manual", synced_at: string, stale: boolean }, status: { state: "reconciled" | "open" | "stale" | "not_configured", as_of: string, unexplained_difference: number, open_counts: { proposed: number, unmatched_external: number, unmatched_ledger: number } }, superseded_by: string, signed_off_through?: string }[]
|
||||
},
|
||||
meta: {
|
||||
request_id: string,
|
||||
@@ -161,7 +161,8 @@ Response `200`:
|
||||
bridge: { key: string, label_sv: string, label_en: string, amount: number, count: number, items_bucket: string }[],
|
||||
counts: { proposed: number, unmatched_external: number, unmatched_ledger: number, matched: number, ignored: number },
|
||||
skattekonto: { saldo_skatteverket: number, fetched_at: string, history_start: string, opening_difference: number, upcoming_count: number, upcoming_total: number, ledger_balance_before_start: number },
|
||||
bank: Record<string, unknown>
|
||||
bank: Record<string, unknown>,
|
||||
signoff?: { id: string, account_key: string, through_date: string, external_balance: number, ledger_balance: number, unexplained_difference: number, note: string, signed_by: string, signed_at: string, reopened_at: string, reopened_by: string, reopen_reason: string }
|
||||
},
|
||||
meta: {
|
||||
request_id: string,
|
||||
@@ -347,6 +348,130 @@ Response `200`:
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/signoff`
|
||||
|
||||
**Sign-off history for one reconcilable account.**
|
||||
`scope:reconciliation:read · risk:low · idempotent · reversible`
|
||||
|
||||
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`.
|
||||
|
||||
**Use when:** You need the attestation trail (who signed what through which date) for an account, e.g. for a close checklist or an audit question.
|
||||
**Do not use for:** 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.
|
||||
|
||||
| Parameter | In | Type | Required | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `companyId` | path | `string` | yes | |
|
||||
| `accountKey` | path | `string` | yes | |
|
||||
|
||||
Response `200`:
|
||||
```ts
|
||||
{
|
||||
data: {
|
||||
signoffs: { id: string, account_key: string, through_date: string, external_balance: number, ledger_balance: number, unexplained_difference: number, note: string, signed_by: string, signed_at: string, reopened_at: string, reopened_by: string, reopen_reason: string }[]
|
||||
},
|
||||
meta: {
|
||||
request_id: string,
|
||||
api_version: string,
|
||||
next_cursor?: string,
|
||||
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
|
||||
partial_expansions?: string[]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/signoff`
|
||||
|
||||
**Mark an account reconciled through a date (sign-off).**
|
||||
`scope:reconciliation:signoff · risk:medium · dry-run · reversible`
|
||||
|
||||
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.
|
||||
|
||||
**Use when:** 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.
|
||||
**Do not use for:** 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.
|
||||
|
||||
| Parameter | In | Type | Required | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `companyId` | path | `string` | yes | |
|
||||
| `accountKey` | path | `string` | yes | |
|
||||
|
||||
Request body:
|
||||
```ts
|
||||
{ through_date: string, note?: string, force?: boolean }
|
||||
```
|
||||
|
||||
Response `200`:
|
||||
```ts
|
||||
{
|
||||
data: {
|
||||
dry_run: boolean,
|
||||
signoff?: { id: string, account_key: string, through_date: string, external_balance: number, ledger_balance: number, unexplained_difference: number, note: string, signed_by: string, signed_at: string, reopened_at: string, reopened_by: string, reopen_reason: string },
|
||||
would_sign?: { account_key: string, through_date: string, external_balance: number, ledger_balance: number, unexplained_difference: number, is_reconciled: boolean, forced: boolean, previous_through_date: string }
|
||||
},
|
||||
meta: {
|
||||
request_id: string,
|
||||
api_version: string,
|
||||
next_cursor?: string,
|
||||
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
|
||||
partial_expansions?: string[]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/signoff/{signoffId}/reopen`
|
||||
|
||||
**Reopen (undo) a reconciliation sign-off.**
|
||||
`scope:reconciliation:signoff · risk:low · idempotent · dry-run · reversible`
|
||||
|
||||
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).
|
||||
|
||||
**Use when:** 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.
|
||||
**Do not use for:** 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.
|
||||
|
||||
| Parameter | In | Type | Required | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `companyId` | path | `string` | yes | |
|
||||
| `accountKey` | path | `string` | yes | |
|
||||
| `signoffId` | path | `string` | yes | |
|
||||
|
||||
Request body:
|
||||
```ts
|
||||
{ reason?: string }
|
||||
```
|
||||
|
||||
Response `200`:
|
||||
```ts
|
||||
{
|
||||
data: {
|
||||
signoff: { id: string, account_key: string, through_date: string, external_balance: number, ledger_balance: number, unexplained_difference: number, note: string, signed_by: string, signed_at: string, reopened_at: string, reopened_by: string, reopen_reason: string }
|
||||
},
|
||||
meta: {
|
||||
request_id: string,
|
||||
api_version: string,
|
||||
next_cursor?: string,
|
||||
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
|
||||
partial_expansions?: string[]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/v1/companies/{companyId}/reconciliation/bank/run`
|
||||
|
||||
**Run the bank-reconciliation matcher.**
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
-- Sign-offs for account reconciliation ("Markera som avstämd t.o.m. <datum>").
|
||||
--
|
||||
-- lib/reconciliation computes, for every account with an outside truth (bank
|
||||
-- accounts, the skattekonto, later manual accounts), whether the outside
|
||||
-- balance and the ledger agree and what explains the difference. A sign-off
|
||||
-- is the human (or agent-staged, user-approved) assertion on top of that
|
||||
-- computation: "through this date, this account is reconciled", with the
|
||||
-- numbers as they stood when it was made. It is what the overview, the Hem
|
||||
-- row and an auditor read; it never touches the ledger.
|
||||
--
|
||||
-- Append-only with a reopen stamp instead of delete: the history of who
|
||||
-- signed what and when is the point of the table. A sign-off that turns out
|
||||
-- wrong is reopened (reopened_at/by/reason), never removed, and a new one can
|
||||
-- then be made for the same date.
|
||||
--
|
||||
-- account_key is the same qualified key the reconciliation API uses
|
||||
-- ("bank:<cash_account_id>", "skattekonto", later "manual:NNNN"), checked
|
||||
-- by regex here so a typo cannot create an orphan account. A bank account's
|
||||
-- rows are not cascaded when the cash account goes away: the sign-off stays
|
||||
-- as history (the key just stops resolving).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.account_reconciliations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
company_id UUID NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
account_key TEXT NOT NULL
|
||||
CHECK (account_key ~ '^(bank:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|skattekonto|manual:[0-9]{4})$'),
|
||||
-- Inclusive: the account is asserted reconciled for every day up to this one.
|
||||
through_date DATE NOT NULL,
|
||||
-- The numbers as computed at sign-off time (null when the outside side was
|
||||
-- unknown, e.g. a bank account without a fetched balance).
|
||||
external_balance NUMERIC(15, 2),
|
||||
ledger_balance NUMERIC(15, 2),
|
||||
unexplained_difference NUMERIC(15, 2),
|
||||
-- Required when unexplained_difference is not zero (the signer explains why
|
||||
-- they sign anyway); optional otherwise.
|
||||
note TEXT,
|
||||
signed_by UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
|
||||
signed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
reopened_at TIMESTAMPTZ,
|
||||
reopened_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
reopen_reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT account_reconciliations_reopen_pair
|
||||
CHECK ((reopened_at IS NULL) = (reopened_by IS NULL))
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.account_reconciliations IS
|
||||
'Reconciliation sign-offs per account (bank:<id> / skattekonto / manual:NNNN): "avstämt t.o.m. through_date". Append-only; reopen stamps instead of deleting.';
|
||||
|
||||
-- One active sign-off per account and date; a reopened one frees the slot.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_account_reconciliations_active
|
||||
ON public.account_reconciliations (company_id, account_key, through_date)
|
||||
WHERE reopened_at IS NULL;
|
||||
|
||||
-- "Latest active sign-off per account" is the hot read (rail, status, worklist).
|
||||
CREATE INDEX IF NOT EXISTS idx_account_reconciliations_latest
|
||||
ON public.account_reconciliations (company_id, account_key, through_date DESC)
|
||||
WHERE reopened_at IS NULL;
|
||||
|
||||
ALTER TABLE public.account_reconciliations ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Every member of the company reads the sign-offs (the overview shows them to
|
||||
-- viewers too). Writes are for owners, admins and members: a viewer may look
|
||||
-- but not attest. The application additionally enforces the write role on
|
||||
-- its routes (requireWrite); this is the defense-in-depth layer.
|
||||
DROP POLICY IF EXISTS "account_reconciliations_select" ON public.account_reconciliations;
|
||||
CREATE POLICY "account_reconciliations_select" ON public.account_reconciliations
|
||||
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
|
||||
|
||||
DROP POLICY IF EXISTS "account_reconciliations_insert" ON public.account_reconciliations;
|
||||
CREATE POLICY "account_reconciliations_insert" ON public.account_reconciliations
|
||||
FOR INSERT WITH CHECK (
|
||||
signed_by = auth.uid()
|
||||
AND company_id IN (
|
||||
SELECT cm.company_id FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.role IN ('owner', 'admin', 'member')
|
||||
)
|
||||
);
|
||||
|
||||
-- UPDATE exists only for the reopen stamp. Rows stay otherwise immutable by
|
||||
-- convention (no route writes anything else); no DELETE policy at all.
|
||||
DROP POLICY IF EXISTS "account_reconciliations_update" ON public.account_reconciliations;
|
||||
CREATE POLICY "account_reconciliations_update" ON public.account_reconciliations
|
||||
FOR UPDATE USING (
|
||||
company_id IN (
|
||||
SELECT cm.company_id FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.role IN ('owner', 'admin', 'member')
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
company_id IN (
|
||||
SELECT cm.company_id FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.role IN ('owner', 'admin', 'member')
|
||||
)
|
||||
);
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,95 @@
|
||||
-- Add 'reconciliation_signoff' to the pending_operations operation_type CHECK
|
||||
-- constraint.
|
||||
--
|
||||
-- gnubok_reconcile_signoff (MCP) stages the "Markera som avstämd t.o.m."
|
||||
-- assertion for one reconcilable account (lib/reconciliation/signoff.ts);
|
||||
-- the user approves it in Granskning and commitReconciliationSignoff in
|
||||
-- lib/pending-operations/commit.ts writes the account_reconciliations row.
|
||||
-- Risk 'medium': it writes nothing to the ledger and is undone by reopen,
|
||||
-- but it is an attestation others rely on.
|
||||
--
|
||||
-- NOTE on the value list: this constraint is re-created wholesale (the
|
||||
-- established pattern here), so the list below is every value of the
|
||||
-- constraint as left by 20260823130000 (the LIVE prod list read on
|
||||
-- 2026-08-23 plus reconciliation_match / reconciliation_unmatch) PLUS the
|
||||
-- new value. Dropping any existing value here would silently revoke it.
|
||||
--
|
||||
-- NOT VALID + separate VALIDATE migration (paired file, same pattern as
|
||||
-- 20260823130000 / 20260823130001).
|
||||
--
|
||||
-- pg-test: tests/pg/pending-operations-op-type-audit.pg.test.ts asserts every
|
||||
-- op type staged in server.ts or tiered in risk-tiers.ts is accepted here.
|
||||
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',
|
||||
'link_documents_to_vouchers',
|
||||
'reconciliation_match',
|
||||
'reconciliation_unmatch',
|
||||
'reconciliation_signoff'
|
||||
)) NOT VALID;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- Validate the operation type CHECK re-added in 20260823140001.
|
||||
-- This separate transaction avoids a full-table scan while the preceding
|
||||
-- migration holds its stronger table lock.
|
||||
|
||||
ALTER TABLE public.pending_operations
|
||||
VALIDATE CONSTRAINT pending_operations_operation_type_check;
|
||||
@@ -0,0 +1,149 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getPool, withUserContext } from './setup'
|
||||
import { seedCompany, insertAuthUser, insertCompanyMember } from './fixtures'
|
||||
|
||||
// pg-real coverage for 20260823140000_account_reconciliations: RLS (member
|
||||
// SELECT, owner/admin/member writes with signed_by = auth.uid(), viewers
|
||||
// read-only, no DELETE policy), the account_key CHECK, the one-active-per-date
|
||||
// partial unique index (a reopened row frees the slot), and the reopen pair
|
||||
// CHECK.
|
||||
|
||||
async function insertSignoff(
|
||||
companyId: string,
|
||||
signedBy: string,
|
||||
overrides: { accountKey?: string; throughDate?: string } = {},
|
||||
): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.account_reconciliations
|
||||
(id, company_id, account_key, through_date, external_balance, ledger_balance, unexplained_difference, signed_by)
|
||||
VALUES ($1, $2, $3, $4, 100.00, 100.00, 0, $5)`,
|
||||
[id, companyId, overrides.accountKey ?? 'skattekonto', overrides.throughDate ?? '2026-07-31', signedBy],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
describe('account_reconciliations RLS', () => {
|
||||
it('lets company members read, strangers see nothing', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const rowId = await insertSignoff(companyId, userId)
|
||||
const stranger = await insertAuthUser()
|
||||
|
||||
const ownerView = await withUserContext(userId, (client) =>
|
||||
client.query<{ id: string }>(`SELECT id FROM public.account_reconciliations WHERE id = $1`, [rowId]),
|
||||
)
|
||||
expect(ownerView.rows).toHaveLength(1)
|
||||
|
||||
const strangerView = await withUserContext(stranger, (client) =>
|
||||
client.query<{ id: string }>(`SELECT id FROM public.account_reconciliations WHERE id = $1`, [rowId]),
|
||||
)
|
||||
expect(strangerView.rows).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lets viewers read but not sign', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const rowId = await insertSignoff(companyId, userId)
|
||||
const viewer = await insertAuthUser()
|
||||
await insertCompanyMember({ companyId, userId: viewer, role: 'viewer' })
|
||||
|
||||
const viewerRead = await withUserContext(viewer, (client) =>
|
||||
client.query<{ id: string }>(`SELECT id FROM public.account_reconciliations WHERE id = $1`, [rowId]),
|
||||
)
|
||||
expect(viewerRead.rows).toHaveLength(1)
|
||||
|
||||
await expect(
|
||||
withUserContext(viewer, (client) =>
|
||||
client.query(
|
||||
`INSERT INTO public.account_reconciliations (company_id, account_key, through_date, signed_by)
|
||||
VALUES ($1, 'skattekonto', '2026-08-31', $2)`,
|
||||
[companyId, viewer],
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(/row-level security/i)
|
||||
})
|
||||
|
||||
it('lets members sign as themselves but not as someone else', async () => {
|
||||
const { userId: owner, companyId } = await seedCompany()
|
||||
const member = await insertAuthUser()
|
||||
await insertCompanyMember({ companyId, userId: member, role: 'member' })
|
||||
|
||||
const inserted = await withUserContext(member, (client) =>
|
||||
client.query<{ id: string }>(
|
||||
`INSERT INTO public.account_reconciliations (company_id, account_key, through_date, signed_by)
|
||||
VALUES ($1, 'skattekonto', '2026-08-31', $2) RETURNING id`,
|
||||
[companyId, member],
|
||||
),
|
||||
)
|
||||
expect(inserted.rows).toHaveLength(1)
|
||||
|
||||
await expect(
|
||||
withUserContext(member, (client) =>
|
||||
client.query(
|
||||
`INSERT INTO public.account_reconciliations (company_id, account_key, through_date, signed_by)
|
||||
VALUES ($1, 'skattekonto', '2026-09-30', $2)`,
|
||||
[companyId, owner],
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(/row-level security/i)
|
||||
})
|
||||
|
||||
it('lets members stamp a reopen but never delete', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const rowId = await insertSignoff(companyId, userId)
|
||||
|
||||
const reopened = await withUserContext(userId, (client) =>
|
||||
client.query<{ id: string; reopened_at: string | null }>(
|
||||
`UPDATE public.account_reconciliations
|
||||
SET reopened_at = NOW(), reopened_by = $2, reopen_reason = 'sen rad'
|
||||
WHERE id = $1 AND reopened_at IS NULL
|
||||
RETURNING id, reopened_at`,
|
||||
[rowId, userId],
|
||||
),
|
||||
)
|
||||
expect(reopened.rows).toHaveLength(1)
|
||||
expect(reopened.rows[0].reopened_at).not.toBeNull()
|
||||
|
||||
// No DELETE policy: the statement succeeds but touches nothing.
|
||||
const deleted = await withUserContext(userId, (client) =>
|
||||
client.query(`DELETE FROM public.account_reconciliations WHERE id = $1`, [rowId]),
|
||||
)
|
||||
expect(deleted.rowCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('account_reconciliations constraints', () => {
|
||||
it('rejects an account_key that is not bank:<uuid>, skattekonto or manual:NNNN', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await expect(insertSignoff(companyId, userId, { accountKey: '1930' })).rejects.toThrow(/account_key/i)
|
||||
await expect(insertSignoff(companyId, userId, { accountKey: 'bank:not-a-uuid' })).rejects.toThrow(/account_key/i)
|
||||
await expect(insertSignoff(companyId, userId, { accountKey: `bank:${randomUUID()}` })).resolves.toBeTruthy()
|
||||
await expect(insertSignoff(companyId, userId, { accountKey: 'manual:1910' })).resolves.toBeTruthy()
|
||||
})
|
||||
|
||||
it('allows one active sign-off per account and date; a reopened one frees the slot', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const first = await insertSignoff(companyId, userId, { throughDate: '2026-07-31' })
|
||||
await expect(insertSignoff(companyId, userId, { throughDate: '2026-07-31' })).rejects.toThrow(
|
||||
/ux_account_reconciliations_active|duplicate key/i,
|
||||
)
|
||||
// A different account on the same date is fine.
|
||||
await expect(
|
||||
insertSignoff(companyId, userId, { accountKey: `bank:${randomUUID()}`, throughDate: '2026-07-31' }),
|
||||
).resolves.toBeTruthy()
|
||||
|
||||
await getPool().query(
|
||||
`UPDATE public.account_reconciliations SET reopened_at = NOW(), reopened_by = $2 WHERE id = $1`,
|
||||
[first, userId],
|
||||
)
|
||||
await expect(insertSignoff(companyId, userId, { throughDate: '2026-07-31' })).resolves.toBeTruthy()
|
||||
})
|
||||
|
||||
it('requires reopened_at and reopened_by together', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const rowId = await insertSignoff(companyId, userId)
|
||||
await expect(
|
||||
getPool().query(`UPDATE public.account_reconciliations SET reopened_at = NOW() WHERE id = $1`, [rowId]),
|
||||
).rejects.toThrow(/account_reconciliations_reopen_pair/i)
|
||||
})
|
||||
})
|
||||
@@ -2492,6 +2492,8 @@ export type PendingOperationType =
|
||||
// rows to existing verifikat / clear such a link. No ledger writes.
|
||||
| 'reconciliation_match'
|
||||
| 'reconciliation_unmatch'
|
||||
// Sign-off "avstämt t.o.m. <datum>" on one account (account_reconciliations row).
|
||||
| 'reconciliation_signoff'
|
||||
// PR5: Skatteverket filing via MCP. Commit = "send for BankID signing"
|
||||
// (returns a signing link); the user's signature in the browser files it.
|
||||
| 'submit_vat_declaration'
|
||||
|
||||
Reference in New Issue
Block a user