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:
co-authored by
Claude Opus 4.7
parent
d29b87fb80
commit
e96cbe05d0
@@ -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
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user