feat(api): v1 invoice draft writes (Phase 2 PR-B-2a) (#453)

* feat(api): v1 invoice draft writes (Phase 2 PR-B-2a)

POST /api/v1/companies/:companyId/invoices creates a draft invoice,
proforma, or delivery note. Reuses the established v1 discipline:
- Idempotency-Key mandatory (wrapper option).
- Dry-runnable: ?dry_run=true returns the validated would-be invoice +
  computed items with VAT totals; no DB writes, no number allocation,
  no event emission.
- Explicit column projections (no SELECT *).
- Per-item VAT rate validated against the customer's allowed rates from
  getVatRules() — mixed-rate invoices supported.
- Currency conversion via fetchExchangeRate() (best-effort, non-fatal).
- F-series number allocation via ensureInvoiceNumber() with soft-cancel
  rollback if allocation fails — preserves sequence integrity for
  ML 17 kap 24§ (no gaps in F-series).
- invoice.created event emitted for real invoices (not proformas /
  delivery notes).

PATCH /api/v1/companies/:companyId/invoices/:id updates a DRAFT invoice's
metadata fields only:
- Allowed: invoice_date, due_date, delivery_date, your_reference,
  our_reference, notes.
- NOT allowed (intentional): customer_id, currency, document_type, items,
  status. Structural changes go through delete-and-recreate (drafts are
  cheap); status transitions via the action verbs in PR-B-2b.
- 409 INVOICE_DELETE_NOT_DRAFT if the invoice has already been sent /
  paid / credited / cancelled. The error code is shared with DELETE
  (reused rather than introducing a new "not draft" code).
- Race-condition guard: the .update() also matches .eq('status', 'draft')
  so a concurrent :send between pre-flight and write returns the same 409.

Dry-run for invoice DRAFT create uses dryRunPreview() (validation-only)
rather than dryRunStaged() — drafts have no journal-entry side effects
yet, so there's nothing to stage in pending_operations. The dryRunStaged()
helper from PR-B-1 stays unused this PR; PR-B-2b's :send will be its
first real consumer (voucher number, journal lines, account deltas).

Tests: 12 new (5 POST + 7 PATCH) covering happy path, customer not
found, VAT rate violation, dry-run preview shape, scope enforcement,
Idempotency-Key requirement, draft-only PATCH guard, forbidden field
rejection, UUID validation, empty body. Stubs ensureInvoiceNumber and
fetchExchangeRate to keep tests deterministic.

3165/3165 vitest pass; build clean; lint clean on v1 paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(api): address PR #453 review (Greptile + swarm + Swedish compliance)

Real fixes (all reviewers agreed):

- Greptile P1 + SOC 2 CC6.3: PATCH was reusing INVOICE_DELETE_NOT_DRAFT
  (httpStatus 400) for a semantically different operation; docstrings +
  tests claimed 409 while code returned 400. Introduced
  INVOICE_UPDATE_NOT_DRAFT with httpStatus 409 in structured-errors.ts.
  PATCH now returns 409 consistently; test name and assertion aligned.
- Greptile P1: POST rollback DELETE on items-insert failure now scoped
  by company_id (defense in depth) AND its error is destructured/logged
  so a double-failure is visible in audit trails (was previously silent
  on the rollback path).
- Greptile P1: refetch error after invoice insert is now logged with
  invoiceId + companyId at warn level; the response gracefully falls
  back to the header-only shape rather than misleading the agent with
  a 5xx (the data WAS committed).

GDPR Art.5(1)(f) × 2, ISO A.8.11 × 2, SOC 2 CC7.2 × 2: client-facing
error responses no longer echo raw Postgres pg_message strings (which
can interpolate field values from constraint detail). pg_code is kept
in the response (machine-readable, no PII leak); pg_message moves to
the internal structured log entry only. Applies to
INVOICE_CREATE_INSERT_FAILED and INVOICE_CREATE_ITEMS_FAILED.

OWASP V2.2: defensive UUID validation on ctx.companyId at POST handler
entry. The wrapper already validated membership, but mirroring the
detail-route's pattern for path params eliminates a class of edge-case
queries with malformed predicates.

Swedish compliance (ML 17 kap 24§ p.2 — most substantive finding):
ensureInvoiceNumber is NO LONGER called at draft-create. The doc string
already said "F-series invoice_number is allocated atomically on the
first send action (PR-B-2b)" but the code contradicted it by allocating
at POST. Code now matches intent: drafts (invoices and proformas) keep
invoice_number=null until :send. Delivery notes continue to allocate
their separate D-series number on insert (different sequence, no F-series
gap concern). This eliminates the soft-cancel path entirely for the
common case where a user creates and abandons a draft — no more legal
gaps in the löpnummer series from ordinary workflow.

Pushing back on:
- Atomicity / Postgres RPC wrapping (V8.2.1 × 2, CC6.1) — substantial
  refactor; the existing internal /api/invoices POST has the identical
  multi-step pattern; not a v1 regression. Track for a future RPC-
  consolidation PR across both surfaces.
- Float-point VAT rounding (V2.3, Swedish #3) — matches internal route
  precisely; consistency over premature decimal-library migration.
- TOCTOU rewrite to single UPDATE-WHERE-RETURNING (V8.2.1, CC6.1) —
  current pre-flight + scoped UPDATE is correct; the suggested cleanup
  is stylistic.
- PATCH response verbose projection (A.8.3, Art.25) — consistency with
  detail endpoint; the agent that just updated likely wants the full
  record back.
- per-line moms_ruta (Swedish #4) — schema migration; the existing
  header-only column is what the codebase has.
- Event emission failure alerting (A.8.15) — defer to PR-C webhooks.
- Test fixture A.8.33 — already addressed (NODE_ENV guard at test
  bootstrap, clearly synthetic UUIDs).

Test fixture UUID v4 fix: COMPANY_ID upgraded to proper v4 format
(was 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', which fails Zod 4's
.uuid() version-digit check now that the POST handler validates
companyId).

3165/3165 vitest pass; build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-12 22:58:56 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent d29b87fb80
commit e96cbe05d0
5 changed files with 1025 additions and 20 deletions
@@ -1,18 +1,38 @@
/**
* GET /api/v1/companies/{companyId}/invoices/{id} — invoice detail.
* /api/v1/companies/{companyId}/invoices/{id} — invoice detail + draft update.
*
* Returns the full invoice record. Customer is embedded by default (the
* detail endpoint is verbose by design); line items and payments require
* `?expand=items,payments` to keep the default response shape predictable.
* GET — full invoice record. ?expand=items,payments controls embedding.
* PATCH — partial update on DRAFT invoices only. Allowed fields are the
* "metadata" subset (dates, references, notes); customer_id,
* currency, document_type, and items are immutable — changing any
* of those means delete-and-recreate (drafts are cheap). Returns
* 409 INVOICE_UPDATE_NOT_DRAFT (reusing existing code) if the
* invoice is not in draft status.
*
* Idempotent (mandatory Idempotency-Key) and dry-runnable.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { parseExpand } from '@/lib/api/v1/expand'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
// Allowed PATCH fields for a draft invoice. Excludes items (separate
// workflow), customer_id / currency / document_type (structural — change
// via delete + recreate), invoice_number (allocated server-side), all
// computed totals, and status (state machine — use action verbs in PR-B-2b).
const V1PatchDraftInvoiceSchema = z.object({
invoice_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD').optional(),
due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD').optional(),
delivery_date: z.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD'), z.null()]).optional(),
your_reference: z.union([z.string(), z.null()]).optional(),
our_reference: z.union([z.string(), z.null()]).optional(),
notes: z.union([z.string(), z.null()]).optional(),
})
// Loose schema — detail responses carry many fields, and pinning the exact
// types in the registry is overkill until Phase 2 PR-B introduces writes
// that reuse the schema for validation.
@@ -151,3 +171,161 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }
return ok(data, { requestId: ctx.requestId })
},
)
// ──────────────────────────────────────────────────────────────────
// PATCH — update a DRAFT invoice (metadata fields only)
// ──────────────────────────────────────────────────────────────────
registerEndpoint({
operation: 'invoices.update',
method: 'PATCH',
path: '/api/v1/companies/:companyId/invoices/:id',
summary: 'Update a draft invoice (metadata fields only).',
description:
'Partial update for invoices in draft status. Allowed fields: invoice_date, due_date, delivery_date, your_reference, our_reference, notes. customer_id, currency, document_type, items, and computed totals are immutable — replace those by deleting the draft and recreating it. Returns 409 INVOICE_UPDATE_NOT_DRAFT if the invoice is no longer in draft status. Idempotent and dry-runnable.',
useWhen:
'You need to correct a typo, push the due date, or update a customer reference on a draft you have not sent yet. The invoice number stays null until the first :send action.',
doNotUseFor:
'Updating a sent / paid / credited invoice (those are immutable per ML 17 kap; issue a credit note via POST /:id:credit in PR-B-2b). Changing items, currency, or customer — drafts are cheap to delete and recreate.',
pitfalls: [
'Idempotency-Key is mandatory.',
'A 409 INVOICE_UPDATE_NOT_DRAFT means the invoice has been sent / paid / credited / cancelled. The error code name is shared with the DELETE handler.',
'Items are immutable here — to change line items, delete the draft and POST a fresh one.',
],
example: {
request: { due_date: '2026-07-15', notes: 'Förlängd förfallotid' },
response: {
data: {
id: '0e9c…',
status: 'draft',
due_date: '2026-07-15',
notes: 'Förlängd förfallotid',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'invoices:write',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: V1PatchDraftInvoiceSchema },
response: { success: InvoiceDetail },
})
const INVOICE_PATCH_RESPONSE_COLUMNS =
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, created_at, updated_at'
export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'invoices.update',
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
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 = V1PatchDraftInvoiceSchema.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
const updateData: Record<string, unknown> = {}
for (const key of [
'invoice_date',
'due_date',
'delivery_date',
'your_reference',
'our_reference',
'notes',
] as const) {
if (body[key] !== undefined) updateData[key] = body[key]
}
if (Object.keys(updateData).length === 0) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'At least one field must be supplied for update.' },
})
}
// Pre-flight: verify the invoice exists in this company AND is still in
// draft status. We do this for both dry-run and commit so the response
// is consistent — dry-run that "succeeds" on a non-draft would mislead.
const { data: current, error: fetchErr } = await ctx.supabase
.from('invoices')
.select(INVOICE_PATCH_RESPONSE_COLUMNS)
.eq('company_id', ctx.companyId!)
.eq('id', invoiceId)
.maybeSingle()
if (fetchErr) {
return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId })
}
if (!current) {
ctx.log.warn('invoices.update: not found', { invoiceId, companyId: ctx.companyId })
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'invoice' },
})
}
if ((current as { status: string }).status !== 'draft') {
return v1ErrorResponseFromCode('INVOICE_UPDATE_NOT_DRAFT', ctx.log, {
requestId: ctx.requestId,
details: { current_status: (current as { status: string }).status },
})
}
if (ctx.dryRun) {
return dryRunPreview({ ...current, ...updateData }, { requestId: ctx.requestId, log: ctx.log })
}
const { data, error } = await ctx.supabase
.from('invoices')
.update({ ...updateData, updated_at: new Date().toISOString() })
.eq('company_id', ctx.companyId!)
.eq('id', invoiceId)
.eq('status', 'draft') // Belt + braces: race condition guard.
.select(INVOICE_PATCH_RESPONSE_COLUMNS)
.maybeSingle()
if (error) {
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
}
if (!data) {
// Race: the invoice transitioned out of draft between the pre-flight
// and the update. Treat as the same 409 as the pre-flight check.
return v1ErrorResponseFromCode('INVOICE_UPDATE_NOT_DRAFT', ctx.log, {
requestId: ctx.requestId,
details: { reason: 'Invoice transitioned out of draft during update.' },
})
}
return ok(data, { requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)
@@ -9,6 +9,11 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`invoices 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'
})
@@ -27,9 +32,26 @@ vi.mock('@supabase/supabase-js', async () => {
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
// Stub the F-series allocator so tests don't depend on the
// generate_invoice_number Postgres RPC. The route's flow is what we're
// testing, not the allocator itself (which has its own pg-real tests).
vi.mock('@/lib/invoices/ensure-invoice-number', () => ({
ensureInvoiceNumber: vi.fn().mockResolvedValue(undefined),
}))
// Riksbanken exchange-rate fetcher — return null by default (treats as
// SEK-only). Individual tests can override.
vi.mock('@/lib/currency/riksbanken', async () => {
const actual = await vi.importActual<typeof import('@/lib/currency/riksbanken')>('@/lib/currency/riksbanken')
return {
...actual,
fetchExchangeRate: vi.fn().mockResolvedValue(null),
}
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { GET as listInvoices } from '../route'
import { GET as getInvoice } from '../[id]/route'
import { GET as listInvoices, POST as createInvoice } from '../route'
import { GET as getInvoice, PATCH as updateInvoice } from '../[id]/route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
@@ -54,7 +76,7 @@ function makeFlexibleSupabase(byTable: Record<string, { data?: unknown; error?:
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const INVOICE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const CUSTOMER_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
const USER_ID = 'user-1'
@@ -377,3 +399,394 @@ describe('scope enforcement', () => {
expect(body.error.code).toBe('NOT_FOUND')
})
})
// ──────────────────────────────────────────────────────────────────
// POST /api/v1/companies/:companyId/invoices
// ──────────────────────────────────────────────────────────────────
function withInvoiceWriteScope() {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['invoices:write'],
mode: 'live',
})
}
function makePostInvoice(url: string, body: unknown, extraHeaders: Record<string, string> = {}): Request {
return new Request(url, {
method: 'POST',
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Content-Type': 'application/json',
'Idempotency-Key': 'idem1234-5555-4abc-8def-1234567890ab',
...extraHeaders,
},
body: JSON.stringify(body),
})
}
function makePatchInvoice(url: string, body: unknown, extraHeaders: Record<string, string> = {}): Request {
return new Request(url, {
method: 'PATCH',
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Content-Type': 'application/json',
'Idempotency-Key': 'idem1234-6666-4abc-8def-1234567890ab',
...extraHeaders,
},
body: JSON.stringify(body),
})
}
// A swedish_business customer with VAT validated — picks up 25% as the
// only allowed rate (vat_treatment: standard_25). Reduced rates (12 / 6)
// would need a wider VAT-rule fixture; SEK + standard 25% is enough for
// the route-level tests here.
const SWEDISH_BUSINESS_CUSTOMER = {
id: CUSTOMER_ID,
customer_type: 'swedish_business',
vat_number_validated: true,
}
describe('POST /api/v1/companies/:companyId/invoices', () => {
it('creates a draft invoice with computed totals', async () => {
withInvoiceWriteScope()
const createdInvoice = {
id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
invoice_number: null,
customer_id: CUSTOMER_ID,
invoice_date: '2026-05-12',
due_date: '2026-06-11',
status: 'draft',
currency: 'SEK',
subtotal: 10000,
vat_amount: 2500,
total: 12500,
remaining_amount: 12500,
document_type: 'invoice',
created_at: '2026-05-12T16:00:00Z',
}
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
customers: { data: SWEDISH_BUSINESS_CUSTOMER, error: null },
invoices: { data: createdInvoice, error: null },
invoice_items: { data: null, error: null },
}),
)
const res = await createInvoice(
makePostInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices`, {
customer_id: CUSTOMER_ID,
invoice_date: '2026-05-12',
due_date: '2026-06-11',
currency: 'SEK',
items: [{ description: 'Konsultation', quantity: 8, unit: 'tim', unit_price: 1250 }],
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(201)
const body = await res.json()
expect(body.data.customer_id).toBe(CUSTOMER_ID)
expect(body.data.total).toBe(12500)
})
it('returns 404 INVOICE_CUSTOMER_NOT_FOUND when customer does not belong to company', async () => {
withInvoiceWriteScope()
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
customers: { data: null, error: null }, // No match
}),
)
const res = await createInvoice(
makePostInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices`, {
customer_id: CUSTOMER_ID,
invoice_date: '2026-05-12',
due_date: '2026-06-11',
currency: 'SEK',
items: [{ description: 'x', quantity: 1, unit: 'st', unit_price: 100 }],
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_CUSTOMER_NOT_FOUND')
})
it('rejects a per-item vat_rate not allowed for the customer', async () => {
withInvoiceWriteScope()
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
customers: { data: SWEDISH_BUSINESS_CUSTOMER, error: null },
}),
)
const res = await createInvoice(
makePostInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices`, {
customer_id: CUSTOMER_ID,
invoice_date: '2026-05-12',
due_date: '2026-06-11',
currency: 'SEK',
// 17 % is not a valid Swedish VAT rate.
items: [{ description: 'x', quantity: 1, unit: 'st', unit_price: 100, vat_rate: 17 }],
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_CREATE_VAT_RULE_VIOLATION')
expect(body.error.details.attempted_rate).toBe(17)
expect(Array.isArray(body.error.details.allowed_rates)).toBe(true)
})
it('dry-run returns 200 + X-Dry-Run + preview with computed totals; no DB writes', async () => {
withInvoiceWriteScope()
const supabaseMock = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
customers: { data: SWEDISH_BUSINESS_CUSTOMER, error: null },
})
mockServiceClient.mockReturnValue(supabaseMock)
const res = await createInvoice(
makePostInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices?dry_run=true`, {
customer_id: CUSTOMER_ID,
invoice_date: '2026-05-12',
due_date: '2026-06-11',
currency: 'SEK',
items: [
{ description: 'A', quantity: 2, unit: 'st', unit_price: 500 },
{ description: 'B', quantity: 1, unit: 'st', unit_price: 1000 },
],
}),
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)
// Preview: subtotal=2000, vat=500 (25%), total=2500.
expect(body.data.preview.subtotal).toBe(2000)
expect(body.data.preview.vat_amount).toBe(500)
expect(body.data.preview.total).toBe(2500)
expect(body.data.preview.items).toHaveLength(2)
// No insert into `invoices` happened.
const insertedInvoice = supabaseMock.from.mock.calls.some((c) => c[0] === 'invoices')
expect(insertedInvoice).toBe(false)
})
it('rejects keys without invoices:write scope', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
scopes: ['invoices:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await createInvoice(
makePostInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices`, {
customer_id: CUSTOMER_ID,
invoice_date: '2026-05-12',
due_date: '2026-06-11',
currency: 'SEK',
items: [{ description: 'x', quantity: 1, unit: 'st', unit_price: 100 }],
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(403)
const body = await res.json()
expect(body.error.code).toBe('INSUFFICIENT_SCOPE')
})
it('rejects requests without Idempotency-Key', async () => {
withInvoiceWriteScope()
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const req = new Request(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices`, {
method: 'POST',
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
customer_id: CUSTOMER_ID,
invoice_date: '2026-05-12',
due_date: '2026-06-11',
currency: 'SEK',
items: [{ description: 'x', quantity: 1, unit: 'st', unit_price: 100 }],
}),
})
const res = await createInvoice(req, companyParams(COMPANY_ID))
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
})
// ──────────────────────────────────────────────────────────────────
// PATCH /api/v1/companies/:companyId/invoices/:id
// ──────────────────────────────────────────────────────────────────
describe('PATCH /api/v1/companies/:companyId/invoices/:id', () => {
it('updates allowed metadata fields on a draft invoice', async () => {
withInvoiceWriteScope()
const draftInvoice = {
id: INVOICE_ID,
status: 'draft',
invoice_date: '2026-05-12',
due_date: '2026-06-11',
notes: 'old note',
}
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: {
data: { ...draftInvoice, due_date: '2026-07-15', notes: 'Förlängd' },
error: null,
},
}),
)
const res = await updateInvoice(
makePatchInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}`, {
due_date: '2026-07-15',
notes: 'Förlängd',
}),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.due_date).toBe('2026-07-15')
expect(body.data.notes).toBe('Förlängd')
})
it('returns 409 INVOICE_UPDATE_NOT_DRAFT for non-draft invoices', async () => {
withInvoiceWriteScope()
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: { id: INVOICE_ID, status: 'sent' }, error: null },
}),
)
const res = await updateInvoice(
makePatchInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}`, {
notes: 'will be rejected',
}),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_UPDATE_NOT_DRAFT')
expect(body.error.details.current_status).toBe('sent')
})
it('rejects an empty body', async () => {
withInvoiceWriteScope()
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await updateInvoice(
makePatchInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}`, {}),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('returns 400 VALIDATION_ERROR when :id is not a UUID', async () => {
withInvoiceWriteScope()
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await updateInvoice(
makePatchInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/not-a-uuid`, {
notes: 'x',
}),
detailParams(COMPANY_ID, 'not-a-uuid'),
)
expect(res.status).toBe(400)
})
it('dry-run merges current + proposed changes without committing', async () => {
withInvoiceWriteScope()
const draftInvoice = {
id: INVOICE_ID,
status: 'draft',
invoice_date: '2026-05-12',
due_date: '2026-06-11',
notes: null,
}
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: draftInvoice, error: null },
}),
)
const res = await updateInvoice(
makePatchInvoice(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}?dry_run=true`,
{ notes: 'preview' },
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('X-Dry-Run')).toBe('true')
const body = await res.json()
expect(body.data.preview.notes).toBe('preview')
expect(body.data.preview.due_date).toBe('2026-06-11') // unchanged from current
})
it('rejects forbidden fields (items / currency / customer_id)', async () => {
withInvoiceWriteScope()
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await updateInvoice(
makePatchInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}`, {
customer_id: CUSTOMER_ID,
currency: 'EUR',
items: [{ description: 'no', quantity: 1, unit: 'st', unit_price: 1 }],
}),
detailParams(COMPANY_ID, INVOICE_ID),
)
// The forbidden fields are stripped by Zod; the resulting body is `{}`
// which fails the "at least one field" guard.
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
})
@@ -1,20 +1,22 @@
/**
* GET /api/v1/companies/{companyId}/invoices — list invoices.
* /api/v1/companies/{companyId}/invoices — list + create invoice endpoints.
*
* Cursor pagination on (invoice_date DESC, id DESC) — most recent first to
* match AR UX. Customer name is denormalised into the response so the agent
* doesn't need an N+1 fetch for display; use `?expand=customer` for the full
* customer record.
*
* Filters (all optional):
* - status single InvoiceStatus
* - customer_id UUID
* - document_type 'invoice' | 'proforma' | 'delivery_note'
* - currency ISO-4217 code
* GET — list with filters (status, customer_id, document_type, currency).
* Cursor pagination on (invoice_date DESC, id DESC).
* POST — create draft invoice. Idempotent (mandatory Idempotency-Key).
* Dry-runnable (?dry_run=true returns the validated would-be
* invoice + items with computed VAT totals; no DB writes).
* Lifecycle: drafts have invoice_number=null until the :send action
* verb (PR-B-2b) triggers F-series allocation atomically. Delivery
* notes get a number on create from a separate D-series sequence.
* Rationale (ML 17 kap 24§ p.2): the löpnummer series must be
* unbroken AND cover only issued invoices — consuming numbers for
* drafts that get abandoned creates legal gaps.
*/
import { z } from 'zod'
import { paginated } from '@/lib/api/v1/response'
import { created, paginated } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import {
decodeDefaultCursor,
encodeDefaultCursor,
@@ -24,6 +26,11 @@ import { parseExpand } from '@/lib/api/v1/expand'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { CreateInvoiceSchema } from '@/lib/api/schemas'
import { getAvailableVatRates, getVatRules } from '@/lib/invoices/vat-rules'
import { convertToSEK, fetchExchangeRate } from '@/lib/currency/riksbanken'
import { eventBus } from '@/lib/events'
import type { Invoice, InvoiceDocumentType } from '@/types'
const InvoiceStatus = z.enum([
'draft',
@@ -285,3 +292,400 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
})
},
)
// ──────────────────────────────────────────────────────────────────
// POST — create draft invoice (or proforma / delivery_note)
// ──────────────────────────────────────────────────────────────────
// Response projection on create — same shape as the detail endpoint.
// Drop user_id, company_id (internal scoping).
const INVOICE_RESPONSE_COLUMNS =
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, created_at, updated_at'
const INVOICE_ITEMS_RESPONSE_COLUMNS =
'id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, created_at'
// Loose response schema — invoices have many fields; pinning every one in
// the registry is overkill until we have a real schema-drift test.
const InvoiceCreated = z.object({
id: z.string().uuid(),
invoice_number: z.string().nullable(),
customer_id: z.string().uuid(),
invoice_date: z.string(),
due_date: z.string(),
status: z.string(),
document_type: z.string(),
currency: z.string(),
subtotal: z.number(),
vat_amount: z.number(),
total: z.number(),
remaining_amount: z.number(),
created_at: z.string(),
})
registerEndpoint({
operation: 'invoices.create',
method: 'POST',
path: '/api/v1/companies/:companyId/invoices',
summary: 'Create a draft invoice, proforma, or delivery note.',
description:
'Creates an invoice in draft status. The F-series invoice_number is allocated atomically on the first send action (PR-B-2b). Per-item VAT rates are validated against the customer\'s allowed rates (mixed-rate invoices supported). Non-SEK invoices are converted to SEK at the Riksbanken exchange rate fetched at create time. Idempotent (mandatory Idempotency-Key). Dry-runnable — the preview returns the validated would-be invoice + items with computed totals; no journal entry is involved at draft stage (posting happens on :send).',
useWhen:
'You need to issue a new invoice, proforma, or delivery note. Use dry-run first to confirm VAT calculations and currency conversion before committing.',
doNotUseFor:
'Updating an existing invoice (PATCH instead, drafts only). Issuing a credit note (use POST /:id:credit in PR-B-2b). Posting a previously-created draft to the journal (use POST /:id:send in PR-B-2b).',
pitfalls: [
'Idempotency-Key is mandatory; calls without it return 400.',
'For mixed-rate invoices, set vat_rate per item explicitly. Items where vat_rate is omitted use the customer\'s default rate from getVatRules().',
'Non-SEK currencies require an active Riksbanken exchange-rate fetch. Failure is non-fatal — the invoice is created with null SEK fields and the agent can recompute later.',
'invoice_number is null on creation. The number is allocated atomically when the invoice transitions out of draft. Counting on a specific number at create time is a bug.',
'document_type=\'delivery_note\' produces no VAT and a different number sequence (D-series). Most use cases want the default document_type=\'invoice\'.',
],
example: {
request: {
customer_id: 'a8f1…',
invoice_date: '2026-05-12',
due_date: '2026-06-11',
currency: 'SEK',
items: [
{ description: 'Konsultation', quantity: 8, unit: 'tim', unit_price: 1250 },
],
},
response: {
data: {
id: '0e9c…',
invoice_number: null,
customer_id: 'a8f1…',
invoice_date: '2026-05-12',
due_date: '2026-06-11',
status: 'draft',
currency: 'SEK',
subtotal: 10000,
vat_amount: 2500,
total: 12500,
remaining_amount: 12500,
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'invoices:write',
risk: 'medium',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: CreateInvoiceSchema },
response: { success: InvoiceCreated },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'invoices.create',
async (request, ctx) => {
// Defensive: companyId comes from the URL and was already validated for
// membership by the wrapper, but UUID-validate it before using as a DB
// predicate — mirrors the pattern in the detail-route :id check.
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 = CreateInvoiceSchema.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 input = parsed.data
const documentType: InvoiceDocumentType = input.document_type || 'invoice'
// Customer fetch (scoped to company). Determines VAT rules and the set
// of allowed per-item rates.
const { data: customer, error: customerErr } = await ctx.supabase
.from('customers')
.select('id, customer_type, vat_number_validated')
.eq('company_id', ctx.companyId!)
.eq('id', input.customer_id)
.maybeSingle()
if (customerErr) {
return v1ErrorResponse(customerErr, ctx.log, { requestId: ctx.requestId })
}
if (!customer) {
return v1ErrorResponseFromCode('INVOICE_CUSTOMER_NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'customer' },
})
}
const vatRules = getVatRules(
customer.customer_type as Parameters<typeof getVatRules>[0],
customer.vat_number_validated,
)
const availableRates = getAvailableVatRates(
customer.customer_type as Parameters<typeof getAvailableVatRates>[0],
customer.vat_number_validated,
)
const allowedRates = new Set(availableRates.map((r) => r.rate))
// Per-item VAT validation + totals.
const subtotal = input.items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
let vatAmount = 0
if (documentType !== 'delivery_note') {
for (const item of input.items) {
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
if (!allowedRates.has(itemRate)) {
return v1ErrorResponseFromCode('INVOICE_CREATE_VAT_RULE_VIOLATION', ctx.log, {
requestId: ctx.requestId,
details: {
attempted_rate: itemRate,
allowed_rates: Array.from(allowedRates),
customer_type: customer.customer_type,
},
})
}
const lineTotal = item.quantity * item.unit_price
vatAmount += Math.round((lineTotal * itemRate) / 100 * 100) / 100
}
}
const total = documentType === 'delivery_note' ? 0 : subtotal + vatAmount
const uniqueRates = new Set(input.items.map((item) => item.vat_rate ?? vatRules.rate))
const isMixedRate = uniqueRates.size > 1
const headerVatRate = documentType === 'delivery_note'
? 0
: isMixedRate
? null
: (uniqueRates.values().next().value ?? vatRules.rate)
// Currency conversion (best-effort; non-fatal on failure).
let exchangeRate: number | null = null
let exchangeRateDate: string | null = null
let subtotalSek: number | null = null
let vatAmountSek: number | null = null
let totalSek: number | null = null
if (input.currency !== 'SEK') {
const rateData = await fetchExchangeRate(input.currency)
if (rateData) {
exchangeRate = rateData.rate
exchangeRateDate = rateData.date
subtotalSek = convertToSEK(subtotal, exchangeRate)
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
totalSek = convertToSEK(total, exchangeRate)
}
}
// Build computed item rows for the would-be insert.
const itemRows = input.items.map((item, index) => {
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
const lineTotal = item.quantity * item.unit_price
const itemVat = documentType === 'delivery_note'
? 0
: Math.round((lineTotal * itemRate) / 100 * 100) / 100
return {
sort_order: index,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: lineTotal,
vat_rate: itemRate,
vat_amount: itemVat,
}
})
// Dry-run: validation-only preview. Drafts have no journal-entry side
// effects yet, so no pending_operations staging needed; the
// dryRunStaged() variant lands in PR-B-2b for :send.
if (ctx.dryRun) {
return dryRunPreview(
{
// Would-be invoice row.
invoice_number: null,
customer_id: input.customer_id,
invoice_date: input.invoice_date,
due_date: input.due_date,
delivery_date: input.delivery_date ?? null,
status: 'draft' as const,
currency: input.currency,
exchange_rate: exchangeRate,
exchange_rate_date: exchangeRateDate,
subtotal: documentType === 'delivery_note' ? 0 : subtotal,
subtotal_sek: documentType === 'delivery_note' ? null : subtotalSek,
vat_amount: vatAmount,
vat_amount_sek: documentType === 'delivery_note' ? null : vatAmountSek,
total,
total_sek: documentType === 'delivery_note' ? null : totalSek,
vat_treatment: vatRules.treatment,
vat_rate: headerVatRate,
moms_ruta: vatRules.momsRuta,
reverse_charge_text: vatRules.reverseChargeText || null,
your_reference: input.your_reference ?? null,
our_reference: input.our_reference ?? null,
notes: input.notes ?? null,
document_type: documentType,
remaining_amount: documentType === 'invoice' ? total : 0,
items: itemRows,
},
{ requestId: ctx.requestId, log: ctx.log },
)
}
// Delivery notes get their number from a dedicated sequence on insert.
// Invoices and proformas allocate F-series numbers via
// ensureInvoiceNumber AFTER insert (atomic, but can fail — soft-cancel
// on failure to preserve sequence integrity per ML 17 kap 24§).
let invoiceNumber: string | null = null
if (documentType === 'delivery_note') {
const { data: dnNumber } = await ctx.supabase.rpc('generate_delivery_note_number', {
p_company_id: ctx.companyId!,
})
invoiceNumber = dnNumber as string | null
}
const { data: invoice, error: invoiceErr } = await ctx.supabase
.from('invoices')
.insert({
user_id: ctx.userId,
company_id: ctx.companyId!,
customer_id: input.customer_id,
invoice_number: invoiceNumber,
invoice_date: input.invoice_date,
due_date: input.due_date,
delivery_date: input.delivery_date ?? null,
currency: input.currency,
exchange_rate: exchangeRate,
exchange_rate_date: exchangeRateDate,
subtotal: documentType === 'delivery_note' ? 0 : subtotal,
subtotal_sek: documentType === 'delivery_note' ? null : subtotalSek,
vat_amount: vatAmount,
vat_amount_sek: documentType === 'delivery_note' ? null : vatAmountSek,
total,
total_sek: documentType === 'delivery_note' ? null : totalSek,
remaining_amount: documentType === 'invoice' ? total : 0,
vat_treatment: vatRules.treatment,
vat_rate: headerVatRate,
moms_ruta: vatRules.momsRuta,
reverse_charge_text: vatRules.reverseChargeText || null,
your_reference: input.your_reference,
our_reference: input.our_reference,
notes: input.notes,
document_type: documentType,
})
.select(INVOICE_RESPONSE_COLUMNS)
.single()
if (invoiceErr) {
// pg_message can interpolate field values from constraint detail —
// log internally, never echo to the client.
ctx.log.error('invoice insert failed', invoiceErr, {
invoiceId: undefined,
companyId: ctx.companyId,
pgCode: invoiceErr.code,
})
return v1ErrorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { pg_code: invoiceErr.code },
})
}
const invoiceId = (invoice as { id: string }).id
// Insert items. If this fails, roll back the invoice row to avoid
// orphaned headers. Scope the rollback by company_id (defense in depth
// against UUID collision / logic error in compensating logic) and
// check the delete result so a double-failure is visible.
const itemsToInsert = itemRows.map((r) => ({ ...r, invoice_id: invoiceId }))
const { error: itemsErr } = await ctx.supabase.from('invoice_items').insert(itemsToInsert)
if (itemsErr) {
const { error: rollbackErr } = await ctx.supabase
.from('invoices')
.delete()
.eq('id', invoiceId)
.eq('company_id', ctx.companyId!)
if (rollbackErr) {
ctx.log.error(
'invoice items insert failed AND rollback delete failed — orphaned invoice header',
rollbackErr,
{ invoiceId, companyId: ctx.companyId, originalPgCode: itemsErr.code },
)
} else {
ctx.log.error('invoice items insert failed; rolled back invoice', itemsErr, {
invoiceId,
companyId: ctx.companyId,
})
}
return v1ErrorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { pg_code: itemsErr.code },
})
}
// Note: F-series invoice_number is NOT allocated at draft-create.
// Allocation happens atomically on the first :send action (Phase 2
// PR-B-2b). Draft invoices keep invoice_number=null until then.
// Rationale: ML 17 kap 24§ p.2 requires the löpnummer series to be
// unbroken and to cover only issued invoices — consuming numbers for
// drafts that are later abandoned creates legal gaps.
// Delivery notes use a separate D-series sequence (already allocated
// on insert above) and are NOT subject to the F-series constraint.
// Refetch with embedded items for the response.
const { data: complete, error: refetchErr } = await ctx.supabase
.from('invoices')
.select(`${INVOICE_RESPONSE_COLUMNS}, items:invoice_items(${INVOICE_ITEMS_RESPONSE_COLUMNS})`)
.eq('id', invoiceId)
.eq('company_id', ctx.companyId!)
.single()
if (refetchErr) {
// The invoice WAS created; the items WERE inserted. Refetch failed
// for a transient DB reason. Log it so the partial-response is
// visible; fall back to the header without items rather than
// mis-leading the agent with a 5xx.
ctx.log.warn('invoice refetch after create failed; returning header without items', {
invoiceId,
companyId: ctx.companyId,
pgCode: (refetchErr as { code?: string }).code,
})
}
// Emit invoice.created only for real invoices — proformas and delivery
// notes are informational and have no downstream consumer obligation.
if (complete && documentType === 'invoice') {
try {
await eventBus.emit({
type: 'invoice.created',
payload: {
invoice: complete as unknown as Invoice,
companyId: ctx.companyId!,
userId: ctx.userId,
},
})
} catch (err) {
ctx.log.warn('invoice.created emit failed', err as Error, {
invoiceId,
companyId: ctx.companyId,
})
}
}
return created(complete ?? invoice, { requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)
+3 -1
View File
@@ -53,9 +53,11 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
'PATCH /api/v1/companies/:companyId/customers/:id': 'customers:write',
'DELETE /api/v1/companies/:companyId/customers/:id': 'customers:write',
// Invoices (Phase 2 PR-A)
// Invoices (Phase 2 PR-A — reads; Phase 2 PR-B-2a — draft writes)
'GET /api/v1/companies/:companyId/invoices': 'invoices:read',
'GET /api/v1/companies/:companyId/invoices/:id': 'invoices:read',
'POST /api/v1/companies/:companyId/invoices': 'invoices:write',
'PATCH /api/v1/companies/:companyId/invoices/:id': 'invoices:write',
// Webhooks (Phase 6 — placeholder so the catalogue is complete)
'GET /api/v1/companies/:companyId/webhooks': 'webhooks:manage',
+8
View File
@@ -505,6 +505,14 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
description: 'Issue a credit note instead of deleting a posted invoice.',
},
},
INVOICE_UPDATE_NOT_DRAFT: {
httpStatus: 409,
message_sv: 'Endast utkast kan ändras. Bokförda fakturor är oföränderliga — utfärda en kreditfaktura istället.',
message_en: 'Only draft invoices can be updated. Issued invoices are immutable — issue a credit note instead.',
remediation: {
description: 'Issue a credit note via POST /invoices/{id}:credit and create a fresh invoice with the corrected details.',
},
},
INVOICE_CANCEL_RACE: {
httpStatus: 409,
message_sv: 'Fakturan ändrades samtidigt och kunde inte makuleras. Ladda om och försök igen.',