diff --git a/DECISIONS.md b/DECISIONS.md index 0810837b..639acb57 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1188,6 +1188,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-24] Declared currency/voucher_series nullable in three MCP listing schemas on column-nullability alone (no traced null producer): loosening an output schema can only stop false validation failures, never cause one, and legacy rows predate the columns' defaults. Declined (for now) a full Ajv execute-vs-schema round-trip harness in output-schema.test.ts: right long-term answer to this bug class, but a session-sized project of its own; the audit's seven confirmed sites are pinned by a targeted declaration test instead. [2026-08-24] Manual matching (PR 6b) ships N:1 only (many outside rows -> one verifikat): bank links are independent per transaction (the engine allows it by design), skattekonto groups are all-or-nothing with the sum settling the verifikat (one guarded UPDATE, partial hit rolled back). 1:M (one row over several verifikat) and residual booking wait for a link table in 6c: the single journal_entry_id pointer on both row kinds cannot express them, and faking it (pointing the row at the residual verifikat) would break the bridge. The worksheet therefore enables Koppla only when the selection nets to zero and says so otherwise. [2026-08-24] Skattekonto payment file gets pain.001 through the supplier-payment generator (generateSupplierPain001), not the salary pain001 generator: the payment is a plain BG+OCR giro transfer (no SALA CtgyPurp), and the supplier dialect is the Validex-validated shape for exactly that; the LB path stays the default so nothing changes for banks still on LB. +[2026-08-24] Residual booking (6c) reuses transaction_voucher_links instead of a new link table: the junction already models one transaction against several verifikat (bulk-book), and both GL RPCs plus the TS bridge now treat it as a link, which also stops samlingsverifikat from polluting the open buckets. Residuals are bank-only (Skatteverket posts ränta/avgifter as their own rows), capped at 5 000 kr (above that it is a missing booking, not a fee), direction-checked against the kind, and the links are made before the booking and undone if the booking is refused. [2026-08-24] query_journal status default 'posted' -> 'all' (posted+reversed, the trial-balance inclusion rule) after a customer's agent summed posted-only lines over a storno-heavy Q2, found phantom VAT residuals on 2614/2641/2645/2647 and demanded a revert of correct books: one-leg sums are never balances. Funded the new status_filter_warning field and richer status docs inside the tools/list token budget by trimming sibling descriptions in the same tool rather than bumping the 59.95K ceiling (the payload guard's own guidance); warning fires off an entry-level opposite-status head count, exact for what the sentence claims and cheap, instead of re-running the line fetch. [2026-08-24] Detach-duplicate underlag ships as a SECURITY DEFINER RPC (detach_underlag_duplicate) instead of loosening the document triggers: the WORM guards stay intact for every other path, the carve-out is transaction-local (gnubok.allow_delete) and audit-logged first, and detach is refused unless another anchored underlag remains on the verifikat (BFL 5 kap 7 par) AND a remaining sibling has an identical sha256_hash (only byte-identical duplicates detach; skeptic-hardened 2026-08-24, along with an enforced posted-status guard and company_id on the audit row). Pinned docs (transactions.document_id / supplier_invoices.document_id) stay replace-only. [2026-08-24] Single-call chat console (general.help, AskConsole → /api/agent/ask) now carries the thread's earlier turns into every model call, via a new optional `history` on the provider-agnostic GenerateTextRequest (real message turns before the prompt in BOTH adapters: Anthropic-family messages array, OpenAI-compatible via AI SDK `messages`; an absent/empty history leaves the request byte-identical to the single-turn call, so hosted extraction and every other caller are untouched). The 08-20 RIP-3 cutover made each turn stateless (conversationId was only the tool actor id), so a follow-up in a resumed thread was answered blind (user report: "frågar vad jag refererar till"). History is loaded server-side from agent_messages (loadChatHistory: text only, hidden + tool rows dropped, alternation repaired, newest 16 rows / 10k chars) rather than sent by the client, so the client cannot forge earlier turns and old streaming threads replay cleanly. Rejected: inlining a transcript into the prompt (works everywhere but weaker turn semantics and blurs data vs instructions) and loading history in AskConsole (client-trusted history). Separately: the docked assistant panel now remembers its open thread per tab in sessionStorage (lib/agent-panel/session-restore) and reopens it after a full reload (the deploy prompt's "Ladda om" wiped it); sessionStorage, not user_preferences, because this is this-tab-this-session state that must not follow the user to other devices or tabs. And DeployReloadPrompt's full-width wrapper gets pointer-events-none: at z-[60] after the panel in DOM order it swallowed clicks on the panel's composer ("går ej att skriva"). diff --git a/app/api/reconciliation/accounts/[accountKey]/residual/route.ts b/app/api/reconciliation/accounts/[accountKey]/residual/route.ts new file mode 100644 index 00000000..1896f912 --- /dev/null +++ b/app/api/reconciliation/accounts/[accountKey]/residual/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { withRouteContext } from '@/lib/api/with-route-context' +import { AccountKeySchema } from '@/lib/reconciliation/schemas' +import { bookResidualAndLink, ReconciliationResidualError } from '@/lib/reconciliation/residual' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { ISO_DATE_RE } from '@/lib/invariants' +import { ensureInitialized } from '@/lib/init' + +// Booking a residual commits a verifikat; the engine emits journal_entry +// events that extension handlers subscribe to. +ensureInitialized() + +const ResidualBodySchema = z.object({ + external_ids: z.array(z.string().uuid()).min(1).max(50), + journal_entry_id: z.string().uuid(), + kind: z.enum(['bank_fee', 'rounding', 'interest_income', 'interest_expense']), + entry_date: z.string().regex(ISO_DATE_RE).optional(), + description: z.string().max(200).optional(), + dry_run: z.boolean().optional(), +}) + +/** + * POST /api/reconciliation/accounts/{accountKey}/residual + * + * The worksheet's "bokför mellanskillnaden och koppla": books the remainder + * of a selection (bank rows vs one verifikat) as a bank fee / interest / + * rounding verifikat and links the selection. Bank accounts only. + */ +export const POST = withRouteContext<{ params: Promise<{ accountKey: string }> }>( + 'reconciliation.accounts.residual', + 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 = ResidualBodySchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: 'Ogiltig body: external_ids, journal_entry_id och kind krävs' }, { status: 400 }) + } + try { + const result = await bookResidualAndLink( + supabase, + companyId, + user.id, + accountKey, + { + external_ids: parsed.data.external_ids, + journal_entry_id: parsed.data.journal_entry_id, + kind: parsed.data.kind, + entry_date: parsed.data.entry_date, + description: parsed.data.description, + }, + { 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 ReconciliationResidualError) { + const status = + err.code === 'RESIDUAL_ROWS_NOT_FOUND' || err.code === 'RESIDUAL_ENTRY_NOT_FOUND' ? 404 : 400 + return NextResponse.json({ error: getErrorMessage(err), code: err.code }, { status }) + } + throw err + } + }, + { requireWrite: true }, +) diff --git a/app/api/reconciliation/accounts/__tests__/residual-route.test.ts b/app/api/reconciliation/accounts/__tests__/residual-route.test.ts new file mode 100644 index 00000000..45ef346d --- /dev/null +++ b/app/api/reconciliation/accounts/__tests__/residual-route.test.ts @@ -0,0 +1,98 @@ +/** + * Tests for POST /api/reconciliation/accounts/{accountKey}/residual (cookie + * session, withRouteContext). The residual engine 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 residualMock = vi.fn() +vi.mock('@/lib/reconciliation/residual', async () => { + const actual = await vi.importActual('@/lib/reconciliation/residual') + return { ...actual, bookResidualAndLink: (...args: unknown[]) => residualMock(...args) } +}) + +import { ReconciliationResidualError } from '@/lib/reconciliation/residual' +import { POST } from '../[accountKey]/residual/route' + +const CASH = '11111111-1111-4111-8111-111111111111' +const KEY = `bank:${CASH}` +const T1 = '22222222-2222-4222-8222-222222222222' +const E1 = '44444444-4444-4444-8444-444444444444' +const URL = `http://localhost/api/reconciliation/accounts/${KEY}/residual` +const p = (obj: Record) => ({ params: Promise.resolve(obj) }) as never +const body = { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' } + +describe('POST /api/reconciliation/accounts/{accountKey}/residual', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + residualMock.mockResolvedValue({ dry_run: false, residual_journal_entry_id: 'res-1', residual_amount: -10, applied: [], skipped: [] }) + }) + + it('401 without a session', async () => { + requireAuthMock.mockResolvedValue({ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }) + const res = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: KEY })) + expect(res.status).toBe(401) + }) + + it('400 on a body without kind, 404 on a malformed key', async () => { + const bad = await POST(createMockRequest(URL, { method: 'POST', body: { external_ids: [T1], journal_entry_id: E1 } }), p({ accountKey: KEY })) + expect(bad.status).toBe(400) + const badKey = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: '1930' })) + expect(badKey.status).toBe(404) + expect(residualMock).not.toHaveBeenCalled() + }) + + it('books and links, forwarding dry_run', async () => { + const res = await POST(createMockRequest(URL, { method: 'POST', body: { ...body, dry_run: true } }), p({ accountKey: KEY })) + expect(res.status).toBe(200) + expect(residualMock).toHaveBeenCalledWith( + supabase, + 'company-1', + 'user-1', + KEY, + { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee', entry_date: undefined, description: undefined }, + { dryRun: true }, + ) + const { body: out } = await parseJsonResponse<{ data: { residual_journal_entry_id: string } }>(res) + expect(out.data.residual_journal_entry_id).toBe('res-1') + }) + + it('maps refusals to 400 + code, missing rows to 404, unknown account to 404, and requires write', async () => { + residualMock.mockRejectedValueOnce(new ReconciliationResidualError('för stort', 'RESIDUAL_TOO_LARGE')) + const refused = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: KEY })) + expect(refused.status).toBe(400) + expect((await parseJsonResponse<{ code: string }>(refused)).body.code).toBe('RESIDUAL_TOO_LARGE') + + residualMock.mockRejectedValueOnce(new ReconciliationResidualError('saknas', 'RESIDUAL_ROWS_NOT_FOUND')) + const missingRows = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: KEY })) + expect(missingRows.status).toBe(404) + + residualMock.mockResolvedValueOnce(null) + const unknown = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: KEY })) + expect(unknown.status).toBe(404) + + requireWriteMock.mockResolvedValue({ ok: false, response: NextResponse.json({ error: 'Läsbehörighet' }, { status: 403 }) }) + const forbidden = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: KEY })) + expect(forbidden.status).toBe(403) + }) +}) diff --git a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/residual/route.ts b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/residual/route.ts new file mode 100644 index 00000000..c1588d1c --- /dev/null +++ b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/residual/route.ts @@ -0,0 +1,147 @@ +/** + * POST /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/residual + * + * Book the remainder of a bank selection (N transactions vs one verifikat) + * as a small verifikat (bank fee / interest / rounding) and link the + * selection in the same call. Writes to the ledger: transactions:write, the + * same scope that books a bank transaction. Dry-runnable; Idempotency-Key + * required. + */ +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 } from '@/lib/reconciliation/schemas' +import { bookResidualAndLink, ReconciliationResidualError, RESIDUAL_MAX_AMOUNT } from '@/lib/reconciliation/residual' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { ISO_DATE_RE } from '@/lib/invariants' +import { ensureInitialized } from '@/lib/init' + +ensureInitialized() + +const ResidualRequest = z.object({ + external_ids: z.array(z.string().uuid()).min(1).max(50), + journal_entry_id: z.string().uuid(), + kind: z.enum(['bank_fee', 'rounding', 'interest_income', 'interest_expense']), + entry_date: z.string().regex(ISO_DATE_RE).optional(), + description: z.string().max(200).optional(), +}) + +const LineSchema = z.object({ account_number: z.string(), debit_amount: z.number(), credit_amount: z.number() }) + +const ResidualResponse = z.object({ + dry_run: z.boolean(), + residual_journal_entry_id: z.string().optional(), + residual_amount: z.number().optional(), + applied: z.array(z.object({ external_id: z.string(), journal_entry_id: z.string() })).optional(), + skipped: z.array(z.object({ code: z.string(), message: z.string() })).optional(), + would_book: z + .object({ + kind: z.string(), + counter_account: z.string(), + ledger_account: z.string(), + currency: z.string(), + transactions_total: z.number(), + entry_net: z.number(), + residual_amount: z.number(), + entry_date: z.string(), + description: z.string(), + lines: z.array(LineSchema), + }) + .optional(), +}) + +registerEndpoint({ + operation: 'reconciliation.accounts.residual', + method: 'POST', + path: '/api/v1/companies/:companyId/reconciliation/accounts/:accountKey/residual', + summary: 'Book the remainder of a bank selection as a fee/interest/rounding verifikat and link the selection.', + description: + `Body: { external_ids: [transaction ids], journal_entry_id, kind: "bank_fee" | "interest_expense" | "interest_income" | "rounding", entry_date?, description? }. Computes the difference between the transactions' sum and the verifikat's net on the bank account, books it on 6570 / 8410 / 8310 / 3740 against the bank account (dated on the latest transaction by default), links the transactions to the main verifikat and anchors the residual verifikat through transaction_voucher_links. Bank accounts only (bank:). Refused when the difference is 0 (RESIDUAL_ZERO), above ${RESIDUAL_MAX_AMOUNT} kr (RESIDUAL_TOO_LARGE: that is a missing booking, not a fee), or when the kind points the wrong way (RESIDUAL_DIRECTION). ?dry_run=true returns would_book without writing.`, + useWhen: + 'A manual match misses by a small amount that is genuinely a bank fee, interest or rounding, and you want to close it in one step instead of booking a verifikat and then linking.', + doNotUseFor: + 'Skattekonto rows (Skatteverket posts ränta and avgifter as their own rows: link them), or differences that are really a missing booking (book that properly).', + pitfalls: [ + 'The kind must match the direction: money that left the bank unbooked is bank_fee / interest_expense; money that arrived unbooked is interest_income; rounding works either way.', + 'Links are made before the booking and undone if the booking is refused (a locked period), so a refusal leaves nothing half done.', + 'Idempotency-Key is required; repeating the same key replays the first response.', + ], + example: { + request: { external_ids: ['22222222-2222-4222-8222-222222222222'], journal_entry_id: '44444444-4444-4444-8444-444444444444', kind: 'bank_fee' }, + response: { + data: { + dry_run: false, + residual_journal_entry_id: '55555555-5555-4555-8555-555555555555', + residual_amount: -10, + applied: [{ external_id: '22222222-2222-4222-8222-222222222222', journal_entry_id: '44444444-4444-4444-8444-444444444444' }], + skipped: [], + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'transactions:write', + risk: 'medium', + idempotent: false, + reversible: false, + dryRunSupported: true, + request: { body: ResidualRequest }, + response: { success: dataEnvelope(ResidualResponse) }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; accountKey: string }> }>( + 'reconciliation.accounts.residual', + 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 = ResidualRequest.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 bookResidualAndLink(ctx.supabase, ctx.companyId!, ctx.userId, accountKey, parsed.data, { + 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 ReconciliationResidualError) { + const v1Code = + err.code === 'RESIDUAL_ROWS_NOT_FOUND' || err.code === 'RESIDUAL_ENTRY_NOT_FOUND' ? 'NOT_FOUND' : '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__/residual-route.test.ts b/app/api/v1/companies/[companyId]/reconciliation/accounts/__tests__/residual-route.test.ts new file mode 100644 index 00000000..78bcf795 --- /dev/null +++ b/app/api/v1/companies/[companyId]/reconciliation/accounts/__tests__/residual-route.test.ts @@ -0,0 +1,129 @@ +/** + * Tests for POST /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/residual: + * real withApiV1 wrapper (auth, scope, membership, idempotency, dry-run), engine 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({}) } +}) +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +const { residualMock } = vi.hoisted(() => ({ residualMock: vi.fn() })) +vi.mock('@/lib/reconciliation/residual', async () => { + const actual = await vi.importActual('@/lib/reconciliation/residual') + return { ...actual, bookResidualAndLink: residualMock } +}) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { ReconciliationResidualError } from '@/lib/reconciliation/residual' +import { POST } from '../[accountKey]/residual/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 CASH = '11111111-1111-4111-8111-111111111111' +const KEY = `bank:${CASH}` +const T1 = '22222222-2222-4222-8222-222222222222' +const E1 = '44444444-4444-4444-8444-444444444444' +const URL = `http://localhost/api/v1/companies/${COMPANY_ID}/reconciliation/accounts/${KEY}/residual` +const body = { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' } + +function req(init: { 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) 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: 'POST', 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 = (accountKey = KEY) => ({ params: Promise.resolve({ companyId: COMPANY_ID, accountKey }) }) as never + +describe('v1 reconciliation residual', () => { + beforeEach(() => { + vi.clearAllMocks() + mockServiceClient.mockReturnValue(makeFlexibleSupabase({ company_members: { data: { role: 'owner' } }, idempotency_keys: { data: null } })) + residualMock.mockResolvedValue({ dry_run: false, residual_journal_entry_id: 'res-1', residual_amount: -10, applied: [{ external_id: T1, journal_entry_id: E1 }], skipped: [] }) + }) + + it('401 without a valid key; 403 without transactions:write', async () => { + mockValidate.mockResolvedValue({ valid: false, error: 'invalid' }) + expect((await POST(req({ body }), params())).status).toBe(401) + authOk(['reconciliation:write']) + expect((await POST(req({ body }), params())).status).toBe(403) + }) + + it('needs an Idempotency-Key, validates the body, 404s a bad key', async () => { + authOk(['transactions:write']) + expect((await POST(req({ body, idem: false }), params())).status).toBe(400) + expect((await POST(req({ body: { external_ids: [T1] } }), params())).status).toBe(400) + expect((await POST(req({ body }), params('1930'))).status).toBe(404) + expect(residualMock).not.toHaveBeenCalled() + }) + + it('books and links, and previews on dry run', async () => { + authOk(['transactions:write']) + const res = await POST(req({ body }), params()) + expect(res.status).toBe(200) + expect((await res.json()).data.residual_journal_entry_id).toBe('res-1') + expect(residualMock).toHaveBeenCalledWith(expect.anything(), COMPANY_ID, 'user-1', KEY, body, { dryRun: false }) + + residualMock.mockResolvedValue({ dry_run: true, would_book: { residual_amount: -10 } }) + const dry = await POST(req({ body, dryRun: true }), params()) + expect(dry.status).toBe(200) + expect(residualMock).toHaveBeenLastCalledWith(expect.anything(), COMPANY_ID, 'user-1', KEY, body, { dryRun: true }) + }) + + it('maps refusals to VALIDATION_ERROR with the residual code, and missing rows to NOT_FOUND', async () => { + authOk(['transactions:write']) + residualMock.mockRejectedValueOnce(new ReconciliationResidualError('fel riktning', 'RESIDUAL_DIRECTION')) + const refused = await POST(req({ body }), params()) + expect(refused.status).toBe(400) + const out = await refused.json() + expect(out.error.code).toBe('VALIDATION_ERROR') + expect(out.error.details.code).toBe('RESIDUAL_DIRECTION') + + residualMock.mockRejectedValueOnce(new ReconciliationResidualError('saknas', 'RESIDUAL_ENTRY_NOT_FOUND')) + expect((await POST(req({ body }), params())).status).toBe(404) + }) +}) diff --git a/components/reconciliation/ManualMatchMode.tsx b/components/reconciliation/ManualMatchMode.tsx index af518554..930295f6 100644 --- a/components/reconciliation/ManualMatchMode.tsx +++ b/components/reconciliation/ManualMatchMode.tsx @@ -14,6 +14,7 @@ import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-m import { roundOre } from '@/lib/money' import type { ReconciliationAccount, ReconciliationItem } from '@/lib/reconciliation/schemas' import type { ReconciliationWindow } from './AccountOverview' +import type { ResidualKind } from '@/lib/reconciliation/residual' /** * "Matcha manuellt": the two-pane worksheet from the approved design. Left: @@ -43,6 +44,7 @@ export function ManualMatchMode({ account, window, onChanged }: ManualMatchModeP const [pickedExternal, setPickedExternal] = useState>(new Set()) const [pickedEntry, setPickedEntry] = useState(null) const [busy, setBusy] = useState(false) + const [residualKind, setResidualKind] = useState('') const base = `/api/reconciliation/accounts/${encodeURIComponent(account.account_key)}` const isSkv = account.kind === 'skattekonto' @@ -125,6 +127,31 @@ export function ManualMatchMode({ account, window, onChanged }: ManualMatchModeP } } + async function bookResidual() { + if (!entry || pickedExternal.size === 0 || !residualKind) return + setBusy(true) + try { + const res = await fetch(`${base}/residual`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ external_ids: [...pickedExternal], journal_entry_id: entry.item_id, kind: residualKind }), + }) + const json = await res.json().catch(() => ({})) + if (!res.ok) { + toast({ title: t('toast_failed'), description: getUserErrorMessage(json, { statusCode: res.status }), variant: 'destructive' }) + return + } + toast({ title: t('toast_residual_booked', { amount: formatCurrency(Math.abs(Number(json.data?.residual_amount ?? 0)), currency) }) }) + setPickedExternal(new Set()) + setPickedEntry(null) + setResidualKind('') + await load() + onChanged() + } finally { + setBusy(false) + } + } + if (loadError) { return ( void load() }}>{t('load_failed')} @@ -269,7 +296,27 @@ export function ManualMatchMode({ account, window, onChanged }: ManualMatchModeP {t('match_difference', { amount: formatCurrency(difference, currency) })} {Math.abs(difference) >= 0.005 && pickedExternal.size > 0 && entry && ( - {t('match_hint_residual')} + isSkv ? ( + {t('match_hint_residual_skv')} + ) : ( + + + + + + ) )}