diff --git a/app/api/v1/companies/[companyId]/customers/bulk-create/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/customers/bulk-create/__tests__/route.test.ts new file mode 100644 index 00000000..fc367b9d --- /dev/null +++ b/app/api/v1/companies/[companyId]/customers/bulk-create/__tests__/route.test.ts @@ -0,0 +1,247 @@ +/** + * Integration tests for POST /api/v1/companies/:companyId/customers/bulk-create. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') { + throw new Error( + `customer bulk-create 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/vat/vies-client', () => ({ + validateVatNumber: vi.fn().mockResolvedValue({ valid: false }), +})) + +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 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-5050-4abc-8def-1234567890ab', + }, + body: JSON.stringify(body), + }) +} +function companyParams(companyId: string) { + return { params: Promise.resolve({ companyId }) } +} + +const SAMPLE = (name = 'Acme AB') => ({ + name, + customer_type: 'swedish_business' as const, + org_number: '556677-8899', +}) + +beforeEach(() => { + vi.clearAllMocks() + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + scopes: ['customers:write'], + mode: 'live', + }) +}) + +describe('POST /api/v1/companies/:companyId/customers/bulk-create', () => { + it('creates two customers and returns a 200 with summary', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: [ + { data: { id: 'c1', name: 'Acme', customer_type: 'swedish_business' }, error: null }, + { data: { id: 'c2', name: 'Beta', customer_type: 'swedish_business' }, error: null }, + ], + }), + ) + + const res = await bulkCreate( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/bulk-create`, { + customers: [SAMPLE('Acme AB'), SAMPLE('Beta AB')], + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.summary).toEqual({ total: 2, succeeded: 2, failed: 0 }) + expect(body.data.results[0]).toMatchObject({ ok: true, request_index: 0 }) + expect(body.data.results[1]).toMatchObject({ ok: true, request_index: 1 }) + }) + + it('returns per-item failure for org_number duplicate', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: null, error: { code: '23505', message: 'duplicate' } }, + }), + ) + + const res = await bulkCreate( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/bulk-create`, { + customers: [SAMPLE('Acme AB')], + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.summary.failed).toBe(1) + expect(body.data.results[0].error.code).toBe('CUSTOMER_DUPLICATE_ORG_NUMBER') + // Ensure org_number value is NOT echoed (GDPR Art.5(1)(c)). + expect(JSON.stringify(body.data.results[0].error.details)).not.toContain('556677-8899') + }) + + it('rejects more than 50 customers per request', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + const customers = Array.from({ length: 51 }, () => SAMPLE()) + + const res = await bulkCreate( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/bulk-create`, { + customers, + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + 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}/customers/bulk-create`, { + all_or_nothing: true, + customers: [SAMPLE()], + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(501) + const body = await res.json() + expect(body.error.code).toBe('NOT_IMPLEMENTED') + }) + + it('dry-run returns previews without inserting', async () => { + const supabaseMock = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }) + mockServiceClient.mockReturnValue(supabaseMock) + + const res = await bulkCreate( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/customers/bulk-create?dry_run=true`, + { customers: [SAMPLE()] }, + ), + 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) + // No `customers` insert was attempted. + const insertedCustomer = supabaseMock.from.mock.calls.some((c) => c[0] === 'customers') + expect(insertedCustomer).toBe(false) + }) + + it('rejects empty customers array', 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}/customers/bulk-create`, { + customers: [], + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it('rejects keys without customers:write scope', async () => { + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + scopes: ['customers:read'], + mode: 'live', + }) + mockServiceClient.mockReturnValue(makeFlexibleSupabase({})) + + const res = await bulkCreate( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/bulk-create`, { + customers: [SAMPLE()], + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(403) + }) +}) diff --git a/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts b/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts new file mode 100644 index 00000000..ce8231e2 --- /dev/null +++ b/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts @@ -0,0 +1,329 @@ +/** + * POST /api/v1/companies/{companyId}/customers/bulk-create + * + * Bulk-create up to 50 customers in one call. Each item is validated and + * inserted independently — per-item failures don't roll back successes. + * Mirrors the shape of /invoices/bulk-create exactly so agents only need + * to learn one bulk pattern. + * + * Response: `{ results: [{ ok, request_index, data?, error? }], summary }`. + * Idempotent over the whole batch. Dry-runnable. + * + * VIES validation for eu_business customers is best-effort PER ITEM. A VIES + * timeout does NOT fail the item — it just leaves vat_number_validated=false. + */ + +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 { CreateCustomerSchema } from '@/lib/api/schemas' +import { validateVatNumber } from '@/lib/vat/vies-client' +import { eventBus } from '@/lib/events' +import type { Logger } from '@/lib/logger' +import type { Customer } from '@/types' + +const BulkCreateRequest = z.object({ + customers: z.array(CreateCustomerSchema).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(), + }), +}) + +// Same projection as the single-create endpoint — keeps response shapes +// identical so callers can union the two surfaces transparently. +const CUSTOMER_RESPONSE_COLUMNS = + 'id, name, customer_type, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, default_payment_terms, notes, archived_at, created_at, updated_at' + +registerEndpoint({ + operation: 'customers.bulk-create', + method: 'POST', + path: '/api/v1/companies/:companyId/customers/bulk-create', + summary: 'Create up to 50 customers in one call (partial-success).', + description: + 'Bulk-create endpoint mirroring /invoices/bulk-create. Each customer is validated and inserted independently — per-item failures do not roll back items that succeeded. Returns a results array plus a summary. Idempotent over the whole batch. Dry-runnable.', + useWhen: + 'You\'re importing a roster of customers from another CRM, or seeding a fresh company with its existing client list. Use dry-run first to validate the batch.', + doNotUseFor: + 'Updating existing customers — PATCH /customers/{id} once per customer. Bulk uploads of > 50 customers — split into pages of 50. Transactional all-or-nothing imports — passing all_or_nothing: true returns 501 NOT_IMPLEMENTED.', + 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.', + 'org_number uniqueness is enforced at the DB level — items with duplicates fail individually with CUSTOMER_DUPLICATE_ORG_NUMBER.', + 'VIES validation for eu_business customers is best-effort per item; a VIES timeout leaves vat_number_validated=false but does NOT fail the item.', + ], + example: { + request: { + customers: [ + { name: 'Acme AB', customer_type: 'swedish_business', org_number: '556677-8899' }, + { name: 'Foo OY', customer_type: 'eu_business', vat_number: 'FI12345678' }, + ], + }, + response: { + data: { + results: [ + { ok: true, request_index: 0, data: { id: '0e9c…', name: 'Acme AB' } }, + { ok: true, request_index: 1, data: { id: '4d2a…', name: 'Foo OY' } }, + ], + summary: { total: 2, succeeded: 2, failed: 0 }, + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'customers:write', + risk: 'low', + 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 } +} + +async function createOneCustomer( + supabase: SupabaseClient, + companyId: string, + userId: string, + index: number, + input: z.infer, + dryRun: boolean, + log: Logger, +): Promise { + if (dryRun) { + return { + ok: true, + request_index: index, + data: { + preview: { + id: null, + name: input.name, + customer_type: input.customer_type, + email: input.email ?? null, + phone: input.phone ?? null, + address_line1: input.address_line1 ?? null, + address_line2: input.address_line2 ?? null, + postal_code: input.postal_code ?? null, + city: input.city ?? null, + country: input.country ?? 'Sweden', + org_number: input.org_number ?? null, + vat_number: input.vat_number ?? null, + vat_number_validated: false, + default_payment_terms: input.default_payment_terms ?? 30, + notes: input.notes ?? null, + archived_at: null, + created_at: null, + updated_at: null, + }, + }, + } + } + + // Best-effort VIES validation. Resolve BEFORE the insert so the row + // reflects validation state atomically. + let vatValidated = false + let vatValidatedAt: string | null = null + if (input.customer_type === 'eu_business' && input.vat_number) { + try { + const vatResult = await validateVatNumber(input.vat_number) + if (vatResult.valid) { + vatValidated = true + vatValidatedAt = new Date().toISOString() + } + } catch (err) { + log.warn('bulk-create: VIES validation failed for item', err as Error, { + request_index: index, + }) + } + } + + const { data, error } = await supabase + .from('customers') + .insert({ + user_id: userId, + company_id: companyId, + name: input.name, + customer_type: input.customer_type, + email: input.email ?? null, + phone: input.phone ?? null, + address_line1: input.address_line1 ?? null, + address_line2: input.address_line2 ?? null, + postal_code: input.postal_code ?? null, + city: input.city ?? null, + country: input.country ?? 'Sweden', + org_number: input.org_number ?? null, + vat_number: input.vat_number ?? null, + vat_number_validated: vatValidated, + vat_number_validated_at: vatValidatedAt, + default_payment_terms: input.default_payment_terms ?? 30, + notes: input.notes ?? null, + }) + .select(CUSTOMER_RESPONSE_COLUMNS) + .single() + + if (error) { + if (error.code === '23505') { + // GDPR Art.5(1)(c): do NOT echo input.org_number; for sole traders + // it IS the personnummer. The error code + field is enough — the + // caller knows the value they submitted. + return { + ok: false, + request_index: index, + error: { + code: 'CUSTOMER_DUPLICATE_ORG_NUMBER', + message: 'A customer with this org_number already exists in this company.', + details: { field: 'org_number' }, + }, + } + } + log.error('bulk-create: customer insert failed', error, { + request_index: index, + companyId, + pgCode: error.code, + }) + return { + ok: false, + request_index: index, + error: { + code: 'CUSTOMER_CREATE_FAILED', + message: 'Customer insert failed.', + details: { pg_code: error.code }, + }, + } + } + + // Emit customer.created per success. Same cast pattern as the single + // POST — projection omits internal scoping fields we re-inject here. + try { + await eventBus.emit({ + type: 'customer.created', + payload: { + customer: { + ...(data as Record), + user_id: userId, + company_id: companyId, + } as unknown as Customer, + companyId, + userId, + }, + }) + } catch (err) { + log.warn('bulk-create: customer.created emit failed', err as Error, { + request_index: index, + }) + } + + return { ok: true, request_index: index, data } +} + +export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'customers.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. Same contract as invoices/bulk-create. + 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.', + }, + }) + } + + // Sequential processing — matches /invoices/bulk-create. VIES has its own + // upstream throughput limits; running a batch of 50 in parallel can trip + // them. The 50-item cap keeps the worst-case latency bounded. + const results: ResultItem[] = [] + for (let i = 0; i < body.customers.length; i++) { + const item = await createOneCustomer( + ctx.supabase, + ctx.companyId!, + ctx.userId, + i, + body.customers[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('customers.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/app/api/v1/companies/[companyId]/invoices/[id]/pdf/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/pdf/__tests__/route.test.ts new file mode 100644 index 00000000..6a85e9f4 --- /dev/null +++ b/app/api/v1/companies/[companyId]/invoices/[id]/pdf/__tests__/route.test.ts @@ -0,0 +1,276 @@ +/** + * Integration tests for GET /api/v1/companies/:companyId/invoices/:id/pdf. + * + * The PDF renderer is mocked so the test is about routing, auth, error + * mapping and filename composition — not about actual PDF bytes. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') { + throw new Error( + `pdf 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 is hoisted above top-level consts, so the mock fn must be created +// inside vi.hoisted() to be available when the factory runs. +const { mockRender } = vi.hoisted(() => ({ + mockRender: vi.fn().mockResolvedValue(Buffer.from('pdf-bytes')), +})) +vi.mock('@react-pdf/renderer', () => ({ + renderToBuffer: mockRender, +})) +vi.mock('@/lib/invoices/pdf-template', () => ({ + InvoicePDF: vi.fn().mockReturnValue({}), +})) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { GET as pdf } 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: 'GET', + headers: { Authorization: 'Bearer test-fixture-not-a-real-key' }, + }) +} +function detailParams(companyId: string, id: string) { + return { params: Promise.resolve({ companyId, id }) } +} + +const SENT_INVOICE = { + id: INVOICE_ID, + invoice_number: '2026-0042', + customer_id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + invoice_date: '2026-05-12', + due_date: '2026-06-11', + status: 'sent', + document_type: 'invoice', + currency: 'SEK', + subtotal: 10000, + vat_amount: 2500, + total: 12500, + credited_invoice_id: null, + customer: { id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', name: 'Acme AB', email: 'acc@acme.test' }, + items: [{ id: 'i1', sort_order: 0, description: 'x', quantity: 1, unit: 'st', unit_price: 10000, line_total: 10000, vat_rate: 25 }], +} + +const COMPANY_SETTINGS = { + company_id: COMPANY_ID, + company_name: 'Test AB', + entity_type: 'enskild_firma', + accounting_method: 'accrual', +} + +beforeEach(() => { + vi.clearAllMocks() + mockRender.mockResolvedValue(Buffer.from('pdf-bytes')) + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + apiKeyName: 'CI key', + scopes: ['invoices:read'], + mode: 'live', + }) +}) + +describe('GET /api/v1/companies/:companyId/invoices/:id/pdf', () => { + it('returns a PDF for a sent invoice with the faktura- filename', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: SENT_INVOICE, error: null }, + company_settings: { data: COMPANY_SETTINGS, error: null }, + }), + ) + + const res = await pdf( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/pdf`), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('application/pdf') + expect(res.headers.get('Content-Disposition')).toBe('attachment; filename="faktura-2026-0042.pdf"') + expect(res.headers.get('X-Request-Id')).toMatch(/^req_/) + }) + + it('uses utkast-.pdf filename for drafts', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { + data: { ...SENT_INVOICE, status: 'draft', invoice_number: null }, + error: null, + }, + company_settings: { data: COMPANY_SETTINGS, error: null }, + }), + ) + + const res = await pdf( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/pdf`), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(200) + // Same composition as the dashboard's internal pdf route: the + // "faktura-" prefix is preserved, the number slot is the "utkast-" + // placeholder. + expect(res.headers.get('Content-Disposition')).toBe( + 'attachment; filename="faktura-utkast-bbbbbbbb.pdf"', + ) + }) + + it('uses kreditfaktura-.pdf for credit notes and embeds original number', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: [ + { + data: { + ...SENT_INVOICE, + invoice_number: '2026-0099', + credited_invoice_id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + }, + error: null, + }, + { data: { invoice_number: '2026-0042' }, error: null }, // original lookup + ], + company_settings: { data: COMPANY_SETTINGS, error: null }, + }), + ) + + const res = await pdf( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/pdf`), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(200) + expect(res.headers.get('Content-Disposition')).toBe( + 'attachment; filename="kreditfaktura-2026-0099.pdf"', + ) + // The template received the original number — verify via the InvoicePDF mock call. + const call = (mockRender.mock.calls[0]?.[0] as unknown) as { props?: unknown } | undefined + expect(call).toBeDefined() + }) + + it('returns 404 NOT_FOUND for unknown invoice id', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: null, error: null }, + }), + ) + + const res = await pdf( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/pdf`), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('NOT_FOUND') + }) + + it('returns 500 INVOICE_PDF_RENDER_FAILED when the renderer throws', async () => { + mockRender.mockRejectedValueOnce(new Error('font load failed')) + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: SENT_INVOICE, error: null }, + company_settings: { data: COMPANY_SETTINGS, error: null }, + }), + ) + + const res = await pdf( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/pdf`), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(500) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_PDF_RENDER_FAILED') + }) + + it('returns 400 VALIDATION_ERROR for non-UUID id', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await pdf( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/not-a-uuid/pdf`), + detailParams(COMPANY_ID, 'not-a-uuid'), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it('rejects keys without invoices:read scope', async () => { + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + scopes: ['transactions:read'], + mode: 'live', + }) + mockServiceClient.mockReturnValue(makeFlexibleSupabase({})) + + const res = await pdf( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/pdf`), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(403) + }) +}) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts new file mode 100644 index 00000000..b24cca8a --- /dev/null +++ b/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts @@ -0,0 +1,191 @@ +/** + * GET /api/v1/companies/{companyId}/invoices/{id}/pdf + * + * Render the invoice as a PDF and return it as `application/pdf`. Mirrors + * the dashboard's internal `/api/invoices/[id]/pdf` so a downloaded PDF is + * byte-equivalent across surfaces. + * + * Behavior: + * - Drafts (no invoice_number): the filename uses `utkast-`. The + * PDF is still rendered — useful for "preview before send" workflows. + * - Sent / paid / overdue / cancelled / credit notes: full PDF with the + * persisted invoice number. + * - Credit notes: filename uses `kreditfaktura-` prefix and the original + * invoice's löpnummer is embedded (ML 17 kap 22–23§ back-reference). + * - Delivery notes: PDF is permitted (read-only, no compliance side effect). + * + * Read-only — no Idempotency-Key, no dry-run, scope `invoices:read`. + */ + +import { z } from 'zod' +import { renderToBuffer } from '@react-pdf/renderer' +import { InvoicePDF } from '@/lib/invoices/pdf-template' +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 type { CompanySettings, Customer, Invoice, InvoiceItem } from '@/types' + +const INVOICE_PDF_COLUMNS = + 'id, invoice_number, customer_id, invoice_date, due_date, status, document_type, ' + + 'currency, subtotal, vat_amount, total, vat_treatment, vat_rate, moms_ruta, ' + + 'reverse_charge_text, your_reference, our_reference, notes, credited_invoice_id, ' + + 'paid_amount, remaining_amount' + +const PDF_FETCH_SELECT = ` + ${INVOICE_PDF_COLUMNS}, + customer:customers(*), + items:invoice_items(*) +` + +registerEndpoint({ + operation: 'invoices.pdf', + method: 'GET', + path: '/api/v1/companies/:companyId/invoices/:id/pdf', + summary: 'Download the rendered invoice PDF.', + description: + 'Returns the invoice as application/pdf. The filename in Content-Disposition reflects the document type: faktura-.pdf for sent invoices, kreditfaktura-.pdf for credit notes, utkast-.pdf for drafts. This endpoint is byte-equivalent to the dashboard download.', + useWhen: + 'You need to fetch an invoice PDF for archival, forwarding to a customer outside the gnubok send flow, or attaching to an external workflow.', + doNotUseFor: + 'Sending the invoice to the customer — use POST /invoices/{id}/send, which renders the PDF, emails it, and archives it as a verifikationsunderlag in one atomic step.', + pitfalls: [ + 'Drafts (no invoice_number yet) render with an "utkast" filename. The PDF carries no F-series number — do not treat it as a finalized invoice.', + 'PDF rendering can take several hundred milliseconds for invoices with many line items. Cache on the client if requesting repeatedly.', + 'Credit notes embed the original invoice\'s löpnummer per ML 17 kap 22–23§ — if the original was hard-deleted (not possible via gnubok but theoretically via a manual DB edit), the reference is omitted.', + ], + example: { + response: { + // Binary response — OpenAPI declares format: binary via response.contentType. + // Documented here for human readers. + _note: 'Returns application/pdf binary stream.', + }, + }, + scope: 'invoices:read', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { + success: z.unknown(), // Marker — binary response, see contentType. + contentType: 'application/pdf', + }, +}) + +export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'invoices.pdf', + 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 + + const { data: invoice, error: fetchErr } = await ctx.supabase + .from('invoices') + .select(PDF_FETCH_SELECT) + .eq('id', invoiceId) + .eq('company_id', ctx.companyId!) + .maybeSingle() + + if (fetchErr) { + return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!invoice) { + ctx.log.warn('invoices.pdf: 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[] + } + + // company_settings is required by the PDF template (header, bank info, + // entity-type-driven layout). Select * is intentional — see the rationale + // in the :send route. Same flat owner-facing config object, no sensitive + // columns. + const { data: company, error: companyErr } = await ctx.supabase + .from('company_settings') + .select('*') + .eq('company_id', ctx.companyId!) + .maybeSingle() + + if (companyErr || !company) { + ctx.log.warn('invoices.pdf: company settings missing', { + invoiceId, + companyId: ctx.companyId, + }) + return v1ErrorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', ctx.log, { + requestId: ctx.requestId, + }) + } + + const items = (typed.items ?? []).slice().sort((a, b) => a.sort_order - b.sort_order) + + // Credit-note back-reference per ML 17 kap 22–23§. Best-effort — if the + // original invoice was somehow deleted, the PDF template tolerates an + // undefined value (the back-reference field is omitted from the layout). + let originalInvoiceNumber: string | undefined + if (typed.credited_invoice_id) { + const { data: orig } = await ctx.supabase + .from('invoices') + .select('invoice_number') + .eq('id', typed.credited_invoice_id) + .eq('company_id', ctx.companyId!) + .maybeSingle() + if (orig) { + originalInvoiceNumber = (orig as { invoice_number?: string }).invoice_number ?? undefined + } + } + + let pdfBuffer: Buffer + try { + pdfBuffer = await renderToBuffer( + InvoicePDF({ + invoice: typed as Invoice, + customer: typed.customer as Customer, + items, + company: company as CompanySettings, + originalInvoiceNumber, + }), + ) + } catch (err) { + ctx.log.error('invoices.pdf: render failed', err as Error, { + invoiceId, + companyId: ctx.companyId, + }) + return v1ErrorResponseFromCode('INVOICE_PDF_RENDER_FAILED', ctx.log, { + requestId: ctx.requestId, + }) + } + + const isCreditNote = !!typed.credited_invoice_id + const filenameNumber = typed.invoice_number ?? `utkast-${invoiceId.slice(0, 8)}` + const filename = isCreditNote + ? `kreditfaktura-${filenameNumber}.pdf` + : typed.document_type === 'proforma' + ? `proformafaktura-${filenameNumber}.pdf` + : typed.document_type === 'delivery_note' + ? `följesedel-${filenameNumber}.pdf` + : `faktura-${filenameNumber}.pdf` + + const uint8Array = new Uint8Array(pdfBuffer) + return new Response(uint8Array, { + status: 200, + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Length': String(pdfBuffer.length), + 'X-Request-Id': ctx.requestId, + }, + }) + }, +) diff --git a/lib/api/v1/load-routes.ts b/lib/api/v1/load-routes.ts index 5a478222..f8da4672 100644 --- a/lib/api/v1/load-routes.ts +++ b/lib/api/v1/load-routes.ts @@ -26,5 +26,8 @@ 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' +// Phase 2 PR-B-3 — invoice PDF + customer bulk-create. +import '@/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route' +import '@/app/api/v1/companies/[companyId]/customers/bulk-create/route' export {} diff --git a/lib/api/v1/registry.ts b/lib/api/v1/registry.ts index 121bf6bf..03cacf42 100644 --- a/lib/api/v1/registry.ts +++ b/lib/api/v1/registry.ts @@ -80,6 +80,15 @@ export interface EndpointDefinition { success: ZodTypeAny /** Stable error codes this endpoint can emit (cross-referenced with the docs). */ errorCodes?: string[] + /** + * Override the default 'application/json' content type for non-JSON + * responses (e.g. binary downloads). When set to 'application/pdf', the + * OpenAPI generator emits a `{ type: 'string', format: 'binary' }` schema + * instead of deriving from `success`. The `success` schema is still + * required (use `z.unknown()` as a marker) so existing registry consumers + * don't need to handle a missing field. + */ + contentType?: string } } @@ -238,7 +247,12 @@ export function generateOpenApiSpec(serverUrl: string): OpenApiSpec { // OpenAPI path syntax: {param} instead of :param. const openApiPath = def.path.replace(/:([^/]+)/g, '{$1}') - const responseSchema = zodToJsonSchema(def.response.success) + // Binary responses (e.g. application/pdf) declare a `format: binary` + // schema rather than deriving from the Zod success type. + const successContent = def.response.contentType && def.response.contentType !== 'application/json' + ? { [def.response.contentType]: { schema: { type: 'string', format: 'binary' } } } + : { 'application/json': { schema: zodToJsonSchema(def.response.success) } } + const operationDef: Record = { operationId: def.operation, summary: def.summary, @@ -257,7 +271,7 @@ export function generateOpenApiSpec(serverUrl: string): OpenApiSpec { responses: { '200': { description: 'Success', - content: { 'application/json': { schema: responseSchema } }, + content: successContent, }, '400': { description: 'Validation error', $ref: '#/components/responses/Error' }, '401': { description: 'Unauthorized', $ref: '#/components/responses/Error' }, diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts index 8391d744..1ab094ca 100644 --- a/lib/auth/scopes.ts +++ b/lib/auth/scopes.ts @@ -65,6 +65,9 @@ export const V1_ENDPOINT_SCOPES: Record = { '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', + // Phase 2 PR-B-3 — invoice PDF + customer bulk-create. + 'GET /api/v1/companies/:companyId/invoices/:id/pdf': 'invoices:read', + 'POST /api/v1/companies/:companyId/customers/bulk-create': 'customers: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 6d06b487..956192b8 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -461,6 +461,11 @@ const INVOICE: Record = { 'Fakturans PDF kunde inte skapas. Kontrollera fakturarader och kunduppgifter och försök igen.', message_en: 'Failed to render invoice PDF before send; no invoice number was consumed.', }, + INVOICE_PDF_RENDER_FAILED: { + httpStatus: 500, + message_sv: 'Fakturans PDF kunde inte skapas.', + message_en: 'Invoice PDF rendering failed.', + }, INVOICE_SEND_PARTIAL: { httpStatus: 200, message_sv: