diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts new file mode 100644 index 00000000..c2d7f4c5 --- /dev/null +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts @@ -0,0 +1,389 @@ +/** + * Integration tests for POST /api/v1/companies/:companyId/invoices/:id/send. + * + * Mocks the email service, PDF renderer, F-series allocator, journal-entry + * helper, and document uploader so the route's orchestration is what's + * under test. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') { + throw new Error( + `send 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({}) } +}) + +vi.mock('@/lib/invoices/ensure-invoice-number', () => ({ + ensureInvoiceNumber: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ + createInvoiceJournalEntry: vi.fn().mockResolvedValue({ + id: 'jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj', + }), +})) + +vi.mock('@/lib/core/documents/document-service', () => ({ + uploadDocument: vi.fn().mockResolvedValue({}), +})) + +vi.mock('@react-pdf/renderer', () => ({ + renderToBuffer: vi.fn().mockResolvedValue(Buffer.from('pdf-content')), +})) + +// Email service mock — configurable per test +const mockSendEmail = vi.fn() +const mockIsConfigured = vi.fn().mockReturnValue(true) +vi.mock('@/lib/email/service', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getEmailService: () => ({ + isConfigured: mockIsConfigured, + sendEmail: mockSendEmail, + }), + } +}) + +vi.mock('@/lib/email/invoice-templates', () => ({ + generateInvoiceEmailHtml: vi.fn().mockReturnValue('...'), + generateInvoiceEmailText: vi.fn().mockReturnValue('plain text'), + generateInvoiceEmailSubject: vi.fn().mockReturnValue('Faktura'), +})) + +vi.mock('@/lib/invoices/pdf-template', () => ({ + InvoicePDF: vi.fn().mockReturnValue({}), +})) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { POST as sendInvoice } from '../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 INVOICE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const USER_ID = 'user-1' + +function makeRequest(url: string): Request { + return new Request(url, { + method: 'POST', + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Idempotency-Key': 'idem1234-3030-4abc-8def-1234567890ab', + }, + }) +} +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', + email: 'billing@acme.test', + 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 COMPANY_SETTINGS = { + company_id: COMPANY_ID, + company_name: 'Test AB', + email: 'support@test-ab.example', + accounting_method: 'accrual', + entity_type: 'enskild_firma', +} + +beforeEach(() => { + vi.clearAllMocks() + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + apiKeyName: 'CI key', + scopes: ['invoices:write'], + mode: 'live', + }) + mockIsConfigured.mockReturnValue(true) + mockSendEmail.mockResolvedValue({ success: true, messageId: 're_abc123' }) +}) + +describe('POST /api/v1/companies/:companyId/invoices/:id/send', () => { + it('sends a draft invoice end-to-end and returns 200 with messageId', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: [ + { data: DRAFT_INVOICE, error: null }, // pre-flight fetch + { data: { invoice_number: '2026-0042' }, error: null }, // re-read after allocation + ], + company_settings: { data: COMPANY_SETTINGS, error: null }, + }), + ) + + const res = await sendInvoice( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`), + 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.message_id).toBe('re_abc123') + expect(body.data.sent_to).toBe('billing@acme.test') + expect(body.data.journal_entry_id).toBe('jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj') + expect(mockSendEmail).toHaveBeenCalledTimes(1) + }) + + it('returns 503 when email service is not configured', async () => { + mockIsConfigured.mockReturnValue(false) + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await sendInvoice( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(503) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_SEND_EMAIL_NOT_CONFIGURED') + }) + + it('returns 400 INVOICE_SEND_NO_CUSTOMER_EMAIL when customer lacks email', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { + data: { ...DRAFT_INVOICE, customer: { ...DRAFT_INVOICE.customer, email: null } }, + error: null, + }, + company_settings: { data: COMPANY_SETTINGS, error: null }, + }), + ) + + const res = await sendInvoice( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_SEND_NO_CUSTOMER_EMAIL') + }) + + it('rejects cancelled invoices with INVOICE_SEND_CANCELLED', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: { ...DRAFT_INVOICE, status: 'cancelled' }, error: null }, + }), + ) + + const res = await sendInvoice( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_SEND_CANCELLED') + }) + + it('rejects already-sent invoices with INVOICE_UPDATE_NOT_DRAFT', 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 sendInvoice( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_UPDATE_NOT_DRAFT') + }) + + it('returns 502 INVOICE_SEND_PROVIDER_FAILED when email send fails', async () => { + mockSendEmail.mockResolvedValue({ success: false, error: 'rate_limited' }) + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: [ + { data: DRAFT_INVOICE, error: null }, + { data: { invoice_number: '2026-0042' }, error: null }, + ], + company_settings: { data: COMPANY_SETTINGS, error: null }, + }), + ) + + const res = await sendInvoice( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(502) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_SEND_PROVIDER_FAILED') + }) + + it('dry-run validates the pipeline without sending email or allocating a number', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: DRAFT_INVOICE, error: null }, + company_settings: { data: COMPANY_SETTINGS, error: null }, + }), + ) + + const res = await sendInvoice( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send?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_send_to).toBe('billing@acme.test') + expect(body.data.preview.would_cc).toBe('support@test-ab.example') + expect(body.data.preview.preflight_pdf_render).toBe('ok') + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + 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: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' }, + error: null, + }, + }), + ) + + const res = await sendInvoice( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`), + 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('flags status flip 0-row no-op as a warning instead of lying in the response', async () => { + // Status flip returns no rows (concurrent state change). Email is gone; + // response status must say 'draft' and carry STATUS_UPDATE_FAILED. + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: [ + { data: DRAFT_INVOICE, error: null }, + { data: { invoice_number: '2026-0042' }, error: null }, + { data: [], error: null }, // status flip: 0 rows matched + ], + company_settings: { data: COMPANY_SETTINGS, error: null }, + }), + ) + + const res = await sendInvoice( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.status).toBe('draft') + expect(body.data.warnings).toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'STATUS_UPDATE_FAILED' })]), + ) + }) + + 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 sendInvoice( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(403) + }) +}) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts new file mode 100644 index 00000000..31461fb5 --- /dev/null +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts @@ -0,0 +1,570 @@ +/** + * POST /api/v1/companies/{companyId}/invoices/{id}/send + * + * Full send pipeline. Renders the invoice PDF, emails it to the customer + * (with a copy to the company), allocates the F-series number, posts the + * journal entry under accrual basis, archives the PDF as underlag, and + * emits invoice.sent. This is :mark-sent + PDF + email + archival. + * + * Failure ordering (matches the dashboard's internal /api/invoices/[id]/send + * exactly so the two surfaces stay reconcilable): + * + * 1. Email service NOT configured → 503 INVOICE_SEND_EMAIL_NOT_CONFIGURED. + * Hard fail before any state changes. + * 2. Customer has no email → 400 INVOICE_SEND_NO_CUSTOMER_EMAIL. + * 3. Company settings missing → 404 INVOICE_SEND_COMPANY_SETTINGS_MISSING. + * 4. Cancelled invoices are rejected — sending one would silently + * re-activate it (the status flip below has no race guard tightening + * `cancelled`). Returns 400 INVOICE_SEND_CANCELLED. + * 5. Preflight PDF render (with a placeholder F-PREVIEW number) validates + * the rendering pipeline BEFORE consuming an F-series number. Fail → + * 500 INVOICE_SEND_PDF_RENDER_FAILED, no number burned. + * 6. ensureInvoiceNumber allocates the F-series number atomically. + * Fail → 500 INVOICE_SEND_NUMBER_ASSIGN_FAILED. + * 7. Final PDF render with the real number. + * 8. Email send via Resend (the email extension). Fail → 502 + * INVOICE_SEND_PROVIDER_FAILED. The number IS consumed at this point; + * same orphan-window as :mark-sent (architecturally tracked). + * 9. POINT OF NO RETURN. Steps below are best-effort; failures surface + * as `warnings` on the response. Status flip → 'sent', journal entry + * (accrual + real invoice), PDF archival via uploadDocument, + * invoice.sent event emission. + * + * Idempotent (mandatory Idempotency-Key). Dry-runnable — dry-run goes + * through steps 1–5 (validation + preflight PDF) without allocating a + * number, sending email, or mutating state. + */ + +import { z } from 'zod' +import { renderToBuffer } from '@react-pdf/renderer' +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 { InvoicePDF } from '@/lib/invoices/pdf-template' +import { getEmailService } from '@/lib/email/service' +import { + generateInvoiceEmailHtml, + generateInvoiceEmailSubject, + generateInvoiceEmailText, +} from '@/lib/email/invoice-templates' +import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' +import { uploadDocument } from '@/lib/core/documents/document-service' +import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' +import { eventBus } from '@/lib/events' +import type { CompanySettings, Customer, EntityType, Invoice, InvoiceItem } from '@/types' + +const INVOICE_SEND_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 InvoiceSendResponse = z.object({ + id: z.string().uuid(), + invoice_number: z.string(), + status: z.literal('sent'), + total: z.number(), + message_id: z.string().nullable(), + sent_to: z.string(), + cc: z.string().nullable(), + journal_entry_id: z.string().uuid().nullable(), + warnings: z + .array(z.object({ code: z.string(), message: z.string() })) + .optional(), +}) + +registerEndpoint({ + operation: 'invoices.send', + method: 'POST', + path: '/api/v1/companies/:companyId/invoices/:id/send', + summary: 'Send a draft invoice to the customer by email.', + description: + 'The full send pipeline: preflight PDF render → allocate F-series number atomically → final PDF render → email via Resend (PDF attachment, copy to company) → flip status to sent → post journal entry (accrual + real invoice) → archive PDF as underlag → emit invoice.sent. Email failure is a hard 502 before state changes; post-email failures surface as warnings but the invoice IS marked sent.', + useWhen: + 'You want gnubok to deliver the invoice to the customer via email. For invoices delivered through another channel (Peppol, postal, own SMTP) use :mark-sent instead.', + doNotUseFor: + 'Re-sending an already-sent invoice (returns 409 INVOICE_UPDATE_NOT_DRAFT). Sending a delivery note (no F-series lifecycle). Sending a credit note (use the :credit endpoint to issue the kreditfaktura; subsequent re-send of the credit note via :mark-sent is the supported path).', + pitfalls: [ + 'Idempotency-Key is mandatory.', + 'Email service must be configured — without RESEND_API_KEY + RESEND_FROM_EMAIL the endpoint returns 503 INVOICE_SEND_EMAIL_NOT_CONFIGURED.', + 'Customer must have an email address. 400 INVOICE_SEND_NO_CUSTOMER_EMAIL otherwise.', + 'A cancelled invoice is rejected (400 INVOICE_SEND_CANCELLED) — its F-series number is preserved for compliance but the document is not a valid faktura.', + 'Email failure before the status flip leaves the F-series number consumed but the invoice in `draft` status. Same orphan window as :mark-sent (architecturally tracked, matches internal route).', + 'After the email succeeds, journal-entry/archive/event failures become warnings on the response; the invoice IS marked sent regardless.', + ], + example: { + response: { + data: { + id: '0e9c…', + invoice_number: '2026-0042', + status: 'sent', + total: 12500, + message_id: 're_abc123', + sent_to: 'finance@acme.test', + cc: 'billing@gnubok-user.test', + journal_entry_id: '7b3a…', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'invoices:write', + risk: 'high', + idempotent: true, + reversible: false, + dryRunSupported: true, + response: { success: InvoiceSendResponse }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'invoices.send', + 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.' }, + }) + } + + // Step 1: email service configured? + const emailService = getEmailService() + if (!emailService.isConfigured()) { + return v1ErrorResponseFromCode('INVOICE_SEND_EMAIL_NOT_CONFIGURED', ctx.log, { + requestId: ctx.requestId, + }) + } + + // Fetch invoice + customer + items. + const { data: invoice, error: fetchErr } = await ctx.supabase + .from('invoices') + .select( + `${INVOICE_SEND_RESPONSE_COLUMNS}, customer:customers(id, name, email, customer_type, country, address_line1, address_line2, postal_code, city, vat_number), 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.send: 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?: Customer + items?: InvoiceItem[] + } + + // Step 4: cancelled invoices. + if (typed.status === 'cancelled') { + return v1ErrorResponseFromCode('INVOICE_SEND_CANCELLED', ctx.log, { + requestId: ctx.requestId, + }) + } + + // Reject already-sent — same contract as :mark-sent. Re-send is not a + // supported v1 operation; use the dashboard or a fresh credit-and-reissue. + if (typed.status !== 'draft') { + return v1ErrorResponseFromCode('INVOICE_UPDATE_NOT_DRAFT', ctx.log, { + requestId: ctx.requestId, + details: { current_status: typed.status }, + }) + } + + // Reject delivery notes — they have a different (D-series) lifecycle. + if (typed.document_type === 'delivery_note') { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'document_type', + message: 'Delivery notes are not sent via this endpoint; use the dashboard or a custom channel.', + }, + }) + } + + // Reject credit notes. `:credit` creates them atomically in 'sent' state + // with their own number — there is no v1 path that produces a draft + // credit note, so reaching :send with credited_invoice_id set is either + // a misuse or a manual DB edit. Allowing it would give a credit note + // an F-series number; ML 17 kap 22–23§ require (a) a distinct + // kreditfaktura series and (b) an explicit back-reference to the + // original invoice's löpnummer — neither enforced by this route. + // Any future "send a credit note" v1 path MUST honor both. + if (typed.credited_invoice_id) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'credited_invoice_id', + message: + 'Credit notes cannot be sent via this endpoint. Use POST /invoices/{id}/credit, which creates and sends the credit note atomically.', + }, + }) + } + + if (!typed.moms_ruta) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'moms_ruta', + message: 'Invoice has no moms_ruta set; re-create the draft via POST /invoices.', + }, + }) + } + + // Step 2: customer email. + const customer = typed.customer + if (!customer?.email) { + return v1ErrorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', ctx.log, { + requestId: ctx.requestId, + details: { customer_id: typed.customer_id }, + }) + } + + // Step 3: company settings. The whole CompanySettings shape is passed to + // the InvoicePDF template — header info, bank details, contact, address, + // entity type. `select('*')` is intentional: CompanySettings is a flat + // owner-facing config object with no sensitive columns today (no API + // tokens, no billing data — those live in scoped tables). If a future + // migration adds a sensitive column, the right fix is to put it in a + // separate table, not retrofit a column allow-list here. + const { data: company, error: companyErr } = await ctx.supabase + .from('company_settings') + .select('*') + .eq('company_id', ctx.companyId!) + .maybeSingle() + if (companyErr || !company) { + return v1ErrorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', ctx.log, { + requestId: ctx.requestId, + }) + } + const settings = company as CompanySettings & { accounting_method?: string } + + const items = (typed.items ?? []).slice().sort((a, b) => a.sort_order - b.sort_order) + // Credit notes are rejected above, so originalInvoiceNumber is never + // needed on this code path. Kept undefined to satisfy the InvoicePDF + // signature (it tolerates undefined for non-credit-notes). + const originalInvoiceNumber: string | undefined = undefined + + // Step 5: preflight PDF render. Validate the pipeline with a placeholder + // number BEFORE consuming an F-series number. + const isFreshAllocation = !typed.invoice_number + if (isFreshAllocation) { + try { + await renderToBuffer( + InvoicePDF({ + invoice: { ...(typed as Invoice), invoice_number: 'F-PREVIEW' }, + customer, + items, + company: settings, + originalInvoiceNumber, + }), + ) + } catch (err) { + ctx.log.error('invoices.send: preflight PDF render failed', err as Error, { + invoiceId, + companyId: ctx.companyId, + }) + return v1ErrorResponseFromCode('INVOICE_SEND_PDF_RENDER_FAILED', ctx.log, { + requestId: ctx.requestId, + }) + } + } + + if (ctx.dryRun) { + // Dry-run stops here. Validated everything that doesn't have side + // effects; preview the would-be sent state. + return dryRunPreview( + { + ...typed, + status: 'sent' as const, + invoice_number: typed.invoice_number ?? '(allocated atomically on commit)', + would_send_to: customer.email, + would_cc: settings.email || null, + would_create_journal_entry: + (!typed.document_type || typed.document_type === 'invoice') && + (settings.accounting_method ?? 'accrual') === 'accrual', + accounting_method: settings.accounting_method ?? 'accrual', + preflight_pdf_render: 'ok', + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + // Step 6: allocate F-series number atomically. + try { + await ensureInvoiceNumber(ctx.supabase, ctx.companyId!, typed as Invoice) + } catch (err) { + ctx.log.error('invoices.send: ensureInvoiceNumber failed', err as Error, { + invoiceId, + companyId: ctx.companyId, + }) + return v1ErrorResponseFromCode('INVOICE_SEND_NUMBER_ASSIGN_FAILED', ctx.log, { + requestId: ctx.requestId, + }) + } + + // Step 7: final PDF render with the assigned number. typed.invoice_number + // was mutated by ensureInvoiceNumber. Re-read to be safe. A re-read + // failure (transient connection error) is non-fatal — `typed.invoice_number` + // was just written by the RPC in step 6, so it's the authoritative + // in-memory value. Log a warning and fall back. + const { data: numbered, error: reReadErr } = await ctx.supabase + .from('invoices') + .select('invoice_number') + .eq('id', invoiceId) + .eq('company_id', ctx.companyId!) + .single() + if (reReadErr) { + ctx.log.warn( + 'invoices.send: re-read after number allocation failed, falling back to in-memory value', + { + invoiceId, + companyId: ctx.companyId, + err: reReadErr, + }, + ) + } + const finalInvoiceNumber = + (numbered as { invoice_number?: string } | null)?.invoice_number ?? typed.invoice_number + const renderableInvoice: Invoice = { + ...(typed as Invoice), + invoice_number: finalInvoiceNumber, + } + + let pdfBuffer: Buffer + try { + pdfBuffer = await renderToBuffer( + InvoicePDF({ + invoice: renderableInvoice, + customer, + items, + company: settings, + originalInvoiceNumber, + }), + ) + } catch (err) { + // F-series number IS consumed at this point (orphan window). + ctx.log.error('invoices.send: final PDF render failed AFTER number allocation', err as Error, { + invoiceId, + companyId: ctx.companyId, + invoiceNumber: finalInvoiceNumber, + }) + return v1ErrorResponseFromCode('INVOICE_SEND_PDF_RENDER_FAILED', ctx.log, { + requestId: ctx.requestId, + }) + } + + // Step 8: send the email. Delivery notes AND credit notes were rejected + // earlier so docType is 'invoice' or 'proforma' here. + const docType = typed.document_type ?? 'invoice' + const filename = + docType === 'proforma' + ? `proformafaktura-${finalInvoiceNumber}.pdf` + : `faktura-${finalInvoiceNumber}.pdf` + + const ccAddress = settings.email ?? null + const emailData = { invoice: renderableInvoice, customer, company: settings } + const result = await emailService.sendEmail({ + to: customer.email, + cc: ccAddress ?? undefined, + subject: generateInvoiceEmailSubject(emailData), + html: generateInvoiceEmailHtml(emailData), + text: generateInvoiceEmailText(emailData), + replyTo: settings.email ?? undefined, + fromName: settings.company_name ?? undefined, + attachments: [ + { + filename, + content: pdfBuffer, + contentType: 'application/pdf', + }, + ], + }) + + if (!result.success) { + ctx.log.error('invoices.send: email provider failed', new Error(result.error ?? 'unknown'), { + invoiceId, + companyId: ctx.companyId, + }) + return v1ErrorResponseFromCode('INVOICE_SEND_PROVIDER_FAILED', ctx.log, { + requestId: ctx.requestId, + }) + } + + // ── POINT OF NO RETURN ──────────────────────────────────────────── + // Email has been delivered. Subsequent failures surface as warnings. + const warnings: { code: string; message: string }[] = [] + + // Step 9a: status flip to 'sent'. The `.eq('status', 'draft')` is an + // optimistic-lock guard against a concurrent state change between fetch + // and write. PostgREST returns `{ error: null }` for 0-row updates, so + // we MUST `.select('id')` and check the row count — a silent zero-row + // miss would leave the DB in 'draft' while the response claims 'sent' + // and the email is already gone. + let statusFlipped = true + const { data: flipRows, error: statusErr } = await ctx.supabase + .from('invoices') + .update({ status: 'sent', updated_at: new Date().toISOString() }) + .eq('id', invoiceId) + .eq('company_id', ctx.companyId!) + .eq('status', 'draft') + .select('id') + if (statusErr || !flipRows || flipRows.length === 0) { + statusFlipped = false + ctx.log.error( + 'invoices.send: status flip failed AFTER email delivery', + (statusErr ?? new Error('0 rows matched (concurrent state change)')) as Error, + { + invoiceId, + companyId: ctx.companyId, + rowsMatched: flipRows?.length ?? 0, + }, + ) + warnings.push({ + code: 'STATUS_UPDATE_FAILED', + message: + 'Email delivered but the invoice could not be marked as sent. Reconcile manually — the DB row may still be in draft.', + }) + } + + // Step 9b: journal entry (accrual + real invoices). + let journalEntryId: string | null = null + const isRealInvoice = !typed.document_type || typed.document_type === 'invoice' + const accountingMethod = settings.accounting_method ?? 'accrual' + if (isRealInvoice && accountingMethod === 'accrual') { + try { + const entry = await createInvoiceJournalEntry( + ctx.supabase, + ctx.companyId!, + ctx.userId, + renderableInvoice, + (settings.entity_type ?? 'enskild_firma') as EntityType, + customer.name, + ) + if (entry) { + journalEntryId = entry.id + 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('invoices.send: journal_entry_id write-back failed', writeBackErr as Error, { + invoiceId, + 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.', + }) + } + } else { + warnings.push({ + code: 'JOURNAL_ENTRY_NOT_POSTED', + message: 'Invoice was sent but the journal entry was not posted (likely no open fiscal period). Reconcile before period close.', + }) + } + } catch (err) { + ctx.log.error('invoices.send: journal entry creation failed', err as Error, { + invoiceId, + companyId: ctx.companyId, + }) + warnings.push({ + code: 'JOURNAL_ENTRY_NOT_POSTED', + message: 'Invoice was sent but the journal entry posting failed. Check engine logs; reconcile for BFL 5 kap compliance.', + }) + } + } + + // Step 9c: archive the PDF as underlag. + if (isRealInvoice) { + try { + const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer + await uploadDocument( + ctx.supabase, + ctx.userId, + ctx.companyId!, + { + name: filename, + buffer: pdfArrayBuffer, + type: 'application/pdf', + }, + { + upload_source: 'system', + journal_entry_id: journalEntryId ?? undefined, + }, + ) + } catch (err) { + ctx.log.error('invoices.send: PDF archival failed', err as Error, { + invoiceId, + companyId: ctx.companyId, + }) + warnings.push({ + code: 'PDF_ARCHIVE_FAILED', + message: 'Invoice was sent but the PDF could not be archived as underlag. Manual upload required for BFL 7 kap retention.', + }) + } + } + + // Step 9d: emit invoice.sent. + try { + await eventBus.emit({ + type: 'invoice.sent', + payload: { + invoice: renderableInvoice, + 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 may miss this transition.', + }) + } + + ctx.log.info('invoices.send success', { + invoiceId, + companyId: ctx.companyId, + userId: ctx.userId, + invoiceNumber: finalInvoiceNumber, + sentTo: customer.email, + journalEntryId, + hadWarnings: warnings.length > 0, + }) + + return ok( + { + id: invoiceId, + invoice_number: finalInvoiceNumber ?? typed.invoice_number ?? null, + status: statusFlipped ? ('sent' as const) : ('draft' as const), + total: typed.total, + message_id: result.messageId ?? null, + sent_to: customer.email, + cc: ccAddress, + journal_entry_id: journalEntryId, + ...(warnings.length > 0 ? { warnings } : {}), + }, + { requestId: ctx.requestId }, + ) + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/invoices/bulk-create/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/bulk-create/__tests__/route.test.ts new file mode 100644 index 00000000..3164f6fd --- /dev/null +++ b/app/api/v1/companies/[companyId]/invoices/bulk-create/__tests__/route.test.ts @@ -0,0 +1,318 @@ +/** + * Integration tests for POST /api/v1/companies/:companyId/invoices/bulk-create. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') { + throw new Error( + `bulk-create 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({}) } +}) +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 { POST as bulkCreate } from '../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 CUSTOMER_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const USER_ID = 'user-1' + +function makeRequest(url: string, body: unknown): Request { + return new Request(url, { + method: 'POST', + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Content-Type': 'application/json', + 'Idempotency-Key': 'idem1234-4040-4abc-8def-1234567890ab', + }, + body: JSON.stringify(body), + }) +} +function companyParams(companyId: string) { + return { params: Promise.resolve({ companyId }) } +} + +const VALID_CUSTOMER = { + id: CUSTOMER_ID, + customer_type: 'swedish_business', + vat_number_validated: true, +} + +const SAMPLE_ITEM = (description = 'A') => ({ + description, + quantity: 1, + unit: 'st', + unit_price: 1000, +}) + +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/bulk-create', () => { + it('creates two invoices and returns a partial-success summary', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: VALID_CUSTOMER, error: null }, + invoices: [ + { data: { id: 'inv-1', invoice_number: null, status: 'draft', total: 1250 }, error: null }, + { data: { id: 'inv-2', invoice_number: null, status: 'draft', total: 1250 }, error: null }, + ], + invoice_items: { data: null, error: null }, + }), + ) + + const res = await bulkCreate( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/bulk-create`, { + invoices: [ + { + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + items: [SAMPLE_ITEM('A')], + }, + { + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + items: [SAMPLE_ITEM('B')], + }, + ], + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.summary.total).toBe(2) + expect(body.data.summary.succeeded).toBe(2) + expect(body.data.summary.failed).toBe(0) + expect(body.data.results[0].ok).toBe(true) + expect(body.data.results[0].request_index).toBe(0) + expect(body.data.results[1].ok).toBe(true) + }) + + it('returns per-item failure when customer not found', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: null, error: null }, // not found for every fetch + }), + ) + + const res = await bulkCreate( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/bulk-create`, { + invoices: [ + { + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + items: [SAMPLE_ITEM('A')], + }, + ], + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.results[0].ok).toBe(false) + expect(body.data.results[0].error.code).toBe('INVOICE_CUSTOMER_NOT_FOUND') + expect(body.data.summary.failed).toBe(1) + expect(body.data.summary.succeeded).toBe(0) + }) + + it('returns 400 VALIDATION_ERROR when the bulk envelope is malformed', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await bulkCreate( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/bulk-create`, { + invoices: [], // min(1) violation + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it('rejects more than 50 invoices in one request', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const invoices = Array.from({ length: 51 }, () => ({ + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + items: [SAMPLE_ITEM('A')], + })) + + const res = await bulkCreate( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/bulk-create`, { + invoices, + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it('dry-run returns previews without inserting', async () => { + const supabaseMock = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: VALID_CUSTOMER, error: null }, + }) + mockServiceClient.mockReturnValue(supabaseMock) + + const res = await bulkCreate( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/bulk-create?dry_run=true`, { + invoices: [ + { + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + items: [SAMPLE_ITEM('A')], + }, + ], + }), + 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) + expect(body.data.preview.summary.succeeded).toBe(1) + expect(body.data.preview.results[0].ok).toBe(true) + expect(body.data.preview.results[0].data.preview.total).toBe(1250) + // No `invoices` insert was called. + const insertedInvoice = supabaseMock.from.mock.calls.some((c) => c[0] === 'invoices') + expect(insertedInvoice).toBe(false) + }) + + it('rejects all_or_nothing: true with 501 NOT_IMPLEMENTED', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await bulkCreate( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/bulk-create`, { + all_or_nothing: true, + invoices: [ + { + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + items: [SAMPLE_ITEM('A')], + }, + ], + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(501) + const body = await res.json() + expect(body.error.code).toBe('NOT_IMPLEMENTED') + }) + + 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 bulkCreate( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/bulk-create`, { + invoices: [ + { + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + items: [SAMPLE_ITEM('A')], + }, + ], + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(403) + }) +}) diff --git a/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts b/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts new file mode 100644 index 00000000..4a9d22c5 --- /dev/null +++ b/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts @@ -0,0 +1,478 @@ +/** + * POST /api/v1/companies/{companyId}/invoices/bulk-create + * + * Bulk create draft invoices in one call. Each invoice in the request array + * is validated and inserted independently. The response is partial-success + * by default — items that fail don't roll back items that succeeded. + * + * Request: + * { + * invoices: CreateInvoiceSchema[], // 1..50 items + * all_or_nothing?: boolean // default false. Passing true returns + * // 501 NOT_IMPLEMENTED — the flag is + * // reserved for a future DB-side RPC. + * } + * + * Response (200): + * { + * results: [ + * { ok: true, request_index: 0, data: { id, invoice_number, total, ... } }, + * { ok: false, request_index: 1, error: { code, message, details? } }, + * ... + * ], + * summary: { total: N, succeeded: X, failed: Y } + * } + * + * Idempotent (mandatory Idempotency-Key applies to the whole batch — replays + * return the cached full result, not per-item retries). Dry-runnable. + * + * Limits: + * - 50 invoices per request. Larger imports should be split. + * - No transactional guarantee between items today; the all_or_nothing + * flag is reserved for a future RPC implementation. + */ + +import { z } from 'zod' +import type { SupabaseClient } from '@supabase/supabase-js' +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 { 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 { Logger } from '@/lib/logger' +import type { Invoice, InvoiceDocumentType } from '@/types' + +const BulkCreateRequest = z.object({ + invoices: z.array(CreateInvoiceSchema).min(1).max(50), + all_or_nothing: z.boolean().optional().default(false), +}) + +const BulkResultItem = z.object({ + ok: z.boolean(), + request_index: z.number().int().nonnegative(), + data: z.unknown().optional(), + error: z.object({ + code: z.string(), + message: z.string(), + details: z.unknown().optional(), + }).optional(), +}) + +const BulkCreateResponse = z.object({ + results: z.array(BulkResultItem), + summary: z.object({ + total: z.number().int(), + succeeded: z.number().int(), + failed: z.number().int(), + }), +}) + +const INVOICE_BULK_RESPONSE_COLUMNS = + 'id, invoice_number, customer_id, invoice_date, due_date, status, currency, subtotal, vat_amount, total, document_type, created_at' + +registerEndpoint({ + operation: 'invoices.bulk-create', + method: 'POST', + path: '/api/v1/companies/:companyId/invoices/bulk-create', + summary: 'Create up to 50 draft invoices in one call (partial-success).', + description: + 'Bulk-creation endpoint. Each invoice in the request array is validated and inserted independently. By default, individual failures do not roll back successes — the response carries a per-item results array with ok/error markers and a summary. Idempotent (the whole batch is keyed by the single Idempotency-Key). Dry-runnable.', + useWhen: + 'You\'re importing a batch of invoices from another system, or producing many invoices programmatically (e.g. monthly subscription billing). Use dry-run first to validate the whole batch before committing.', + doNotUseFor: + 'Sending the same invoice to multiple customers — POST /invoices once per customer. Long-running imports of > 50 invoices — split into pages. Transactional all-or-nothing imports — not yet supported (passing all_or_nothing: true returns 501 NOT_IMPLEMENTED; the flag is reserved for a future RPC).', + pitfalls: [ + 'Idempotency-Key is mandatory and covers the WHOLE batch. A retried bulk-create returns the cached full response — it does not retry only the failed items.', + 'Passing all_or_nothing: true returns 501 NOT_IMPLEMENTED. Today only partial-success batches exist; omit the flag (or pass false).', + 'Each per-item invoice still goes through the same VAT-rule validation as POST /invoices. A mismatched per-item vat_rate produces a per-item failure, not a whole-batch failure.', + 'Currency conversion is best-effort PER ITEM. A failed Riksbanken fetch leaves that item\'s SEK columns null but does NOT fail the item.', + ], + example: { + request: { + invoices: [ + { + customer_id: 'a8f1…', + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + items: [{ description: 'A', quantity: 1, unit: 'st', unit_price: 1000 }], + }, + ], + }, + response: { + data: { + results: [ + { + ok: true, + request_index: 0, + data: { + id: '0e9c…', + invoice_number: null, + status: 'draft', + total: 1250, + }, + }, + ], + summary: { total: 1, succeeded: 1, failed: 0 }, + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'invoices:write', + risk: 'medium', + idempotent: true, + reversible: true, + dryRunSupported: true, + request: { body: BulkCreateRequest }, + response: { success: BulkCreateResponse }, +}) + +interface ResultItem { + ok: boolean + request_index: number + data?: unknown + error?: { code: string; message: string; details?: unknown } +} + +/** + * Create one invoice (the bulk-create item path). Mirrors the inline logic + * in POST /invoices/route.ts. Returns a ResultItem instead of an HTTP + * response so the caller can aggregate. + * + * TODO: extract POST /invoices's create logic into a shared lib function + * so this and the per-call POST share one implementation. Tracked as + * cross-route cleanup. + */ +async function createOneInvoice( + supabase: SupabaseClient, + companyId: string, + userId: string, + index: number, + input: z.infer, + dryRun: boolean, + log: Logger, +): Promise { + const documentType: InvoiceDocumentType = input.document_type || 'invoice' + + // Customer fetch (scoped to company). We use the DB-returned `customer.id` + // (not `input.customer_id`) downstream as defense in depth — the .eq() + // pair already enforces company scoping, but echoing the trusted value + // from the query makes the guarantee explicit at the call site and + // immune to refactoring drift. + const { data: customer } = await supabase + .from('customers') + .select('id, customer_type, vat_number_validated') + .eq('company_id', companyId) + .eq('id', input.customer_id) + .maybeSingle() + if (!customer) { + return { + ok: false, + request_index: index, + error: { code: 'INVOICE_CUSTOMER_NOT_FOUND', message: 'Customer not found in this company.' }, + } + } + const verifiedCustomerId = (customer as { id: string }).id + + 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)) + + 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 { + ok: false, + request_index: index, + error: { + code: 'INVOICE_CREATE_VAT_RULE_VIOLATION', + message: 'A line item carries a VAT rate not allowed for the customer type.', + 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((it) => it.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 per item). + 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') { + try { + const rate = await fetchExchangeRate(input.currency) + if (rate) { + exchangeRate = rate.rate + exchangeRateDate = rate.date + subtotalSek = convertToSEK(subtotal, exchangeRate) + vatAmountSek = convertToSEK(vatAmount, exchangeRate) + totalSek = convertToSEK(total, exchangeRate) + } + } catch (err) { + log.warn('bulk-create: exchange-rate fetch failed for item', err as Error, { + request_index: index, + currency: input.currency, + }) + } + } + + const itemRows = input.items.map((item, sortOrder) => { + 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: sortOrder, + description: item.description, + quantity: item.quantity, + unit: item.unit, + unit_price: item.unit_price, + line_total: lineTotal, + vat_rate: itemRate, + vat_amount: itemVat, + } + }) + + if (dryRun) { + return { + ok: true, + request_index: index, + data: { + preview: { + invoice_number: null, + customer_id: verifiedCustomerId, + invoice_date: input.invoice_date, + due_date: input.due_date, + status: 'draft' as const, + currency: input.currency, + subtotal: documentType === 'delivery_note' ? 0 : subtotal, + vat_amount: vatAmount, + total, + document_type: documentType, + items: itemRows, + }, + }, + } + } + + // Commit path. Insert invoice header. + const { data: invoice, error: invoiceErr } = await supabase + .from('invoices') + .insert({ + user_id: userId, + company_id: companyId, + customer_id: verifiedCustomerId, + invoice_number: null, + 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_BULK_RESPONSE_COLUMNS) + .single() + + if (invoiceErr) { + log.error('bulk-create: invoice insert failed', invoiceErr, { + request_index: index, + companyId, + pgCode: invoiceErr.code, + }) + return { + ok: false, + request_index: index, + error: { + code: 'INVOICE_CREATE_INSERT_FAILED', + message: 'Invoice insert failed.', + details: { pg_code: invoiceErr.code }, + }, + } + } + + const invoiceId = (invoice as { id: string }).id + const itemsToInsert = itemRows.map((r) => ({ ...r, invoice_id: invoiceId })) + const { error: itemsErr } = await supabase.from('invoice_items').insert(itemsToInsert) + if (itemsErr) { + // Roll back this invoice; other batch items are unaffected. + const { error: rbErr } = await supabase + .from('invoices') + .delete() + .eq('id', invoiceId) + .eq('company_id', companyId) + if (rbErr) { + log.error( + 'bulk-create: items insert failed AND rollback delete failed — orphaned header', + rbErr, + { request_index: index, invoiceId, companyId, originalPgCode: itemsErr.code }, + ) + } else { + log.error('bulk-create: items insert failed; rolled back invoice', itemsErr, { + request_index: index, + invoiceId, + companyId, + }) + } + return { + ok: false, + request_index: index, + error: { + code: 'INVOICE_CREATE_ITEMS_FAILED', + message: 'Invoice items insert failed; the invoice was rolled back.', + details: { pg_code: itemsErr.code }, + }, + } + } + + // Emit invoice.created per successful item (matches POST /invoices). + if (documentType === 'invoice') { + try { + await eventBus.emit({ + type: 'invoice.created', + payload: { invoice: invoice as unknown as Invoice, companyId, userId }, + }) + } catch (err) { + log.warn('bulk-create: invoice.created emit failed', err as Error, { + request_index: index, + invoiceId, + }) + } + } + + return { ok: true, request_index: index, data: invoice } +} + +export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'invoices.bulk-create', + async (request, ctx) => { + 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 = BulkCreateRequest.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 + + // Reject all_or_nothing: true loudly. The schema accepts it for forward + // compatibility, but a caller asking for atomic semantics must not get + // partial-success behaviour silently — that would let an automation + // depend on a guarantee that doesn't exist. The flag will be honored + // once a DB-side RPC ships; until then it's 501. + if (body.all_or_nothing) { + return v1ErrorResponseFromCode('NOT_IMPLEMENTED', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'all_or_nothing', + message: + 'all_or_nothing: true is not yet implemented. Omit the flag (or pass false) to use partial-success semantics.', + }, + }) + } + + // Run items sequentially. Parallel would be faster but the database-side + // sequence allocation for delivery notes and the auditability of the + // log stream are easier to reason about sequentially. 50-item cap keeps + // the worst-case latency bounded. + const results: ResultItem[] = [] + for (let i = 0; i < body.invoices.length; i++) { + const item = await createOneInvoice( + ctx.supabase, + ctx.companyId!, + ctx.userId, + i, + body.invoices[i], + ctx.dryRun, + ctx.log, + ) + results.push(item) + } + + const summary = { + total: results.length, + succeeded: results.filter((r) => r.ok).length, + failed: results.filter((r) => !r.ok).length, + } + + ctx.log.info('invoices.bulk-create completed', { + companyId: ctx.companyId, + userId: ctx.userId, + ...summary, + dryRun: ctx.dryRun, + }) + + if (ctx.dryRun) { + return dryRunPreview({ results, summary }, { requestId: ctx.requestId, log: ctx.log }) + } + + return ok({ results, summary }, { requestId: ctx.requestId }) + }, + { requireIdempotencyKey: true }, +) diff --git a/lib/api/v1/load-routes.ts b/lib/api/v1/load-routes.ts index ab2aa9e9..5a478222 100644 --- a/lib/api/v1/load-routes.ts +++ b/lib/api/v1/load-routes.ts @@ -24,5 +24,7 @@ import '@/app/api/v1/companies/[companyId]/customers/[id]/route' import '@/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route' import '@/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route' import '@/app/api/v1/companies/[companyId]/invoices/[id]/credit/route' +import '@/app/api/v1/companies/[companyId]/invoices/[id]/send/route' +import '@/app/api/v1/companies/[companyId]/invoices/bulk-create/route' export {} diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts index 46baddc9..8391d744 100644 --- a/lib/auth/scopes.ts +++ b/lib/auth/scopes.ts @@ -63,6 +63,8 @@ export const V1_ENDPOINT_SCOPES: Record = { 'POST /api/v1/companies/:companyId/invoices/:id/mark-sent': 'invoices:write', 'POST /api/v1/companies/:companyId/invoices/:id/mark-paid': 'invoices:write', 'POST /api/v1/companies/:companyId/invoices/:id/credit': 'invoices:write', + 'POST /api/v1/companies/:companyId/invoices/:id/send': 'invoices:write', + 'POST /api/v1/companies/:companyId/invoices/bulk-create': '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 961be636..6d06b487 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -80,6 +80,11 @@ const GENERIC: Record = { message_sv: 'För många förfrågningar. Vänta en stund och försök igen.', message_en: 'Rate limit exceeded.', }, + NOT_IMPLEMENTED: { + httpStatus: 501, + message_sv: 'Funktionen är inte implementerad ännu.', + message_en: 'This feature is accepted by the schema but not yet implemented.', + }, COMPANY_CONTEXT_MISSING: { httpStatus: 400, message_sv: 'Ingen aktiv företagskontext. Välj ett företag och försök igen.', diff --git a/lib/supabase/middleware.ts b/lib/supabase/middleware.ts index 432c7594..6b64462d 100644 --- a/lib/supabase/middleware.ts +++ b/lib/supabase/middleware.ts @@ -58,12 +58,21 @@ export async function updateSession(request: NextRequest) { return supabaseResponse } + // Reset-password is reachable in both auth states. The recovery flow lands + // here with a fresh session (created by the OTP exchange in /auth/callback) + // precisely so the user can call supabase.auth.updateUser({ password }). If + // we bounce authenticated users to '/', the recovery email link silently + // fails. An already-logged-in user typing /reset-password directly just gets + // the same "change password" experience as in settings — no security loss. + if (pathname.startsWith('/reset-password')) { + return supabaseResponse + } + // Public auth routes — allow access if ( pathname.startsWith('/login') || pathname.startsWith('/register') || pathname.startsWith('/auth') || - pathname.startsWith('/reset-password') || pathname.startsWith('/sandbox') ) { // If user is logged in and trying to access auth pages, redirect to dashboard