e96cbe05d0
* 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>