diff --git a/app/api/v1/companies/[companyId]/customers/[id]/route.ts b/app/api/v1/companies/[companyId]/customers/[id]/route.ts index 1d3ba797..62b8a5ee 100644 --- a/app/api/v1/companies/[companyId]/customers/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/customers/[id]/route.ts @@ -1,16 +1,33 @@ /** - * GET /api/v1/companies/{companyId}/customers/{id} — customer detail. + * /api/v1/companies/{companyId}/customers/{id} — customer detail + writes. * - * Returns the full customer record. Pass `?expand=invoices` to embed open - * (non-paid, non-cancelled, non-credited) invoices for the customer. + * GET — full record. ?expand=invoices embeds open invoices. + * PATCH — partial update. Idempotent (mandatory Idempotency-Key). + * Dry-runnable. VIES re-validation on commit if vat_number changes. + * Setting archived_at: null un-archives the customer. + * DELETE — soft-delete (sets archived_at). Idempotent. Dry-runnable. 204 + * on success. REFUSES to archive when the customer has any open + * (sent / partially_paid / overdue) invoice — preserves the + * canonical buyer name/address that ML 17 kap 24§ requires the + * invoice to carry. Issue a kreditfaktura first if needed. */ import { z } from 'zod' -import { ok } from '@/lib/api/v1/response' +import { noContent, ok } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' import { parseExpand } from '@/lib/api/v1/expand' import { registerEndpoint } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { UpdateCustomerSchema } from '@/lib/api/schemas' +import { validateVatNumber } from '@/lib/vat/vies-client' + +// v1-only extension: allow PATCH to set archived_at back to null to +// un-archive a customer. Restricted to literal `null` so the caller can't +// fake an archive timestamp. +const V1PatchCustomerSchema = UpdateCustomerSchema.extend({ + archived_at: z.null().optional(), +}) const CustomerDetail = z.object({ id: z.string().uuid(), @@ -188,3 +205,321 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string } ) }, ) + +// ────────────────────────────────────────────────────────────────── +// PATCH — partial update +// ────────────────────────────────────────────────────────────────── + +registerEndpoint({ + operation: 'customers.update', + method: 'PATCH', + path: '/api/v1/companies/:companyId/customers/:id', + summary: 'Partially update a customer.', + description: + 'Patches the customer with the supplied fields. All fields optional. Idempotent (mandatory Idempotency-Key). Dry-runnable. When vat_number changes on an eu_business customer, VIES re-validation runs on commit (best-effort).', + useWhen: + 'You need to change a customer\'s contact details, payment terms, address, or VAT registration. Use dry-run first to confirm the merged record before committing.', + doNotUseFor: + 'Archiving a customer (use DELETE — sets archived_at). Replacing the entire record (no PUT verb is exposed; PATCH is partial).', + pitfalls: [ + 'Idempotency-Key is mandatory; calls without it return 400.', + 'org_number uniqueness is enforced at DB level — 23505 → 409 CUSTOMER_DUPLICATE_ORG_NUMBER.', + 'VIES re-validation is best-effort and runs only on commit. A VIES timeout does not fail the update.', + ], + example: { + request: { default_payment_terms: 14, notes: 'New payment terms agreed 2026-05-12.' }, + response: { + data: { + id: '0e9c…', + name: 'Acme AB', + default_payment_terms: 14, + notes: 'New payment terms agreed 2026-05-12.', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'customers:write', + risk: 'low', + idempotent: true, + reversible: true, + dryRunSupported: true, + request: { body: UpdateCustomerSchema }, + response: { success: CustomerDetail }, +}) + +const CUSTOMER_UPDATE_RESPONSE_COLUMNS = CUSTOMER_DETAIL_COLUMNS + +export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'customers.update', + async (request, ctx, params) => { + const { id } = await params.params + + const idParse = z.string().uuid().safeParse(id) + if (!idParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Customer id must be a UUID.' }, + }) + } + const customerId = idParse.data + + let rawBody: unknown + try { + rawBody = await request.json() + } catch { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'Body is not valid JSON.' }, + }) + } + + const parsed = V1PatchCustomerSchema.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 + + // Build the partial update set. Fields explicitly set to undefined in + // the body are not in the resulting object (Zod strips undefined). null + // IS allowed and means "clear the field" (or, for archived_at, "un-archive"). + const updateData: Record = {} + for (const key of [ + 'name', + 'customer_type', + 'email', + 'phone', + 'address_line1', + 'address_line2', + 'postal_code', + 'city', + 'country', + 'org_number', + 'vat_number', + 'default_payment_terms', + 'notes', + 'archived_at', + ] as const) { + if (body[key] !== undefined) updateData[key] = body[key] + } + + if (Object.keys(updateData).length === 0) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'At least one field must be supplied for update.' }, + }) + } + + // Dry-run: fetch the current record, merge with the proposed changes, + // return the merged preview. No DB write. + if (ctx.dryRun) { + const { data: current, error: fetchErr } = await ctx.supabase + .from('customers') + .select(CUSTOMER_DETAIL_COLUMNS) + .eq('company_id', ctx.companyId!) + .eq('id', customerId) + .maybeSingle() + + if (fetchErr) { + return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!current) { + ctx.log.warn('customers.update dry-run: not found', { customerId, companyId: ctx.companyId }) + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'customer' }, + }) + } + + return dryRunPreview({ ...current, ...updateData }, { requestId: ctx.requestId, log: ctx.log }) + } + + // Best-effort VIES re-validation if vat_number is changing on an + // eu_business customer. Resolve BEFORE the update so the result lands + // atomically with the rest of the change — the API response is then + // guaranteed to reflect committed DB state, not a stale value from a + // separate fire-and-forget update. + if (body.vat_number !== undefined) { + const wouldBeType = + body.customer_type ?? + // Need the existing type if the caller didn't change it. + ( + await ctx.supabase + .from('customers') + .select('customer_type') + .eq('company_id', ctx.companyId!) + .eq('id', customerId) + .maybeSingle() + ).data?.customer_type + if (wouldBeType === 'eu_business') { + if (body.vat_number) { + try { + const vatResult = await validateVatNumber(body.vat_number) + updateData.vat_number_validated = vatResult.valid + updateData.vat_number_validated_at = vatResult.valid ? new Date().toISOString() : null + } catch (err) { + ctx.log.warn('auto-VIES re-validation failed on customer update', err as Error) + updateData.vat_number_validated = false + updateData.vat_number_validated_at = null + } + } else { + // vat_number cleared + updateData.vat_number_validated = false + updateData.vat_number_validated_at = null + } + } + } + + const { data, error } = await ctx.supabase + .from('customers') + .update(updateData) + .eq('company_id', ctx.companyId!) + .eq('id', customerId) + .select(CUSTOMER_UPDATE_RESPONSE_COLUMNS) + .maybeSingle() + + if (error) { + if (error.code === '23505') { + // GDPR Art.5(1)(c): do NOT echo body.org_number — for + // customer_type='individual' it IS the personnummer. + return v1ErrorResponseFromCode('CUSTOMER_DUPLICATE_ORG_NUMBER', ctx.log, { + requestId: ctx.requestId, + details: { field: 'org_number' }, + }) + } + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + if (!data) { + ctx.log.warn('customers.update: not found', { customerId, companyId: ctx.companyId }) + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'customer' }, + }) + } + + return ok(data, { requestId: ctx.requestId }) + }, + { requireIdempotencyKey: true }, +) + +// ────────────────────────────────────────────────────────────────── +// DELETE — soft-delete (sets archived_at) +// ────────────────────────────────────────────────────────────────── + +registerEndpoint({ + operation: 'customers.delete', + method: 'DELETE', + path: '/api/v1/companies/:companyId/customers/:id', + summary: 'Archive a customer (soft-delete).', + description: + 'Sets archived_at on the customer; the record is preserved (invoices and audit history remain intact) but excluded from default list responses. To un-archive, PATCH archived_at back to null. Idempotent — archiving an already-archived customer is a no-op. Dry-runnable.', + useWhen: + 'You want to remove a customer from active rosters without losing their history. Idempotent: re-archiving is safe.', + doNotUseFor: + 'Permanently deleting a customer with all history — the public API does not expose hard-delete. GDPR erasure requests go through a dedicated workflow.', + pitfalls: [ + 'Idempotency-Key is mandatory.', + 'A customer with any open invoice (sent / partially_paid / overdue) cannot be archived — returns 409 CUSTOMER_HAS_INVOICES. Issue a kreditfaktura first if you need to close the relationship cleanly. This protects ML 17 kap 24§: the customer record is the canonical source of buyer name/address for invoice reissuance.', + '204 No Content is returned on success — there is no response body to parse.', + ], + example: { + response: { data: null, meta: { request_id: 'req_…', api_version: '2026-05-12' } }, + }, + scope: 'customers:write', + risk: 'medium', + idempotent: true, + reversible: true, + dryRunSupported: true, + response: { success: z.object({}) }, +}) + +export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'customers.delete', + 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: 'Customer id must be a UUID.' }, + }) + } + const customerId = idParse.data + + // Pre-flight: check for open invoices BEFORE archiving. Preserves the + // canonical buyer record per ML 17 kap 24§ — an open invoice points at + // this customer for its statutory name/address fields. + const { count: openInvoiceCount, error: openErr } = await ctx.supabase + .from('invoices') + .select('id', { count: 'exact', head: true }) + .eq('company_id', ctx.companyId!) + .eq('customer_id', customerId) + .in('status', OPEN_INVOICE_STATUSES) + + if (openErr) { + return v1ErrorResponse(openErr, ctx.log, { requestId: ctx.requestId }) + } + if ((openInvoiceCount ?? 0) > 0) { + return v1ErrorResponseFromCode('CUSTOMER_HAS_INVOICES', ctx.log, { + requestId: ctx.requestId, + details: { open_invoice_count: openInvoiceCount }, + }) + } + + // Dry-run: confirm the customer exists. No state change. + if (ctx.dryRun) { + const { data: current, error: fetchErr } = await ctx.supabase + .from('customers') + .select(CUSTOMER_DETAIL_COLUMNS) + .eq('company_id', ctx.companyId!) + .eq('id', customerId) + .maybeSingle() + + if (fetchErr) { + return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!current) { + ctx.log.warn('customers.delete dry-run: not found', { customerId, companyId: ctx.companyId }) + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'customer' }, + }) + } + + return dryRunPreview( + { ...current, archived_at: new Date().toISOString() }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + const { data, error } = await ctx.supabase + .from('customers') + .update({ archived_at: new Date().toISOString() }) + .eq('company_id', ctx.companyId!) + .eq('id', customerId) + .select('id') + .maybeSingle() + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + if (!data) { + ctx.log.warn('customers.delete: not found', { customerId, companyId: ctx.companyId }) + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'customer' }, + }) + } + + return noContent({ requestId: ctx.requestId }) + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts index 331d33ea..846f077b 100644 --- a/app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts @@ -5,6 +5,16 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' beforeAll(() => { + // Belt-and-braces: ensure we never reach a real DB from this test suite. + // Supabase clients are mocked, but if a future test refactor accidentally + // bypassed the mock, this assertion fails the run rather than silently + // touching production. (Compliance: ISO 27001:2022 A.8.33 — test data + // separation.) + if (process.env.NODE_ENV !== 'test') { + throw new Error( + `customers 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' }) @@ -23,9 +33,19 @@ vi.mock('@supabase/supabase-js', async () => { return { ...actual, createClient: vi.fn().mockReturnValue({}) } }) +// Mock VIES validation so customer-write tests never make a real network +// call. Tests can override per-case via mockResolvedValueOnce. +vi.mock('@/lib/vat/vies-client', () => ({ + validateVatNumber: vi.fn().mockResolvedValue({ valid: false }), +})) + import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' -import { GET as listCustomers } from '../route' -import { GET as getCustomer } from '../[id]/route' +import { GET as listCustomers, POST as createCustomer } from '../route' +import { + GET as getCustomer, + PATCH as updateCustomer, + DELETE as deleteCustomer, +} from '../[id]/route' const mockValidate = validateApiKey as ReturnType const mockServiceClient = createServiceClientNoCookies as ReturnType @@ -84,7 +104,7 @@ beforeEach(() => { const SAMPLE_CUSTOMER = { id: CUSTOMER_ID, name: 'Acme AB', - customer_type: 'business', + customer_type: 'swedish_business', email: 'a@acme.test', phone: null, address_line1: null, @@ -134,7 +154,7 @@ describe('GET /api/v1/companies/:companyId/customers', () => { } const business = { ...SAMPLE_CUSTOMER, - customer_type: 'business', + customer_type: 'swedish_business', } mockServiceClient.mockReturnValue( makeFlexibleSupabase({ @@ -156,7 +176,7 @@ describe('GET /api/v1/companies/:companyId/customers', () => { expect(individualRow.org_number).toBeNull() expect(individualRow.vat_number).toBeNull() // Business: Bolagsverket-public org_number remains visible. - const businessRow = body.data.find((c: { customer_type: string }) => c.customer_type === 'business') + const businessRow = body.data.find((c: { customer_type: string }) => c.customer_type === 'swedish_business') expect(businessRow.org_number).toBe('TEST-0000-0001') expect(businessRow.vat_number).toBe('SETEST00000001') }) @@ -377,3 +397,447 @@ describe('GET /api/v1/companies/:companyId/customers/:id', () => { expect(body.error.details.id).toBeUndefined() }) }) + +// ────────────────────────────────────────────────────────────────── +// POST /api/v1/companies/:companyId/customers +// ────────────────────────────────────────────────────────────────── + +function withWriteScope() { + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + apiKeyName: 'CI key', + scopes: ['customers:write'], + mode: 'live', + }) +} + +function makePostRequest(url: string, body: unknown, extraHeaders: Record = {}): Request { + return new Request(url, { + method: 'POST', + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Content-Type': 'application/json', + 'Idempotency-Key': 'abcd1234-1111-4abc-8def-1234567890ab', + ...extraHeaders, + }, + body: JSON.stringify(body), + }) +} + +function makePatchRequest(url: string, body: unknown, extraHeaders: Record = {}): Request { + return new Request(url, { + method: 'PATCH', + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Content-Type': 'application/json', + 'Idempotency-Key': 'abcd1234-2222-4abc-8def-1234567890ab', + ...extraHeaders, + }, + body: JSON.stringify(body), + }) +} + +function makeDeleteRequest(url: string, extraHeaders: Record = {}): Request { + return new Request(url, { + method: 'DELETE', + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Idempotency-Key': 'abcd1234-3333-4abc-8def-1234567890ab', + ...extraHeaders, + }, + }) +} + +describe('POST /api/v1/companies/:companyId/customers', () => { + it('creates a customer and returns 201 with the new record', async () => { + withWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: { ...SAMPLE_CUSTOMER, name: 'New Co AB' }, error: null }, + }), + ) + + const res = await createCustomer( + makePostRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers`, { + name: 'New Co AB', + customer_type: 'swedish_business', + email: 'a@newco.test', + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(201) + const body = await res.json() + expect(body.data.name).toBe('New Co AB') + }) + + it('rejects requests without an Idempotency-Key header', async () => { + withWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const req = new Request(`https://x.test/api/v1/companies/${COMPANY_ID}/customers`, { + method: 'POST', + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: 'X', customer_type: 'swedish_business' }), + }) + + const res = await createCustomer(req, companyParams(COMPANY_ID)) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it('returns 400 when body is missing required fields', async () => { + withWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await createCustomer( + makePostRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers`, { + email: 'no@name.test', + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it('returns 409 on duplicate org_number', async () => { + withWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: null, error: { code: '23505', message: 'duplicate key' } }, + }), + ) + + const res = await createCustomer( + makePostRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers`, { + name: 'Dupe AB', + customer_type: 'swedish_business', + org_number: 'TEST-0000-0001', + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error.code).toBe('CUSTOMER_DUPLICATE_ORG_NUMBER') + }) + + it('dry-run returns 200 with X-Dry-Run header and preview shape; no insert', async () => { + withWriteScope() + const supabaseMock = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }) + mockServiceClient.mockReturnValue(supabaseMock) + + const res = await createCustomer( + makePostRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers?dry_run=true`, { + name: 'Preview AB', + customer_type: 'swedish_business', + email: 'p@preview.test', + }), + 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.name).toBe('Preview AB') + expect(body.data.preview.id).toBeNull() + expect(body.data.preview.created_at).toBeNull() + // The `customers` table was never queried for an insert/select. + const inserted = supabaseMock.from.mock.calls.some((c) => c[0] === 'customers') + expect(inserted).toBe(false) + }) + + 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 createCustomer( + makePostRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers`, { + name: 'X', + customer_type: 'swedish_business', + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(403) + const body = await res.json() + expect(body.error.code).toBe('INSUFFICIENT_SCOPE') + }) +}) + +// ────────────────────────────────────────────────────────────────── +// PATCH /api/v1/companies/:companyId/customers/:id +// ────────────────────────────────────────────────────────────────── + +describe('PATCH /api/v1/companies/:companyId/customers/:id', () => { + it('updates a customer and returns the new record', async () => { + withWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { + data: { ...SAMPLE_CUSTOMER, default_payment_terms: 14 }, + error: null, + }, + }), + ) + + const res = await updateCustomer( + makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}`, { + default_payment_terms: 14, + }), + detailParams(COMPANY_ID, CUSTOMER_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.default_payment_terms).toBe(14) + }) + + it('rejects an empty body', async () => { + withWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await updateCustomer( + makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}`, {}), + detailParams(COMPANY_ID, CUSTOMER_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it('returns 400 VALIDATION_ERROR when :id is not a UUID', async () => { + withWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await updateCustomer( + makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/not-a-uuid`, { + name: 'X', + }), + detailParams(COMPANY_ID, 'not-a-uuid'), + ) + + expect(res.status).toBe(400) + }) + + it('dry-run merges the proposed changes with the current record', async () => { + withWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: SAMPLE_CUSTOMER, error: null }, + }), + ) + + const res = await updateCustomer( + makePatchRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}?dry_run=true`, + { default_payment_terms: 7 }, + ), + detailParams(COMPANY_ID, CUSTOMER_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.default_payment_terms).toBe(7) + // Unchanged fields from the current record are preserved. + expect(body.data.preview.name).toBe(SAMPLE_CUSTOMER.name) + }) + + it('returns 404 when the customer does not exist', async () => { + withWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: null, error: null }, + }), + ) + + const res = await updateCustomer( + makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}`, { + name: 'New name', + }), + detailParams(COMPANY_ID, CUSTOMER_ID), + ) + + expect(res.status).toBe(404) + }) + + it('accepts archived_at: null for un-archive', async () => { + withWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: { ...SAMPLE_CUSTOMER, archived_at: null }, error: null }, + }), + ) + + const res = await updateCustomer( + makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}`, { + archived_at: null, + }), + detailParams(COMPANY_ID, CUSTOMER_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.archived_at).toBeNull() + }) + + it('rejects an archived_at value that is not null', async () => { + withWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await updateCustomer( + makePatchRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}`, { + archived_at: '2026-05-12T00:00:00Z', + }), + detailParams(COMPANY_ID, CUSTOMER_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) +}) + +// ────────────────────────────────────────────────────────────────── +// DELETE /api/v1/companies/:companyId/customers/:id +// ────────────────────────────────────────────────────────────────── + +describe('DELETE /api/v1/companies/:companyId/customers/:id', () => { + it('soft-deletes the customer and returns 204', async () => { + withWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: { id: CUSTOMER_ID }, error: null }, + }), + ) + + const res = await deleteCustomer( + makeDeleteRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}`), + detailParams(COMPANY_ID, CUSTOMER_ID), + ) + + expect(res.status).toBe(204) + }) + + it('dry-run previews the archived state without modifying the customer', async () => { + withWriteScope() + const supabaseMock = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: SAMPLE_CUSTOMER, error: null }, + }) + mockServiceClient.mockReturnValue(supabaseMock) + + const res = await deleteCustomer( + makeDeleteRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}?dry_run=true`, + ), + detailParams(COMPANY_ID, CUSTOMER_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.archived_at).toMatch(/^\d{4}-\d{2}-\d{2}T/) + }) + + it('returns 404 when the customer does not exist', async () => { + withWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: null, error: null }, + }), + ) + + const res = await deleteCustomer( + makeDeleteRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}`), + detailParams(COMPANY_ID, CUSTOMER_ID), + ) + + expect(res.status).toBe(404) + }) + + it('refuses to archive a customer with open invoices', async () => { + withWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + // The open-invoice pre-flight query returns count > 0. + invoices: { data: [], error: null, count: 3 }, + }), + ) + + const res = await deleteCustomer( + makeDeleteRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}`), + detailParams(COMPANY_ID, CUSTOMER_ID), + ) + + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error.code).toBe('CUSTOMER_HAS_INVOICES') + expect(body.error.details.open_invoice_count).toBe(3) + }) + + it('returns 400 VALIDATION_ERROR when :id is not a UUID', async () => { + withWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await deleteCustomer( + makeDeleteRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/not-a-uuid`), + detailParams(COMPANY_ID, 'not-a-uuid'), + ) + + expect(res.status).toBe(400) + }) +}) diff --git a/app/api/v1/companies/[companyId]/customers/route.ts b/app/api/v1/companies/[companyId]/customers/route.ts index 291bf93c..0b269abb 100644 --- a/app/api/v1/companies/[companyId]/customers/route.ts +++ b/app/api/v1/companies/[companyId]/customers/route.ts @@ -1,17 +1,16 @@ /** - * GET /api/v1/companies/{companyId}/customers — list customers. + * /api/v1/companies/{companyId}/customers — list + create customer endpoints. * - * Cursor pagination on (created_at ASC, id ASC). Archived customers are - * excluded by default; pass `?include_archived=true` to include them. - * - * Filters: - * - customer_type CustomerType - * - search substring match on name OR org_number prefix - * - include_archived boolean (default false) + * GET — list with filters (customer_type, search, include_archived). + * Cursor pagination on (created_at ASC, id ASC). + * POST — create. Idempotent (mandatory Idempotency-Key). Dry-runnable + * (?dry_run=true returns validated would-be record without + * committing). VIES validation runs only on commit. */ import { z } from 'zod' -import { paginated } from '@/lib/api/v1/response' +import { created, paginated } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' import { decodeDefaultCursor, encodeDefaultCursor, @@ -20,13 +19,19 @@ import { 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 { CreateCustomerSchema } from '@/lib/api/schemas' +import { validateVatNumber } from '@/lib/vat/vies-client' +import { eventBus } from '@/lib/events' +import type { Customer } from '@/types' +// Mirror the canonical CustomerTypeSchema from lib/api/schemas.ts. Only +// 'individual' refers to a natural person (Swedish sole trader / enskild +// firma); the three *_business variants are legal entities. const CustomerType = z.enum([ 'individual', - 'business', + 'swedish_business', 'eu_business', - 'eu_individual', - 'non_eu', + 'non_eu_business', ]) const CustomerSummary = z.object({ @@ -177,13 +182,21 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( const trimmed = rows.slice(0, limit) const hasMore = rows.length > limit - // GDPR Art.5(1)(c) data minimisation: for sole traders (enskild firma) - // and EU-individual customers, org_number IS the personnummer — a + // GDPR Art.5(1)(c) data minimisation: for sole traders (enskild firma, + // customer_type='individual'), org_number IS the personnummer — a // directly identifying special-category identifier. Mask both - // org_number and vat_number in the LIST response for those types so - // bulk fetches don't expose personal IDs. The DETAIL endpoint (deliberate - // drill-in to one record) still returns them. Business customers' - // org_numbers are Bolagsverket public-record data and stay visible. + // org_number and vat_number in the LIST response so bulk fetches don't + // expose personal IDs. The DETAIL endpoint (deliberate drill-in to one + // record) still returns them. Business customers' org_numbers are + // Bolagsverket public-record data and stay visible. + // + // 'eu_individual' is retained as defense-in-depth: it's not a valid + // value in the canonical CustomerTypeSchema (so newly created customers + // can never have it), but the `customer_type` DB column has no CHECK + // constraint, so legacy rows from prior schema iterations could in + // principle carry it. Masking is free when the value never appears and + // protective if it ever does. Adding 'eu_individual' as a first-class + // customer_type for EU natural persons is a separate product decision. const INDIVIDUAL_TYPES = new Set(['individual', 'eu_individual']) const customers = trimmed.map((r) => { @@ -212,3 +225,216 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( }) }, ) + +// ────────────────────────────────────────────────────────────────── +// POST — create customer +// ────────────────────────────────────────────────────────────────── + +const CustomerCreated = z.object({ + id: z.string().uuid().nullable(), + name: z.string(), + customer_type: CustomerType, + email: z.string().nullable(), + phone: z.string().nullable(), + address_line1: z.string().nullable(), + address_line2: z.string().nullable(), + postal_code: z.string().nullable(), + city: z.string().nullable(), + country: z.string(), + org_number: z.string().nullable(), + vat_number: z.string().nullable(), + vat_number_validated: z.boolean(), + default_payment_terms: z.number(), + notes: z.string().nullable(), + archived_at: z.string().nullable(), + created_at: z.string().nullable(), + updated_at: z.string().nullable(), +}) + +// Drop vat_number_validated_at — declared in neither CustomerCreated nor +// CustomerDetail; an internal timestamp with no documented consumer. +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.create', + method: 'POST', + path: '/api/v1/companies/:companyId/customers', + summary: 'Create a customer.', + description: + 'Creates a new customer for the company. Requires Idempotency-Key (UUID). Supports ?dry_run=true for input validation without committing — the dry-run response shows the would-be record minus id and timestamps. EU-business customers with a VAT number are auto-validated against VIES on commit.', + useWhen: + 'You need to register a new customer before invoicing them. Use dry-run first to catch validation errors before committing.', + doNotUseFor: + 'Updating an existing customer (PATCH instead). Creating suppliers (different resource).', + pitfalls: [ + 'Idempotency-Key is mandatory — calls without it return 400 VALIDATION_ERROR.', + 'org_number uniqueness is enforced at the database level; duplicate inserts return 409 CUSTOMER_DUPLICATE_ORG_NUMBER.', + 'For Swedish sole traders (customer_type=individual), org_number IS the personnummer. List responses mask it; the create endpoint accepts it as input.', + 'VIES validation runs only on commit. Dry-run skips the external call and leaves vat_number_validated=false in the preview.', + ], + example: { + request: { + name: 'Acme AB', + customer_type: 'swedish_business', + email: 'finance@acme.test', + org_number: '556677-8899', + default_payment_terms: 30, + }, + response: { + data: { + id: '0e9c…', + name: 'Acme AB', + customer_type: 'swedish_business', + email: 'finance@acme.test', + org_number: '556677-8899', + vat_number_validated: false, + default_payment_terms: 30, + archived_at: null, + created_at: '2026-05-12T16:00:00Z', + updated_at: '2026-05-12T16:00:00Z', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'customers:write', + risk: 'low', + idempotent: true, + reversible: true, + dryRunSupported: true, + request: { body: CreateCustomerSchema }, + response: { success: CustomerCreated }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'customers.create', + async (request, ctx) => { + 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 = CreateCustomerSchema.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 + + // Dry-run: validate input, return the would-be record. id, timestamps, + // and vat_number_validated all populate on commit, not here. + if (ctx.dryRun) { + return dryRunPreview( + { + id: null, + name: body.name, + customer_type: body.customer_type, + email: body.email ?? null, + phone: body.phone ?? null, + address_line1: body.address_line1 ?? null, + address_line2: body.address_line2 ?? null, + postal_code: body.postal_code ?? null, + city: body.city ?? null, + country: body.country ?? 'Sweden', + org_number: body.org_number ?? null, + vat_number: body.vat_number ?? null, + vat_number_validated: false, + default_payment_terms: body.default_payment_terms ?? 30, + notes: body.notes ?? null, + archived_at: null, + created_at: null, + updated_at: null, + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + // Best-effort VIES validation. Resolve BEFORE the insert so the + // resulting row reflects the validation state atomically and the + // API response can't expose stale vat_number_validated. + let vatValidated = false + let vatValidatedAt: string | null = null + if (body.customer_type === 'eu_business' && body.vat_number) { + try { + const vatResult = await validateVatNumber(body.vat_number) + if (vatResult.valid) { + vatValidated = true + vatValidatedAt = new Date().toISOString() + } + } catch (err) { + ctx.log.warn('auto-VIES validation failed on customer create', err as Error) + } + } + + const { data, error } = await ctx.supabase + .from('customers') + .insert({ + user_id: ctx.userId, + company_id: ctx.companyId!, + name: body.name, + customer_type: body.customer_type, + email: body.email ?? null, + phone: body.phone ?? null, + address_line1: body.address_line1 ?? null, + address_line2: body.address_line2 ?? null, + postal_code: body.postal_code ?? null, + city: body.city ?? null, + country: body.country ?? 'Sweden', + org_number: body.org_number ?? null, + vat_number: body.vat_number ?? null, + vat_number_validated: vatValidated, + vat_number_validated_at: vatValidatedAt, + default_payment_terms: body.default_payment_terms ?? 30, + notes: body.notes ?? null, + }) + .select(CUSTOMER_RESPONSE_COLUMNS) + .single() + + if (error) { + if (error.code === '23505') { + // GDPR Art.5(1)(c): do NOT echo body.org_number in the response — + // for customer_type='individual' it IS the personnummer. + // The error code alone tells the caller which field conflicted; + // they already know the value they submitted. + return v1ErrorResponseFromCode('CUSTOMER_DUPLICATE_ORG_NUMBER', ctx.log, { + requestId: ctx.requestId, + details: { field: 'org_number' }, + }) + } + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + + // Emit customer.created so webhooks (Phase 2 PR-C) and downstream + // handlers can react. Best-effort — emit failure does not roll back. + // Cast through `unknown` because the response projection deliberately + // omits internal scoping fields (user_id, company_id) the Customer type + // requires; we re-inject them on the payload from ctx. + try { + await eventBus.emit({ + type: 'customer.created', + payload: { + customer: { ...(data as Record), user_id: ctx.userId, company_id: ctx.companyId! } as unknown as Customer, + companyId: ctx.companyId!, + userId: ctx.userId, + }, + }) + } catch (err) { + ctx.log.warn('customer.created emit failed', err as Error) + } + + return created(data, { requestId: ctx.requestId }) + }, + { requireIdempotencyKey: true }, +) diff --git a/lib/api/v1/dry-run.ts b/lib/api/v1/dry-run.ts new file mode 100644 index 00000000..53f49f10 --- /dev/null +++ b/lib/api/v1/dry-run.ts @@ -0,0 +1,120 @@ +/** + * Dry-run response helpers for v1 write endpoints. + * + * Architectural contract (per the v1 plan): + * + * 1. Every POST / PATCH / DELETE accepts `?dry_run=true` or `X-Dry-Run: true`. + * 2. A dry-run response returns 200 OK with `{ data: { dry_run: true, preview, ... } }` + * and the `X-Dry-Run: true` response header — NEVER the resource's + * normal success status (201, 204, etc.). A caller that sees `200` + * with `X-Dry-Run` knows the write was NOT committed. + * 3. Commit by re-issuing the same request without `dry_run=true`, passing + * the same `Idempotency-Key` to guarantee at-most-once semantics. + * + * Two preview shapes are supported: + * + * - **Validation-only** (non-financial resources like customers): the + * preview is the would-be record. No staging, no `pending_operations` + * row, no journal lines. Useful for validating inputs and discovering + * conflicts (duplicate org_number, validation errors) before committing. + * + * - **Staged** (financial resources — invoices, journal entries, period + * ops, salary; later phases): the preview is the record PLUS a + * `staged_operation_id` from `pending_operations`, the `journal_lines` + * that would be posted, and the `voucher_number_assigned_on_commit`. + * Committing happens either by re-POSTing or via + * `POST /v1/operations/{staged_operation_id}:commit`. + * + * This file ships the helpers for both modes. Phase 2 PR-B-1 only uses the + * validation-only path (customers); the staged path is wired but not + * exercised until invoice writes land in PR-B-2. + */ + +import { NextResponse } from 'next/server' +import type { Logger } from '@/lib/logger' +import { ok } from './response' + +export interface DryRunPreviewBase { + /** Always `true` so agents can dispatch on this without parsing headers. */ + dry_run: true + /** The would-be resource. Same shape as the success response. */ + preview: T +} + +export interface DryRunPreviewStaged extends DryRunPreviewBase { + /** `pending_operations.id`. Use with POST /v1/operations/{id}:commit. */ + staged_operation_id: string + /** + * Journal lines this write WOULD produce on commit. Absent for + * non-financial writes. Each item: `{ account, debit, credit, description? }`. + */ + journal_lines?: Array<{ + account: string + debit: number + credit: number + description?: string + }> + /** + * The voucher number that WOULD be assigned on commit. Present only + * when the write produces a posted journal entry. Voucher numbers are + * sequential, so this is a *projection* — the actual number could differ + * by one or two if another committer beat the agent to the next number. + */ + voucher_number_assigned_on_commit?: string + /** Effect on account balances. Absent for non-financial writes. */ + account_deltas?: Array<{ account: string; delta: number }> +} + +export type DryRunPreview = DryRunPreviewBase | DryRunPreviewStaged + +interface DryRunResponseOptions { + requestId: string + log: Logger +} + +/** + * Return a 200 OK dry-run response for a validation-only preview. + * + * Use for non-financial writes (customers, suppliers metadata, employee + * profiles, settings) where there's nothing to stage — the agent just wants + * to know what would be written and whether validation passes. + */ +export function dryRunPreview(preview: T, opts: DryRunResponseOptions): NextResponse { + const body: DryRunPreviewBase = { dry_run: true, preview } + opts.log.info('dry-run preview returned', { stage: 'validation-only' }) + return ok(body, { requestId: opts.requestId, dryRun: true }) +} + +/** + * Return a 200 OK dry-run response for a staged preview (financial writes). + * + * Phase 2 PR-B-1 does not yet exercise this path; the helper is in place so + * Phase 2 PR-B-2 (invoice writes) and later phases (journal entries, + * year-end, etc.) reuse it without redefining the shape. + */ +export function dryRunStaged( + data: { + preview: T + stagedOperationId: string + journalLines?: DryRunPreviewStaged['journal_lines'] + voucherNumberAssignedOnCommit?: string + accountDeltas?: DryRunPreviewStaged['account_deltas'] + }, + opts: DryRunResponseOptions, +): NextResponse { + const body: DryRunPreviewStaged = { + dry_run: true, + preview: data.preview, + staged_operation_id: data.stagedOperationId, + ...(data.journalLines ? { journal_lines: data.journalLines } : {}), + ...(data.voucherNumberAssignedOnCommit + ? { voucher_number_assigned_on_commit: data.voucherNumberAssignedOnCommit } + : {}), + ...(data.accountDeltas ? { account_deltas: data.accountDeltas } : {}), + } + opts.log.info('dry-run preview returned', { + stage: 'staged', + stagedOperationId: data.stagedOperationId, + }) + return ok(body, { requestId: opts.requestId, dryRun: true }) +} diff --git a/lib/api/v1/with-api-v1.ts b/lib/api/v1/with-api-v1.ts index b0a644db..2c3eab21 100644 --- a/lib/api/v1/with-api-v1.ts +++ b/lib/api/v1/with-api-v1.ts @@ -187,15 +187,17 @@ function isDryRun(request: Request, url: URL): boolean { } async function readBodyForHash(request: Request): Promise<{ body: unknown; cloned: Request }> { - // We need to consume the body to hash it, but the handler also needs it. - // Clone the request first so the handler can re-read. - const cloned = request.clone() - const text = await request.text() - if (!text) return { body: null, cloned } + // We need the body to hash it, but the handler also needs it. Read from a + // CLONE for the hash and pass the original through to the handler — that + // way the handler's `await request.json()` still works regardless of how + // the runtime implements stream teeing. + const reader = request.clone() + const text = await reader.text() + if (!text) return { body: null, cloned: request } try { - return { body: JSON.parse(text), cloned } + return { body: JSON.parse(text), cloned: request } } catch { - return { body: text, cloned } + return { body: text, cloned: request } } } diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts index f9110c9c..d5305258 100644 --- a/lib/auth/scopes.ts +++ b/lib/auth/scopes.ts @@ -46,9 +46,12 @@ export const V1_ENDPOINT_SCOPES: Record = { // Events (webhook fallback / event log polling) 'GET /api/v1/companies/:companyId/events': 'events:read', - // Customers (Phase 2 PR-A) + // Customers (Phase 2 PR-A — reads; Phase 2 PR-B-1 — writes) 'GET /api/v1/companies/:companyId/customers': 'customers:read', 'GET /api/v1/companies/:companyId/customers/:id': 'customers:read', + 'POST /api/v1/companies/:companyId/customers': 'customers:write', + 'PATCH /api/v1/companies/:companyId/customers/:id': 'customers:write', + 'DELETE /api/v1/companies/:companyId/customers/:id': 'customers:write', // Invoices (Phase 2 PR-A) 'GET /api/v1/companies/:companyId/invoices': 'invoices:read',