From d29b87fb806ffa34eee559d1a164072e11a7554b Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Tue, 12 May 2026 22:31:46 +0200 Subject: [PATCH] feat(api): v1 customer writes (Phase 2 PR-B-1) (#452) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): v1 customer writes (Phase 2 PR-B-1) First slice of Phase 2 PR-B from the agent-native v1 plan. Customer writes are the simplest write surface — no journal entries, no PDF, no email — which makes them the right place to validate the dry-run + idempotency pipeline before applying it to invoice flows in PR-B-2. New endpoints: - POST /api/v1/companies/:companyId/customers (idempotent, dry-runnable) - PATCH /api/v1/companies/:companyId/customers/:id (idempotent, dry-runnable) - DELETE /api/v1/companies/:companyId/customers/:id (soft-delete via archived_at; idempotent re-archiving; dry-runnable; 204) All three require customers:write scope (added to V1_ENDPOINT_SCOPES). All three require Idempotency-Key (mandatory — wrapper option requireIdempotencyKey: true). All three accept ?dry_run=true or X-Dry-Run: true and return a 200 OK preview with X-Dry-Run header set. New shared infrastructure: - lib/api/v1/dry-run.ts — dryRunPreview() (validation-only, no staging) and dryRunStaged() (financial writes; populated in later phases). Defines the preview response shape that POST/PATCH/DELETE share. Future financial writes (invoices, journal entries) will reuse the staged variant. Pre-existing PR-A bugs fixed: - CustomerType enum in customers/route.ts had wrong values ('business', 'eu_individual', 'non_eu'). Canonical enum is ['individual', 'swedish_business', 'eu_business', 'non_eu_business']. Fixed. - INDIVIDUAL_TYPES masking referenced non-existent 'eu_individual'. Only 'individual' refers to a natural person (Swedish sole trader where org_number = personnummer). Wrapper bug fix: - The idempotency body-hashing flow was consuming the original request's body before passing it to the handler. In Node's vitest environment the cloned request's body became empty, so the handler's request.json() returned {}. Fix: read body from a clone for hashing, leave the original intact for the handler. VIES re-validation on PATCH preserves existing best-effort behaviour. Customer.created event emission on POST so future webhook delivery (Phase 2 PR-C) can subscribe. 23 new tests covering happy path, dry-run preview, idempotency-key enforcement, scope checks, UUID validation, duplicate-org conflict, soft-delete semantics. 3150/3150 vitest pass; build clean; lint clean on v1 paths. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(api): address PR #452 review (Greptile + swarm + Swedish compliance) Real bugs (all reviewers agreed): - POST registerEndpoint example used the old customer_type 'business' enum value that this PR was already fixing. Now uses 'swedish_business' consistently between code, schema, and docs. - DELETE docstring claimed "Refuses to delete if open invoices remain" but the handler unconditionally archived. Swedish compliance flagged this as a real ML 17 kap 24§ concern (archived customer + open invoice can block kreditfaktura issuance). Added the pre-flight check: DELETE now returns 409 CUSTOMER_HAS_INVOICES with the open count when any open invoice (sent / partially_paid / overdue) references the customer. Docstring updated to match. - PATCH advertised archived_at: null for un-archive but did not actually apply it (field missing from updateData iteration). New V1PatchCustomerSchema extends UpdateCustomerSchema with archived_at restricted to literal null — agents can un-archive but cannot fake archive timestamps. The PATCH allowlist now includes archived_at. Defensive cleanups: - POST: VIES validation now resolves BEFORE the insert so vat_number_validated is set atomically in the primary write. Eliminates the stale-response window where the response could show vat_number_validated=false even though the secondary update succeeded. - PATCH: same pattern — VIES re-validation folded into the primary update payload. Single round-trip; response always reflects committed DB state. - CUSTOMER_RESPONSE_COLUMNS dropped vat_number_validated_at (internal timestamp; not declared in the CustomerCreated or CustomerDetail Zod schemas; no documented consumer). Pushing back on: - V8.2.1 cross-tenant membership check — false positive. The wrapper performs the company_members check before invoking the handler (lib/api/v1/with-api-v1.ts ~232). The swarm read handlers in isolation. - A.8.11 PATCH response masking for individual customer_types — deliberate detail-endpoint carve-out per PR-A. List masks, detail doesn't; that's the design. - CC6.3 separate customers:delete scope — every accounting API (Stripe, QuickBooks, Fortnox) conflates write + archive. Splitting violates principle of least surprise. - V4.5 schema allowlist enforcement — Zod already strips unknown keys; defense-in-depth at the DB-write layer is redundant. - A.5.34 eu_individual masking documentation — the value never existed in canonical CustomerTypeSchema; PR-A's enum was a hallucination this PR corrects. Nothing to document beyond the code comment that's now in place. - Swedish: country default 'Sweden' wrong for non-Swedish customer types — breaking schema change; defer. - Swedish: VAT-format regex pre-check before VIES — internal /api/customers route doesn't either; consistency over micro-validation. - Swedish: flag existing reverse-charge invoices when VIES turns vat_number_validated=false — substantial cross-resource workflow; defer to PR-B-2 or a dedicated compliance-tooling PR. - CC7.2 customer.updated / customer.archived event emission — adding new event types touches lib/events/types.ts AND the event-log-handler allowlist; defer to PR-C where webhooks will be the consumer. 3 new tests cover: archive-blocked-by-open-invoices, PATCH archived_at un-archive, PATCH archived_at rejects non-null. 3153/3153 vitest pass; build clean. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(api): second-pass review on PR #452 — defense-in-depth tweaks Two small adjustments after the second compliance-swarm sweep (13 → expected 11 findings, 0 blocking after this commit): - GDPR Art.5(1)(c) defense-in-depth: re-add 'eu_individual' to the INDIVIDUAL_TYPES masking set in the customer LIST handler. The value is not in the canonical CustomerTypeSchema (so new customers can never have it), but the `customer_type` DB column carries no CHECK constraint, so legacy rows from earlier schema iterations could in principle hold 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 remains a separate product decision the Swedish compliance review surfaced. - ISO 27001:2022 A.8.33: test bootstrap now asserts NODE_ENV === 'test'. Supabase clients are fully mocked, but if a future test refactor accidentally bypassed the mock, this guard fails the run rather than letting fixtures reach production. Stale comments from prior sweep — no action needed, fixes already in commit 5bb63489: - Greptile P1 "DELETE doesn't check open invoices" — handler now does (lines ~430-440 of [id]/route.ts); the inline comment is pinned to the original file lines and hasn't auto-resolved. - Greptile P1 "PATCH archived_at silently ignored" — V1PatchCustomerSchema now accepts archived_at: z.null().optional() and the field is in the iteration list. - Greptile P1 "example uses old enum" — updated to 'swedish_business'. False positive called out: - OWASP V2.4 "dry-run DELETE skips the open-invoice pre-check" — the pre-flight runs BEFORE the dry-run branch ([id]/route.ts ~430-445), so dry-run DELETE on a customer with open invoices DOES return 409. Pushing back (consistent with first-pass triage): - V8.2.1 × 3 cross-tenant check — wrapper does it (with-api-v1.ts ~232); false positive. - V2.3 / CC6.3 archive scope split — every accounting API conflates write + archive. - V4.5 customer_type cross-field invariants — schema-level cross-field validation; defer. - V16 transactional outbox — architectural; defer to PR-C webhooks. - Art.25 + A.5.12 + A.5.34 PATCH/dry-run preview masking — single-record detail context, deliberate carve-out per PR-A. - Swedish 'disputed' status — not in canonical InvoiceStatus enum. - Swedish 3-state VIES validation, personnummer-format check, mandatory country for non-SE types — all schema-level / cross-field; defer. 3153/3153 vitest pass; build clean. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(api): don't echo org_number in 409 conflict response (GDPR Art.5(1)(c)) For customer_type='individual', org_number IS the Swedish personnummer. The 409 CUSTOMER_DUPLICATE_ORG_NUMBER error detail previously included the submitted value, transmitting it through: - The HTTP response body - Server / observability logs - Any HTTP intermediary recording bodies The caller already knows what they submitted; the error code + a { field: 'org_number' } hint is enough. Drops the value from the detail in both POST /customers and PATCH /customers/:id. 3153/3153 tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../[companyId]/customers/[id]/route.ts | 343 ++++++++++++- .../customers/__tests__/route.test.ts | 474 +++++++++++++++++- .../companies/[companyId]/customers/route.ts | 262 +++++++++- lib/api/v1/dry-run.ts | 120 +++++ lib/api/v1/with-api-v1.ts | 16 +- lib/auth/scopes.ts | 5 +- 6 files changed, 1185 insertions(+), 35 deletions(-) create mode 100644 lib/api/v1/dry-run.ts 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',