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:
co-authored by
Claude Opus 4.7
parent
db592d922d
commit
32ad6da28c
@@ -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,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user