From 32ad6da28c6a375a1a33c6692f6632b005261871 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Tue, 12 May 2026 21:49:22 +0200 Subject: [PATCH] feat(api): v1 invoice + customer reads (Phase 2 PR-A) (#451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): v1 invoice + customer read endpoints (Phase 2 PR-A) First slice of the Phase 2 invoices vertical. Read-only endpoints landing in this PR; writes + webhooks land in PR-B and PR-C. After all three a developer can ship an end-to-end invoicing integration. New endpoints (all wrapped, scoped, cursor-paginated): - GET /api/v1/companies/:companyId/invoices — list, filters: status, customer_id, document_type, currency. Cursor on (invoice_date DESC, id DESC). Customer name embedded inline; ?expand=customer for full record, ?expand=items for line items. - GET /api/v1/companies/:companyId/invoices/:id — detail with embedded customer. ?expand=items,payments. - GET /api/v1/companies/:companyId/customers — list, filters: customer_type, search (name/org_number prefix), include_archived. Cursor on (created_at ASC, id ASC). - GET /api/v1/companies/:companyId/customers/:id — detail. ?expand=invoices embeds open invoices in a single round-trip. Shared infra: - lib/api/v1/expand.ts — parseExpand() validates ?expand=a,b,c against a per-endpoint allowlist; unknown keys yield VALIDATION_ERROR with the full invalid list and the allowlist (agent-friendly). - All four routes register with the Zod schema registry so they show up in /api/v1/openapi.json with x-action-risk and use-when / do-not-use-for metadata. - Compound keyset filter on both list endpoints (per Greptile review on PR #450) — no skipped or duplicated rows on page boundaries. Tests: - lib/api/v1/__tests__/expand.test.ts (8 tests) - app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts (10 tests) - app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts (8 tests) Full repo suite green (3127/3127), build clean, lint clean on v1 paths. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(api): address PR #451 review (Greptile + compliance swarm) - Greptile P1 (customers search) + OWASP V1.2.5: customer search term now escapes both PostgREST .or() delimiters (,()) AND SQL LIKE wildcards (% _ \). '100%' searches for the literal string instead of any customer containing '100'. - OWASP V8.2.1 + V16.1: detail endpoints now UUID-validate the :id path param before touching the database, and no longer echo the raw id in the NOT_FOUND response details. Adds a structured warn log on 404 with the queried (id, companyId) for audit purposes. - V4.5 + Art.25(1) + A.8.3 / A.8.11 + CC6.3 + PI1.3 (~12 findings): every select('*') replaced with explicit column lists per the documented Zod schemas. Includes joined sub-queries — customer:customers(...), items:invoice_items(...), payments:invoice_payments(...). Future schema migrations adding sensitive columns must now update these projections before the field becomes visible on the public API. - A.8.5: hardcoded 'Bearer gnubok_sk_x' in test fixtures replaced with 'Bearer test-fixture-not-a-real-key' to avoid false-positive secret scanner alerts. Fixture UUIDs upgraded to valid v4 format (Zod 4's .uuid() enforces version+variant digits). - Art.5(1)(f): customer-invoices expansion soft-degrade now logs only the error code + message rather than the full Supabase error object. Pushing back on: - Art.5(1)(b) org_number in customer list — Bolagsverket-public data (same triage as PR #450; required by integration use case) - Art.25(2) customer_name always-joined — denormalising via trigger is a real schema migration for a marginal data-flow gain - A.8.15 _partial flag on soft-degrade — ?expand is documented as a hint 50/50 v1 tests; 3131/3131 full suite; build clean. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(api): second-pass review on PR #451 — partial_expansions + fake fixtures Address the residual compliance-swarm findings after the first fix round: - CC6.1 (medium): the customer-detail handler now sets meta.partial_expansions=['invoices'] when the ?expand=invoices subquery fails, signalling the degraded response to the caller without escalating to error-level logs (alert fatigue). The primary resource still returns with an empty invoices array. New ResponseOptions.partialExpansions threaded through buildMeta(). 1 new test for the failure path, plus a happy-path assertion that the flag is absent. - A.8.33 (low): SAMPLE_CUSTOMER fixture's org_number and vat_number replaced with 'TEST-0000-0001' / 'SETEST00000001' — cannot be confused with real Bolagsverket entries or pass external VIES validation. Pushing back on: - CC6.3 (medium) — separate scope for ?expand=items on invoices: every accounting API I know (Stripe, QuickBooks, Fortnox) treats line items as part of the invoice resource. Splitting would violate principle of least surprise for integrators; the plan deliberately treats invoices:read as covering the full invoice including items. 3132/3132 vitest pass; build clean. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(api): third-pass review on PR #451 — PII minimisation refinements Third compliance-swarm sweep (6 → 0 highs, 4 medium, 2 low). Addressed: - Art.5(1)(c) personnummer leakage: customer LIST response now masks org_number AND vat_number for customer_type IN ('individual', 'eu_individual') — for sole traders (enskild firma) org_number IS the personnummer. Business customers' Bolagsverket-public org_numbers stay visible. Detail endpoint (deliberate single-record fetch) unchanged. - Art.5(1)(c) over-broad invoice-list expansion: ?expand=customer on the invoice LIST endpoint now uses a new CUSTOMER_LIST_CONTEXT_COLUMNS projection (id, name, customer_type, email, country, archived_at) — full address/phone/notes/vat_number stay on the customer DETAIL endpoint. Drops PII transmitted in bulk-list contexts by ~60%. - A.8.15 permission-error differentiation: customer-detail soft-degrade for ?expand=invoices now bumps Postgres error class 42 (insufficient privilege, RLS denial) to error-level log so Sentry alerts on misconfigurations. Transient errors stay at warn. - PI1.1 ISO-4217 currency: invoice list ?currency now requires /^[A-Z]{3}$/ instead of accepting any 3-8 char string. Two new tests. Pushing back on: - Art.5(1)(f) UUID logging on 404 — UUIDs have 122 bits of entropy; you cannot enumerate the space, so the "log scraping = enumeration" framing doesn't hold. Operational audit value > theoretical risk. - Art.25(1) notes-by-default in customer DETAIL — kept inline. Detail is a deliberate single-record fetch; the dashboard shows notes inline; agents calling /customers/{id} reasonably expect them. Notes are already excluded from the LIST endpoint AND from the invoice-list ?expand=customer projection (above). 3135/3135 vitest pass; build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../[companyId]/customers/[id]/route.ts | 190 +++++++++ .../customers/__tests__/route.test.ts | 379 ++++++++++++++++++ .../companies/[companyId]/customers/route.ts | 214 ++++++++++ .../[companyId]/invoices/[id]/route.ts | 153 +++++++ .../invoices/__tests__/route.test.ts | 379 ++++++++++++++++++ .../companies/[companyId]/invoices/route.ts | 287 +++++++++++++ lib/api/v1/__tests__/expand.test.ts | 59 +++ lib/api/v1/expand.ts | 69 ++++ lib/api/v1/load-routes.ts | 6 + lib/api/v1/response.ts | 12 + lib/auth/scopes.ts | 8 + 11 files changed, 1756 insertions(+) create mode 100644 app/api/v1/companies/[companyId]/customers/[id]/route.ts create mode 100644 app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts create mode 100644 app/api/v1/companies/[companyId]/customers/route.ts create mode 100644 app/api/v1/companies/[companyId]/invoices/[id]/route.ts create mode 100644 app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts create mode 100644 app/api/v1/companies/[companyId]/invoices/route.ts create mode 100644 lib/api/v1/__tests__/expand.test.ts create mode 100644 lib/api/v1/expand.ts diff --git a/app/api/v1/companies/[companyId]/customers/[id]/route.ts b/app/api/v1/companies/[companyId]/customers/[id]/route.ts new file mode 100644 index 00000000..1d3ba797 --- /dev/null +++ b/app/api/v1/companies/[companyId]/customers/[id]/route.ts @@ -0,0 +1,190 @@ +/** + * GET /api/v1/companies/{companyId}/customers/{id} — customer detail. + * + * Returns the full customer record. Pass `?expand=invoices` to embed open + * (non-paid, non-cancelled, non-credited) invoices for the customer. + */ + +import { z } from 'zod' +import { ok } from '@/lib/api/v1/response' +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' + +const CustomerDetail = z.object({ + id: z.string().uuid(), + name: z.string(), + customer_type: z.string(), + 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(), + updated_at: z.string(), +}) + +const ALLOWED_EXPAND = ['invoices'] as const +const OPEN_INVOICE_STATUSES = ['sent', 'partially_paid', 'overdue'] + +// Explicit projection. Excludes user_id, company_id (internal scoping), +// and vat_number_validated_at (internal timestamp not in the public schema). +const CUSTOMER_DETAIL_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' + +const OPEN_INVOICE_COLUMNS = + 'id, invoice_number, invoice_date, due_date, status, currency, total, remaining_amount' + +registerEndpoint({ + operation: 'customers.get', + method: 'GET', + path: '/api/v1/companies/:companyId/customers/:id', + summary: 'Retrieve a single customer by id.', + description: + 'Returns the full customer record. Pass ?expand=invoices to embed any open invoices (sent / partially_paid / overdue) for the customer in the same response.', + useWhen: + 'You need the full customer record — address, payment terms, VAT validation status, contact details — before invoicing or syncing to another system.', + doNotUseFor: + 'Listing customers (use the list endpoint). Looking up arbitrary supplier or employee records (different resources).', + pitfalls: [ + 'archived_at is non-null when the customer has been soft-deleted; the customer is still queryable by id but excluded from default lists.', + 'vat_number_validated reflects the last successful VIES check; it can become stale if the EU registry revokes a number.', + ], + example: { + response: { + data: { + id: 'a8f1…', + name: 'Acme AB', + customer_type: 'business', + email: 'finance@acme.example', + org_number: '556677-8899', + vat_number: 'SE556677889901', + vat_number_validated: true, + country: 'Sweden', + default_payment_terms: 30, + archived_at: null, + created_at: '2025-04-12T08:30:00Z', + updated_at: '2026-04-30T11:22:09Z', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'customers:read', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: CustomerDetail }, +}) + +export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'customers.get', + async (request, ctx, params) => { + const { id } = await params.params + + // Defense in depth: validate the path id is a UUID before touching the + // database or reflecting it in error details. + 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 + + const url = new URL(request.url) + + const expandResult = parseExpand(url, ALLOWED_EXPAND) + if (!expandResult.ok) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'expand', + invalidKeys: expandResult.invalidKeys, + allowed: expandResult.allowed, + }, + }) + } + const expand = expandResult.expand + + const { data: customer, error } = await ctx.supabase + .from('customers') + .select(CUSTOMER_DETAIL_COLUMNS) + .eq('company_id', ctx.companyId!) + .eq('id', customerId) + .maybeSingle() + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + if (!customer) { + // Generic NOT_FOUND — do not echo the queried id back to the caller + // (enumeration hardening). + ctx.log.warn('customers.get: not found', { customerId, companyId: ctx.companyId }) + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'customer' }, + }) + } + + // Open invoices expansion — separate query to avoid bloating the + // customer base shape with a join that's only sometimes needed. + let invoices: unknown[] | undefined + const partialExpansions: string[] = [] + if (expand.has('invoices')) { + const { data: invs, error: invErr } = await ctx.supabase + .from('invoices') + .select(OPEN_INVOICE_COLUMNS) + .eq('company_id', ctx.companyId!) + .eq('customer_id', customerId) + .in('status', OPEN_INVOICE_STATUSES) + .order('invoice_date', { ascending: false }) + + if (invErr) { + // Soft-degrade: log but still return the customer. The agent gets + // the primary resource; ?expand is a hint, not a guarantee. + // `meta.partial_expansions` signals which expansions failed so + // careful callers can retry or fall back without parsing the body. + const errMsg = (invErr as { code?: string; message?: string }).message ?? 'unknown' + const errCode = (invErr as { code?: string }).code ?? 'unknown' + // Postgres error class 42 = "Syntax Error or Access Rule Violation" + // (includes 42501 insufficient_privilege). These indicate a real + // misconfiguration — a revoked grant or an incorrect RLS policy — + // and should reach Sentry/error monitoring rather than blending + // into informational warn logs. Other classes are typically + // transient (network, timeout) and stay at warn. + const isPermissionError = typeof errCode === 'string' && errCode.startsWith('42') + if (isPermissionError) { + ctx.log.error('customers.get: open-invoices expansion permission denied', new Error(errMsg), { + errCode, + customerId, + }) + } else { + ctx.log.warn('customers.get: open-invoices expansion failed', { errCode, errMsg }) + } + invoices = [] + partialExpansions.push('invoices') + } else { + invoices = invs ?? [] + } + } + + return ok( + { ...customer, ...(invoices !== undefined ? { invoices } : {}) }, + { + requestId: ctx.requestId, + partialExpansions: partialExpansions.length > 0 ? partialExpansions : undefined, + }, + ) + }, +) diff --git a/app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts new file mode 100644 index 00000000..331d33ea --- /dev/null +++ b/app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts @@ -0,0 +1,379 @@ +/** + * Integration tests for GET /api/v1/companies/:companyId/customers and + * /api/v1/companies/:companyId/customers/:id. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321' + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key' +}) + +vi.mock('@/lib/auth/api-keys', async () => { + const actual = await vi.importActual('@/lib/auth/api-keys') + return { + ...actual, + validateApiKey: vi.fn(), + createServiceClientNoCookies: vi.fn(), + } +}) + +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { GET as listCustomers } from '../route' +import { GET as getCustomer } from '../[id]/route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType + +function makeFlexibleSupabase(byTable: Record) { + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => + resolve(byTable[table] ?? { data: null, error: null }) + } + return (..._args: unknown[]) => buildChain(table) + }, + } + return new Proxy({}, handler) + } + return { from: vi.fn((table: string) => buildChain(table)) } +} + +const COMPANY_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' +const CUSTOMER_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const USER_ID = 'user-1' + +function makeRequest(url: string, init?: RequestInit): Request { + return new Request(url, { + ...init, + headers: { Authorization: 'Bearer test-fixture-not-a-real-key', ...(init?.headers ?? {}) }, + }) +} + +function companyParams(companyId: string) { + return { params: Promise.resolve({ companyId }) } +} + +function detailParams(companyId: string, id: string) { + return { params: Promise.resolve({ companyId, id }) } +} + +beforeEach(() => { + vi.clearAllMocks() + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + apiKeyName: 'CI key', + scopes: ['customers:read'], + mode: 'live', + }) +}) + +// Deliberately fake org_number / vat_number: cannot be confused with real +// Bolagsverket-registered entities and cannot pass VIES validation. The +// 'TEST-' prefix makes it obvious to log scrapers and secret scanners that +// these are test fixtures. +const SAMPLE_CUSTOMER = { + id: CUSTOMER_ID, + name: 'Acme AB', + customer_type: 'business', + email: 'a@acme.test', + phone: null, + address_line1: null, + address_line2: null, + postal_code: null, + city: null, + country: 'Sweden', + org_number: 'TEST-0000-0001', + vat_number: 'SETEST00000001', + vat_number_validated: true, + vat_number_validated_at: '2025-04-12T09:00:00Z', + default_payment_terms: 30, + notes: null, + archived_at: null, + created_at: '2025-04-12T08:30:00Z', + updated_at: '2026-04-30T11:22:09Z', +} + +describe('GET /api/v1/companies/:companyId/customers', () => { + it('returns a paginated customer list, excluding archived rows by default', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: [SAMPLE_CUSTOMER], error: null }, + }), + ) + + const res = await listCustomers( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers`), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toHaveLength(1) + expect(body.data[0].name).toBe('Acme AB') + expect(body.data[0].org_number).toBe('TEST-0000-0001') + }) + + it('masks org_number and vat_number in the list response for individual customer_types', async () => { + const individual = { + ...SAMPLE_CUSTOMER, + id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + customer_type: 'individual', + org_number: '195512319876', // would be a personnummer in real life + vat_number: null, + } + const business = { + ...SAMPLE_CUSTOMER, + customer_type: 'business', + } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: [individual, business], error: null }, + }), + ) + + const res = await listCustomers( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers`), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toHaveLength(2) + // Individual: org_number & vat_number masked to null in the list. + const individualRow = body.data.find((c: { customer_type: string }) => c.customer_type === 'individual') + 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') + expect(businessRow.org_number).toBe('TEST-0000-0001') + expect(businessRow.vat_number).toBe('SETEST00000001') + }) + + it('accepts include_archived=true', async () => { + const archived = { ...SAMPLE_CUSTOMER, archived_at: '2026-01-01T00:00:00Z' } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: [SAMPLE_CUSTOMER, archived], error: null }, + }), + ) + + const res = await listCustomers( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers?include_archived=true`), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toHaveLength(2) + }) + + it('rejects an invalid customer_type filter', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await listCustomers( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers?customer_type=alien`), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it('emits a next_cursor when the page is full', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { + data: [ + SAMPLE_CUSTOMER, + { ...SAMPLE_CUSTOMER, id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' }, + ], + error: null, + }, + }), + ) + + const res = await listCustomers( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers?limit=1`), + companyParams(COMPANY_ID), + ) + + const body = await res.json() + expect(body.data).toHaveLength(1) + expect(body.meta.next_cursor).toBeTruthy() + }) +}) + +describe('GET /api/v1/companies/:companyId/customers/:id', () => { + it('returns the customer record', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: SAMPLE_CUSTOMER, error: null }, + }), + ) + + const res = await getCustomer( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}`), + detailParams(COMPANY_ID, CUSTOMER_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.id).toBe(CUSTOMER_ID) + expect(body.data.name).toBe('Acme AB') + // Default response MUST NOT include the invoices expansion. + expect(body.data.invoices).toBeUndefined() + }) + + it('soft-degrades and signals partial_expansions when the invoices subquery fails', async () => { + // Custom mock: customers succeeds, invoices subquery returns an error. + const supabaseMock = { + from: vi.fn((table: string) => { + const result = + table === 'company_members' + ? { data: { company_id: COMPANY_ID, role: 'owner' }, error: null } + : table === 'customers' + ? { data: SAMPLE_CUSTOMER, error: null } + : table === 'invoices' + ? { data: null, error: { code: '42501', message: 'permission denied for table invoices' } } + : { data: null, error: null } + const handler: ProxyHandler = { + get(_t, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => resolve(result) + } + return (..._args: unknown[]) => new Proxy({}, handler) + }, + } + return new Proxy({}, handler) + }), + } + mockServiceClient.mockReturnValue(supabaseMock) + + const res = await getCustomer( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}?expand=invoices`, + ), + detailParams(COMPANY_ID, CUSTOMER_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + // Primary resource still returns. + expect(body.data.id).toBe(CUSTOMER_ID) + // Failed expansion falls back to an empty array … + expect(body.data.invoices).toEqual([]) + // … and the caller is signalled via meta so they can detect the + // degraded response without re-parsing the body. + expect(body.meta.partial_expansions).toEqual(['invoices']) + }) + + it('embeds open invoices when ?expand=invoices is requested', async () => { + const openInvoice = { + id: 'inv-open-1', + invoice_number: '2026-0001', + invoice_date: '2026-04-01', + due_date: '2026-04-30', + status: 'sent', + currency: 'SEK', + total: 5000, + remaining_amount: 5000, + } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: SAMPLE_CUSTOMER, error: null }, + invoices: { data: [openInvoice], error: null }, + }), + ) + + const res = await getCustomer( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}?expand=invoices`, + ), + detailParams(COMPANY_ID, CUSTOMER_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.invoices).toHaveLength(1) + expect(body.data.invoices[0].id).toBe('inv-open-1') + // Successful expansion MUST NOT set the partial flag. + expect(body.meta.partial_expansions).toBeUndefined() + }) + + it('returns 404 when the customer does not exist for the company', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: null, error: null }, + }), + ) + + const res = await getCustomer( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}`), + detailParams(COMPANY_ID, CUSTOMER_ID), + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('NOT_FOUND') + }) + + it('returns 400 VALIDATION_ERROR when :id is not a UUID', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await getCustomer( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/not-a-uuid`), + detailParams(COMPANY_ID, 'not-a-uuid'), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details.field).toBe('id') + }) + + it('does not echo the queried id on 404 (enumeration hardening)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: null, error: null }, + }), + ) + + const res = await getCustomer( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/${CUSTOMER_ID}`), + detailParams(COMPANY_ID, CUSTOMER_ID), + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('NOT_FOUND') + expect(body.error.details).toEqual({ resource: 'customer' }) + expect(body.error.details.id).toBeUndefined() + }) +}) diff --git a/app/api/v1/companies/[companyId]/customers/route.ts b/app/api/v1/companies/[companyId]/customers/route.ts new file mode 100644 index 00000000..291bf93c --- /dev/null +++ b/app/api/v1/companies/[companyId]/customers/route.ts @@ -0,0 +1,214 @@ +/** + * GET /api/v1/companies/{companyId}/customers — list customers. + * + * 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) + */ + +import { z } from 'zod' +import { paginated } from '@/lib/api/v1/response' +import { + decodeDefaultCursor, + encodeDefaultCursor, + parsePaginationParams, +} from '@/lib/api/v1/pagination' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' + +const CustomerType = z.enum([ + 'individual', + 'business', + 'eu_business', + 'eu_individual', + 'non_eu', +]) + +const CustomerSummary = z.object({ + id: z.string().uuid(), + name: z.string(), + customer_type: CustomerType, + email: z.string().nullable(), + org_number: z.string().nullable(), + vat_number: z.string().nullable(), + default_payment_terms: z.number(), + archived_at: z.string().nullable(), + created_at: z.string(), +}) + +const CustomersListResponse = z.object({ + customers: z.array(CustomerSummary), +}) + +// Explicit projection — never SELECT *. Schema migrations adding columns +// must update this list before the field becomes visible on the public API. +const CUSTOMER_SUMMARY_COLUMNS = + 'id, name, customer_type, email, org_number, vat_number, default_payment_terms, archived_at, created_at' + +registerEndpoint({ + operation: 'customers.list', + method: 'GET', + path: '/api/v1/companies/:companyId/customers', + summary: 'List customers for a company.', + description: + 'Returns active customers in created-first order. Pass ?include_archived=true to include archived rows. Use ?search to match against name or org_number.', + useWhen: + 'You need a customer roster — for building a UI picker, syncing a CRM, or resolving a customer_id before creating an invoice.', + doNotUseFor: + 'Fetching a single customer you already know the id of — use GET /api/v1/companies/{companyId}/customers/{id}. Suppliers are a separate resource.', + pitfalls: [ + 'Archived customers are hidden by default; the dashboard makes the same choice.', + 'org_number is included so callers can match against external CRM identifiers; for sole traders (enskild firma) it equals the personnummer.', + ], + example: { + response: { + data: [ + { + id: 'a8f1…', + name: 'Acme AB', + customer_type: 'business', + email: 'finance@acme.example', + org_number: '556677-8899', + vat_number: 'SE556677889901', + default_payment_terms: 30, + archived_at: null, + created_at: '2025-04-12T08:30:00Z', + }, + ], + meta: { request_id: 'req_…', api_version: '2026-05-12', next_cursor: null }, + }, + }, + scope: 'customers:read', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: CustomersListResponse }, +}) + +export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'customers.list', + async (request, ctx) => { + const url = new URL(request.url) + const { limit, cursor } = parsePaginationParams(url) + const decoded = decodeDefaultCursor(cursor) + + const FiltersSchema = z.object({ + customer_type: CustomerType.optional(), + search: z.string().min(1).max(200).optional(), + include_archived: z.enum(['true', 'false']).optional(), + }) + const filtersResult = FiltersSchema.safeParse({ + customer_type: url.searchParams.get('customer_type') ?? undefined, + search: url.searchParams.get('search') ?? undefined, + include_archived: url.searchParams.get('include_archived') ?? undefined, + }) + if (!filtersResult.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: filtersResult.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })), + }, + }) + } + const filters = filtersResult.data + const includeArchived = filters.include_archived === 'true' + + let query = ctx.supabase + .from('customers') + .select(CUSTOMER_SUMMARY_COLUMNS) + .eq('company_id', ctx.companyId!) + .order('created_at', { ascending: true }) + .order('id', { ascending: true }) + .limit(limit + 1) + + if (!includeArchived) { + query = query.is('archived_at', null) + } + if (filters.customer_type) { + query = query.eq('customer_type', filters.customer_type) + } + if (filters.search) { + // Build a safe ilike pattern. Two layers of escaping: + // 1. PostgREST `.or()` filter syntax uses commas + parens as + // delimiters; strip them from the user-supplied term. + // 2. SQL LIKE treats `%` and `_` (and `\` as the default escape) as + // wildcards; escape them so '100%' searches for the literal + // string '100%' rather than 'anything containing 100'. + const term = filters.search + .replace(/[,()]/g, '') // PostgREST delimiters + .replace(/[%_\\]/g, '\\$&') // LIKE wildcards + query = query.or(`name.ilike.%${term}%,org_number.ilike.${term}%`) + } + + if (decoded) { + query = query.or( + `created_at.gt.${decoded.ts},and(created_at.eq.${decoded.ts},id.gt.${decoded.id})`, + ) + } + + const { data, error } = await query + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + + type Row = { + id: string + name: string + customer_type: string + email: string | null + org_number: string | null + vat_number: string | null + default_payment_terms: number + archived_at: string | null + created_at: string + } & Record + + const rows = ((data ?? []) as unknown) as Row[] + 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 + // 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. + const INDIVIDUAL_TYPES = new Set(['individual', 'eu_individual']) + + const customers = trimmed.map((r) => { + const isIndividual = INDIVIDUAL_TYPES.has(r.customer_type) + return { + id: r.id, + name: r.name, + customer_type: r.customer_type, + email: r.email, + org_number: isIndividual ? null : r.org_number, + vat_number: isIndividual ? null : r.vat_number, + default_payment_terms: r.default_payment_terms, + archived_at: r.archived_at, + created_at: r.created_at, + } + }) + + const last = trimmed[trimmed.length - 1] + const nextCursor = hasMore && last + ? encodeDefaultCursor({ id: last.id, created_at: last.created_at }) + : null + + return paginated(customers, { + requestId: ctx.requestId, + nextCursor: nextCursor ?? undefined, + }) + }, +) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/route.ts new file mode 100644 index 00000000..f242b54e --- /dev/null +++ b/app/api/v1/companies/[companyId]/invoices/[id]/route.ts @@ -0,0 +1,153 @@ +/** + * GET /api/v1/companies/{companyId}/invoices/{id} — invoice detail. + * + * Returns the full invoice record. Customer is embedded by default (the + * detail endpoint is verbose by design); line items and payments require + * `?expand=items,payments` to keep the default response shape predictable. + */ + +import { z } from 'zod' +import { ok } from '@/lib/api/v1/response' +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' + +// Loose schema — detail responses carry many fields, and pinning the exact +// types in the registry is overkill until Phase 2 PR-B introduces writes +// that reuse the schema for validation. +const InvoiceDetail = z.object({ + id: z.string().uuid(), + invoice_number: z.string().nullable(), + customer_id: z.string().uuid(), + invoice_date: z.string(), + due_date: z.string(), + status: z.string(), + document_type: z.string(), + currency: z.string(), + total: z.number(), + remaining_amount: z.number(), + paid_at: z.string().nullable(), + created_at: z.string(), +}) + +const ALLOWED_EXPAND = ['items', 'payments'] as const + +// Explicit projections. Detail endpoint is more verbose than list — includes +// VAT treatment, conversion, FX, and notes — but still drops user_id and +// company_id (internal scoping). +const INVOICE_DETAIL_COLUMNS = + 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, created_at, updated_at' + +const CUSTOMER_DETAIL_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' + +const INVOICE_ITEM_COLUMNS = + 'id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, created_at' + +// Payment projection — drops invoice_id (redundant on the parent), user_id, +// company_id (internal scoping). +const INVOICE_PAYMENT_COLUMNS = + 'id, payment_date, amount, currency, exchange_rate, exchange_rate_difference, journal_entry_id, transaction_id, notes, created_at' + +registerEndpoint({ + operation: 'invoices.get', + method: 'GET', + path: '/api/v1/companies/:companyId/invoices/:id', + summary: 'Retrieve a single invoice by id.', + description: + 'Returns the full invoice record with the customer embedded. Pass ?expand=items for line items, ?expand=payments for payment history, or ?expand=items,payments for both.', + useWhen: + 'You have an invoice id (from a webhook, the list endpoint, or a customer transaction) and need the full record including amounts, dates, status, and the customer details.', + doNotUseFor: + 'Listing invoices (use GET /api/v1/companies/{companyId}/invoices). Bookkeeping verifikationer tied to the invoice (use the journal-entries endpoints in a later phase).', + pitfalls: [ + 'Returns 404 if the invoice does not belong to the company in the URL — does not leak existence across companies.', + 'paid_at and remaining_amount can lag behind the latest payment by a few seconds during high-volume reconciliation.', + ], + example: { + response: { + data: { + id: '0e9c…', + invoice_number: '2026-0042', + customer_id: 'a8f1…', + customer: { id: 'a8f1…', name: 'Acme AB' }, + invoice_date: '2026-05-01', + due_date: '2026-05-31', + status: 'sent', + total: 12500, + remaining_amount: 12500, + paid_at: null, + created_at: '2026-05-01T09:14:33Z', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'invoices:read', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: InvoiceDetail }, +}) + +export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'invoices.get', + async (request, ctx, params) => { + const { id } = await params.params + + // Defense in depth: validate the path id is a UUID before touching the + // database or reflecting it in error details. + const idParse = z.string().uuid().safeParse(id) + if (!idParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Invoice id must be a UUID.' }, + }) + } + const invoiceId = idParse.data + + const url = new URL(request.url) + + const expandResult = parseExpand(url, ALLOWED_EXPAND) + if (!expandResult.ok) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'expand', + invalidKeys: expandResult.invalidKeys, + allowed: expandResult.allowed, + }, + }) + } + const expand = expandResult.expand + + const itemsSelect = expand.has('items') ? `, items:invoice_items(${INVOICE_ITEM_COLUMNS})` : '' + const paymentsSelect = expand.has('payments') + ? `, payments:invoice_payments(${INVOICE_PAYMENT_COLUMNS})` + : '' + const selectClause = `${INVOICE_DETAIL_COLUMNS}, customer:customers(${CUSTOMER_DETAIL_COLUMNS})${itemsSelect}${paymentsSelect}` + + const { data, error } = await ctx.supabase + .from('invoices') + .select(selectClause) + .eq('company_id', ctx.companyId!) + .eq('id', invoiceId) + .maybeSingle() + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + + if (!data) { + // Generic NOT_FOUND — do not echo the queried id back to the caller. + ctx.log.warn('invoices.get: not found', { invoiceId, companyId: ctx.companyId }) + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'invoice' }, + }) + } + + return ok(data, { requestId: ctx.requestId }) + }, +) diff --git a/app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts new file mode 100644 index 00000000..f5e5cc8d --- /dev/null +++ b/app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts @@ -0,0 +1,379 @@ +/** + * Integration tests for GET /api/v1/companies/:companyId/invoices and + * /api/v1/companies/:companyId/invoices/:id. + * + * Mocks validateApiKey + the service-role Supabase client. The mock supports + * per-table results so the wrapper's `company_members` membership check and + * the handler's `invoices` query both resolve correctly in the same call. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321' + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key' +}) + +vi.mock('@/lib/auth/api-keys', async () => { + const actual = await vi.importActual('@/lib/auth/api-keys') + return { + ...actual, + validateApiKey: vi.fn(), + createServiceClientNoCookies: vi.fn(), + } +}) + +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { GET as listInvoices } from '../route' +import { GET as getInvoice } from '../[id]/route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType + +/** + * Build a Supabase client mock keyed by table name. Every chained method + * call returns a proxy that resolves to byTable[table] when awaited. + */ +function makeFlexibleSupabase(byTable: Record) { + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => + resolve(byTable[table] ?? { data: null, error: null }) + } + return (..._args: unknown[]) => buildChain(table) + }, + } + return new Proxy({}, handler) + } + return { from: vi.fn((table: string) => buildChain(table)) } +} + +const COMPANY_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' +const INVOICE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const CUSTOMER_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const USER_ID = 'user-1' + +function makeRequest(url: string, init?: RequestInit): Request { + return new Request(url, { + ...init, + headers: { Authorization: 'Bearer test-fixture-not-a-real-key', ...(init?.headers ?? {}) }, + }) +} + +function companyParams(companyId: string) { + return { params: Promise.resolve({ companyId }) } +} + +function detailParams(companyId: string, id: string) { + return { params: Promise.resolve({ companyId, id }) } +} + +beforeEach(() => { + vi.clearAllMocks() + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + apiKeyName: 'CI key', + scopes: ['invoices:read'], + mode: 'live', + }) +}) + +const SAMPLE_INVOICE = { + id: INVOICE_ID, + invoice_number: '2026-0042', + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-01', + due_date: '2026-05-31', + status: 'sent', + document_type: 'invoice', + currency: 'SEK', + subtotal: 10000, + vat_amount: 2500, + total: 12500, + remaining_amount: 12500, + paid_at: null, + created_at: '2026-05-01T09:14:33Z', + customer: { id: CUSTOMER_ID, name: 'Acme AB' }, +} + +describe('GET /api/v1/companies/:companyId/invoices', () => { + it('returns a paginated invoice list with inline customer_name', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: [SAMPLE_INVOICE], error: null }, + }), + ) + + const res = await listInvoices( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices`), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toHaveLength(1) + expect(body.data[0].customer_name).toBe('Acme AB') + expect(body.data[0].invoice_number).toBe('2026-0042') + // Default response shape MUST NOT include the full customer object. + expect(body.data[0].customer).toBeUndefined() + expect(body.meta.request_id).toMatch(/^req_/) + }) + + it('embeds the full customer when ?expand=customer is requested', async () => { + const sampleWithFullCustomer = { + ...SAMPLE_INVOICE, + customer: { + id: CUSTOMER_ID, + name: 'Acme AB', + email: 'a@acme.test', + country: 'Sweden', + }, + } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: [sampleWithFullCustomer], error: null }, + }), + ) + + const res = await listInvoices( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices?expand=customer`), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data[0].customer).toEqual(sampleWithFullCustomer.customer) + }) + + it('rejects unknown ?expand values with VALIDATION_ERROR', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await listInvoices( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices?expand=bogus`), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details.invalidKeys).toEqual(['bogus']) + }) + + it('rejects an invalid currency filter (not ISO-4217)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await listInvoices( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices?currency=sek`), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it('accepts a valid ISO-4217 currency code', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: [SAMPLE_INVOICE], error: null }, + }), + ) + + const res = await listInvoices( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices?currency=SEK`), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + }) + + it('rejects an invalid status filter', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await listInvoices( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices?status=quantum`), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it('emits a next_cursor when the page is full', async () => { + const overFetched = [ + SAMPLE_INVOICE, + { ...SAMPLE_INVOICE, id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' }, + ] + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: overFetched, error: null }, + }), + ) + + const res = await listInvoices( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices?limit=1`), + companyParams(COMPANY_ID), + ) + + const body = await res.json() + expect(body.data).toHaveLength(1) + expect(body.meta.next_cursor).toBeTruthy() + }) +}) + +describe('GET /api/v1/companies/:companyId/invoices/:id', () => { + it('returns the invoice with the embedded customer', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: SAMPLE_INVOICE, error: null }, + }), + ) + + const res = await getInvoice( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}`), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.id).toBe(INVOICE_ID) + expect(body.data.customer.name).toBe('Acme AB') + }) + + it('returns 404 when the invoice does not exist for the company', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: null, error: null }, + }), + ) + + const res = await getInvoice( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}`), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('NOT_FOUND') + }) + + it('rejects unknown ?expand values', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await getInvoice( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}?expand=foo`), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it('returns 400 VALIDATION_ERROR when :id is not a UUID', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await getInvoice( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/not-a-uuid`), + detailParams(COMPANY_ID, 'not-a-uuid'), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details.field).toBe('id') + }) + + it('does not echo the queried id on 404 (enumeration hardening)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: null, error: null }, + }), + ) + + const res = await getInvoice( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}`), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('NOT_FOUND') + expect(body.error.details).toEqual({ resource: 'invoice' }) + expect(body.error.details.id).toBeUndefined() + }) +}) + +describe('scope enforcement', () => { + it('returns 403 INSUFFICIENT_SCOPE when key lacks invoices:read', async () => { + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + scopes: ['customers:read'], + mode: 'live', + }) + mockServiceClient.mockReturnValue(makeFlexibleSupabase({})) + + const res = await listInvoices( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices`), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(403) + const body = await res.json() + expect(body.error.code).toBe('INSUFFICIENT_SCOPE') + }) + + it('returns 404 when the URL companyId is not one the key user belongs to', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: null, error: null }, // no membership + }), + ) + + const res = await listInvoices( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices`), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('NOT_FOUND') + }) +}) diff --git a/app/api/v1/companies/[companyId]/invoices/route.ts b/app/api/v1/companies/[companyId]/invoices/route.ts new file mode 100644 index 00000000..7ae52715 --- /dev/null +++ b/app/api/v1/companies/[companyId]/invoices/route.ts @@ -0,0 +1,287 @@ +/** + * GET /api/v1/companies/{companyId}/invoices — list invoices. + * + * Cursor pagination on (invoice_date DESC, id DESC) — most recent first to + * match AR UX. Customer name is denormalised into the response so the agent + * doesn't need an N+1 fetch for display; use `?expand=customer` for the full + * customer record. + * + * Filters (all optional): + * - status single InvoiceStatus + * - customer_id UUID + * - document_type 'invoice' | 'proforma' | 'delivery_note' + * - currency ISO-4217 code + */ + +import { z } from 'zod' +import { paginated } from '@/lib/api/v1/response' +import { + decodeDefaultCursor, + encodeDefaultCursor, + parsePaginationParams, +} from '@/lib/api/v1/pagination' +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' + +const InvoiceStatus = z.enum([ + 'draft', + 'sent', + 'paid', + 'partially_paid', + 'overdue', + 'cancelled', + 'credited', +]) + +const InvoiceDocumentType = z.enum(['invoice', 'proforma', 'delivery_note']) + +const InvoiceSummary = z.object({ + id: z.string().uuid(), + invoice_number: z.string().nullable(), + customer_id: z.string().uuid(), + customer_name: z.string(), + invoice_date: z.string(), + due_date: z.string(), + status: InvoiceStatus, + document_type: InvoiceDocumentType, + currency: z.string(), + subtotal: z.number(), + vat_amount: z.number(), + total: z.number(), + remaining_amount: z.number(), + paid_at: z.string().nullable(), + created_at: z.string(), +}) + +const InvoicesListResponse = z.object({ + invoices: z.array(InvoiceSummary), +}) + +const ALLOWED_EXPAND = ['customer', 'items'] as const + +// Explicit projection — excludes user_id, company_id, and SEK-conversion +// fields not in the summary schema. Schema migrations adding columns must +// update this list before the field becomes visible on the public API. +const INVOICE_SUMMARY_COLUMNS = + 'id, invoice_number, customer_id, invoice_date, due_date, status, document_type, currency, subtotal, vat_amount, total, remaining_amount, paid_at, created_at' + +// Customer projections — three tiers for different contexts: +// - NAME_ONLY: default for the invoice list (inline customer_name only) +// - LIST_CONTEXT: ?expand=customer in a LIST endpoint. Contact-summary +// subset only — full PII like address/phone/notes/vat_number lives on +// the dedicated customer detail endpoint. GDPR Art.5(1)(c) +// data-minimisation: bulk fetches should not transmit a full PII +// record per row. +// All projections deliberately omit user_id, company_id, and +// vat_number_validated_at (internal scoping / timestamp). +const CUSTOMER_NAME_ONLY_COLUMNS = 'id, name' +const CUSTOMER_LIST_CONTEXT_COLUMNS = 'id, name, customer_type, email, country, archived_at' + +// Invoice items projection — excludes invoice_id (redundant) and internal +// linkage fields not in the documented response shape. +const INVOICE_ITEM_COLUMNS = + 'id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, created_at' + +registerEndpoint({ + operation: 'invoices.list', + method: 'GET', + path: '/api/v1/companies/:companyId/invoices', + summary: 'List invoices for a company.', + description: + 'Returns invoices in most-recent-first order. Includes the customer name inline; pass ?expand=customer for the full customer record, ?expand=items for line items.', + useWhen: + 'You need to enumerate invoices for a company — for AR reporting, payment matching, or building an invoice dashboard.', + doNotUseFor: + 'Fetching a single invoice you already know the id of — use GET /api/v1/companies/{companyId}/invoices/{id}. Supplier invoices are a different resource (supplier-invoices).', + pitfalls: [ + 'Draft invoices have invoice_number=null until they are sent.', + 'remaining_amount is the unpaid portion (total − paid_amount); use status=paid or remaining_amount=0 to filter for closed invoices.', + 'Credit notes appear with status=credited and a credited_invoice_id field on the detail endpoint.', + ], + example: { + response: { + data: [ + { + id: '0e9c…', + invoice_number: '2026-0042', + customer_id: 'a8f1…', + customer_name: 'Acme AB', + invoice_date: '2026-05-01', + due_date: '2026-05-31', + status: 'sent', + document_type: 'invoice', + currency: 'SEK', + subtotal: 10000, + vat_amount: 2500, + total: 12500, + remaining_amount: 12500, + paid_at: null, + created_at: '2026-05-01T09:14:33Z', + }, + ], + meta: { request_id: 'req_…', api_version: '2026-05-12', next_cursor: null }, + }, + }, + scope: 'invoices:read', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: InvoicesListResponse }, +}) + +export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'invoices.list', + async (request, ctx) => { + const url = new URL(request.url) + const { limit, cursor } = parsePaginationParams(url) + const decoded = decodeDefaultCursor(cursor) + + // Validate ?expand against the allowlist; reject unknown values clearly. + const expandResult = parseExpand(url, ALLOWED_EXPAND) + if (!expandResult.ok) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'expand', + invalidKeys: expandResult.invalidKeys, + allowed: expandResult.allowed, + }, + }) + } + const expand = expandResult.expand + + // Validate query filters. Currency is strict ISO-4217 (3 uppercase + // letters) — accepting arbitrary 3-8 char strings would pass through + // to the DB filter without serving any documented purpose. + const FiltersSchema = z.object({ + status: InvoiceStatus.optional(), + customer_id: z.string().uuid().optional(), + document_type: InvoiceDocumentType.optional(), + currency: z.string().regex(/^[A-Z]{3}$/, 'currency must be a 3-letter ISO-4217 code').optional(), + }) + const filtersResult = FiltersSchema.safeParse({ + status: url.searchParams.get('status') ?? undefined, + customer_id: url.searchParams.get('customer_id') ?? undefined, + document_type: url.searchParams.get('document_type') ?? undefined, + currency: url.searchParams.get('currency') ?? undefined, + }) + if (!filtersResult.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: filtersResult.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })), + }, + }) + } + const filters = filtersResult.data + + // Build the select clause. customer is always joined for the inline + // customer_name; ?expand=customer upgrades it from a name-only shape to + // the full record. ?expand=items pulls line items. + const customerSelect = expand.has('customer') + ? `customer:customers(${CUSTOMER_LIST_CONTEXT_COLUMNS})` + : `customer:customers(${CUSTOMER_NAME_ONLY_COLUMNS})` + const itemsSelect = expand.has('items') ? `, items:invoice_items(${INVOICE_ITEM_COLUMNS})` : '' + const selectClause = `${INVOICE_SUMMARY_COLUMNS}, ${customerSelect}${itemsSelect}` + + let query = ctx.supabase + .from('invoices') + .select(selectClause) + .eq('company_id', ctx.companyId!) + .order('invoice_date', { ascending: false }) + .order('id', { ascending: false }) + .limit(limit + 1) + + if (filters.status) query = query.eq('status', filters.status) + if (filters.customer_id) query = query.eq('customer_id', filters.customer_id) + if (filters.document_type) query = query.eq('document_type', filters.document_type) + if (filters.currency) query = query.eq('currency', filters.currency) + + if (decoded) { + // Keyset on (invoice_date DESC, id DESC): + // invoice_date < ts OR (invoice_date = ts AND id < cursor_id) + query = query.or( + `invoice_date.lt.${decoded.ts},and(invoice_date.eq.${decoded.ts},id.lt.${decoded.id})`, + ) + } + + const { data, error } = await query + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + + // The joined customer can return as either an object or a single-element + // array, mirroring the pattern in /companies. Pick safely. + type CustomerObj = { id: string; name: string } & Record + type InvoiceRow = { + id: string + invoice_number: string | null + customer_id: string + invoice_date: string + due_date: string + status: string + document_type: string + currency: string + subtotal: number + vat_amount: number + total: number + remaining_amount: number + paid_at: string | null + created_at: string + customer: CustomerObj | CustomerObj[] | null + items?: unknown + } & Record + + const rows = ((data ?? []) as unknown) as InvoiceRow[] + const trimmed = rows.slice(0, limit) + const hasMore = rows.length > limit + + const pickCustomer = (c: InvoiceRow['customer']): CustomerObj | null => { + if (!c) return null + return Array.isArray(c) ? (c[0] ?? null) : c + } + + const invoices = trimmed.map((r) => { + const c = pickCustomer(r.customer) + const base = { + id: r.id, + invoice_number: r.invoice_number, + customer_id: r.customer_id, + customer_name: c?.name ?? '', + invoice_date: r.invoice_date, + due_date: r.due_date, + status: r.status, + document_type: r.document_type, + currency: r.currency, + subtotal: r.subtotal, + vat_amount: r.vat_amount, + total: r.total, + remaining_amount: r.remaining_amount, + paid_at: r.paid_at, + created_at: r.created_at, + } + return { + ...base, + ...(expand.has('customer') && c ? { customer: c } : {}), + ...(expand.has('items') ? { items: r.items ?? [] } : {}), + } + }) + + const last = trimmed[trimmed.length - 1] + const nextCursor = hasMore && last + ? encodeDefaultCursor({ id: last.id, created_at: last.invoice_date }) + : null + + return paginated(invoices, { + requestId: ctx.requestId, + nextCursor: nextCursor ?? undefined, + }) + }, +) diff --git a/lib/api/v1/__tests__/expand.test.ts b/lib/api/v1/__tests__/expand.test.ts new file mode 100644 index 00000000..c2172e19 --- /dev/null +++ b/lib/api/v1/__tests__/expand.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { parseExpand } from '../expand' + +const ALLOWED = ['customer', 'items', 'payments'] as const + +const urlWith = (q: string): URL => new URL(`https://x.test/path${q}`) + +describe('parseExpand', () => { + it('returns an empty Set when no expand param is present', () => { + const r = parseExpand(urlWith(''), ALLOWED) + expect(r.ok).toBe(true) + if (r.ok) expect(r.expand.size).toBe(0) + }) + + it('parses a single key', () => { + const r = parseExpand(urlWith('?expand=customer'), ALLOWED) + expect(r.ok).toBe(true) + if (r.ok) expect([...r.expand]).toEqual(['customer']) + }) + + it('parses multiple comma-separated keys', () => { + const r = parseExpand(urlWith('?expand=customer,items'), ALLOWED) + expect(r.ok).toBe(true) + if (r.ok) expect([...r.expand].sort()).toEqual(['customer', 'items']) + }) + + it('trims whitespace around keys', () => { + const r = parseExpand(urlWith('?expand=customer , items'), ALLOWED) + expect(r.ok).toBe(true) + if (r.ok) expect([...r.expand].sort()).toEqual(['customer', 'items']) + }) + + it('collapses duplicates', () => { + const r = parseExpand(urlWith('?expand=customer,customer'), ALLOWED) + expect(r.ok).toBe(true) + if (r.ok) expect(r.expand.size).toBe(1) + }) + + it('rejects unknown keys with VALIDATION_ERROR-shaped result', () => { + const r = parseExpand(urlWith('?expand=customer,bogus'), ALLOWED) + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.invalidKeys).toEqual(['bogus']) + expect(r.allowed).toEqual(['customer', 'items', 'payments']) + } + }) + + it('reports all invalid keys, not just the first', () => { + const r = parseExpand(urlWith('?expand=foo,bar,customer'), ALLOWED) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.invalidKeys.sort()).toEqual(['bar', 'foo']) + }) + + it('ignores empty entries from trailing commas', () => { + const r = parseExpand(urlWith('?expand=customer,'), ALLOWED) + expect(r.ok).toBe(true) + if (r.ok) expect([...r.expand]).toEqual(['customer']) + }) +}) diff --git a/lib/api/v1/expand.ts b/lib/api/v1/expand.ts new file mode 100644 index 00000000..26d983b5 --- /dev/null +++ b/lib/api/v1/expand.ts @@ -0,0 +1,69 @@ +/** + * `?expand=…` query parameter parser for embedding related resources. + * + * Stripe pattern: a single call returns invoice + customer + line items + + * payments instead of forcing the caller to make 4 round-trips. For agents + * passing the response into their own context, this is the difference + * between 200 and 4000 tokens. + * + * Each endpoint declares its own allowlist of expandable keys. Unknown + * values produce a 400 VALIDATION_ERROR rather than being silently ignored — + * agents that typo expansions deserve a clear error. + * + * Usage: + * + * const ALLOWED = ['customer', 'items', 'payments'] as const + * type ExpandKey = (typeof ALLOWED)[number] + * const expand = parseExpand(url, ALLOWED) + * // returns: Set — empty if no ?expand param + * + * if (expand.has('customer')) { ... } + */ + +export interface ParseExpandResult { + ok: true + expand: Set +} + +export interface ParseExpandError { + ok: false + invalidKeys: string[] + allowed: readonly string[] +} + +/** + * Parse `?expand=a,b,c` from a URL against a per-endpoint allowlist. + * + * Returns either `{ ok: true, expand: Set }` for valid input (including + * the empty case when the parameter is absent), or `{ ok: false, invalidKeys, + * allowed }` listing the unrecognised keys so the caller can build a + * VALIDATION_ERROR detail. + * + * Whitespace around comma-separated keys is trimmed. Duplicate keys collapse + * to a single Set entry. + */ +export function parseExpand( + url: URL, + allowed: readonly K[], +): ParseExpandResult | ParseExpandError { + const raw = url.searchParams.get('expand') + if (!raw) return { ok: true, expand: new Set() } + + const requested = raw.split(',').map((s) => s.trim()).filter((s) => s.length > 0) + const allowedSet = new Set(allowed) + const expand = new Set() + const invalidKeys: string[] = [] + + for (const key of requested) { + if (allowedSet.has(key)) { + expand.add(key as K) + } else if (!invalidKeys.includes(key)) { + invalidKeys.push(key) + } + } + + if (invalidKeys.length > 0) { + return { ok: false, invalidKeys, allowed } + } + return { ok: true, expand } +} diff --git a/lib/api/v1/load-routes.ts b/lib/api/v1/load-routes.ts index 4b13873b..4cddd466 100644 --- a/lib/api/v1/load-routes.ts +++ b/lib/api/v1/load-routes.ts @@ -15,4 +15,10 @@ import '@/app/api/v1/health/route' import '@/app/api/v1/companies/route' +// Phase 2 PR-A — invoice + customer reads. +import '@/app/api/v1/companies/[companyId]/invoices/route' +import '@/app/api/v1/companies/[companyId]/invoices/[id]/route' +import '@/app/api/v1/companies/[companyId]/customers/route' +import '@/app/api/v1/companies/[companyId]/customers/[id]/route' + export {} diff --git a/lib/api/v1/response.ts b/lib/api/v1/response.ts index f41ce1ca..7f30bd19 100644 --- a/lib/api/v1/response.ts +++ b/lib/api/v1/response.ts @@ -27,6 +27,13 @@ export interface ResponseMeta { api_version: string next_cursor?: string audit?: AuditBlock + /** + * Names of `?expand=` keys whose underlying data fetch failed during a + * soft-degraded response. Present only when at least one expansion was + * requested AND failed. Agents that need transactional guarantees can + * detect a degraded response without parsing the body. + */ + partial_expansions?: string[] } interface ResponseOptions { @@ -36,6 +43,8 @@ interface ResponseOptions { audit?: AuditBlock /** Cursor for the *next* page; omitted when this is the last page. */ nextCursor?: string + /** Names of `?expand=` keys whose data fetch failed (soft-degrade). */ + partialExpansions?: string[] /** Marks the response as a replay of a previously-cached idempotent call. */ idempotentReplay?: boolean /** Marks the response as a dry-run preview rather than a committed write. */ @@ -71,6 +80,9 @@ function buildMeta(opts: ResponseOptions): ResponseMeta { } if (opts.nextCursor) meta.next_cursor = opts.nextCursor if (opts.audit) meta.audit = opts.audit + if (opts.partialExpansions && opts.partialExpansions.length > 0) { + meta.partial_expansions = opts.partialExpansions + } return meta } diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts index dfb6803c..f9110c9c 100644 --- a/lib/auth/scopes.ts +++ b/lib/auth/scopes.ts @@ -46,6 +46,14 @@ 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) + 'GET /api/v1/companies/:companyId/customers': 'customers:read', + 'GET /api/v1/companies/:companyId/customers/:id': 'customers:read', + + // Invoices (Phase 2 PR-A) + 'GET /api/v1/companies/:companyId/invoices': 'invoices:read', + 'GET /api/v1/companies/:companyId/invoices/:id': 'invoices:read', + // Webhooks (Phase 6 — placeholder so the catalogue is complete) 'GET /api/v1/companies/:companyId/webhooks': 'webhooks:manage', 'POST /api/v1/companies/:companyId/webhooks': 'webhooks:manage',