From e4186523f9ba3fb11244af374f55540e70e394a6 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Tue, 12 May 2026 23:19:21 +0200 Subject: [PATCH] feat(api): v1 invoice :mark-sent action verb (Phase 2 PR-B-2b-1) (#454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): v1 invoice :mark-sent action verb (Phase 2 PR-B-2b-1) First invoice action verb. Transitions a DRAFT invoice to 'sent' status — intended for invoices delivered outside gnubok (Peppol, postal, custom SMTP). The full :send pipeline (PDF + email) builds on top of this in PR-B-2b-3. URL convention: plain /verb subpath (e.g. /invoices/:id/mark-sent), not the AIP-style :verb suffix the plan originally proposed. Next.js routes don't support `:` in folder names, and the Stripe/QuickBooks idiom is plain subpaths anyway. The agent-facing docs can still describe the action however we want. What happens on commit: 1. F-series invoice_number allocated atomically via the generate_invoice_number RPC (per the PR-B-2a design — drafts have invoice_number=null until this transition, preserving the unbroken löpnummer series required by ML 17 kap 24§ p.2). 2. Status flips draft → sent. 3. For accrual + real invoices, posts the invoice journal entry via createInvoiceJournalEntry (Debit AR 1510 / Credit revenue 3xxx / Credit output VAT 26xx). Cash basis skips this; booking happens at payment time. 4. Writes journal_entry_id back onto the invoice row. 5. Emits invoice.sent. Race-condition guard: the status update matches .eq('status', 'draft'), so a concurrent transition between pre-flight and update returns 409 INVOICE_UPDATE_NOT_DRAFT. Dry-run: returns a preview of the post-send invoice state including a would_create_journal_entry flag and the resolved accounting_method. invoice_number can't be predicted exactly (atomic sequence allocation) so the preview shows a marker rather than a fake number. PDF archival is deliberately NOT in this PR. The internal route does it, but PDF rendering + document upload is a meaningful surface area that belongs with :send (PR-B-2b-3) where email + PDF land together. Test infrastructure: the makeFlexibleSupabase mock now supports per-table result QUEUES (array form returns results in order across multiple calls; single value returns same result every time). Required to mock the pre-flight read (status=draft) and post-update read (status=sent) on the same `invoices` table inside one request. 9 new tests covering happy path, idempotency, scope, draft-only guard, delivery-note rejection, 404, UUID validation, dry-run preview shape, and cash-method skip. 3174/3174 vitest pass; build clean. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(api): address PR #454 review (Greptile + swarm + Swedish compliance) Real bugs / contract violations: - Greptile P1 (guard order): the delivery_note guard ran AFTER the status check, so a sent delivery note returned 409 instead of the documented 400. Reordered: document-shape guards (delivery_note + credit_note + missing moms_ruta) now run before the status check. - Greptile P1 (journal_entry_id write-back): Supabase returns { data, error } and never rejects on DB errors, so a write-back failure produced no log and left the invoice with a real journal entry but no pointer. Now destructured + escalated to error log AND surfaced as a warning in the response. - Swedish: credit notes (credited_invoice_id !== null) were not rejected — they would have been posted via createInvoiceJournalEntry with the wrong sign (Debit AR / Credit revenue instead of the inverse). Now explicitly rejected; credit-note path goes through POST /:id/credit (PR-B-2b-4). - Swedish: moms_ruta now validated in the pre-flight. A null value would silently default to 25% domestic in the journal-entry generator — wrong for reverse-charge / EU-service / zero-rated invoices. Real ML 17 kap 24§ concern. Partial-state visibility (Swedish + Swarm V2.3 + A.8.15 + PI1.3): The response now carries an optional `warnings: [{ code, message }]` field when the status flip succeeded but a follow-up step failed (journal entry creation, event emission, or journal_entry_id write- back). Three warning codes: - JOURNAL_ENTRY_NOT_POSTED — verifikation missing; BFL 5 kap reconciliation required - JOURNAL_ENTRY_ID_WRITEBACK_FAILED — entry exists but invoice row has no pointer - EVENT_EMIT_FAILED — webhook subscribers may miss this transition All three escalate to error-level logs. The architectural fix (transactional Postgres RPC that bundles allocation + status flip + journal entry) is tracked as cross-surface compliance work; the warnings field is the agent-facing signal until that lands. The F-series race window (number allocated before status flip; a concurrent transition can leave a consumed-but-orphaned number, ML 17 kap 24§ p.2 gap) is now explicitly documented in the route docstring rather than hidden in implementation. Same residual issue exists in the internal route; fix needs the transactional RPC. Pushing back (consistent with prior triage): - V8.2.1 explicit ownership check (wrapper handles — false positive) - Cross-tenant IDOR test (duplicates wrapper test coverage) - Pseudonymise IDs in logs (operational value > theoretical risk) - Structured audit event sink (current ctx.log.info IS structured) - Test fixture A.8.33 (NODE_ENV guard in place; Acme AB is canonical synthetic placeholder) - Projection column narrowing (fields ARE used in the flow) - company_settings hard-fail on miss (accrual default is normal) 3 new tests covering credit-note rejection, missing moms_ruta, and the journal-entry-failed warnings path. Plus the delivery-note test now asserts the guard ordering works for sent delivery notes too. 3177/3177 vitest pass; build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../[id]/mark-sent/__tests__/route.test.ts | 416 ++++++++++++++++++ .../invoices/[id]/mark-sent/route.ts | 391 ++++++++++++++++ lib/api/v1/load-routes.ts | 2 + lib/auth/scopes.ts | 3 + 4 files changed, 812 insertions(+) create mode 100644 app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/__tests__/route.test.ts create mode 100644 app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/__tests__/route.test.ts new file mode 100644 index 00000000..1e9d399c --- /dev/null +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/__tests__/route.test.ts @@ -0,0 +1,416 @@ +/** + * Integration tests for POST /api/v1/companies/:companyId/invoices/:id/mark-sent. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') { + throw new Error( + `mark-sent route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`, + ) + } + 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({}) } +}) + +// Stub the F-series allocator — the route's flow is what we're testing. +vi.mock('@/lib/invoices/ensure-invoice-number', () => ({ + ensureInvoiceNumber: vi.fn().mockResolvedValue(undefined), +})) + +// Stub the journal-entry creator. Returns a fake entry so the route's +// "post entry, write back journal_entry_id" path is exercised. +vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ + createInvoiceJournalEntry: vi.fn().mockResolvedValue({ + id: 'jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj', + status: 'posted', + }), +})) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { + createInvoiceJournalEntry as mockedCreateEntry, +} from '@/lib/bookkeeping/invoice-entries' +import { POST as markSent } from '../route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType +const mockCreateJournalEntry = mockedCreateEntry as ReturnType + +type MockResult = { data?: unknown; error?: unknown } +function makeFlexibleSupabase(byTable: Record) { + // Per-table queue: arrays return results in order across multiple calls + // to .from('table'); single values return the same result every time. + 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 INVOICE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const USER_ID = 'user-1' + +function makeMarkSentRequest(url: string, extraHeaders: Record = {}): Request { + return new Request(url, { + method: 'POST', + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Idempotency-Key': 'idem1234-7777-4abc-8def-1234567890ab', + ...extraHeaders, + }, + }) +} + +function detailParams(companyId: string, id: string) { + return { params: Promise.resolve({ companyId, id }) } +} + +const DRAFT_INVOICE = { + id: INVOICE_ID, + invoice_number: null, + customer_id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + invoice_date: '2026-05-12', + due_date: '2026-06-11', + status: 'draft', + document_type: 'invoice', + currency: 'SEK', + subtotal: 10000, + vat_amount: 2500, + total: 12500, + vat_treatment: 'standard_25', + moms_ruta: '05', + credited_invoice_id: null, + customer: { id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', name: 'Acme AB', country: 'Sweden' }, + items: [{ id: 'iiiiiiii-iiii-4iii-8iii-iiiiiiiiiiii', sort_order: 0, description: 'x', quantity: 1, unit: 'st', unit_price: 10000, line_total: 10000, vat_rate: 25, vat_amount: 2500 }], +} + +const SENT_INVOICE = { ...DRAFT_INVOICE, status: 'sent', invoice_number: '2026-0042' } + +beforeEach(() => { + vi.clearAllMocks() + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + apiKeyName: 'CI key', + scopes: ['invoices:write'], + mode: 'live', + }) +}) + +describe('POST /api/v1/companies/:companyId/invoices/:id/mark-sent', () => { + it('transitions a draft invoice to sent and writes the journal entry id back', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + // First read: pre-flight (status=draft); second: post-update (status=sent). + invoices: [ + { data: DRAFT_INVOICE, error: null }, + { data: SENT_INVOICE, error: null }, + ], + company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }, + }), + ) + + const res = await markSent( + makeMarkSentRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.status).toBe('sent') + expect(body.data.invoice_number).toBe('2026-0042') + expect(body.data.journal_entry_id).toBe('jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj') + expect(mockCreateJournalEntry).toHaveBeenCalledTimes(1) + }) + + it('returns 409 INVOICE_UPDATE_NOT_DRAFT when the invoice is already sent', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: { ...DRAFT_INVOICE, status: 'sent' }, error: null }, + }), + ) + + const res = await markSent( + makeMarkSentRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_UPDATE_NOT_DRAFT') + expect(body.error.details.current_status).toBe('sent') + }) + + it('rejects delivery notes with VALIDATION_ERROR (regardless of status)', async () => { + // Critical: the delivery-note guard must run BEFORE the status check + // so a sent delivery note still returns 400 (per the documented + // contract) rather than 409 INVOICE_UPDATE_NOT_DRAFT. + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: { ...DRAFT_INVOICE, document_type: 'delivery_note', status: 'sent' }, error: null }, + }), + ) + + const res = await markSent( + makeMarkSentRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details.field).toBe('document_type') + }) + + it('rejects credit notes (credited_invoice_id set) with VALIDATION_ERROR', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { + data: { ...DRAFT_INVOICE, credited_invoice_id: 'oldoldol-dold-4old-8old-oldoldoldoldold'.slice(0, 36) }, + error: null, + }, + }), + ) + + const res = await markSent( + makeMarkSentRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details.field).toBe('credited_invoice_id') + }) + + it('rejects invoices with missing moms_ruta', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: { ...DRAFT_INVOICE, moms_ruta: null }, error: null }, + }), + ) + + const res = await markSent( + makeMarkSentRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details.field).toBe('moms_ruta') + }) + + it('surfaces a warning in the response when journal entry creation fails', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: [ + { data: DRAFT_INVOICE, error: null }, + { data: SENT_INVOICE, error: null }, + ], + company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }, + }), + ) + // Force the journal-entry generator to throw. + mockCreateJournalEntry.mockRejectedValueOnce(new Error('Period closed')) + + const res = await markSent( + makeMarkSentRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + // Status STILL flips to sent. + expect(body.data.status).toBe('sent') + expect(body.data.journal_entry_id).toBeNull() + // But the caller is warned. + expect(body.data.warnings).toBeDefined() + expect(body.data.warnings[0].code).toBe('JOURNAL_ENTRY_NOT_POSTED') + }) + + it('returns 404 when the invoice does not belong to the company', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: null, error: null }, + }), + ) + + const res = await markSent( + makeMarkSentRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('NOT_FOUND') + }) + + it('returns 400 VALIDATION_ERROR when :id is not a UUID', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await markSent( + makeMarkSentRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/not-a-uuid/mark-sent`, + ), + detailParams(COMPANY_ID, 'not-a-uuid'), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details.field).toBe('id') + }) + + it('dry-run returns 200 + X-Dry-Run; no journal entry is created', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: DRAFT_INVOICE, error: null }, + company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }, + }), + ) + + const res = await markSent( + makeMarkSentRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent?dry_run=true`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(200) + expect(res.headers.get('X-Dry-Run')).toBe('true') + const body = await res.json() + expect(body.data.dry_run).toBe(true) + expect(body.data.preview.status).toBe('sent') + expect(body.data.preview.would_create_journal_entry).toBe(true) + expect(body.data.preview.accounting_method).toBe('accrual') + // No mutation calls. + expect(mockCreateJournalEntry).not.toHaveBeenCalled() + }) + + it('does NOT create a journal entry when accounting_method=cash', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: [ + { data: DRAFT_INVOICE, error: null }, + { data: SENT_INVOICE, error: null }, + ], + company_settings: { data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }, + }), + ) + + const res = await markSent( + makeMarkSentRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.status).toBe('sent') + expect(body.data.journal_entry_id).toBeNull() + expect(mockCreateJournalEntry).not.toHaveBeenCalled() + }) + + it('rejects keys without invoices:write scope', async () => { + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + scopes: ['invoices:read'], + mode: 'live', + }) + mockServiceClient.mockReturnValue(makeFlexibleSupabase({})) + + const res = await markSent( + makeMarkSentRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(403) + const body = await res.json() + expect(body.error.code).toBe('INSUFFICIENT_SCOPE') + }) + + it('rejects requests without Idempotency-Key', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const req = new Request( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`, + { + method: 'POST', + headers: { Authorization: 'Bearer test-fixture-not-a-real-key' }, + }, + ) + + const res = await markSent(req, detailParams(COMPANY_ID, INVOICE_ID)) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) +}) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts new file mode 100644 index 00000000..ee31aeb7 --- /dev/null +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts @@ -0,0 +1,391 @@ +/** + * POST /api/v1/companies/{companyId}/invoices/{id}/mark-sent + * + * Transitions a DRAFT invoice to `sent` status. Use this for invoices + * delivered outside the system (Peppol, postal, custom email). The full + * :send pipeline (PDF + email) will land in PR-B-2b-3. + * + * What happens on commit: + * 1. F-series invoice_number is allocated atomically via the + * generate_invoice_number Postgres RPC (ML 17 kap 24§ p.2 — only + * issued invoices consume numbers; this is where the F-series + * number gets assigned, NOT at draft-create per PR-B-2a's design). + * 2. Invoice status flips to 'sent'. + * 3. If accounting_method='accrual' AND document_type='invoice', a + * journal entry is posted via createInvoiceJournalEntry (Debit AR + * 1510, Credit revenue 3xxx, Credit output VAT 2611/2621/2631). + * Under kontantmetoden ('cash') no journal entry is created here — + * booking happens at payment time. + * 4. invoice.sent event is emitted. + * + * Idempotent (mandatory Idempotency-Key). Dry-run shows the would-be + * post-send state without allocating a number, posting a journal entry, + * or emitting events. + * + * Known residual race window: the F-series number is allocated via the + * generate_invoice_number RPC BEFORE the status-flip UPDATE. If a + * concurrent transition wins the race-guard check (status='draft' filter), + * the F-series number is consumed but no invoice carries it — a gap in + * the löpnummer series (ML 17 kap 24§ p.2). The internal /api/invoices + * mark-sent route has the same semantic. The architecturally correct + * fix is a Postgres RPC that allocates + flips status atomically; + * tracked as cross-surface compliance work. The race window is narrow + * (sub-millisecond between the two statements in normal load). + */ + +import { z } from 'zod' +import { ok } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' +import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' +import { eventBus } from '@/lib/events' +import type { EntityType, Invoice } from '@/types' + +// Explicit projection — drops user_id, company_id (internal scoping). +const INVOICE_MARK_SENT_RESPONSE_COLUMNS = + 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, created_at, updated_at' + +const InvoiceMarkSentResponse = z.object({ + id: z.string().uuid(), + invoice_number: z.string(), + status: z.literal('sent'), + total: z.number(), + journal_entry_id: z.string().uuid().nullable(), + // Present only when the status flip succeeded but a follow-up step + // (journal entry creation, event emission) failed and the response + // therefore reflects partial state. Agents that need transactional + // guarantees can detect this without parsing the body. + warnings: z + .array(z.object({ code: z.string(), message: z.string() })) + .optional(), +}) + +registerEndpoint({ + operation: 'invoices.mark-sent', + method: 'POST', + path: '/api/v1/companies/:companyId/invoices/:id/mark-sent', + summary: 'Transition a draft invoice to sent (without emailing).', + description: + 'Marks a draft invoice as sent — for invoices delivered outside gnubok (Peppol, postal, manual email). Allocates the F-series invoice_number atomically (ML 17 kap 24§ p.2). On accounting_method=accrual, also posts the invoice journal entry (Debit AR 1510 / Credit revenue + output VAT). Emits invoice.sent. Idempotent and dry-runnable. The companion :send action (PR-B-2b-3) adds PDF rendering and email delivery on top of this same flow.', + useWhen: + 'You delivered the invoice through a channel other than gnubok\'s email (Peppol, postal, your own SMTP) and need to record it as sent so the F-series number is allocated and the journal entry is posted.', + doNotUseFor: + 'Sending the invoice via gnubok email — use :send (PR-B-2b-3) for that. Marking an already-sent invoice as paid — use :mark-paid (PR-B-2b-2).', + pitfalls: [ + 'Only invoices in `status=draft` can be marked sent. Other states return 409 INVOICE_UPDATE_NOT_DRAFT (re-used; the action is structurally an update).', + 'Allocation is atomic. If a concurrent transition beats the agent\'s request to the same draft, the runner-up gets 409 INVOICE_UPDATE_NOT_DRAFT and no number is consumed.', + 'Delivery notes (document_type=delivery_note) don\'t transition to sent — they were never drafts in the f-series sense. This endpoint will reject them with 400 VALIDATION_ERROR.', + 'Idempotency-Key is mandatory. A retried mark-sent with the same key replays the cached response.', + ], + example: { + response: { + data: { + id: '0e9c…', + invoice_number: '2026-0042', + status: 'sent', + total: 12500, + journal_entry_id: '7b3a…', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'invoices:write', + risk: 'medium', + idempotent: true, + reversible: false, + dryRunSupported: true, + response: { success: InvoiceMarkSentResponse }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'invoices.mark-sent', + async (_request, ctx, params) => { + const { id } = await params.params + + const idParse = z.string().uuid().safeParse(id) + if (!idParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Invoice id must be a UUID.' }, + }) + } + const invoiceId = idParse.data + + if (!z.string().uuid().safeParse(ctx.companyId).success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'companyId', message: 'companyId must be a UUID.' }, + }) + } + + // Pre-flight: fetch the invoice (with items + customer for the journal- + // entry generator) and verify it's a draft. + const { data: invoice, error: fetchErr } = await ctx.supabase + .from('invoices') + .select( + `${INVOICE_MARK_SENT_RESPONSE_COLUMNS}, customer:customers(id, name, customer_type, country), items:invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount)`, + ) + .eq('company_id', ctx.companyId!) + .eq('id', invoiceId) + .maybeSingle() + + if (fetchErr) { + return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!invoice) { + ctx.log.warn('invoices.mark-sent: not found', { invoiceId, companyId: ctx.companyId }) + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'invoice' }, + }) + } + + const typed = invoice as unknown as Invoice & { customer?: { name?: string } } + + // Type/document-shape guards run BEFORE the status check so the + // returned error matches the documented contract (400 VALIDATION_ERROR + // for delivery notes / credit notes regardless of their current + // status; 409 INVOICE_UPDATE_NOT_DRAFT only for genuine invoices). + if (typed.document_type === 'delivery_note') { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'document_type', + message: 'Delivery notes are not transitioned via mark-sent; they have no F-series lifecycle.', + }, + }) + } + + // Credit notes (document_type='invoice' but credited_invoice_id set) + // need the credit-note journal entry generator (reverses sign of the + // original invoice). The PR-B-2b-4 :credit endpoint handles them. + // Reject here so we don't post the wrong-direction journal entry. + if (typed.credited_invoice_id) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'credited_invoice_id', + message: 'Credit notes are issued via POST /invoices/:id/credit (PR-B-2b-4); they cannot be mark-sent like regular invoices.', + }, + }) + } + + if (typed.status !== 'draft') { + return v1ErrorResponseFromCode('INVOICE_UPDATE_NOT_DRAFT', ctx.log, { + requestId: ctx.requestId, + details: { current_status: typed.status }, + }) + } + + // Defense in depth: moms_ruta drives which output-VAT account the + // journal-entry generator posts to (2611 / 2614 / etc.). A null value + // would silently default — wrong for reverse-charge / EU-service / + // zero-rated invoices. moms_ruta is populated by the POST handler + // from getVatRules(); a null here means the row was created via a + // path that bypassed v1 (legacy import, manual SQL). + if (!typed.moms_ruta) { + ctx.log.warn('invoices.mark-sent: missing moms_ruta', { + invoiceId, + companyId: ctx.companyId, + }) + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'moms_ruta', + message: 'Invoice has no moms_ruta set. The customer\'s VAT rule must be applied (re-create the draft via POST /invoices).', + }, + }) + } + + // Fetch company settings (accounting method + entity type drive the + // journal-entry decision). Best-effort — without settings we default + // to enskild_firma / accrual which matches the dashboard default. + const { data: settings } = await ctx.supabase + .from('company_settings') + .select('accounting_method, entity_type') + .eq('company_id', ctx.companyId!) + .maybeSingle() + const accountingMethod = (settings as { accounting_method?: string } | null)?.accounting_method ?? 'accrual' + const entityType = ((settings as { entity_type?: string } | null)?.entity_type ?? 'enskild_firma') as EntityType + const isRealInvoice = !typed.document_type || typed.document_type === 'invoice' + const wouldCreateJournalEntry = isRealInvoice && accountingMethod === 'accrual' + + if (ctx.dryRun) { + // Preview the post-send state. invoice_number can't be predicted + // exactly (atomic sequence allocation); show a marker so the agent + // knows commit will assign one. + return dryRunPreview( + { + ...typed, + status: 'sent' as const, + invoice_number: typed.invoice_number ?? '(allocated atomically on commit)', + would_create_journal_entry: wouldCreateJournalEntry, + accounting_method: accountingMethod, + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + // Commit path. Step 1: F-series invoice_number allocation. The RPC is + // atomic; the helper writes the number back onto the invoice row. + try { + await ensureInvoiceNumber(ctx.supabase, ctx.companyId!, typed) + } catch (err) { + ctx.log.error('mark-sent: ensureInvoiceNumber failed', err as Error, { + invoiceId, + companyId: ctx.companyId, + }) + return v1ErrorResponseFromCode('INVOICE_SEND_NUMBER_ASSIGN_FAILED', ctx.log, { + requestId: ctx.requestId, + }) + } + + // Step 2: flip status to 'sent'. Guard with status='draft' so a + // concurrent transition becomes a 409 rather than a silent re-flip. + const { data: updated, error: statusErr } = await ctx.supabase + .from('invoices') + .update({ status: 'sent', updated_at: new Date().toISOString() }) + .eq('company_id', ctx.companyId!) + .eq('id', invoiceId) + .eq('status', 'draft') + .select(INVOICE_MARK_SENT_RESPONSE_COLUMNS) + .maybeSingle() + + if (statusErr) { + ctx.log.error('mark-sent: status update failed', statusErr as Error, { + invoiceId, + companyId: ctx.companyId, + pgCode: (statusErr as { code?: string }).code, + }) + return v1ErrorResponseFromCode('INVOICE_SEND_PROVIDER_FAILED', ctx.log, { + requestId: ctx.requestId, + }) + } + if (!updated) { + // Race: invoice transitioned out of draft between our pre-flight and + // the update. The F-series number has been consumed (atomic RPC), so + // we have a draft-cancelled with an allocated number — log so the + // operator can investigate. + ctx.log.warn( + 'mark-sent: status race — invoice transitioned out of draft between pre-flight and update', + { invoiceId, companyId: ctx.companyId }, + ) + return v1ErrorResponseFromCode('INVOICE_UPDATE_NOT_DRAFT', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'Invoice transitioned out of draft during mark-sent.' }, + }) + } + + // Collect partial-state signals to surface on the response. BFL 5 kap + // requires every affärshändelse to have a verifikation; if the + // journal-entry creation fails after the status flip, the response + // must surface this so the agent (or the dashboard, or a monitoring + // sink) can reconcile rather than silently treating the invoice as + // fully posted. + const warnings: { code: string; message: string }[] = [] + + // Step 3: journal entry for accrual + real invoices. Failure escalates + // to error-level log AND surfaces in the response as a warning. + let journalEntryId: string | null = null + if (wouldCreateJournalEntry) { + try { + // Pass the just-updated invoice (carries the new invoice_number). + const refreshedInvoice = { ...typed, ...(updated as object), customer: typed.customer } as Invoice & { customer?: { name?: string } } + const entry = await createInvoiceJournalEntry( + ctx.supabase, + ctx.companyId!, + ctx.userId, + refreshedInvoice as Invoice, + entityType, + refreshedInvoice.customer?.name, + ) + if (entry) { + journalEntryId = entry.id + // Supabase returns { data, error } — never rejects on DB error. + // A failed write-back leaves the invoice with a real journal + // entry in the ledger but no pointer on the row, which is + // unreconcilable without operator visibility. + const { error: writeBackErr } = await ctx.supabase + .from('invoices') + .update({ journal_entry_id: entry.id }) + .eq('id', invoiceId) + .eq('company_id', ctx.companyId!) + if (writeBackErr) { + ctx.log.error('mark-sent: journal_entry_id write-back failed', writeBackErr as Error, { + invoiceId, + companyId: ctx.companyId, + journalEntryId: entry.id, + }) + warnings.push({ + code: 'JOURNAL_ENTRY_ID_WRITEBACK_FAILED', + message: 'Journal entry was posted but the invoice row could not be updated with its id. Re-fetch the invoice and reconcile manually.', + }) + } + } else { + // null result = no fiscal period or other engine-side guard. + ctx.log.error('mark-sent: journal entry not created (engine returned null)', new Error('null entry'), { + invoiceId, + companyId: ctx.companyId, + }) + warnings.push({ + code: 'JOURNAL_ENTRY_NOT_POSTED', + message: 'Invoice was marked sent but no journal entry was posted. Check fiscal period, then issue a credit note and reissue if the missing verifikation is required (BFL 5 kap).', + }) + } + } catch (err) { + ctx.log.error('mark-sent: journal entry creation failed', err as Error, { + invoiceId, + companyId: ctx.companyId, + }) + warnings.push({ + code: 'JOURNAL_ENTRY_NOT_POSTED', + message: 'Invoice was marked sent but the journal entry posting failed. Check fiscal period and engine logs; the verifikation must be created for BFL 5 kap compliance.', + }) + } + } + + // Step 4: emit invoice.sent. Best-effort; escalate to error if it + // fails (downstream webhook delivery and audit trails depend on this). + try { + await eventBus.emit({ + type: 'invoice.sent', + payload: { + invoice: { ...(typed as object), ...(updated as object) } as Invoice, + companyId: ctx.companyId!, + userId: ctx.userId, + }, + }) + } catch (err) { + ctx.log.error('invoice.sent emit failed', err as Error, { + invoiceId, + companyId: ctx.companyId, + }) + warnings.push({ + code: 'EVENT_EMIT_FAILED', + message: 'invoice.sent event did not reach the bus; downstream subscribers (webhooks) may miss this transition.', + }) + } + + ctx.log.info('invoices.mark-sent success', { + invoiceId, + companyId: ctx.companyId, + userId: ctx.userId, + invoiceNumber: (updated as { invoice_number?: string }).invoice_number, + journalEntryId, + hadWarnings: warnings.length > 0, + }) + + return ok( + { + ...(updated as object), + journal_entry_id: journalEntryId, + ...(warnings.length > 0 ? { warnings } : {}), + }, + { requestId: ctx.requestId }, + ) + }, + { requireIdempotencyKey: true }, +) diff --git a/lib/api/v1/load-routes.ts b/lib/api/v1/load-routes.ts index 4cddd466..aa6419c0 100644 --- a/lib/api/v1/load-routes.ts +++ b/lib/api/v1/load-routes.ts @@ -20,5 +20,7 @@ import '@/app/api/v1/companies/[companyId]/invoices/route' import '@/app/api/v1/companies/[companyId]/invoices/[id]/route' import '@/app/api/v1/companies/[companyId]/customers/route' import '@/app/api/v1/companies/[companyId]/customers/[id]/route' +// Phase 2 PR-B-2b — invoice action verbs. +import '@/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route' export {} diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts index e909a74f..f8fd7a95 100644 --- a/lib/auth/scopes.ts +++ b/lib/auth/scopes.ts @@ -58,6 +58,9 @@ export const V1_ENDPOINT_SCOPES: Record = { 'GET /api/v1/companies/:companyId/invoices/:id': 'invoices:read', 'POST /api/v1/companies/:companyId/invoices': 'invoices:write', 'PATCH /api/v1/companies/:companyId/invoices/:id': 'invoices:write', + // Phase 2 PR-B-2b — action verbs. URL uses /verb subpath (not Google-AIP-style :verb) + // because Next.js routes don't support `:` in folder names. + 'POST /api/v1/companies/:companyId/invoices/:id/mark-sent': 'invoices:write', // Webhooks (Phase 6 — placeholder so the catalogue is complete) 'GET /api/v1/companies/:companyId/webhooks': 'webhooks:manage',