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:
co-authored by
Claude Opus 4.7
parent
64e6aa67a7
commit
01e99d3220
@@ -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,
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -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
@@ -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' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user