feat(api): v1 invoice + customer reads (Phase 2 PR-A) (#451)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-12 21:49:22 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent db592d922d
commit 32ad6da28c
11 changed files with 1756 additions and 0 deletions
@@ -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,
},
)
},
)
@@ -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<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@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<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
function makeFlexibleSupabase(byTable: Record<string, { data?: unknown; error?: unknown }>) {
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
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<object> = {
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()
})
})
@@ -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<string, unknown>
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,
})
},
)
@@ -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 })
},
)
@@ -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<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@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<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
/**
* 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<string, { data?: unknown; error?: unknown }>) {
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
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')
})
})
@@ -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<string, unknown>
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<string, unknown>
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,
})
},
)
+59
View File
@@ -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'])
})
})
+69
View File
@@ -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<ExpandKey> — empty if no ?expand param
*
* if (expand.has('customer')) { ... }
*/
export interface ParseExpandResult<K extends string> {
ok: true
expand: Set<K>
}
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<K> }` 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<K extends string>(
url: URL,
allowed: readonly K[],
): ParseExpandResult<K> | ParseExpandError {
const raw = url.searchParams.get('expand')
if (!raw) return { ok: true, expand: new Set<K>() }
const requested = raw.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
const allowedSet = new Set<string>(allowed)
const expand = new Set<K>()
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 }
}
+6
View File
@@ -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 {}
+12
View File
@@ -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
}
+8
View File
@@ -46,6 +46,14 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
// 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',