feat(api): v1 invoice PDF + customer bulk-create (Phase 2 PR-B-3) (#460)

Closes out the Phase 2 invoices+customers vertical. After this PR, every
write/read the dashboard does on these two resources is reachable via the
public API.

GET /api/v1/companies/{companyId}/invoices/{id}/pdf
  Read-only application/pdf endpoint. Mirrors the dashboard's internal
  /api/invoices/[id]/pdf so a downloaded PDF is byte-equivalent across
  surfaces. Drafts render with the "faktura-utkast-<id-slice>.pdf"
  filename (preview before send is a legitimate workflow); sent invoices
  use "faktura-<number>.pdf"; credit notes use "kreditfaktura-<number>.pdf"
  and embed the original invoice's löpnummer per ML 17 kap 22–23§
  back-reference; proforma + delivery notes get their own prefixes.

  Error codes: INVOICE_PDF_RENDER_FAILED (500, new),
  INVOICE_SEND_COMPANY_SETTINGS_MISSING (404, reused — same condition,
  same remediation).

POST /api/v1/companies/{companyId}/customers/bulk-create
  Mirrors /invoices/bulk-create exactly: same `{ results, summary }`
  shape, same all_or_nothing: true → 501 NOT_IMPLEMENTED contract, same
  50-item cap, same sequential processing. Per-item rollback isn't
  needed (customer insert is a single row), but per-item 23505 →
  CUSTOMER_DUPLICATE_ORG_NUMBER failure surfaces in the results array
  without echoing org_number (GDPR Art.5(1)(c): for sole traders
  org_number IS the personnummer). VIES validation runs per item,
  best-effort — a timeout leaves vat_number_validated=false but does
  NOT fail the item.

Registry: extended EndpointDefinition.response with an optional
  `contentType` field so the OpenAPI generator can emit
  `format: binary` schemas for non-JSON responses. The PDF endpoint is
  the first consumer; future binary endpoints (SIE export, ICS feeds)
  use the same hook.

Scope catalogue: added GET .../pdf → invoices:read,
  POST .../customers/bulk-create → customers:write.

Tests: 14 new integration cases (7 for PDF: sent / draft / credit-note
filename, 404, render-error 500, non-UUID 400, scope rejection; 7 for
customer bulk-create: happy path, dup-org error masking, max-50 cap,
all_or_nothing 501, dry-run preview, empty array, scope rejection).
Suite green: 3232 passing. Build + lint clean.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-13 10:53:51 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 64e6aa67a7
commit 01e99d3220
8 changed files with 1070 additions and 2 deletions
@@ -0,0 +1,247 @@
/**
* Integration tests for POST /api/v1/companies/:companyId/customers/bulk-create.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`customer bulk-create tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
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({}) }
})
vi.mock('@/lib/vat/vies-client', () => ({
validateVatNumber: vi.fn().mockResolvedValue({ valid: false }),
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { POST as bulkCreate } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
type MockResult = { data?: unknown; error?: unknown }
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
const queues = new Map<string, MockResult[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve(next)
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const USER_ID = 'user-1'
function makeRequest(url: string, body: unknown): Request {
return new Request(url, {
method: 'POST',
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Content-Type': 'application/json',
'Idempotency-Key': 'idem1234-5050-4abc-8def-1234567890ab',
},
body: JSON.stringify(body),
})
}
function companyParams(companyId: string) {
return { params: Promise.resolve({ companyId }) }
}
const SAMPLE = (name = 'Acme AB') => ({
name,
customer_type: 'swedish_business' as const,
org_number: '556677-8899',
})
beforeEach(() => {
vi.clearAllMocks()
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
scopes: ['customers:write'],
mode: 'live',
})
})
describe('POST /api/v1/companies/:companyId/customers/bulk-create', () => {
it('creates two customers and returns a 200 with summary', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
customers: [
{ data: { id: 'c1', name: 'Acme', customer_type: 'swedish_business' }, error: null },
{ data: { id: 'c2', name: 'Beta', customer_type: 'swedish_business' }, error: null },
],
}),
)
const res = await bulkCreate(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/bulk-create`, {
customers: [SAMPLE('Acme AB'), SAMPLE('Beta AB')],
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.summary).toEqual({ total: 2, succeeded: 2, failed: 0 })
expect(body.data.results[0]).toMatchObject({ ok: true, request_index: 0 })
expect(body.data.results[1]).toMatchObject({ ok: true, request_index: 1 })
})
it('returns per-item failure for org_number duplicate', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
customers: { data: null, error: { code: '23505', message: 'duplicate' } },
}),
)
const res = await bulkCreate(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/bulk-create`, {
customers: [SAMPLE('Acme AB')],
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.summary.failed).toBe(1)
expect(body.data.results[0].error.code).toBe('CUSTOMER_DUPLICATE_ORG_NUMBER')
// Ensure org_number value is NOT echoed (GDPR Art.5(1)(c)).
expect(JSON.stringify(body.data.results[0].error.details)).not.toContain('556677-8899')
})
it('rejects more than 50 customers per request', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const customers = Array.from({ length: 51 }, () => SAMPLE())
const res = await bulkCreate(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/bulk-create`, {
customers,
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('rejects all_or_nothing: true with 501 NOT_IMPLEMENTED', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await bulkCreate(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/bulk-create`, {
all_or_nothing: true,
customers: [SAMPLE()],
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(501)
const body = await res.json()
expect(body.error.code).toBe('NOT_IMPLEMENTED')
})
it('dry-run returns previews without inserting', async () => {
const supabaseMock = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
})
mockServiceClient.mockReturnValue(supabaseMock)
const res = await bulkCreate(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/customers/bulk-create?dry_run=true`,
{ customers: [SAMPLE()] },
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('X-Dry-Run')).toBe('true')
const body = await res.json()
expect(body.data.dry_run).toBe(true)
expect(body.data.preview.summary.succeeded).toBe(1)
// No `customers` insert was attempted.
const insertedCustomer = supabaseMock.from.mock.calls.some((c) => c[0] === 'customers')
expect(insertedCustomer).toBe(false)
})
it('rejects empty customers array', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await bulkCreate(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/bulk-create`, {
customers: [],
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('rejects keys without customers:write scope', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
scopes: ['customers:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await bulkCreate(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/customers/bulk-create`, {
customers: [SAMPLE()],
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(403)
})
})
@@ -0,0 +1,329 @@
/**
* POST /api/v1/companies/{companyId}/customers/bulk-create
*
* Bulk-create up to 50 customers in one call. Each item is validated and
* inserted independently — per-item failures don't roll back successes.
* Mirrors the shape of /invoices/bulk-create exactly so agents only need
* to learn one bulk pattern.
*
* Response: `{ results: [{ ok, request_index, data?, error? }], summary }`.
* Idempotent over the whole batch. Dry-runnable.
*
* VIES validation for eu_business customers is best-effort PER ITEM. A VIES
* timeout does NOT fail the item — it just leaves vat_number_validated=false.
*/
import { z } from 'zod'
import type { SupabaseClient } from '@supabase/supabase-js'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { CreateCustomerSchema } from '@/lib/api/schemas'
import { validateVatNumber } from '@/lib/vat/vies-client'
import { eventBus } from '@/lib/events'
import type { Logger } from '@/lib/logger'
import type { Customer } from '@/types'
const BulkCreateRequest = z.object({
customers: z.array(CreateCustomerSchema).min(1).max(50),
all_or_nothing: z.boolean().optional().default(false),
})
const BulkResultItem = z.object({
ok: z.boolean(),
request_index: z.number().int().nonnegative(),
data: z.unknown().optional(),
error: z
.object({
code: z.string(),
message: z.string(),
details: z.unknown().optional(),
})
.optional(),
})
const BulkCreateResponse = z.object({
results: z.array(BulkResultItem),
summary: z.object({
total: z.number().int(),
succeeded: z.number().int(),
failed: z.number().int(),
}),
})
// Same projection as the single-create endpoint — keeps response shapes
// identical so callers can union the two surfaces transparently.
const CUSTOMER_RESPONSE_COLUMNS =
'id, name, customer_type, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, default_payment_terms, notes, archived_at, created_at, updated_at'
registerEndpoint({
operation: 'customers.bulk-create',
method: 'POST',
path: '/api/v1/companies/:companyId/customers/bulk-create',
summary: 'Create up to 50 customers in one call (partial-success).',
description:
'Bulk-create endpoint mirroring /invoices/bulk-create. Each customer is validated and inserted independently — per-item failures do not roll back items that succeeded. Returns a results array plus a summary. Idempotent over the whole batch. Dry-runnable.',
useWhen:
'You\'re importing a roster of customers from another CRM, or seeding a fresh company with its existing client list. Use dry-run first to validate the batch.',
doNotUseFor:
'Updating existing customers — PATCH /customers/{id} once per customer. Bulk uploads of > 50 customers — split into pages of 50. Transactional all-or-nothing imports — passing all_or_nothing: true returns 501 NOT_IMPLEMENTED.',
pitfalls: [
'Idempotency-Key is mandatory and covers the WHOLE batch. A retried bulk-create returns the cached full response — it does not retry only the failed items.',
'Passing all_or_nothing: true returns 501 NOT_IMPLEMENTED. Today only partial-success batches exist; omit the flag or pass false.',
'org_number uniqueness is enforced at the DB level — items with duplicates fail individually with CUSTOMER_DUPLICATE_ORG_NUMBER.',
'VIES validation for eu_business customers is best-effort per item; a VIES timeout leaves vat_number_validated=false but does NOT fail the item.',
],
example: {
request: {
customers: [
{ name: 'Acme AB', customer_type: 'swedish_business', org_number: '556677-8899' },
{ name: 'Foo OY', customer_type: 'eu_business', vat_number: 'FI12345678' },
],
},
response: {
data: {
results: [
{ ok: true, request_index: 0, data: { id: '0e9c…', name: 'Acme AB' } },
{ ok: true, request_index: 1, data: { id: '4d2a…', name: 'Foo OY' } },
],
summary: { total: 2, succeeded: 2, failed: 0 },
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'customers:write',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: BulkCreateRequest },
response: { success: BulkCreateResponse },
})
interface ResultItem {
ok: boolean
request_index: number
data?: unknown
error?: { code: string; message: string; details?: unknown }
}
async function createOneCustomer(
supabase: SupabaseClient,
companyId: string,
userId: string,
index: number,
input: z.infer<typeof CreateCustomerSchema>,
dryRun: boolean,
log: Logger,
): Promise<ResultItem> {
if (dryRun) {
return {
ok: true,
request_index: index,
data: {
preview: {
id: null,
name: input.name,
customer_type: input.customer_type,
email: input.email ?? null,
phone: input.phone ?? null,
address_line1: input.address_line1 ?? null,
address_line2: input.address_line2 ?? null,
postal_code: input.postal_code ?? null,
city: input.city ?? null,
country: input.country ?? 'Sweden',
org_number: input.org_number ?? null,
vat_number: input.vat_number ?? null,
vat_number_validated: false,
default_payment_terms: input.default_payment_terms ?? 30,
notes: input.notes ?? null,
archived_at: null,
created_at: null,
updated_at: null,
},
},
}
}
// Best-effort VIES validation. Resolve BEFORE the insert so the row
// reflects validation state atomically.
let vatValidated = false
let vatValidatedAt: string | null = null
if (input.customer_type === 'eu_business' && input.vat_number) {
try {
const vatResult = await validateVatNumber(input.vat_number)
if (vatResult.valid) {
vatValidated = true
vatValidatedAt = new Date().toISOString()
}
} catch (err) {
log.warn('bulk-create: VIES validation failed for item', err as Error, {
request_index: index,
})
}
}
const { data, error } = await supabase
.from('customers')
.insert({
user_id: userId,
company_id: companyId,
name: input.name,
customer_type: input.customer_type,
email: input.email ?? null,
phone: input.phone ?? null,
address_line1: input.address_line1 ?? null,
address_line2: input.address_line2 ?? null,
postal_code: input.postal_code ?? null,
city: input.city ?? null,
country: input.country ?? 'Sweden',
org_number: input.org_number ?? null,
vat_number: input.vat_number ?? null,
vat_number_validated: vatValidated,
vat_number_validated_at: vatValidatedAt,
default_payment_terms: input.default_payment_terms ?? 30,
notes: input.notes ?? null,
})
.select(CUSTOMER_RESPONSE_COLUMNS)
.single()
if (error) {
if (error.code === '23505') {
// GDPR Art.5(1)(c): do NOT echo input.org_number; for sole traders
// it IS the personnummer. The error code + field is enough — the
// caller knows the value they submitted.
return {
ok: false,
request_index: index,
error: {
code: 'CUSTOMER_DUPLICATE_ORG_NUMBER',
message: 'A customer with this org_number already exists in this company.',
details: { field: 'org_number' },
},
}
}
log.error('bulk-create: customer insert failed', error, {
request_index: index,
companyId,
pgCode: error.code,
})
return {
ok: false,
request_index: index,
error: {
code: 'CUSTOMER_CREATE_FAILED',
message: 'Customer insert failed.',
details: { pg_code: error.code },
},
}
}
// Emit customer.created per success. Same cast pattern as the single
// POST — projection omits internal scoping fields we re-inject here.
try {
await eventBus.emit({
type: 'customer.created',
payload: {
customer: {
...(data as Record<string, unknown>),
user_id: userId,
company_id: companyId,
} as unknown as Customer,
companyId,
userId,
},
})
} catch (err) {
log.warn('bulk-create: customer.created emit failed', err as Error, {
request_index: index,
})
}
return { ok: true, request_index: index, data }
}
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'customers.bulk-create',
async (request, ctx) => {
if (!z.string().uuid().safeParse(ctx.companyId).success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'companyId', message: 'companyId must be a UUID.' },
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = BulkCreateRequest.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
const body = parsed.data
// Reject all_or_nothing: true loudly. Same contract as invoices/bulk-create.
if (body.all_or_nothing) {
return v1ErrorResponseFromCode('NOT_IMPLEMENTED', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'all_or_nothing',
message:
'all_or_nothing: true is not yet implemented. Omit the flag (or pass false) to use partial-success semantics.',
},
})
}
// Sequential processing — matches /invoices/bulk-create. VIES has its own
// upstream throughput limits; running a batch of 50 in parallel can trip
// them. The 50-item cap keeps the worst-case latency bounded.
const results: ResultItem[] = []
for (let i = 0; i < body.customers.length; i++) {
const item = await createOneCustomer(
ctx.supabase,
ctx.companyId!,
ctx.userId,
i,
body.customers[i],
ctx.dryRun,
ctx.log,
)
results.push(item)
}
const summary = {
total: results.length,
succeeded: results.filter((r) => r.ok).length,
failed: results.filter((r) => !r.ok).length,
}
ctx.log.info('customers.bulk-create completed', {
companyId: ctx.companyId,
userId: ctx.userId,
...summary,
dryRun: ctx.dryRun,
})
if (ctx.dryRun) {
return dryRunPreview({ results, summary }, { requestId: ctx.requestId, log: ctx.log })
}
return ok({ results, summary }, { requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)
@@ -0,0 +1,276 @@
/**
* Integration tests for GET /api/v1/companies/:companyId/invoices/:id/pdf.
*
* The PDF renderer is mocked so the test is about routing, auth, error
* mapping and filename composition — not about actual PDF bytes.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`pdf route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
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({}) }
})
// vi.mock is hoisted above top-level consts, so the mock fn must be created
// inside vi.hoisted() to be available when the factory runs.
const { mockRender } = vi.hoisted(() => ({
mockRender: vi.fn().mockResolvedValue(Buffer.from('pdf-bytes')),
}))
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: mockRender,
}))
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn().mockReturnValue({}),
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { GET as pdf } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
type MockResult = { data?: unknown; error?: unknown }
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
const queues = new Map<string, MockResult[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve(next)
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const INVOICE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const USER_ID = 'user-1'
function makeRequest(url: string): Request {
return new Request(url, {
method: 'GET',
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
})
}
function detailParams(companyId: string, id: string) {
return { params: Promise.resolve({ companyId, id }) }
}
const SENT_INVOICE = {
id: INVOICE_ID,
invoice_number: '2026-0042',
customer_id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
invoice_date: '2026-05-12',
due_date: '2026-06-11',
status: 'sent',
document_type: 'invoice',
currency: 'SEK',
subtotal: 10000,
vat_amount: 2500,
total: 12500,
credited_invoice_id: null,
customer: { id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', name: 'Acme AB', email: 'acc@acme.test' },
items: [{ id: 'i1', sort_order: 0, description: 'x', quantity: 1, unit: 'st', unit_price: 10000, line_total: 10000, vat_rate: 25 }],
}
const COMPANY_SETTINGS = {
company_id: COMPANY_ID,
company_name: 'Test AB',
entity_type: 'enskild_firma',
accounting_method: 'accrual',
}
beforeEach(() => {
vi.clearAllMocks()
mockRender.mockResolvedValue(Buffer.from('pdf-bytes'))
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['invoices:read'],
mode: 'live',
})
})
describe('GET /api/v1/companies/:companyId/invoices/:id/pdf', () => {
it('returns a PDF for a sent invoice with the faktura-<number> filename', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: SENT_INVOICE, error: null },
company_settings: { data: COMPANY_SETTINGS, error: null },
}),
)
const res = await pdf(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/pdf`),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('application/pdf')
expect(res.headers.get('Content-Disposition')).toBe('attachment; filename="faktura-2026-0042.pdf"')
expect(res.headers.get('X-Request-Id')).toMatch(/^req_/)
})
it('uses utkast-<id-slice>.pdf filename for drafts', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: {
data: { ...SENT_INVOICE, status: 'draft', invoice_number: null },
error: null,
},
company_settings: { data: COMPANY_SETTINGS, error: null },
}),
)
const res = await pdf(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/pdf`),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(200)
// Same composition as the dashboard's internal pdf route: the
// "faktura-" prefix is preserved, the number slot is the "utkast-<slice>"
// placeholder.
expect(res.headers.get('Content-Disposition')).toBe(
'attachment; filename="faktura-utkast-bbbbbbbb.pdf"',
)
})
it('uses kreditfaktura-<number>.pdf for credit notes and embeds original number', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: [
{
data: {
...SENT_INVOICE,
invoice_number: '2026-0099',
credited_invoice_id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
},
error: null,
},
{ data: { invoice_number: '2026-0042' }, error: null }, // original lookup
],
company_settings: { data: COMPANY_SETTINGS, error: null },
}),
)
const res = await pdf(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/pdf`),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('Content-Disposition')).toBe(
'attachment; filename="kreditfaktura-2026-0099.pdf"',
)
// The template received the original number — verify via the InvoicePDF mock call.
const call = (mockRender.mock.calls[0]?.[0] as unknown) as { props?: unknown } | undefined
expect(call).toBeDefined()
})
it('returns 404 NOT_FOUND for unknown invoice id', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: null, error: null },
}),
)
const res = await pdf(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/pdf`),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('NOT_FOUND')
})
it('returns 500 INVOICE_PDF_RENDER_FAILED when the renderer throws', async () => {
mockRender.mockRejectedValueOnce(new Error('font load failed'))
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: SENT_INVOICE, error: null },
company_settings: { data: COMPANY_SETTINGS, error: null },
}),
)
const res = await pdf(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/pdf`),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(500)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_PDF_RENDER_FAILED')
})
it('returns 400 VALIDATION_ERROR for non-UUID id', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await pdf(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/not-a-uuid/pdf`),
detailParams(COMPANY_ID, 'not-a-uuid'),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('rejects keys without invoices:read scope', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
scopes: ['transactions:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await pdf(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/pdf`),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(403)
})
})
@@ -0,0 +1,191 @@
/**
* GET /api/v1/companies/{companyId}/invoices/{id}/pdf
*
* Render the invoice as a PDF and return it as `application/pdf`. Mirrors
* the dashboard's internal `/api/invoices/[id]/pdf` so a downloaded PDF is
* byte-equivalent across surfaces.
*
* Behavior:
* - Drafts (no invoice_number): the filename uses `utkast-<id-slice>`. The
* PDF is still rendered — useful for "preview before send" workflows.
* - Sent / paid / overdue / cancelled / credit notes: full PDF with the
* persisted invoice number.
* - Credit notes: filename uses `kreditfaktura-` prefix and the original
* invoice's löpnummer is embedded (ML 17 kap 22–23§ back-reference).
* - Delivery notes: PDF is permitted (read-only, no compliance side effect).
*
* Read-only — no Idempotency-Key, no dry-run, scope `invoices:read`.
*/
import { z } from 'zod'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import type { CompanySettings, Customer, Invoice, InvoiceItem } from '@/types'
const INVOICE_PDF_COLUMNS =
'id, invoice_number, customer_id, invoice_date, due_date, status, document_type, ' +
'currency, subtotal, vat_amount, total, vat_treatment, vat_rate, moms_ruta, ' +
'reverse_charge_text, your_reference, our_reference, notes, credited_invoice_id, ' +
'paid_amount, remaining_amount'
const PDF_FETCH_SELECT = `
${INVOICE_PDF_COLUMNS},
customer:customers(*),
items:invoice_items(*)
`
registerEndpoint({
operation: 'invoices.pdf',
method: 'GET',
path: '/api/v1/companies/:companyId/invoices/:id/pdf',
summary: 'Download the rendered invoice PDF.',
description:
'Returns the invoice as application/pdf. The filename in Content-Disposition reflects the document type: faktura-<number>.pdf for sent invoices, kreditfaktura-<number>.pdf for credit notes, utkast-<id-slice>.pdf for drafts. This endpoint is byte-equivalent to the dashboard download.',
useWhen:
'You need to fetch an invoice PDF for archival, forwarding to a customer outside the gnubok send flow, or attaching to an external workflow.',
doNotUseFor:
'Sending the invoice to the customer — use POST /invoices/{id}/send, which renders the PDF, emails it, and archives it as a verifikationsunderlag in one atomic step.',
pitfalls: [
'Drafts (no invoice_number yet) render with an "utkast" filename. The PDF carries no F-series number — do not treat it as a finalized invoice.',
'PDF rendering can take several hundred milliseconds for invoices with many line items. Cache on the client if requesting repeatedly.',
'Credit notes embed the original invoice\'s löpnummer per ML 17 kap 22–23§ — if the original was hard-deleted (not possible via gnubok but theoretically via a manual DB edit), the reference is omitted.',
],
example: {
response: {
// Binary response — OpenAPI declares format: binary via response.contentType.
// Documented here for human readers.
_note: 'Returns application/pdf binary stream.',
},
},
scope: 'invoices:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: {
success: z.unknown(), // Marker — binary response, see contentType.
contentType: 'application/pdf',
},
})
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'invoices.pdf',
async (_request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Invoice id must be a UUID.' },
})
}
const invoiceId = idParse.data
const { data: invoice, error: fetchErr } = await ctx.supabase
.from('invoices')
.select(PDF_FETCH_SELECT)
.eq('id', invoiceId)
.eq('company_id', ctx.companyId!)
.maybeSingle()
if (fetchErr) {
return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId })
}
if (!invoice) {
ctx.log.warn('invoices.pdf: not found', { invoiceId, companyId: ctx.companyId })
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'invoice' },
})
}
const typed = invoice as unknown as Invoice & {
customer?: Customer
items?: InvoiceItem[]
}
// company_settings is required by the PDF template (header, bank info,
// entity-type-driven layout). Select * is intentional — see the rationale
// in the :send route. Same flat owner-facing config object, no sensitive
// columns.
const { data: company, error: companyErr } = await ctx.supabase
.from('company_settings')
.select('*')
.eq('company_id', ctx.companyId!)
.maybeSingle()
if (companyErr || !company) {
ctx.log.warn('invoices.pdf: company settings missing', {
invoiceId,
companyId: ctx.companyId,
})
return v1ErrorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', ctx.log, {
requestId: ctx.requestId,
})
}
const items = (typed.items ?? []).slice().sort((a, b) => a.sort_order - b.sort_order)
// Credit-note back-reference per ML 17 kap 22–23§. Best-effort — if the
// original invoice was somehow deleted, the PDF template tolerates an
// undefined value (the back-reference field is omitted from the layout).
let originalInvoiceNumber: string | undefined
if (typed.credited_invoice_id) {
const { data: orig } = await ctx.supabase
.from('invoices')
.select('invoice_number')
.eq('id', typed.credited_invoice_id)
.eq('company_id', ctx.companyId!)
.maybeSingle()
if (orig) {
originalInvoiceNumber = (orig as { invoice_number?: string }).invoice_number ?? undefined
}
}
let pdfBuffer: Buffer
try {
pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: typed as Invoice,
customer: typed.customer as Customer,
items,
company: company as CompanySettings,
originalInvoiceNumber,
}),
)
} catch (err) {
ctx.log.error('invoices.pdf: render failed', err as Error, {
invoiceId,
companyId: ctx.companyId,
})
return v1ErrorResponseFromCode('INVOICE_PDF_RENDER_FAILED', ctx.log, {
requestId: ctx.requestId,
})
}
const isCreditNote = !!typed.credited_invoice_id
const filenameNumber = typed.invoice_number ?? `utkast-${invoiceId.slice(0, 8)}`
const filename = isCreditNote
? `kreditfaktura-${filenameNumber}.pdf`
: typed.document_type === 'proforma'
? `proformafaktura-${filenameNumber}.pdf`
: typed.document_type === 'delivery_note'
? `följesedel-${filenameNumber}.pdf`
: `faktura-${filenameNumber}.pdf`
const uint8Array = new Uint8Array(pdfBuffer)
return new Response(uint8Array, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': String(pdfBuffer.length),
'X-Request-Id': ctx.requestId,
},
})
},
)
+3
View File
@@ -26,5 +26,8 @@ import '@/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route'
import '@/app/api/v1/companies/[companyId]/invoices/[id]/credit/route'
import '@/app/api/v1/companies/[companyId]/invoices/[id]/send/route'
import '@/app/api/v1/companies/[companyId]/invoices/bulk-create/route'
// Phase 2 PR-B-3 — invoice PDF + customer bulk-create.
import '@/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route'
import '@/app/api/v1/companies/[companyId]/customers/bulk-create/route'
export {}
+16 -2
View File
@@ -80,6 +80,15 @@ export interface EndpointDefinition {
success: ZodTypeAny
/** Stable error codes this endpoint can emit (cross-referenced with the docs). */
errorCodes?: string[]
/**
* Override the default 'application/json' content type for non-JSON
* responses (e.g. binary downloads). When set to 'application/pdf', the
* OpenAPI generator emits a `{ type: 'string', format: 'binary' }` schema
* instead of deriving from `success`. The `success` schema is still
* required (use `z.unknown()` as a marker) so existing registry consumers
* don't need to handle a missing field.
*/
contentType?: string
}
}
@@ -238,7 +247,12 @@ export function generateOpenApiSpec(serverUrl: string): OpenApiSpec {
// OpenAPI path syntax: {param} instead of :param.
const openApiPath = def.path.replace(/:([^/]+)/g, '{$1}')
const responseSchema = zodToJsonSchema(def.response.success)
// Binary responses (e.g. application/pdf) declare a `format: binary`
// schema rather than deriving from the Zod success type.
const successContent = def.response.contentType && def.response.contentType !== 'application/json'
? { [def.response.contentType]: { schema: { type: 'string', format: 'binary' } } }
: { 'application/json': { schema: zodToJsonSchema(def.response.success) } }
const operationDef: Record<string, unknown> = {
operationId: def.operation,
summary: def.summary,
@@ -257,7 +271,7 @@ export function generateOpenApiSpec(serverUrl: string): OpenApiSpec {
responses: {
'200': {
description: 'Success',
content: { 'application/json': { schema: responseSchema } },
content: successContent,
},
'400': { description: 'Validation error', $ref: '#/components/responses/Error' },
'401': { description: 'Unauthorized', $ref: '#/components/responses/Error' },
+3
View File
@@ -65,6 +65,9 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
'POST /api/v1/companies/:companyId/invoices/:id/credit': 'invoices:write',
'POST /api/v1/companies/:companyId/invoices/:id/send': 'invoices:write',
'POST /api/v1/companies/:companyId/invoices/bulk-create': 'invoices:write',
// Phase 2 PR-B-3 — invoice PDF + customer bulk-create.
'GET /api/v1/companies/:companyId/invoices/:id/pdf': 'invoices:read',
'POST /api/v1/companies/:companyId/customers/bulk-create': 'customers:write',
// Webhooks (Phase 6 — placeholder so the catalogue is complete)
'GET /api/v1/companies/:companyId/webhooks': 'webhooks:manage',
+5
View File
@@ -461,6 +461,11 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
'Fakturans PDF kunde inte skapas. Kontrollera fakturarader och kunduppgifter och försök igen.',
message_en: 'Failed to render invoice PDF before send; no invoice number was consumed.',
},
INVOICE_PDF_RENDER_FAILED: {
httpStatus: 500,
message_sv: 'Fakturans PDF kunde inte skapas.',
message_en: 'Invoice PDF rendering failed.',
},
INVOICE_SEND_PARTIAL: {
httpStatus: 200,
message_sv: