From e96cbe05d05192a80853c0ee92e15b7ed340c0bd Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Tue, 12 May 2026 22:58:56 +0200 Subject: [PATCH] feat(api): v1 invoice draft writes (Phase 2 PR-B-2a) (#453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): v1 invoice draft writes (Phase 2 PR-B-2a) POST /api/v1/companies/:companyId/invoices creates a draft invoice, proforma, or delivery note. Reuses the established v1 discipline: - Idempotency-Key mandatory (wrapper option). - Dry-runnable: ?dry_run=true returns the validated would-be invoice + computed items with VAT totals; no DB writes, no number allocation, no event emission. - Explicit column projections (no SELECT *). - Per-item VAT rate validated against the customer's allowed rates from getVatRules() — mixed-rate invoices supported. - Currency conversion via fetchExchangeRate() (best-effort, non-fatal). - F-series number allocation via ensureInvoiceNumber() with soft-cancel rollback if allocation fails — preserves sequence integrity for ML 17 kap 24§ (no gaps in F-series). - invoice.created event emitted for real invoices (not proformas / delivery notes). PATCH /api/v1/companies/:companyId/invoices/:id updates a DRAFT invoice's metadata fields only: - Allowed: invoice_date, due_date, delivery_date, your_reference, our_reference, notes. - NOT allowed (intentional): customer_id, currency, document_type, items, status. Structural changes go through delete-and-recreate (drafts are cheap); status transitions via the action verbs in PR-B-2b. - 409 INVOICE_DELETE_NOT_DRAFT if the invoice has already been sent / paid / credited / cancelled. The error code is shared with DELETE (reused rather than introducing a new "not draft" code). - Race-condition guard: the .update() also matches .eq('status', 'draft') so a concurrent :send between pre-flight and write returns the same 409. Dry-run for invoice DRAFT create uses dryRunPreview() (validation-only) rather than dryRunStaged() — drafts have no journal-entry side effects yet, so there's nothing to stage in pending_operations. The dryRunStaged() helper from PR-B-1 stays unused this PR; PR-B-2b's :send will be its first real consumer (voucher number, journal lines, account deltas). Tests: 12 new (5 POST + 7 PATCH) covering happy path, customer not found, VAT rate violation, dry-run preview shape, scope enforcement, Idempotency-Key requirement, draft-only PATCH guard, forbidden field rejection, UUID validation, empty body. Stubs ensureInvoiceNumber and fetchExchangeRate to keep tests deterministic. 3165/3165 vitest pass; build clean; lint clean on v1 paths. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(api): address PR #453 review (Greptile + swarm + Swedish compliance) Real fixes (all reviewers agreed): - Greptile P1 + SOC 2 CC6.3: PATCH was reusing INVOICE_DELETE_NOT_DRAFT (httpStatus 400) for a semantically different operation; docstrings + tests claimed 409 while code returned 400. Introduced INVOICE_UPDATE_NOT_DRAFT with httpStatus 409 in structured-errors.ts. PATCH now returns 409 consistently; test name and assertion aligned. - Greptile P1: POST rollback DELETE on items-insert failure now scoped by company_id (defense in depth) AND its error is destructured/logged so a double-failure is visible in audit trails (was previously silent on the rollback path). - Greptile P1: refetch error after invoice insert is now logged with invoiceId + companyId at warn level; the response gracefully falls back to the header-only shape rather than misleading the agent with a 5xx (the data WAS committed). GDPR Art.5(1)(f) × 2, ISO A.8.11 × 2, SOC 2 CC7.2 × 2: client-facing error responses no longer echo raw Postgres pg_message strings (which can interpolate field values from constraint detail). pg_code is kept in the response (machine-readable, no PII leak); pg_message moves to the internal structured log entry only. Applies to INVOICE_CREATE_INSERT_FAILED and INVOICE_CREATE_ITEMS_FAILED. OWASP V2.2: defensive UUID validation on ctx.companyId at POST handler entry. The wrapper already validated membership, but mirroring the detail-route's pattern for path params eliminates a class of edge-case queries with malformed predicates. Swedish compliance (ML 17 kap 24§ p.2 — most substantive finding): ensureInvoiceNumber is NO LONGER called at draft-create. The doc string already said "F-series invoice_number is allocated atomically on the first send action (PR-B-2b)" but the code contradicted it by allocating at POST. Code now matches intent: drafts (invoices and proformas) keep invoice_number=null until :send. Delivery notes continue to allocate their separate D-series number on insert (different sequence, no F-series gap concern). This eliminates the soft-cancel path entirely for the common case where a user creates and abandons a draft — no more legal gaps in the löpnummer series from ordinary workflow. Pushing back on: - Atomicity / Postgres RPC wrapping (V8.2.1 × 2, CC6.1) — substantial refactor; the existing internal /api/invoices POST has the identical multi-step pattern; not a v1 regression. Track for a future RPC- consolidation PR across both surfaces. - Float-point VAT rounding (V2.3, Swedish #3) — matches internal route precisely; consistency over premature decimal-library migration. - TOCTOU rewrite to single UPDATE-WHERE-RETURNING (V8.2.1, CC6.1) — current pre-flight + scoped UPDATE is correct; the suggested cleanup is stylistic. - PATCH response verbose projection (A.8.3, Art.25) — consistency with detail endpoint; the agent that just updated likely wants the full record back. - per-line moms_ruta (Swedish #4) — schema migration; the existing header-only column is what the codebase has. - Event emission failure alerting (A.8.15) — defer to PR-C webhooks. - Test fixture A.8.33 — already addressed (NODE_ENV guard at test bootstrap, clearly synthetic UUIDs). Test fixture UUID v4 fix: COMPANY_ID upgraded to proper v4 format (was 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', which fails Zod 4's .uuid() version-digit check now that the POST handler validates companyId). 3165/3165 vitest pass; build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../[companyId]/invoices/[id]/route.ts | 186 +++++++- .../invoices/__tests__/route.test.ts | 419 ++++++++++++++++- .../companies/[companyId]/invoices/route.ts | 428 +++++++++++++++++- lib/auth/scopes.ts | 4 +- lib/errors/structured-errors.ts | 8 + 5 files changed, 1025 insertions(+), 20 deletions(-) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/route.ts index f242b54e..b2ea882b 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/route.ts @@ -1,18 +1,38 @@ /** - * GET /api/v1/companies/{companyId}/invoices/{id} — invoice detail. + * /api/v1/companies/{companyId}/invoices/{id} — invoice detail + draft update. * - * Returns the full invoice record. Customer is embedded by default (the - * detail endpoint is verbose by design); line items and payments require - * `?expand=items,payments` to keep the default response shape predictable. + * GET — full invoice record. ?expand=items,payments controls embedding. + * PATCH — partial update on DRAFT invoices only. Allowed fields are the + * "metadata" subset (dates, references, notes); customer_id, + * currency, document_type, and items are immutable — changing any + * of those means delete-and-recreate (drafts are cheap). Returns + * 409 INVOICE_UPDATE_NOT_DRAFT (reusing existing code) if the + * invoice is not in draft status. + * + * Idempotent (mandatory Idempotency-Key) and dry-runnable. */ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' import { parseExpand } from '@/lib/api/v1/expand' import { registerEndpoint } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +// Allowed PATCH fields for a draft invoice. Excludes items (separate +// workflow), customer_id / currency / document_type (structural — change +// via delete + recreate), invoice_number (allocated server-side), all +// computed totals, and status (state machine — use action verbs in PR-B-2b). +const V1PatchDraftInvoiceSchema = z.object({ + invoice_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD').optional(), + due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD').optional(), + delivery_date: z.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD'), z.null()]).optional(), + your_reference: z.union([z.string(), z.null()]).optional(), + our_reference: z.union([z.string(), z.null()]).optional(), + notes: z.union([z.string(), z.null()]).optional(), +}) + // Loose schema — detail responses carry many fields, and pinning the exact // types in the registry is overkill until Phase 2 PR-B introduces writes // that reuse the schema for validation. @@ -151,3 +171,161 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string } return ok(data, { requestId: ctx.requestId }) }, ) + +// ────────────────────────────────────────────────────────────────── +// PATCH — update a DRAFT invoice (metadata fields only) +// ────────────────────────────────────────────────────────────────── + +registerEndpoint({ + operation: 'invoices.update', + method: 'PATCH', + path: '/api/v1/companies/:companyId/invoices/:id', + summary: 'Update a draft invoice (metadata fields only).', + description: + 'Partial update for invoices in draft status. Allowed fields: invoice_date, due_date, delivery_date, your_reference, our_reference, notes. customer_id, currency, document_type, items, and computed totals are immutable — replace those by deleting the draft and recreating it. Returns 409 INVOICE_UPDATE_NOT_DRAFT if the invoice is no longer in draft status. Idempotent and dry-runnable.', + useWhen: + 'You need to correct a typo, push the due date, or update a customer reference on a draft you have not sent yet. The invoice number stays null until the first :send action.', + doNotUseFor: + 'Updating a sent / paid / credited invoice (those are immutable per ML 17 kap; issue a credit note via POST /:id:credit in PR-B-2b). Changing items, currency, or customer — drafts are cheap to delete and recreate.', + pitfalls: [ + 'Idempotency-Key is mandatory.', + 'A 409 INVOICE_UPDATE_NOT_DRAFT means the invoice has been sent / paid / credited / cancelled. The error code name is shared with the DELETE handler.', + 'Items are immutable here — to change line items, delete the draft and POST a fresh one.', + ], + example: { + request: { due_date: '2026-07-15', notes: 'Förlängd förfallotid' }, + response: { + data: { + id: '0e9c…', + status: 'draft', + due_date: '2026-07-15', + notes: 'Förlängd förfallotid', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'invoices:write', + risk: 'low', + idempotent: true, + reversible: true, + dryRunSupported: true, + request: { body: V1PatchDraftInvoiceSchema }, + response: { success: InvoiceDetail }, +}) + +const INVOICE_PATCH_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' + +export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'invoices.update', + 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 + + 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 = V1PatchDraftInvoiceSchema.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, + })), + }, + }) + } + const body = parsed.data + + const updateData: Record = {} + for (const key of [ + 'invoice_date', + 'due_date', + 'delivery_date', + 'your_reference', + 'our_reference', + 'notes', + ] as const) { + if (body[key] !== undefined) updateData[key] = body[key] + } + + if (Object.keys(updateData).length === 0) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'At least one field must be supplied for update.' }, + }) + } + + // Pre-flight: verify the invoice exists in this company AND is still in + // draft status. We do this for both dry-run and commit so the response + // is consistent — dry-run that "succeeds" on a non-draft would mislead. + const { data: current, error: fetchErr } = await ctx.supabase + .from('invoices') + .select(INVOICE_PATCH_RESPONSE_COLUMNS) + .eq('company_id', ctx.companyId!) + .eq('id', invoiceId) + .maybeSingle() + + if (fetchErr) { + return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!current) { + ctx.log.warn('invoices.update: not found', { invoiceId, companyId: ctx.companyId }) + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'invoice' }, + }) + } + if ((current as { status: string }).status !== 'draft') { + return v1ErrorResponseFromCode('INVOICE_UPDATE_NOT_DRAFT', ctx.log, { + requestId: ctx.requestId, + details: { current_status: (current as { status: string }).status }, + }) + } + + if (ctx.dryRun) { + return dryRunPreview({ ...current, ...updateData }, { requestId: ctx.requestId, log: ctx.log }) + } + + const { data, error } = await ctx.supabase + .from('invoices') + .update({ ...updateData, updated_at: new Date().toISOString() }) + .eq('company_id', ctx.companyId!) + .eq('id', invoiceId) + .eq('status', 'draft') // Belt + braces: race condition guard. + .select(INVOICE_PATCH_RESPONSE_COLUMNS) + .maybeSingle() + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + if (!data) { + // Race: the invoice transitioned out of draft between the pre-flight + // and the update. Treat as the same 409 as the pre-flight check. + return v1ErrorResponseFromCode('INVOICE_UPDATE_NOT_DRAFT', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'Invoice transitioned out of draft during update.' }, + }) + } + + return ok(data, { requestId: ctx.requestId }) + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts index f5e5cc8d..0e0d612f 100644 --- a/app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts @@ -9,6 +9,11 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' beforeAll(() => { + if (process.env.NODE_ENV !== 'test') { + throw new Error( + `invoices 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' }) @@ -27,9 +32,26 @@ vi.mock('@supabase/supabase-js', async () => { return { ...actual, createClient: vi.fn().mockReturnValue({}) } }) +// Stub the F-series allocator so tests don't depend on the +// generate_invoice_number Postgres RPC. The route's flow is what we're +// testing, not the allocator itself (which has its own pg-real tests). +vi.mock('@/lib/invoices/ensure-invoice-number', () => ({ + ensureInvoiceNumber: vi.fn().mockResolvedValue(undefined), +})) + +// Riksbanken exchange-rate fetcher — return null by default (treats as +// SEK-only). Individual tests can override. +vi.mock('@/lib/currency/riksbanken', async () => { + const actual = await vi.importActual('@/lib/currency/riksbanken') + return { + ...actual, + fetchExchangeRate: vi.fn().mockResolvedValue(null), + } +}) + import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' -import { GET as listInvoices } from '../route' -import { GET as getInvoice } from '../[id]/route' +import { GET as listInvoices, POST as createInvoice } from '../route' +import { GET as getInvoice, PATCH as updateInvoice } from '../[id]/route' const mockValidate = validateApiKey as ReturnType const mockServiceClient = createServiceClientNoCookies as ReturnType @@ -54,7 +76,7 @@ function makeFlexibleSupabase(byTable: Record buildChain(table)) } } -const COMPANY_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' +const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' const INVOICE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' const CUSTOMER_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' const USER_ID = 'user-1' @@ -377,3 +399,394 @@ describe('scope enforcement', () => { expect(body.error.code).toBe('NOT_FOUND') }) }) + +// ────────────────────────────────────────────────────────────────── +// POST /api/v1/companies/:companyId/invoices +// ────────────────────────────────────────────────────────────────── + +function withInvoiceWriteScope() { + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + apiKeyName: 'CI key', + scopes: ['invoices:write'], + mode: 'live', + }) +} + +function makePostInvoice(url: string, body: unknown, extraHeaders: Record = {}): Request { + return new Request(url, { + method: 'POST', + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Content-Type': 'application/json', + 'Idempotency-Key': 'idem1234-5555-4abc-8def-1234567890ab', + ...extraHeaders, + }, + body: JSON.stringify(body), + }) +} + +function makePatchInvoice(url: string, body: unknown, extraHeaders: Record = {}): Request { + return new Request(url, { + method: 'PATCH', + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Content-Type': 'application/json', + 'Idempotency-Key': 'idem1234-6666-4abc-8def-1234567890ab', + ...extraHeaders, + }, + body: JSON.stringify(body), + }) +} + +// A swedish_business customer with VAT validated — picks up 25% as the +// only allowed rate (vat_treatment: standard_25). Reduced rates (12 / 6) +// would need a wider VAT-rule fixture; SEK + standard 25% is enough for +// the route-level tests here. +const SWEDISH_BUSINESS_CUSTOMER = { + id: CUSTOMER_ID, + customer_type: 'swedish_business', + vat_number_validated: true, +} + +describe('POST /api/v1/companies/:companyId/invoices', () => { + it('creates a draft invoice with computed totals', async () => { + withInvoiceWriteScope() + const createdInvoice = { + id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + invoice_number: null, + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + status: 'draft', + currency: 'SEK', + subtotal: 10000, + vat_amount: 2500, + total: 12500, + remaining_amount: 12500, + document_type: 'invoice', + created_at: '2026-05-12T16:00:00Z', + } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: SWEDISH_BUSINESS_CUSTOMER, error: null }, + invoices: { data: createdInvoice, error: null }, + invoice_items: { data: null, error: null }, + }), + ) + + const res = await createInvoice( + makePostInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices`, { + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + items: [{ description: 'Konsultation', quantity: 8, unit: 'tim', unit_price: 1250 }], + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(201) + const body = await res.json() + expect(body.data.customer_id).toBe(CUSTOMER_ID) + expect(body.data.total).toBe(12500) + }) + + it('returns 404 INVOICE_CUSTOMER_NOT_FOUND when customer does not belong to company', async () => { + withInvoiceWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: null, error: null }, // No match + }), + ) + + const res = await createInvoice( + makePostInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices`, { + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + items: [{ description: 'x', quantity: 1, unit: 'st', unit_price: 100 }], + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_CUSTOMER_NOT_FOUND') + }) + + it('rejects a per-item vat_rate not allowed for the customer', async () => { + withInvoiceWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: SWEDISH_BUSINESS_CUSTOMER, error: null }, + }), + ) + + const res = await createInvoice( + makePostInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices`, { + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + // 17 % is not a valid Swedish VAT rate. + items: [{ description: 'x', quantity: 1, unit: 'st', unit_price: 100, vat_rate: 17 }], + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_CREATE_VAT_RULE_VIOLATION') + expect(body.error.details.attempted_rate).toBe(17) + expect(Array.isArray(body.error.details.allowed_rates)).toBe(true) + }) + + it('dry-run returns 200 + X-Dry-Run + preview with computed totals; no DB writes', async () => { + withInvoiceWriteScope() + const supabaseMock = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: SWEDISH_BUSINESS_CUSTOMER, error: null }, + }) + mockServiceClient.mockReturnValue(supabaseMock) + + const res = await createInvoice( + makePostInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices?dry_run=true`, { + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + items: [ + { description: 'A', quantity: 2, unit: 'st', unit_price: 500 }, + { description: 'B', quantity: 1, unit: 'st', unit_price: 1000 }, + ], + }), + companyParams(COMPANY_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) + // Preview: subtotal=2000, vat=500 (25%), total=2500. + expect(body.data.preview.subtotal).toBe(2000) + expect(body.data.preview.vat_amount).toBe(500) + expect(body.data.preview.total).toBe(2500) + expect(body.data.preview.items).toHaveLength(2) + // No insert into `invoices` happened. + const insertedInvoice = supabaseMock.from.mock.calls.some((c) => c[0] === 'invoices') + expect(insertedInvoice).toBe(false) + }) + + 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 createInvoice( + makePostInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices`, { + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + items: [{ description: 'x', quantity: 1, unit: 'st', unit_price: 100 }], + }), + companyParams(COMPANY_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 () => { + withInvoiceWriteScope() + 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`, { + method: 'POST', + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + items: [{ description: 'x', quantity: 1, unit: 'st', unit_price: 100 }], + }), + }) + + const res = await createInvoice(req, companyParams(COMPANY_ID)) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) +}) + +// ────────────────────────────────────────────────────────────────── +// PATCH /api/v1/companies/:companyId/invoices/:id +// ────────────────────────────────────────────────────────────────── + +describe('PATCH /api/v1/companies/:companyId/invoices/:id', () => { + it('updates allowed metadata fields on a draft invoice', async () => { + withInvoiceWriteScope() + const draftInvoice = { + id: INVOICE_ID, + status: 'draft', + invoice_date: '2026-05-12', + due_date: '2026-06-11', + notes: 'old note', + } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { + data: { ...draftInvoice, due_date: '2026-07-15', notes: 'Förlängd' }, + error: null, + }, + }), + ) + + const res = await updateInvoice( + makePatchInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}`, { + due_date: '2026-07-15', + notes: 'Förlängd', + }), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.due_date).toBe('2026-07-15') + expect(body.data.notes).toBe('Förlängd') + }) + + it('returns 409 INVOICE_UPDATE_NOT_DRAFT for non-draft invoices', async () => { + withInvoiceWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: { id: INVOICE_ID, status: 'sent' }, error: null }, + }), + ) + + const res = await updateInvoice( + makePatchInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}`, { + notes: 'will be rejected', + }), + 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 an empty body', async () => { + withInvoiceWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await updateInvoice( + makePatchInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}`, {}), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it('returns 400 VALIDATION_ERROR when :id is not a UUID', async () => { + withInvoiceWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await updateInvoice( + makePatchInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/not-a-uuid`, { + notes: 'x', + }), + detailParams(COMPANY_ID, 'not-a-uuid'), + ) + + expect(res.status).toBe(400) + }) + + it('dry-run merges current + proposed changes without committing', async () => { + withInvoiceWriteScope() + const draftInvoice = { + id: INVOICE_ID, + status: 'draft', + invoice_date: '2026-05-12', + due_date: '2026-06-11', + notes: null, + } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: draftInvoice, error: null }, + }), + ) + + const res = await updateInvoice( + makePatchInvoice( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}?dry_run=true`, + { notes: 'preview' }, + ), + 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.preview.notes).toBe('preview') + expect(body.data.preview.due_date).toBe('2026-06-11') // unchanged from current + }) + + it('rejects forbidden fields (items / currency / customer_id)', async () => { + withInvoiceWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await updateInvoice( + makePatchInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}`, { + customer_id: CUSTOMER_ID, + currency: 'EUR', + items: [{ description: 'no', quantity: 1, unit: 'st', unit_price: 1 }], + }), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + // The forbidden fields are stripped by Zod; the resulting body is `{}` + // which fails the "at least one field" guard. + 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/route.ts b/app/api/v1/companies/[companyId]/invoices/route.ts index 7ae52715..2396039a 100644 --- a/app/api/v1/companies/[companyId]/invoices/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/route.ts @@ -1,20 +1,22 @@ /** - * GET /api/v1/companies/{companyId}/invoices — list invoices. + * /api/v1/companies/{companyId}/invoices — list + create invoice endpoints. * - * Cursor pagination on (invoice_date DESC, id DESC) — most recent first to - * match AR UX. Customer name is denormalised into the response so the agent - * doesn't need an N+1 fetch for display; use `?expand=customer` for the full - * customer record. - * - * Filters (all optional): - * - status single InvoiceStatus - * - customer_id UUID - * - document_type 'invoice' | 'proforma' | 'delivery_note' - * - currency ISO-4217 code + * GET — list with filters (status, customer_id, document_type, currency). + * Cursor pagination on (invoice_date DESC, id DESC). + * POST — create draft invoice. Idempotent (mandatory Idempotency-Key). + * Dry-runnable (?dry_run=true returns the validated would-be + * invoice + items with computed VAT totals; no DB writes). + * Lifecycle: drafts have invoice_number=null until the :send action + * verb (PR-B-2b) triggers F-series allocation atomically. Delivery + * notes get a number on create from a separate D-series sequence. + * Rationale (ML 17 kap 24§ p.2): the löpnummer series must be + * unbroken AND cover only issued invoices — consuming numbers for + * drafts that get abandoned creates legal gaps. */ import { z } from 'zod' -import { paginated } from '@/lib/api/v1/response' +import { created, paginated } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' import { decodeDefaultCursor, encodeDefaultCursor, @@ -24,6 +26,11 @@ import { parseExpand } from '@/lib/api/v1/expand' 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 { CreateInvoiceSchema } from '@/lib/api/schemas' +import { getAvailableVatRates, getVatRules } from '@/lib/invoices/vat-rules' +import { convertToSEK, fetchExchangeRate } from '@/lib/currency/riksbanken' +import { eventBus } from '@/lib/events' +import type { Invoice, InvoiceDocumentType } from '@/types' const InvoiceStatus = z.enum([ 'draft', @@ -285,3 +292,400 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( }) }, ) + +// ────────────────────────────────────────────────────────────────── +// POST — create draft invoice (or proforma / delivery_note) +// ────────────────────────────────────────────────────────────────── + +// Response projection on create — same shape as the detail endpoint. +// Drop user_id, company_id (internal scoping). +const INVOICE_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 INVOICE_ITEMS_RESPONSE_COLUMNS = + 'id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, created_at' + +// Loose response schema — invoices have many fields; pinning every one in +// the registry is overkill until we have a real schema-drift test. +const InvoiceCreated = z.object({ + id: z.string().uuid(), + invoice_number: z.string().nullable(), + customer_id: z.string().uuid(), + invoice_date: z.string(), + due_date: z.string(), + status: z.string(), + document_type: z.string(), + currency: z.string(), + subtotal: z.number(), + vat_amount: z.number(), + total: z.number(), + remaining_amount: z.number(), + created_at: z.string(), +}) + +registerEndpoint({ + operation: 'invoices.create', + method: 'POST', + path: '/api/v1/companies/:companyId/invoices', + summary: 'Create a draft invoice, proforma, or delivery note.', + description: + 'Creates an invoice in draft status. The F-series invoice_number is allocated atomically on the first send action (PR-B-2b). Per-item VAT rates are validated against the customer\'s allowed rates (mixed-rate invoices supported). Non-SEK invoices are converted to SEK at the Riksbanken exchange rate fetched at create time. Idempotent (mandatory Idempotency-Key). Dry-runnable — the preview returns the validated would-be invoice + items with computed totals; no journal entry is involved at draft stage (posting happens on :send).', + useWhen: + 'You need to issue a new invoice, proforma, or delivery note. Use dry-run first to confirm VAT calculations and currency conversion before committing.', + doNotUseFor: + 'Updating an existing invoice (PATCH instead, drafts only). Issuing a credit note (use POST /:id:credit in PR-B-2b). Posting a previously-created draft to the journal (use POST /:id:send in PR-B-2b).', + pitfalls: [ + 'Idempotency-Key is mandatory; calls without it return 400.', + 'For mixed-rate invoices, set vat_rate per item explicitly. Items where vat_rate is omitted use the customer\'s default rate from getVatRules().', + 'Non-SEK currencies require an active Riksbanken exchange-rate fetch. Failure is non-fatal — the invoice is created with null SEK fields and the agent can recompute later.', + 'invoice_number is null on creation. The number is allocated atomically when the invoice transitions out of draft. Counting on a specific number at create time is a bug.', + 'document_type=\'delivery_note\' produces no VAT and a different number sequence (D-series). Most use cases want the default document_type=\'invoice\'.', + ], + example: { + request: { + customer_id: 'a8f1…', + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + items: [ + { description: 'Konsultation', quantity: 8, unit: 'tim', unit_price: 1250 }, + ], + }, + response: { + data: { + id: '0e9c…', + invoice_number: null, + customer_id: 'a8f1…', + invoice_date: '2026-05-12', + due_date: '2026-06-11', + status: 'draft', + currency: 'SEK', + subtotal: 10000, + vat_amount: 2500, + total: 12500, + remaining_amount: 12500, + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'invoices:write', + risk: 'medium', + idempotent: true, + reversible: true, + dryRunSupported: true, + request: { body: CreateInvoiceSchema }, + response: { success: InvoiceCreated }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'invoices.create', + async (request, ctx) => { + // Defensive: companyId comes from the URL and was already validated for + // membership by the wrapper, but UUID-validate it before using as a DB + // predicate — mirrors the pattern in the detail-route :id check. + 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.' }, + }) + } + + 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 = CreateInvoiceSchema.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, + })), + }, + }) + } + const input = parsed.data + const documentType: InvoiceDocumentType = input.document_type || 'invoice' + + // Customer fetch (scoped to company). Determines VAT rules and the set + // of allowed per-item rates. + const { data: customer, error: customerErr } = await ctx.supabase + .from('customers') + .select('id, customer_type, vat_number_validated') + .eq('company_id', ctx.companyId!) + .eq('id', input.customer_id) + .maybeSingle() + + if (customerErr) { + return v1ErrorResponse(customerErr, ctx.log, { requestId: ctx.requestId }) + } + if (!customer) { + return v1ErrorResponseFromCode('INVOICE_CUSTOMER_NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'customer' }, + }) + } + + const vatRules = getVatRules( + customer.customer_type as Parameters[0], + customer.vat_number_validated, + ) + const availableRates = getAvailableVatRates( + customer.customer_type as Parameters[0], + customer.vat_number_validated, + ) + const allowedRates = new Set(availableRates.map((r) => r.rate)) + + // Per-item VAT validation + totals. + const subtotal = input.items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0) + let vatAmount = 0 + if (documentType !== 'delivery_note') { + for (const item of input.items) { + const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate + if (!allowedRates.has(itemRate)) { + return v1ErrorResponseFromCode('INVOICE_CREATE_VAT_RULE_VIOLATION', ctx.log, { + requestId: ctx.requestId, + details: { + attempted_rate: itemRate, + allowed_rates: Array.from(allowedRates), + customer_type: customer.customer_type, + }, + }) + } + const lineTotal = item.quantity * item.unit_price + vatAmount += Math.round((lineTotal * itemRate) / 100 * 100) / 100 + } + } + const total = documentType === 'delivery_note' ? 0 : subtotal + vatAmount + const uniqueRates = new Set(input.items.map((item) => item.vat_rate ?? vatRules.rate)) + const isMixedRate = uniqueRates.size > 1 + const headerVatRate = documentType === 'delivery_note' + ? 0 + : isMixedRate + ? null + : (uniqueRates.values().next().value ?? vatRules.rate) + + // Currency conversion (best-effort; non-fatal on failure). + let exchangeRate: number | null = null + let exchangeRateDate: string | null = null + let subtotalSek: number | null = null + let vatAmountSek: number | null = null + let totalSek: number | null = null + if (input.currency !== 'SEK') { + const rateData = await fetchExchangeRate(input.currency) + if (rateData) { + exchangeRate = rateData.rate + exchangeRateDate = rateData.date + subtotalSek = convertToSEK(subtotal, exchangeRate) + vatAmountSek = convertToSEK(vatAmount, exchangeRate) + totalSek = convertToSEK(total, exchangeRate) + } + } + + // Build computed item rows for the would-be insert. + const itemRows = input.items.map((item, index) => { + const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate + const lineTotal = item.quantity * item.unit_price + const itemVat = documentType === 'delivery_note' + ? 0 + : Math.round((lineTotal * itemRate) / 100 * 100) / 100 + return { + sort_order: index, + description: item.description, + quantity: item.quantity, + unit: item.unit, + unit_price: item.unit_price, + line_total: lineTotal, + vat_rate: itemRate, + vat_amount: itemVat, + } + }) + + // Dry-run: validation-only preview. Drafts have no journal-entry side + // effects yet, so no pending_operations staging needed; the + // dryRunStaged() variant lands in PR-B-2b for :send. + if (ctx.dryRun) { + return dryRunPreview( + { + // Would-be invoice row. + invoice_number: null, + customer_id: input.customer_id, + invoice_date: input.invoice_date, + due_date: input.due_date, + delivery_date: input.delivery_date ?? null, + status: 'draft' as const, + currency: input.currency, + exchange_rate: exchangeRate, + exchange_rate_date: exchangeRateDate, + subtotal: documentType === 'delivery_note' ? 0 : subtotal, + subtotal_sek: documentType === 'delivery_note' ? null : subtotalSek, + vat_amount: vatAmount, + vat_amount_sek: documentType === 'delivery_note' ? null : vatAmountSek, + total, + total_sek: documentType === 'delivery_note' ? null : totalSek, + vat_treatment: vatRules.treatment, + vat_rate: headerVatRate, + moms_ruta: vatRules.momsRuta, + reverse_charge_text: vatRules.reverseChargeText || null, + your_reference: input.your_reference ?? null, + our_reference: input.our_reference ?? null, + notes: input.notes ?? null, + document_type: documentType, + remaining_amount: documentType === 'invoice' ? total : 0, + items: itemRows, + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + // Delivery notes get their number from a dedicated sequence on insert. + // Invoices and proformas allocate F-series numbers via + // ensureInvoiceNumber AFTER insert (atomic, but can fail — soft-cancel + // on failure to preserve sequence integrity per ML 17 kap 24§). + let invoiceNumber: string | null = null + if (documentType === 'delivery_note') { + const { data: dnNumber } = await ctx.supabase.rpc('generate_delivery_note_number', { + p_company_id: ctx.companyId!, + }) + invoiceNumber = dnNumber as string | null + } + + const { data: invoice, error: invoiceErr } = await ctx.supabase + .from('invoices') + .insert({ + user_id: ctx.userId, + company_id: ctx.companyId!, + customer_id: input.customer_id, + invoice_number: invoiceNumber, + invoice_date: input.invoice_date, + due_date: input.due_date, + delivery_date: input.delivery_date ?? null, + currency: input.currency, + exchange_rate: exchangeRate, + exchange_rate_date: exchangeRateDate, + subtotal: documentType === 'delivery_note' ? 0 : subtotal, + subtotal_sek: documentType === 'delivery_note' ? null : subtotalSek, + vat_amount: vatAmount, + vat_amount_sek: documentType === 'delivery_note' ? null : vatAmountSek, + total, + total_sek: documentType === 'delivery_note' ? null : totalSek, + remaining_amount: documentType === 'invoice' ? total : 0, + vat_treatment: vatRules.treatment, + vat_rate: headerVatRate, + moms_ruta: vatRules.momsRuta, + reverse_charge_text: vatRules.reverseChargeText || null, + your_reference: input.your_reference, + our_reference: input.our_reference, + notes: input.notes, + document_type: documentType, + }) + .select(INVOICE_RESPONSE_COLUMNS) + .single() + + if (invoiceErr) { + // pg_message can interpolate field values from constraint detail — + // log internally, never echo to the client. + ctx.log.error('invoice insert failed', invoiceErr, { + invoiceId: undefined, + companyId: ctx.companyId, + pgCode: invoiceErr.code, + }) + return v1ErrorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { pg_code: invoiceErr.code }, + }) + } + + const invoiceId = (invoice as { id: string }).id + + // Insert items. If this fails, roll back the invoice row to avoid + // orphaned headers. Scope the rollback by company_id (defense in depth + // against UUID collision / logic error in compensating logic) and + // check the delete result so a double-failure is visible. + const itemsToInsert = itemRows.map((r) => ({ ...r, invoice_id: invoiceId })) + const { error: itemsErr } = await ctx.supabase.from('invoice_items').insert(itemsToInsert) + if (itemsErr) { + const { error: rollbackErr } = await ctx.supabase + .from('invoices') + .delete() + .eq('id', invoiceId) + .eq('company_id', ctx.companyId!) + if (rollbackErr) { + ctx.log.error( + 'invoice items insert failed AND rollback delete failed — orphaned invoice header', + rollbackErr, + { invoiceId, companyId: ctx.companyId, originalPgCode: itemsErr.code }, + ) + } else { + ctx.log.error('invoice items insert failed; rolled back invoice', itemsErr, { + invoiceId, + companyId: ctx.companyId, + }) + } + return v1ErrorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { pg_code: itemsErr.code }, + }) + } + + // Note: F-series invoice_number is NOT allocated at draft-create. + // Allocation happens atomically on the first :send action (Phase 2 + // PR-B-2b). Draft invoices keep invoice_number=null until then. + // Rationale: ML 17 kap 24§ p.2 requires the löpnummer series to be + // unbroken and to cover only issued invoices — consuming numbers for + // drafts that are later abandoned creates legal gaps. + // Delivery notes use a separate D-series sequence (already allocated + // on insert above) and are NOT subject to the F-series constraint. + + // Refetch with embedded items for the response. + const { data: complete, error: refetchErr } = await ctx.supabase + .from('invoices') + .select(`${INVOICE_RESPONSE_COLUMNS}, items:invoice_items(${INVOICE_ITEMS_RESPONSE_COLUMNS})`) + .eq('id', invoiceId) + .eq('company_id', ctx.companyId!) + .single() + + if (refetchErr) { + // The invoice WAS created; the items WERE inserted. Refetch failed + // for a transient DB reason. Log it so the partial-response is + // visible; fall back to the header without items rather than + // mis-leading the agent with a 5xx. + ctx.log.warn('invoice refetch after create failed; returning header without items', { + invoiceId, + companyId: ctx.companyId, + pgCode: (refetchErr as { code?: string }).code, + }) + } + + // Emit invoice.created only for real invoices — proformas and delivery + // notes are informational and have no downstream consumer obligation. + if (complete && documentType === 'invoice') { + try { + await eventBus.emit({ + type: 'invoice.created', + payload: { + invoice: complete as unknown as Invoice, + companyId: ctx.companyId!, + userId: ctx.userId, + }, + }) + } catch (err) { + ctx.log.warn('invoice.created emit failed', err as Error, { + invoiceId, + companyId: ctx.companyId, + }) + } + } + + return created(complete ?? invoice, { requestId: ctx.requestId }) + }, + { requireIdempotencyKey: true }, +) diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts index d5305258..e909a74f 100644 --- a/lib/auth/scopes.ts +++ b/lib/auth/scopes.ts @@ -53,9 +53,11 @@ export const V1_ENDPOINT_SCOPES: Record = { 'PATCH /api/v1/companies/:companyId/customers/:id': 'customers:write', 'DELETE /api/v1/companies/:companyId/customers/:id': 'customers:write', - // Invoices (Phase 2 PR-A) + // Invoices (Phase 2 PR-A — reads; Phase 2 PR-B-2a — draft writes) 'GET /api/v1/companies/:companyId/invoices': 'invoices:read', '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', // Webhooks (Phase 6 — placeholder so the catalogue is complete) 'GET /api/v1/companies/:companyId/webhooks': 'webhooks:manage', diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 2edd9cd6..961be636 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -505,6 +505,14 @@ const INVOICE: Record = { description: 'Issue a credit note instead of deleting a posted invoice.', }, }, + INVOICE_UPDATE_NOT_DRAFT: { + httpStatus: 409, + message_sv: 'Endast utkast kan ändras. Bokförda fakturor är oföränderliga — utfärda en kreditfaktura istället.', + message_en: 'Only draft invoices can be updated. Issued invoices are immutable — issue a credit note instead.', + remediation: { + description: 'Issue a credit note via POST /invoices/{id}:credit and create a fresh invoice with the corrected details.', + }, + }, INVOICE_CANCEL_RACE: { httpStatus: 409, message_sv: 'Fakturan ändrades samtidigt och kunde inte makuleras. Ladda om och försök igen.',