diff --git a/DECISIONS.md b/DECISIONS.md index 888b0dfe..8649252d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1179,3 +1179,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/reconciliation/page.tsx b/app/(dashboard)/reconciliation/page.tsx index 3190d0ba..58aced2c 100644 --- a/app/(dashboard)/reconciliation/page.tsx +++ b/app/(dashboard)/reconciliation/page.tsx @@ -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. - + ) } diff --git a/app/api/reconciliation/accounts/[accountKey]/signoff/[signoffId]/reopen/route.ts b/app/api/reconciliation/accounts/[accountKey]/signoff/[signoffId]/reopen/route.ts new file mode 100644 index 00000000..4b1e8c07 --- /dev/null +++ b/app/api/reconciliation/accounts/[accountKey]/signoff/[signoffId]/reopen/route.ts @@ -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 }, +) diff --git a/app/api/reconciliation/accounts/[accountKey]/signoff/route.ts b/app/api/reconciliation/accounts/[accountKey]/signoff/route.ts new file mode 100644 index 00000000..888c404a --- /dev/null +++ b/app/api/reconciliation/accounts/[accountKey]/signoff/route.ts @@ -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. ". 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 }, +) diff --git a/app/api/reconciliation/accounts/__tests__/signoff-route.test.ts b/app/api/reconciliation/accounts/__tests__/signoff-route.test.ts new file mode 100644 index 00000000..de528576 --- /dev/null +++ b/app/api/reconciliation/accounts/__tests__/signoff-route.test.ts @@ -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('@/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) => ({ 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) + }) +}) diff --git a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/[signoffId]/reopen/route.ts b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/[signoffId]/reopen/route.ts new file mode 100644 index 00000000..98353965 --- /dev/null +++ b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/[signoffId]/reopen/route.ts @@ -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 }, +) diff --git a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/route.ts b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/route.ts new file mode 100644 index 00000000..e11ee0ac --- /dev/null +++ b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/route.ts @@ -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. + * "). 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 }, +) diff --git a/app/api/v1/companies/[companyId]/reconciliation/accounts/__tests__/signoff-route.test.ts b/app/api/v1/companies/[companyId]/reconciliation/accounts/__tests__/signoff-route.test.ts new file mode 100644 index 00000000..f77496aa --- /dev/null +++ b/app/api/v1/companies/[companyId]/reconciliation/accounts/__tests__/signoff-route.test.ts @@ -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('@/lib/auth/api-keys') + return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() } +}) +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@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('@/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 +const mockServiceClient = createServiceClientNoCookies as ReturnType + +type MockResult = { data?: unknown; error?: unknown } +function makeFlexibleSupabase(byTable: Record) { + const queues = new Map() + for (const [t, val] of Object.entries(byTable)) queues.set(t, Array.isArray(val) ? [...val] : [val]) + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + 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 = { + 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 = {}) => + ({ 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) + }) +}) diff --git a/components/dashboard/AttGoraSection.tsx b/components/dashboard/AttGoraSection.tsx index 3c7f0858..0bb42d58 100644 --- a/components/dashboard/AttGoraSection.tsx +++ b/components/dashboard/AttGoraSection.tsx @@ -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 && ( + + )} {expiringBankConnections.length > 0 && ( import('@/components/skattekonto/SkattekontoBookDialog'), @@ -57,13 +58,20 @@ const FOLDED_BY_DEFAULT: ReadonlySet = 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(null) const [unfolded, setUnfolded] = useState>(new Set()) const [bookRow, setBookRow] = useState(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 (
{/* Tiles: label + number, nothing else. */} @@ -348,6 +392,23 @@ export function AccountOverview({ account, onChanged }: AccountOverviewProps) {

{t('reconciled_line')}

) : null} + {status.signoff && ( +

+ + {t('signed_off_line', { date: formatDate(status.signoff.through_date), when: formatDate(status.signoff.signed_at) })} + {status.signoff.note && · {t('signed_off_forced')}} + + +

+ )} + {/* Bridge: how the difference is explained. */} {status.bridge.length > 0 && (
@@ -389,6 +450,11 @@ export function AccountOverview({ account, onChanged }: AccountOverviewProps) { {t('action_run_bank_matcher')} )} + {signoffEnabled && ( + + )} {isSkv ? t('action_open_skattekonto') : t('action_open_bank_view')} @@ -504,6 +570,17 @@ export function AccountOverview({ account, onChanged }: AccountOverviewProps) { }} /> )} + +
) } diff --git a/components/reconciliation/ReconciliationRail.tsx b/components/reconciliation/ReconciliationRail.tsx index 86a108c3..c894076a 100644 --- a/components/reconciliation/ReconciliationRail.tsx +++ b/components/reconciliation/ReconciliationRail.tsx @@ -100,7 +100,11 @@ export function ReconciliationRail({ accounts, selectedKey, onSelect }: Reconcil {account.account_number} {' · '} - {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')} diff --git a/components/reconciliation/ReconciliationWorkspace.tsx b/components/reconciliation/ReconciliationWorkspace.tsx index adb5df10..de18a048 100644 --- a/components/reconciliation/ReconciliationWorkspace.tsx +++ b/components/reconciliation/ReconciliationWorkspace.tsx @@ -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(null) const [loadError, setLoadError] = useState(false) + const [periodId, setPeriodId] = useState(null) + const [periodBounds, setPeriodBounds] = useState<{ start: string; end: string } | null>(null) + const [dateRange, setDateRange] = useState({}) + + // The effective window: the range within the period, defaulting to the + // period bounds. Null until the period picker has resolved. + const window = useMemo(() => { + 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() {

{t('help_text')}

} + action={ +
+ { + 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 && ( + + )} +
+ } /> ) @@ -86,7 +145,7 @@ export function ReconciliationWorkspace() { ) } - if (accounts === null) { + if (accounts === null || !window) { return (
{header} @@ -124,7 +183,14 @@ export function ReconciliationWorkspace() {
- {selected && void load()} />} + {selected && ( + void load()} + /> + )}
diff --git a/components/reconciliation/SignoffDialog.tsx b/components/reconciliation/SignoffDialog.tsx new file mode 100644 index 00000000..2144bccb --- /dev/null +++ b/components/reconciliation/SignoffDialog.tsx @@ -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 +} + +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(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 ( + + + + {t('signoff_title')} + {t('signoff_body', { account: accountName })} + +
+
+ + setDate(e.target.value)} + className="tabular-nums" + /> +
+ {needsForce && ( +
+

+ {unexplained == null + ? t('tile_unknown') + : t('signoff_unexplained_warning', { amount: formatCurrency(unexplained, currency) })} +

+ +
+ )} +
+ +