Files
accounted/lib/api/v1/response.ts
T
Jakob Wennberg 32ad6da28c 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>
2026-05-12 21:49:22 +02:00

138 lines
4.6 KiB
TypeScript

/**
* v1 REST response envelopes.
*
* ok(data) → 200 { data, meta: { request_id, api_version } }
* paginated(data, next_cursor) → 200 { data, meta: { request_id, api_version, next_cursor } }
* accepted(operationId, type) → 202 { data: { operation_id, status, poll_url, webhook_event }, meta }
* created(data) → 201 same shape as ok
*
* Every helper stamps X-Request-Id + Gnubok-Version on the response and
* accepts an optional `audit` block for write responses (per the architectural
* decision in the plan that writes return their voucher_number / audit_url
* inline so the agent doesn't need a second round-trip).
*/
import { NextResponse } from 'next/server'
import { API_V1_VERSION, API_V1_VERSION_HEADER } from './version'
export interface AuditBlock {
voucher_number?: string
voucher_url?: string
audit_trail_url?: string
immutable_at?: string
}
export interface ResponseMeta {
request_id: string
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 {
requestId: string
status?: number
headers?: Record<string, string>
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. */
dryRun?: boolean
/** Rate-limit headers, when known. */
rateLimit?: { limit: number; remaining: number; resetAt?: Date }
}
function applyStandardHeaders(res: NextResponse, opts: ResponseOptions): NextResponse {
res.headers.set('X-Request-Id', opts.requestId)
res.headers.set(API_V1_VERSION_HEADER, API_V1_VERSION)
if (opts.idempotentReplay) res.headers.set('Idempotent-Replayed', 'true')
if (opts.dryRun) res.headers.set('X-Dry-Run', 'true')
if (opts.rateLimit) {
res.headers.set('X-RateLimit-Limit', String(opts.rateLimit.limit))
res.headers.set('X-RateLimit-Remaining', String(opts.rateLimit.remaining))
if (opts.rateLimit.resetAt) {
res.headers.set('X-RateLimit-Reset', String(Math.floor(opts.rateLimit.resetAt.getTime() / 1000)))
}
}
if (opts.headers) {
for (const [k, v] of Object.entries(opts.headers)) {
res.headers.set(k, v)
}
}
return res
}
function buildMeta(opts: ResponseOptions): ResponseMeta {
const meta: ResponseMeta = {
request_id: opts.requestId,
api_version: API_V1_VERSION,
}
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
}
/**
* 200 OK with `{ data, meta }`.
*/
export function ok<T>(data: T, opts: ResponseOptions): NextResponse {
const res = NextResponse.json({ data, meta: buildMeta(opts) }, { status: opts.status ?? 200 })
return applyStandardHeaders(res, opts)
}
/**
* 200 OK with `{ data: T[], meta: { next_cursor } }`. Use for list endpoints.
*/
export function paginated<T>(data: T[], opts: ResponseOptions): NextResponse {
const res = NextResponse.json({ data, meta: buildMeta(opts) }, { status: 200 })
return applyStandardHeaders(res, opts)
}
/**
* 201 Created. Mirror of ok() with status 201 for POST that creates a resource.
*/
export function created<T>(data: T, opts: ResponseOptions): NextResponse {
return ok(data, { ...opts, status: 201 })
}
/**
* 202 Accepted for async long-running operations. Returns the operation_id +
* polling URL + the webhook event the caller can subscribe to for completion.
*/
export function accepted(
operationId: string,
operationType: string,
opts: ResponseOptions,
): NextResponse {
const data = {
operation_id: operationId,
type: operationType,
status: 'queued' as const,
poll_url: `/api/v1/operations/${operationId}`,
webhook_event: 'operation.completed',
}
return ok(data, { ...opts, status: 202 })
}
/**
* 204 No Content. Used for DELETE responses. No body.
*/
export function noContent(opts: ResponseOptions): NextResponse {
const res = new NextResponse(null, { status: 204 })
return applyStandardHeaders(res, opts)
}