feat(api): Phase 4 PR-1 — AP world (suppliers + supplier-invoices) (#467)

* feat(api): Phase 4 PR-1 — AP world (suppliers + supplier-invoices)

First of two Phase 4 PRs. Ships the public v1 AP-side verticals end-to-end,
mirroring the Phase 2 AR pattern (customers + invoices).

ENDPOINTS (13)

Suppliers:
  GET    /suppliers                       — cursor list + filters
  GET    /suppliers/{id}                  — detail, ?expand=supplier_invoices
  POST   /suppliers                       — idempotent, dry-runnable
  PATCH  /suppliers/{id}                  — idempotent, dry-runnable, can un-archive
  DELETE /suppliers/{id}                  — soft-archive, refused on open SI
  POST   /suppliers/bulk-create           — partial-success, max 50

Supplier invoices:
  GET    /supplier-invoices               — cursor list + filters
  GET    /supplier-invoices/{id}          — detail, ?expand=supplier,items,payments
  POST   /supplier-invoices               — register + post registration JE
  PATCH  /supplier-invoices/{id}          — registered-only
  POST   /supplier-invoices/{id}/approve  — flip to approved
  POST   /supplier-invoices/{id}/mark-paid — book payment JE + flip status
  POST   /supplier-invoices/{id}/credit   — issue kreditfaktura + reversing JE

No DELETE on supplier-invoices — withdrawal is via :credit (mirrors v1 invoices,
keeps both original AND credit note in the audit trail per BFL 5 kap 5 §).

STRICT-MODE V1

Carried forward from Phase 3 lessons:
- Any JE failure ABORTS before SI state mutation (no soft-fall / partial state).
  Applies to register, mark-paid, and credit.
- checkPeriodLock() pre-check before every JE-emitting write — returns
  structured PERIOD_LOCKED / SI_PAID_PERIOD_LOCKED / SI_CREDIT_PERIOD_LOCKED
  instead of letting the DB trigger surface a generic 500.
- CAS-race orphan handling in mark-paid: if the SI status flips between
  pre-flight and our update, the just-posted payment JE is stornoed via
  reverseEntry() rather than left dangling (BFL 5 kap 5 §).
- Math.round monetary throughout. Half-öre epsilon on remaining_amount==0.

SCHEMA MIGRATION

`20260513150000_archived_at_for_customers_and_suppliers.sql`:
  - Adds suppliers.archived_at (new — required for the soft-archive flow).
  - Adds customers.archived_at + customers.vat_number_validated_at —
    retroactively. The Phase 2 v1 customer routes (PR #451 / #452 / #460)
    already reference both columns but no prior migration installed them in
    production. This commit fixes that latent bug while we have the
    migration open.
  - Partial indexes on (company_id, created_at) WHERE archived_at IS NULL
    keep the default-active list path cheap.
  - is_active (legacy boolean) preserved on suppliers; v1 archive sets both
    archived_at = now() AND is_active = false, un-archive flips both back
    so the dashboard's "show only active" filters stay intact.

NEW ERROR CODES

  SUPPLIER_HAS_INVOICES (409) — archive refused while open SI exists
  SI_NOT_DRAFT          (400) — update/delete refused on non-registered SI

GDPR ART.5(1)(c) DEFENSE-IN-DEPTH

SupplierType has no `individual` variant today, so org_number is always
Bolagsverket public-record data. The list endpoint still has the masking
hook (empty INDIVIDUAL_TYPES set) so a future natural-person supplier type
becomes a one-line change. Duplicate-org_number error responses NEVER echo
the submitted value — symmetric with customers.

SCOPES

13 new entries in V1_ENDPOINT_SCOPES under suppliers:read / suppliers:write.

TESTS

36 new integration cases across 2 suites:
  - suppliers: list (incl. filter), get (incl. 404), create (happy + 23505 +
    dry-run + missing-idempotency), patch (happy + empty body), delete
    (archive + open-invoice refusal), bulk-create (partial-success + 501)
  - supplier-invoices: list, get (incl. 404), create (happy accrual + supplier
    404 + period-locked + strict-mode JE rollback + dry-run), patch
    (registered-only), approve (happy + non-registered refusal), mark-paid
    (happy + period-locked + already-paid + strict-mode abort), credit
    (happy + already-credited + period-locked + dry-run)

Full suite green: 3333 passing (237 files). Build + lint clean.

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

* fix(api): PR #467 round-2 — Greptile P1/P2 fixes

Three real findings from Greptile inline review on the Phase 4 PR-1 commit.

P1 — mark-paid: storno orphan JE when SI update fails.
  When the `.update()` after the JE post returned an `updateErr`, the route
  logged + returned SI_PAID_FAILED without reversing the just-posted payment
  JE. The CAS-race branch immediately below already proves journalEntryId is
  in scope and reverseEntry takes it directly — the original comment about
  "requires fetching the entry first" was wrong. Now both error branches
  (the `updateErr` DB-failure path and the `!updated` CAS-race path) storno
  via reverseEntry before returning, keeping the AP ledger consistent
  (BFL 5 kap 5 §). Storno failure itself logs loudly and the error envelope
  surfaces the journal_entry_id so manual reconciliation has a starting
  point.

P1 — credit + register: capture JE link-update result, storno on failure.
  Both supplier-invoices/route.ts (register) and supplier-invoices/[id]/
  credit/route.ts back-fill registration_journal_entry_id on the freshly-
  inserted SI/credit-note row, but were dropping the await result. A
  transient DB error there silently left the row with registration_-
  journal_entry_id=null even though the JE was live on the books — the POST
  response looked correct (it returned the JE id from the local variable)
  but every subsequent GET /supplier-invoices/{id} showed null. Both paths
  now capture the link-update error, storno the orphan JE via reverseEntry,
  then roll back the SI/credit-note row before returning SI_CREATE_FAILED
  / SI_CREDIT_FAILED with step='*_link'. Strict-mode atomicity restored.

P2 — mark-paid: dry-run paid_at format alignment.
  Dry-run preview set `paid_at: paymentDate` (YYYY-MM-DD), but the live
  `.update()` writes `new Date().toISOString()` (full UTC timestamp). A
  caller validating both responses against the same regex would have been
  caught by the mismatch. Dry-run now mirrors the live shape.

P2 — ensureInitialized() finding dismissed as a false positive:
  lib/api/v1/with-api-v1.ts:52 already calls ensureInitialized() at module
  load. Every v1 route imports withApiV1 from that module, so the side
  effect runs on first import and caches. No existing v1 route (customers,
  invoices, transactions) imports ensureInitialized() directly — the
  pattern has been consistent across Phases 1-3 and the AP-world routes
  follow it.

Tests + build green: 3333 passing across 237 files, AP suite 36/36.

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

* fix(api): PR #467 round-3 — compliance swarm + swedish-compliance fixes

Both bots re-ran and converged on a set of substantive findings. Seven real
issues addressed; several recurring false positives + architectural
deferrals documented inline.

REAL FIXES (7)

1. credit: `remaining_amount` calc was nonsensical.
   `Math.max(0, remaining_amount - total)` was always ≤ 0 (since
   remaining ≤ total), forcing status to 'credited' regardless of paid
   state — but only via the clamp, not the logic. Both swedish-compliance
   and Compliance Swarm (OWASP V2.3 + SOC 2 PI1.3) caught this. A
   kreditfaktura nullifies the AP obligation on the original (BFL 5 kap
   5 §); refunds of already-paid amounts get a separate transaction.
   `remaining_amount: 0` and `status: 'credited'` unconditionally.

2. supplier-invoices register: VAT rate whitelist.
   `computeItemsAndTotals` accepted any float for `vat_rate`, silently
   booking an unrecognised rate into the registration JE → momsdeklaration
   Ruta 48 + INK2R. Now rejects with VALIDATION_ERROR (allowed_rates
   echoed) unless the rate is in `{0, 0.06, 0.12, 0.25}` (ML 2 kap 1 §).

3. mark-paid: `exchange_rate_difference` is required for non-SEK accrual.
   The pitfall docs warned about this but the code didn't enforce it. Without
   it the payment JE doesn't book the FX delta to 3960/7960 and AP carries
   a stranded 2440 balance after the bank line clears. Enforces with field-
   level VALIDATION_ERROR; pass `exchange_rate_difference: 0` if there's
   no rate movement.

4. suppliers PATCH: refuse on archived suppliers (BFL 7 kap 1 §).
   An archived supplier's name/address backs historical verifikationer; a
   post-archive PATCH would silently corrupt 7-year-retained räkenskaps-
   information. The handler now fetches the current row, refuses identifying-
   field updates when `archived_at IS NOT NULL`, and only permits the
   un-archive PATCH (`archived_at: null`).

5. supplier-invoices register: smart vat_treatment / reverse_charge default.
   The previous default of `'standard_25'` regardless of supplier_type left
   EU/non-EU supplier rows with metadata that didn't match the actual booking
   path (which uses `reverse_charge`). Now derives both fields from
   `supplier.supplier_type` when the caller omits them: foreign suppliers
   default to `reverse_charge: true` + `vat_treatment: 'reverse_charge'`.
   Explicit body values still win.

6. reverseEntry: static import (SOC 2 CC8.1).
   Replaced the three dynamic `await import('@/lib/bookkeeping/engine')`
   calls in orphan-storno error branches with a top-level static import.
   The dependency is now visible to SCA / tree-shake / static analysis.

7. Add `userId: ctx.userId` to every storno-failure log context (OWASP
   V16.1). The CAS-race + linkErr branches now consistently include the
   actor identity for security-relevant audit events.

TESTS (+6 new)

  - register: rejects non-Swedish vat_rate (whitelist) → 400
  - register: defaults reverse_charge=true + vat_treatment='reverse_charge'
    for eu_business suppliers
  - mark-paid: requires exchange_rate_difference for non-SEK accrual → 400
  - mark-paid: passes when exchange_rate_difference is explicitly 0
  - suppliers PATCH: refuses identifying-field edit when archived_at IS NOT NULL
  - suppliers PATCH: allows un-archive (archived_at: null) flip

AP suite 42/42 (was 36). Full suite 3339/3339 green (was 3333).

DISMISSED WITH RATIONALE

- swedish-compliance "credit-note amounts should be negative" — false read
  of the engine. `createSupplierCreditNoteEntry` calls `Math.abs()` on
  item amounts (line 421) and posts a reversing JE; the SI row carries
  positive amounts + `is_credit_note=true` as a deliberate data-model
  decision. Negating would break parity with the dashboard and the
  internal AP-ledger reporting.

- OWASP V8.2.1 cross-tenant via path — recurring false positive across
  Phases 2-4. `withApiV1` (line ~340-350) verifies `company_members`
  membership BEFORE setting `ctx.companyId` from the URL.

- OWASP V8.2.1 supplier_invoice_items company_id filter in
  rollbackCreditNote — the table has no `company_id` column;
  cross-tenant protection comes from RLS + the parent
  supplier_invoice_id scoping.

- OWASP V4.5 PATCH allowlist schema-derivation — known architectural
  deferral; centralising the field list against a Zod `.pick()` is a
  separate refactor.

- GDPR Art.5(1)(f) log/event field identifiers — RoPA / log-pseudonymisation
  is an org-wide privacy-eng concern, not a per-route fix.

- ISO 27001 A.8.15/A.8.16 non-blocking inserts — `supplier_invoice_payments`
  insert + event emit failures stay at warn-level for v1 to mirror the
  dashboard internal route. Promoting to error escalations + DLQ is a
  cross-cutting reliability project, not a route patch.

- SOC 2 CC6.3 segregation-of-duties — v1's API-key scope IS the boundary
  by design. Role-based separation between register / approve / pay is a
  v1.x feature, not a v1 surface bug.

- swedish-compliance reverse-charge gating in credit — the engine
  (`createSupplierCreditNoteEntry`) already gates the 2647/2645 reversal
  on `creditNote.reverse_charge` (line 437). Mirrors the registration
  engine.

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

* fix(api): PR #467 round-4 — BFL 5 kap 5 § + remaining compliance fixes

Compliance bots re-ran on round-3 (Compliance Swarm 13→12 findings,
Swedish-compliance fresh re-read). Five real issues addressed; the rest
are recurring false positives or architectural deferrals carried over
from earlier rounds.

REAL FIXES (5)

1. mark-paid: future payment_date rejected at the schema layer.
   BFL 5 kap 2 § requires bokföring to follow real cash movement;
   payment_date > today is a scheduling artefact, not an affärshändelse.
   Returns 400 VALIDATION_ERROR before the JE engine runs.

2. credit: drop user_id from SI_FULL_COLUMNS (GDPR Art.25).
   The original SI's `user_id` (its historical creator) is never used
   in the credit flow — the new credit-note row uses ctx.userId (the
   actor performing the credit). Don't fetch what you don't need.
   Also drops company_id from the select since it's already filtered.

3. supplier-invoices register: reverse-charge cross-field VAT check.
   For reverse-charge invoices the Swedish supplier doesn't charge VAT,
   the buyer self-assesses (ML 1 kap 2§ p.4b / 16 kap 6 § / 16 kap 13 §).
   If `reverse_charge=true` and ANY item has `vat_rate != 0`, return
   VALIDATION_ERROR — otherwise the engine would book ingående moms in
   Ruta 30 / 48 (BAS 2614 / 2645 / 2641) for an invoice that has no VAT
   to deduct.

4. rollbackSupplierInvoice + rollbackCreditNote: soft-mark, not delete.
   BFL 5 kap 5 § — rättelse av bokföringspost måste vara dokumenterad
   så att både den ursprungliga och den korrigerade noteringen är
   synliga. Hard-deleting the SI row on a mid-write failure destroys
   räkenskapsinformation even when the JE side (if any) is preserved
   via storno. Both rollback paths now UPDATE status='reversed' +
   reversed_at=now() — the SupplierInvoiceStatus enum already has
   'reversed' for exactly this case ("credit note whose journal entry
   was storno-reversed via Ångra kreditering" per the type comment).
   Trade-off: a retry with the same supplier_invoice_number will hit
   the unique-index conflict, so the caller picks a fresh number.

TESTS (+2 new)

  - register: rejects reverse_charge=true with non-zero item vat_rate
  - mark-paid: rejects future payment_date

Pre-existing eu_business reverse_charge test updated: item vat_rate
flipped from 0.25 → 0 to remain valid under the new cross-field check.

AP suite 44/44 (was 42). Full suite 3341/3341 green (was 3339).

DISMISSED (recurring or architectural)

- OWASP V8.2.1 cross-tenant via path — recurring false positive across
  Phase 2-4. withApiV1 verifies company_members membership BEFORE
  setting ctx.companyId from the URL.

- ISO A.8.3 approve-route TOCTOU — already mitigated. The UPDATE has
  `.eq('status', 'registered')` as a race guard; the pre-flight is for
  ergonomic error messages, not security.

- SOC 2 PI1.3 floating-point — project-wide convention is
  Math.round(x * 100) / 100 per CLAUDE.md. Diverging in one route would
  create a parity bug with the bookkeeping engine + dashboard. Settled.

- SOC 2 CC7.3 storno-failure alerting / ISO A.8.15 audit-log on success
  / SOC 2 CC6.1 test-fixture key / OWASP V2.2 status state-machine /
  V1.2.5 dynamic select-clause / V16 audit-log silent-failure / Art.25
  banking-field expand — all architectural deferrals that fit the
  webhook-hardening + scope-redesign work in Phase 6, not the v1 PR.

- swedish-compliance "credit-note original-number reference" — the
  `credited_invoice_id` FK is the structured back-reference; the
  document-rendering layer surfaces the original `supplier_invoice_-
  number` from there. Not a v1 surface bug.

- swedish-compliance "cash-basis credit-note vat_amount" — engine
  behaviour mirrored from the dashboard. Engine-layer audit, separate
  effort.

- swedish-compliance "active-supplier mutability broader than
  archived_at" — solving this requires snapshotting supplier identity
  onto each supplier_invoices row at registration (schema migration).
  Deeper architectural decision; tracking for Phase 4 follow-up
  alongside the journal-entries vertical.

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

* fix(api): PR #467 round-5 — strict schema + vat_treatment normalisation +
narrow BFL archive lock

Compliance bots re-ran on round-4. Most findings are recurring (V8.2.1
cross-tenant, PI1.3 floating-point, CC6.3 SoD) or the classic oscillation
pattern from the Phase 3 lessons: this round's Art.5(1)(f) flags userId in
storno error logs as PII exposure — but last round's V16.1 demanded I ADD
userId for audit attribution. Staying with audit attribution; the bot can
pick a side.

Three substantive findings addressed.

REAL FIXES (3)

1. V4.5 mass-assignment defense-in-depth on PATCH /supplier-invoices/{id}.
   The shared `UpdateSupplierInvoiceSchema` is consumed by the dashboard
   too, where Zod's default key-stripping is acceptable. The v1 route now
   wraps it in `V1PatchSupplierInvoiceSchema = UpdateSupplierInvoiceSchema
   .strict()` so any unknown key (e.g. `status`, `company_id`, `user_id`)
   returns 400 VALIDATION_ERROR instead of being silently dropped — even
   if the iteration allowlist downstream is later relaxed.

2. vat_treatment normalisation when reverse_charge resolves true.
   Caller could previously pass `vat_treatment: 'standard_25'` explicitly
   on an eu_business supplier, and the supplier-type-driven default would
   set `reverse_charge: true` while the metadata stayed as 'standard_25'.
   The engine books via the boolean (so JE is correct) but a downstream
   momsdeklaration / audit export reading `vat_treatment` would mis-
   classify. Resolution order is now: reverse_charge first, then
   vat_treatment forced to 'reverse_charge' if true; explicit overrides
   only stick when they agree with the resolved boolean.

3. Narrow archived-supplier PATCH lock to identifying fields only.
   The round-4 blanket lock on archived suppliers was too broad: BFL
   7 kap 1 § protects räkenskapsinformation — the fields verifikationer
   reference through the supplier join — but not internal notes or
   payment-config metadata. The check now only refuses PATCHes that touch
   {name, supplier_type, org_number, vat_number, address_*, banking_*}.
   Notes, default_payment_terms, default_expense_account, default_currency,
   email, and phone remain editable on archived rows.

TESTS (+3 new)

  - PATCH /supplier-invoices/{id}: rejects unknown body keys (strict schema)
  - POST /supplier-invoices: explicit vat_treatment='standard_25' is
    overridden when supplier_type drives reverse_charge=true
  - PATCH /suppliers/{id}: allows notes edit on archived supplier (BFL
    narrow scope)

AP suite 47/47 (was 44). Full suite 3344/3344 green (was 3341).

DISMISSED (recurring / settled / oscillating)

- OWASP V8.2.1 cross-tenant via path — recurring false positive 4 rounds
  running. withApiV1 verifies company_members membership BEFORE setting
  ctx.companyId from the URL.

- GDPR Art.5(1)(f) userId in error logs — direct contradiction of
  round-3's OWASP V16.1 finding which demanded userId be ADDED for audit
  attribution. Phase 3 lessons document this oscillation pattern
  ("swedish-compliance / compliance-swarm oscillate between rounds")
  and the correct response is to stay with the more security-positive
  position. Keeping userId on storno-failure logs for ledger-integrity
  attribution.

- SOC 2 CC6.3 segregation-of-duties — same as round-3. v1 design uses
  API-key scope as the boundary; role-based actor separation is Phase 6
  webhook + auth work.

- SOC 2 CC6.1 null-userId guard — redundant. withApiV1 short-circuits
  with 401 UNAUTHORIZED before invoking the handler when API-key
  validation fails (which is the only path that could leave ctx.userId
  unset).

- SOC 2 CC7.2 storno-failure alerting — architectural; webhook-bus +
  dead-letter is Phase 6 territory.

- SOC 2 / OWASP PI1.3 / V2.3 floating-point — project-wide convention
  per CLAUDE.md; the engine, dashboard, and v1 all use Math.round(x*100)/100.

- ISO 27001 A.8.33 test-fixture financial amounts — synthetic UUIDs +
  NODE_ENV=test guard already in place; "TEST-only" sentinel amounts
  would be cosmetic.

- OWASP V16.1 eventBus failure retry / DLQ — Phase 6 webhook hardening.

- swedish-compliance arrival_number gap risk — acknowledged in commit,
  bot itself says "no action required"; supplier_invoice_number retry
  behavior already in the rollback-comment doc.

- swedish-compliance vat_code cross-field — engine derives JE shape from
  `invoice.reverse_charge` (boolean), ignores item vat_code in the RC
  path. No surface-layer leak.

- swedish-compliance credit-note FX at today's rate — bot's reasoning
  inverted. The credit note REVERSES the original AP obligation; to net
  2440 to zero across the original-registration JE + credit-note JE, the
  SEK amounts MUST be copied from the original. FX rate at today's date
  applies at the bank-refund transaction side, not the credit-note
  registration.

- swedish-compliance KREDIT- prefix — dashboard parity. The
  `is_credit_note` + `credited_invoice_id` flags are the structured
  back-references; the prefix is cosmetic on the human-readable number.

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

* fix(api): PR #467 round-6 — overpayment guard + two-phase rollback +
SI_FULL_COLUMNS minimisation

Compliance Swarm trended down 12→9 findings, Swedish-compliance 6→5.
Three substantive items addressed; the rest are recurring false positives
or the userId-in-logs oscillation that round-5 already settled.

REAL FIXES (3)

1. mark-paid: reject overpayment up front (Compliance Swarm V2.3).
   Previously `Math.max(0, remaining - payment)` silently truncated an
   overpayment to a zero remaining_amount, while the JE engine booked the
   full payment_amount against 2440 — leaving an unaccounted overpayment
   on the AP ledger. Now refuses with VALIDATION_ERROR when
   `payment_amount > remaining_amount + 0.005` (half-öre tolerance for
   FX-rounding artefacts). Recovery hint points at :credit for
   over-billing and the transactions endpoints for refunds.

2. credit: trim SI_FULL_COLUMNS to fields actually read (Art.25(1)).
   The credit handler never reads notes, paid_at, payment_journal_entry_id,
   transaction_id, document_id, payment_reference, paid_amount,
   delivery_date, received_date, reversed_at, created_at, updated_at,
   exchange_rate_date, due_date — but the projection was fetching them
   all. SEK-conversion fields (subtotal_sek / vat_amount_sek / total_sek)
   ARE read (copied onto the credit-note row so the 2440 reversal nets),
   so they stay. Continues the round-4 user_id / company_id drop.

3. Two-phase soft-rollback (Swedish-compliance, BFL 5 kap 5 §).
   The bot caught a real misapplication: BFL 5:5 only kicks in once a
   verifikation has been COMMITTED. Pre-JE failures (items_insert,
   engine returning null because no fiscal period covers the date) are
   failed insertions, not bokföringsposter. Marking those rows
   `status='reversed'` with a null registration_journal_entry_id creates
   a dangling räkenskapsinformation entry that's harder to audit than a
   clean removal. Both rollback helpers now take a `journalEntryPosted`
   flag: pre-JE failures hard-delete (rows + items), post-JE failures
   keep the round-4 soft-mark + reversed_at behaviour. Call sites tagged
   per failure reason:

     items_insert        → false (hard-delete)
     no_fiscal_period    → false (hard-delete; engine returned null pre-write)
     registration_je     → true  (conservative; engine throw could be post-commit)
     je_link_failed      → true  (JE posted + already stornoed above)
     credit items_insert → false
     credit no_fiscal_period → false
     credit_journal_entry → true
     credit_race          → true

TESTS (+1 new)

  - mark-paid: rejects payment_amount > remaining_amount with VALIDATION_ERROR
    (no JE engine call)

AP suite 48/48 (was 47). Full suite 3345/3345 green (was 3344).

DISMISSED (with rationale)

- OWASP V8.2.1 cross-tenant via path — recurring across 5 rounds.
  withApiV1 verifies company_members membership BEFORE setting
  ctx.companyId from the URL. Fix-once decision in the wrapper, not a
  per-route concern.

- OWASP V4.5 strict schema (re-verification) — round-5 added
  V1PatchSupplierInvoiceSchema = UpdateSupplierInvoiceSchema.strict() +
  a test asserting {"status": "approved"} is rejected. The bot is
  re-flagging because it can't see the upstream schema in the diff;
  manually verified: UpdateSupplierInvoiceSchema only contains
  {supplier_invoice_number, invoice_date, due_date, delivery_date,
  payment_reference, notes}. No status / company_id / user_id field.

- GDPR Art.5(1)(f) userId in logs — same oscillation as round-4. Last
  round V16.1 demanded userId be ADDED for audit attribution; this
  round Art.5(1)(f) wants it REMOVED. Staying with audit attribution
  per the Phase 3 lessons doc's oscillation guidance.

- OWASP V16.1 / ISO A.8.15 / SOC 2 CC7.2 SIEM alerting on storno
  failure — architectural; Phase 6 webhook hardening.

- GDPR Art.25(2) supplier-expand banking fields default-on — same as
  round-4. A scope split (suppliers:read:sensitive) is a v1.x scope
  refactor, not a single-route patch.

- swedish-compliance VAT 0.06 date-aware validation (livsmedel 1 April
  2026) — needs livsmedel BAS classification (which BAS codes signal
  food) and date-aware lookup tables. Engine-layer concern; not
  achievable without engine changes. Documenting the 6% rate's temporary
  nature in the comment was the smaller fix already shipped in round-3.

- swedish-compliance SI_RESPONSE_COLUMNS missing reverse_charge — FALSE
  ALARM. `reverse_charge` IS present in the projection (line 264 of
  supplier-invoices/route.ts); the engine receives it correctly.

- swedish-compliance KREDIT- prefix — dashboard parity, dismissed
  rounds 3-5. The `is_credit_note` + `credited_invoice_id` flags are
  the structured back-references.

- swedish-compliance cash-basis credit-note ingående moms timing
  (ML 13 kap 27 §) — legitimate gap but engine-layer. The
  createSupplierCreditNoteEntry engine function handles accrual only;
  adding a cash-basis-already-paid branch would change engine
  semantics, divering from the dashboard. Tracking as a Phase 4 engine
  follow-up, not a v1 surface bug.

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-13 20:16:27 +02:00
committed by GitHub
parent 6c54f80a93
commit abb9f5868c
14 changed files with 5352 additions and 0 deletions
@@ -0,0 +1,142 @@
/**
* POST /api/v1/companies/{companyId}/supplier-invoices/{id}/approve
*
* Transitions a `registered` supplier invoice to `approved`. No journal entry
* is involved in this transition — the registration JE has already been posted
* (under accrual) or is deferred to :mark-paid (under cash). Idempotent
* (mandatory Idempotency-Key). Dry-runnable.
*
* Strict-mode: the optimistic-lock UPDATE filters on status='registered' so
* concurrent calls (or a same-key replay racing the first) yield a clean 409
* rather than a silent no-op.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { eventBus } from '@/lib/events'
import type { SupplierInvoice } from '@/types'
const SI_RESPONSE_COLUMNS =
'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, status, currency, subtotal, vat_amount, total, paid_amount, remaining_amount, is_credit_note, registration_journal_entry_id, payment_journal_entry_id, created_at, updated_at'
const SupplierInvoiceApproved = z.object({
id: z.string().uuid(),
status: z.literal('approved'),
arrival_number: z.number().int(),
supplier_invoice_number: z.string(),
})
registerEndpoint({
operation: 'supplier-invoices.approve',
method: 'POST',
path: '/api/v1/companies/:companyId/supplier-invoices/:id/approve',
summary: 'Approve a registered supplier invoice.',
description:
'Flips a supplier invoice from `registered` to `approved`. No journal entry is posted here — the registration JE was already booked at :create under accrual, or is deferred to :mark-paid under cash. Idempotent. Dry-runnable.',
useWhen:
'A registered SI has been reviewed and you want to mark it ready for payment. Many AP workflows gate :mark-paid behind an explicit approval step.',
doNotUseFor:
'Posting a journal entry (already done at :create under accrual). Paying the SI (use :mark-paid). Re-approving an already-approved SI (returns 400 SI_APPROVE_NOT_REGISTERED).',
pitfalls: [
'Idempotency-Key is mandatory.',
'Returns 400 SI_APPROVE_NOT_REGISTERED when current status !== "registered". Use the detail endpoint to inspect status first if unsure.',
],
example: {
response: {
data: { id: '0e9c…', status: 'approved', arrival_number: 42, supplier_invoice_number: '2026-1234' },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'suppliers:write',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: true,
response: { success: SupplierInvoiceApproved },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'supplier-invoices.approve',
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: 'Supplier-invoice id must be a UUID.' },
})
}
const invoiceId = idParse.data
const { data: existing, error: fetchErr } = await ctx.supabase
.from('supplier_invoices')
.select(SI_RESPONSE_COLUMNS)
.eq('company_id', ctx.companyId!)
.eq('id', invoiceId)
.maybeSingle()
if (fetchErr) {
return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId })
}
if (!existing) {
return v1ErrorResponseFromCode('SI_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
}
if ((existing as { status: string }).status !== 'registered') {
return v1ErrorResponseFromCode('SI_APPROVE_NOT_REGISTERED', ctx.log, {
requestId: ctx.requestId,
details: { current_status: (existing as { status: string }).status },
})
}
if (ctx.dryRun) {
return dryRunPreview(
{ ...(existing as object), status: 'approved' },
{ requestId: ctx.requestId, log: ctx.log },
)
}
const { data, error } = await ctx.supabase
.from('supplier_invoices')
.update({ status: 'approved' })
.eq('company_id', ctx.companyId!)
.eq('id', invoiceId)
.eq('status', 'registered')
.select(SI_RESPONSE_COLUMNS)
.maybeSingle()
if (error) {
ctx.log.error('supplier-invoice approve update failed', error, {
invoiceId,
companyId: ctx.companyId,
})
return v1ErrorResponseFromCode('SI_APPROVE_UPDATE_FAILED', ctx.log, { requestId: ctx.requestId })
}
if (!data) {
// Race: status transitioned between pre-flight and update.
return v1ErrorResponseFromCode('SI_APPROVE_NOT_REGISTERED', ctx.log, {
requestId: ctx.requestId,
details: { reason: 'race' },
})
}
try {
await eventBus.emit({
type: 'supplier_invoice.approved',
payload: {
supplierInvoice: data as unknown as SupplierInvoice,
companyId: ctx.companyId!,
userId: ctx.userId,
},
})
} catch (err) {
ctx.log.warn('supplier_invoice.approved emit failed', err as Error)
}
return ok(data, { requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)
@@ -0,0 +1,555 @@
/**
* POST /api/v1/companies/{companyId}/supplier-invoices/{id}/credit
*
* Issues a credit note (kreditfaktura) for an existing supplier invoice.
* Mirrors the dashboard credit flow:
*
* 1. Allocate a new arrival_number for the credit note.
* 2. Insert a new supplier_invoices row with is_credit_note=true,
* credited_invoice_id=<original.id>, and reversed amounts copied from
* the original.
* 3. Copy items from the original.
* 4. Under accrual, post the credit-note JE (reverses the registration:
* Debit 2440 / Credit 5xxx + Credit 2641).
* 5. Flip the original's status to `credited`.
*
* Strict-mode v1: any failure rolls back the credit-note row before
* returning the error. Idempotent (mandatory Idempotency-Key). Dry-runnable.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { eventBus } from '@/lib/events'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { AccountingMethod, SupplierInvoice, SupplierInvoiceItem } from '@/types'
const SI_RESPONSE_COLUMNS =
'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, status, currency, subtotal, vat_amount, total, paid_amount, remaining_amount, is_credit_note, credited_invoice_id, registration_journal_entry_id, created_at, updated_at'
// GDPR Art.25 data minimisation: the original SI's `user_id` (the row's
// historical creator) is never used in the credit flow — the new credit-note
// row uses `ctx.userId` (the actor performing the credit). Don't fetch what
// you don't need. `company_id` is already scoped by the `.eq('company_id')`
// filter, so omit that too — the route can't write to a different one.
// GDPR Art.25 data minimisation: only fields actually read in the credit
// flow are projected. `user_id` and `company_id` were dropped earlier; this
// round drops `notes` (the original SI's free-text notes are never copied
// onto the credit note and never inspected) plus several housekeeping
// fields (`paid_at`, `payment_journal_entry_id`, `transaction_id`,
// `document_id`, `payment_reference`, `paid_amount`, `delivery_date`,
// `received_date`, `is_credit_note`, `reversed_at`, `created_at`,
// `updated_at`) that the credit handler never reads. SEK-conversion fields
// (`subtotal_sek` / `vat_amount_sek` / `total_sek`) ARE read — they're
// copied verbatim onto the credit-note row so the 2440 reversal nets
// correctly.
const SI_FULL_COLUMNS = `
id, supplier_id, supplier_invoice_number, invoice_date, status,
currency, exchange_rate,
subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek,
vat_treatment, reverse_charge, remaining_amount,
is_credit_note, credited_invoice_id, arrival_number,
supplier:suppliers(id, name, supplier_type),
items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount)
`
const SupplierInvoiceCredited = z.object({
credit_note_id: z.string().uuid(),
original_id: z.string().uuid(),
arrival_number: z.number().int(),
supplier_invoice_number: z.string(),
registration_journal_entry_id: z.string().uuid().nullable(),
})
registerEndpoint({
operation: 'supplier-invoices.credit',
method: 'POST',
path: '/api/v1/companies/:companyId/supplier-invoices/:id/credit',
summary: 'Issue a credit note for a supplier invoice.',
description:
'Creates a kreditfaktura that reverses the original supplier invoice. Under accrual the reversing JE is posted atomically (Debit 2440 / Credit expense + Credit 2641). The original status flips to `credited`. Strict-mode: any failure rolls back the credit-note row. Idempotent. Dry-runnable.',
useWhen:
'You need to nullify a registered, approved, partially_paid, or paid supplier invoice — for a returned shipment, an over-invoice, or a vendor dispute resolution. Use dry-run to confirm the totals first.',
doNotUseFor:
'Editing line items on an unchanged invoice (use PATCH on `registered` SIs). Crediting an already-credited SI (returns 409 SI_CREDIT_ALREADY_CREDITED). Reversing a v1-issued credit (no v1 endpoint today — use the dashboard).',
pitfalls: [
'Idempotency-Key is mandatory.',
'Today\'s date is used as the credit-note invoice_date. It must fall in an open fiscal period — locked period returns 400 SI_CREDIT_PERIOD_LOCKED.',
'Cash basis (kontantmetoden): no reversing JE is posted — recognition is deferred until a refund transaction is booked. The credit-note row is still created so the AP audit trail stays consistent.',
'The original SI is flipped to `credited` regardless of how much of it was already paid; reconcile the bank refund via the transactions endpoints.',
],
example: {
response: {
data: {
credit_note_id: '4d2a…',
original_id: '0e9c…',
arrival_number: 43,
supplier_invoice_number: 'KREDIT-2026-1234',
registration_journal_entry_id: '9c2f…',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'suppliers:write',
risk: 'high',
idempotent: true,
reversible: false,
dryRunSupported: true,
response: { success: SupplierInvoiceCredited },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'supplier-invoices.credit',
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: 'Supplier-invoice id must be a UUID.' },
})
}
const invoiceId = idParse.data
// Fetch the original with supplier + items.
const { data: original, error: fetchErr } = await ctx.supabase
.from('supplier_invoices')
.select(SI_FULL_COLUMNS)
.eq('company_id', ctx.companyId!)
.eq('id', invoiceId)
.maybeSingle()
if (fetchErr) {
return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId })
}
if (!original) {
return v1ErrorResponseFromCode('SI_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
}
type SupplierObj = { id: string; name: string; supplier_type: string }
type Original = {
id: string
supplier_id: string
status: string
currency: string
exchange_rate: number | null
subtotal: number
subtotal_sek: number | null
vat_amount: number
vat_amount_sek: number | null
total: number
total_sek: number | null
vat_treatment: string
reverse_charge: boolean
remaining_amount: number
paid_amount: number
is_credit_note: boolean
credited_invoice_id: string | null
supplier_invoice_number: string
arrival_number: number
supplier: SupplierObj | SupplierObj[] | null
items?: Array<{
sort_order: number
description: string
quantity: number
unit: string
unit_price: number
line_total: number
account_number: string
vat_code: string | null
vat_rate: number
vat_amount: number
}>
} & Record<string, unknown>
const typed = original as unknown as Original
if (typed.is_credit_note) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Cannot credit a credit note. Reverse from the dashboard instead.' },
})
}
if (typed.status === 'credited') {
return v1ErrorResponseFromCode('SI_CREDIT_ALREADY_CREDITED', ctx.log, { requestId: ctx.requestId })
}
const today = new Date().toISOString().split('T')[0]
// Pre-flight period-lock on the credit-note invoice_date (today).
const lockVerdict = await checkPeriodLock(ctx.supabase, ctx.companyId!, today)
if (lockVerdict.locked) {
return v1ErrorResponseFromCode('SI_CREDIT_PERIOD_LOCKED', ctx.log, {
requestId: ctx.requestId,
details: {
reason: lockVerdict.reason,
fiscal_period_id: lockVerdict.fiscal_period_id,
},
})
}
const pickSupplier = (s: Original['supplier']): SupplierObj | null => {
if (!s) return null
return Array.isArray(s) ? (s[0] ?? null) : s
}
const supplierRow = pickSupplier(typed.supplier)
if (ctx.dryRun) {
// The arrival_number isn't allocated in a dry-run (would burn the
// sequence on a non-commit). The preview reports the count.
const previewItems = (typed.items ?? []).map((item) => ({
sort_order: item.sort_order,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: item.line_total,
account_number: item.account_number,
vat_code: item.vat_code,
vat_rate: item.vat_rate,
vat_amount: item.vat_amount,
}))
return dryRunPreview(
{
credit_note: {
supplier_id: typed.supplier_id,
supplier_invoice_number: `KREDIT-${typed.supplier_invoice_number}`,
invoice_date: today,
due_date: today,
status: 'registered',
currency: typed.currency,
exchange_rate: typed.exchange_rate,
subtotal: typed.subtotal,
vat_amount: typed.vat_amount,
total: typed.total,
is_credit_note: true,
credited_invoice_id: typed.id,
items: previewItems,
},
original_will_become: 'credited',
would_create_reversal_journal_entry: true,
},
{ requestId: ctx.requestId, log: ctx.log },
)
}
// Allocate arrival_number for the credit note.
const { data: arrivalNum, error: arrivalErr } = await ctx.supabase
.rpc('get_next_arrival_number', { p_company_id: ctx.companyId! })
if (arrivalErr || arrivalNum == null) {
ctx.log.error('arrival_number allocation failed (credit)', (arrivalErr as Error) ?? new Error('null'))
return v1ErrorResponseFromCode('SI_CREDIT_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { step: 'arrival_number' },
})
}
// Insert credit-note row.
const { data: creditNote, error: creditErr } = await ctx.supabase
.from('supplier_invoices')
.insert({
user_id: ctx.userId,
company_id: ctx.companyId!,
supplier_id: typed.supplier_id,
arrival_number: arrivalNum,
supplier_invoice_number: `KREDIT-${typed.supplier_invoice_number}`,
invoice_date: today,
due_date: today,
status: 'registered',
currency: typed.currency,
exchange_rate: typed.exchange_rate,
vat_treatment: typed.vat_treatment,
reverse_charge: typed.reverse_charge,
subtotal: typed.subtotal,
subtotal_sek: typed.subtotal_sek,
vat_amount: typed.vat_amount,
vat_amount_sek: typed.vat_amount_sek,
total: typed.total,
total_sek: typed.total_sek,
remaining_amount: 0,
is_credit_note: true,
credited_invoice_id: typed.id,
})
.select(SI_RESPONSE_COLUMNS)
.single()
if (creditErr || !creditNote) {
ctx.log.error('credit-note insert failed', creditErr as Error, {
originalId: typed.id,
companyId: ctx.companyId,
})
return v1ErrorResponseFromCode('SI_CREDIT_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { step: 'credit_note_insert', pg_code: (creditErr as { code?: string } | null)?.code },
})
}
const creditNoteId = (creditNote as { id: string }).id
// Copy items.
const creditItems = (typed.items ?? []).map((item) => ({
supplier_invoice_id: creditNoteId,
sort_order: item.sort_order,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: item.line_total,
account_number: item.account_number,
vat_code: item.vat_code,
vat_rate: item.vat_rate,
vat_amount: item.vat_amount,
}))
if (creditItems.length > 0) {
const { error: itemsErr } = await ctx.supabase
.from('supplier_invoice_items')
.insert(creditItems)
if (itemsErr) {
// items_insert fires before any engine call — no JE could exist.
await rollbackCreditNote(ctx.supabase, creditNoteId, ctx.companyId!, ctx.log, 'items_insert', false)
return v1ErrorResponseFromCode('SI_CREDIT_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { step: 'credit_items_insert', pg_code: (itemsErr as { code?: string }).code },
})
}
}
// Accrual: post the reversing JE. Cash basis: skip (no original
// registration entry to reverse; refund is recognized when the bank
// transaction is booked).
const { data: settings } = await ctx.supabase
.from('company_settings')
.select('accounting_method')
.eq('company_id', ctx.companyId!)
.maybeSingle()
const accountingMethod = ((settings as { accounting_method?: string } | null)?.accounting_method
?? 'accrual') as AccountingMethod
let journalEntryId: string | null = null
if (accountingMethod === 'accrual') {
try {
const entry = await createSupplierCreditNoteEntry(
ctx.supabase,
ctx.companyId!,
ctx.userId,
creditNote as unknown as SupplierInvoice,
creditItems as unknown as SupplierInvoiceItem[],
supplierRow?.supplier_type ?? 'swedish_business',
supplierRow?.name,
)
if (entry) {
journalEntryId = entry.id
const { error: linkErr } = await ctx.supabase
.from('supplier_invoices')
.update({ registration_journal_entry_id: entry.id })
.eq('id', creditNoteId)
.eq('company_id', ctx.companyId!)
if (linkErr) {
// Symmetric with the SI register path: storno the posted JE +
// roll back the credit note before returning, so we never leave
// a credit note row with registration_journal_entry_id=null while
// the reversing JE sits live on the books.
ctx.log.error('credit-note JE link update failed — stornoing JE and rolling back row', linkErr, {
creditNoteId,
originalId: typed.id,
journalEntryId: entry.id,
companyId: ctx.companyId,
userId: ctx.userId,
})
try {
await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, entry.id, today)
} catch (revErr) {
ctx.log.error('JE storno failed after credit-note link-update error — manual reconciliation required', revErr as Error, {
creditNoteId,
journalEntryId: entry.id,
userId: ctx.userId,
})
}
// je_link_failed: the credit-note JE was posted (and we just
// stornoed it above). Soft-mark keeps the trail per BFL 5:5.
await rollbackCreditNote(ctx.supabase, creditNoteId, ctx.companyId!, ctx.log, 'je_link_failed', true)
return v1ErrorResponseFromCode('SI_CREDIT_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { step: 'credit_journal_entry_link' },
})
}
} else {
// Engine returned null before posting — no JE exists.
await rollbackCreditNote(ctx.supabase, creditNoteId, ctx.companyId!, ctx.log, 'no_fiscal_period', false)
return v1ErrorResponseFromCode('SI_CREDIT_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { step: 'credit_journal_entry', reason: 'no_fiscal_period' },
})
}
} catch (err) {
// Engine threw — conservatively assume the JE may have committed.
await rollbackCreditNote(ctx.supabase, creditNoteId, ctx.companyId!, ctx.log, 'credit_journal_entry', true)
if (isBookkeepingError(err)) {
return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
}
ctx.log.error('supplier credit-note JE creation failed', err as Error, {
creditNoteId,
originalId: typed.id,
companyId: ctx.companyId,
})
return v1ErrorResponseFromCode('SI_CREDIT_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { step: 'credit_journal_entry' },
})
}
}
// Step 5: flip the original to `credited`. CAS guard: only transition
// from a non-terminal state (avoids a concurrent credit/credit race
// leaving us with two credit notes against one original).
//
// A kreditfaktura nullifies the AP obligation on the original (BFL 5 kap
// 5 §); refunds of already-paid amounts are recorded separately via the
// transactions endpoints. So both `remaining_amount` and `status` are set
// unconditionally — no arithmetic on remaining_amount - total (the prior
// calc was a logic error: remaining_amount ≤ total, so the result was
// always ≤ 0, forcing status to 'credited' anyway via the clamp).
const { data: originalUpdated, error: originalUpdateErr } = await ctx.supabase
.from('supplier_invoices')
.update({
status: 'credited',
remaining_amount: 0,
})
.eq('company_id', ctx.companyId!)
.eq('id', typed.id)
// Don't re-credit an already-credited/reversed original.
.not('status', 'in', '(credited,reversed)')
.select('id, status, remaining_amount')
.maybeSingle()
if (originalUpdateErr) {
ctx.log.error('original SI status flip to credited failed', originalUpdateErr, {
originalId: typed.id,
creditNoteId,
})
// Don't roll back the credit note here — the JE exists on the books
// and rolling back leaves a partial state. Surface the error; manual
// reconciliation will flip the original.
return v1ErrorResponseFromCode('SI_CREDIT_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { step: 'original_status_flip', credit_note_id: creditNoteId, journal_entry_id: journalEntryId },
})
}
if (!originalUpdated) {
// Race: original was credited/reversed between fetch and update. Roll
// back the new credit note (and its JE) to avoid a double-credit state.
ctx.log.warn('credit race detected; rolling back new credit note', {
originalId: typed.id,
creditNoteId,
journalEntryId,
userId: ctx.userId,
})
if (journalEntryId) {
try {
await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, journalEntryId, today)
} catch (revErr) {
ctx.log.error('orphan credit JE storno failed', revErr as Error, {
creditNoteId,
journalEntryId,
userId: ctx.userId,
})
}
}
// credit_race: the credit-note JE was posted (and just stornoed above).
await rollbackCreditNote(ctx.supabase, creditNoteId, ctx.companyId!, ctx.log, 'credit_race', true)
return v1ErrorResponseFromCode('SI_CREDIT_ALREADY_CREDITED', ctx.log, {
requestId: ctx.requestId,
details: { reason: 'race' },
})
}
try {
await eventBus.emit({
type: 'supplier_invoice.credited',
payload: {
supplierInvoice: typed as unknown as SupplierInvoice,
creditNote: creditNote as unknown as SupplierInvoice,
companyId: ctx.companyId!,
userId: ctx.userId,
},
})
} catch (err) {
ctx.log.warn('supplier_invoice.credited emit failed', err as Error)
}
return ok(
{
credit_note_id: creditNoteId,
original_id: typed.id,
arrival_number: (creditNote as { arrival_number: number }).arrival_number,
supplier_invoice_number: (creditNote as { supplier_invoice_number: string }).supplier_invoice_number,
registration_journal_entry_id: journalEntryId,
},
{ requestId: ctx.requestId },
)
},
{ requireIdempotencyKey: true },
)
async function rollbackCreditNote(
supabase: SupabaseClient,
creditNoteId: string,
companyId: string,
log: import('@/lib/logger').Logger,
reason: string,
journalEntryPosted: boolean,
) {
// BFL 5 kap 5 § applies once a verifikation has been committed to the
// books. Pre-JE failures (items_insert, engine-returned-null) are not
// bokföringsposter and should hard-delete. Post-JE failures soft-mark
// `'reversed'` so the audit trail of the attempt (and the JE+storno pair
// on the verifikation side) stays visible.
if (!journalEntryPosted) {
await supabase.from('supplier_invoice_items').delete().eq('supplier_invoice_id', creditNoteId)
const { error: parentErr } = await supabase
.from('supplier_invoices')
.delete()
.eq('id', creditNoteId)
.eq('company_id', companyId)
if (parentErr) {
log.error('credit-note hard-rollback failed — orphan row', parentErr, {
creditNoteId,
companyId,
rollbackReason: reason,
})
} else {
log.warn('credit-note hard-rolled back (no JE existed)', {
creditNoteId,
companyId,
rollbackReason: reason,
})
}
return
}
const { error: updateErr } = await supabase
.from('supplier_invoices')
.update({ status: 'reversed', reversed_at: new Date().toISOString() })
.eq('id', creditNoteId)
.eq('company_id', companyId)
if (updateErr) {
log.error('credit-note soft-rollback failed — manual reconciliation required', updateErr, {
creditNoteId,
companyId,
rollbackReason: reason,
})
} else {
log.warn('credit-note soft-rolled back (status=reversed)', {
creditNoteId,
companyId,
rollbackReason: reason,
})
}
}
@@ -0,0 +1,485 @@
/**
* POST /api/v1/companies/{companyId}/supplier-invoices/{id}/mark-paid
*
* Records a payment against a supplier invoice. Books the payment journal
* entry via createSupplierInvoicePaymentEntry (accrual) or
* createSupplierInvoiceCashEntry (cash basis — recognizes the expense here),
* then flips status to `paid` or `partially_paid` with an optimistic-lock
* UPDATE that prevents double-booking under concurrent calls.
*
* Strict-mode v1 (per Phase 3 lessons): if JE creation fails, the route
* ABORTS before any SI state mutation — no payment row is written, status
* is unchanged. The caller can retry cleanly.
*
* Idempotent (mandatory Idempotency-Key). Dry-runnable.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas'
import {
createSupplierInvoiceCashEntry,
createSupplierInvoicePaymentEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { eventBus } from '@/lib/events'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
const SI_PAID_RESPONSE_COLUMNS =
'id, supplier_id, arrival_number, supplier_invoice_number, status, currency, total, paid_amount, remaining_amount, paid_at, payment_journal_entry_id'
const PAYABLE_STATUSES = ['registered', 'approved', 'partially_paid', 'overdue'] as const
const SupplierInvoicePaidResponse = z.object({
id: z.string().uuid(),
status: z.enum(['paid', 'partially_paid']),
total: z.number(),
paid_amount: z.number(),
remaining_amount: z.number(),
paid_at: z.string().nullable(),
payment_journal_entry_id: z.string().uuid().nullable(),
})
registerEndpoint({
operation: 'supplier-invoices.mark-paid',
method: 'POST',
path: '/api/v1/companies/:companyId/supplier-invoices/:id/mark-paid',
summary: 'Record a payment against a supplier invoice.',
description:
'Books the payment journal entry (Debit 2440 / Credit 1930 under accrual; or Debit expense + Debit 2641 / Credit 1930 under cash) and flips the SI status to `paid` (full settlement) or `partially_paid`. Strict-mode: a JE failure aborts before any SI mutation. Idempotent. Dry-runnable.',
useWhen:
'You paid a registered or approved leverantörsfaktura through a channel other than the synced bank flow. For bank-matched payments use POST /transactions/{id}/match-supplier-invoice instead — that path also reconciles the bank line.',
doNotUseFor:
'Refunding a payment (the public API does not expose unmark-paid; credit the SI instead). Paying a credited or already-paid SI (returns 409 SI_PAID_ALREADY).',
pitfalls: [
'Idempotency-Key is mandatory.',
'payment_date must fall in an open fiscal period — locked period returns 400 PERIOD_LOCKED.',
'exchange_rate_difference (SEK delta vs the booked rate at registration) is required for foreign-currency SIs to book the FX gain/loss to 3960 / 7960. Omitting it on a non-SEK SI under accrual mis-books FX.',
'Strict-mode: a JE creation failure ABORTS before the status flip. There is no partial-state recovery banner — retry the call.',
'Cash basis (kontantmetoden) recognizes the expense + ingående moms HERE, not at :create.',
],
example: {
request: { payment_date: '2026-05-13' },
response: {
data: {
id: '0e9c…',
status: 'paid',
total: 1250,
paid_amount: 1250,
remaining_amount: 0,
paid_at: '2026-05-13',
payment_journal_entry_id: '7b3a…',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'suppliers:write',
risk: 'medium',
idempotent: true,
reversible: false,
dryRunSupported: true,
request: { body: MarkSupplierInvoicePaidSchema },
response: { success: SupplierInvoicePaidResponse },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'supplier-invoices.mark-paid',
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: 'Supplier-invoice id must be a UUID.' },
})
}
const invoiceId = idParse.data
// Body is optional — empty POST = pay the full remaining_amount today.
let rawBody: unknown = null
try {
const text = await request.text()
if (text.trim()) rawBody = JSON.parse(text)
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
let bodyAmount: number | undefined
let bodyPaymentDate: string | undefined
let exchangeRateDifference: number | undefined
let bodyNotes: string | undefined
if (rawBody) {
const parsed = MarkSupplierInvoicePaidSchema.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,
})),
},
})
}
bodyAmount = parsed.data.amount
bodyPaymentDate = parsed.data.payment_date
exchangeRateDifference = parsed.data.exchange_rate_difference
bodyNotes = parsed.data.notes
}
const today = new Date().toISOString().split('T')[0]
const paymentDate = bodyPaymentDate || today
// Reject future payment_date at the schema layer. BFL 5 kap 2 §
// requires bokföring to follow real cash movement; a payment booked
// in the future is a scheduling artefact, not an affärshändelse.
// No legitimate v1 workflow needs to backstamp tomorrow; if the user
// wants to schedule, that's a different surface.
if (paymentDate > today) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'payment_date',
message: 'payment_date cannot be in the future.',
attempted: paymentDate,
today,
},
})
}
// Fetch SI with supplier + items (needed by the engine for cash-basis).
const { data: invoice, error: fetchErr } = await ctx.supabase
.from('supplier_invoices')
.select(`
id, supplier_id, status, currency, exchange_rate, total, paid_amount, remaining_amount,
supplier_invoice_number, arrival_number, invoice_date, vat_treatment, reverse_charge,
subtotal, subtotal_sek, vat_amount, vat_amount_sek, total_sek, due_date, received_date,
is_credit_note, credited_invoice_id, payment_journal_entry_id,
supplier:suppliers(id, name, supplier_type),
items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount)
`)
.eq('company_id', ctx.companyId!)
.eq('id', invoiceId)
.maybeSingle()
if (fetchErr) {
return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId })
}
if (!invoice) {
return v1ErrorResponseFromCode('SI_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
}
type SupplierObj = { id: string; name: string; supplier_type: string }
type SI = {
id: string
supplier_id: string
status: string
currency: string
total: number
paid_amount: number
remaining_amount: number
supplier_invoice_number: string
arrival_number: number
invoice_date: string
is_credit_note: boolean
supplier: SupplierObj | SupplierObj[] | null
items?: unknown[]
} & Record<string, unknown>
const typed = invoice as unknown as SI
if (typed.is_credit_note) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Credit notes cannot be marked paid.' },
})
}
if (!PAYABLE_STATUSES.includes(typed.status as (typeof PAYABLE_STATUSES)[number])) {
const code = typed.status === 'paid' || typed.status === 'credited' || typed.status === 'reversed'
? 'SI_PAID_ALREADY'
: 'SI_PAID_NOT_PAYABLE'
return v1ErrorResponseFromCode(code, ctx.log, {
requestId: ctx.requestId,
details: { current_status: typed.status },
})
}
// Application-layer period-lock pre-check.
const lockVerdict = await checkPeriodLock(ctx.supabase, ctx.companyId!, paymentDate)
if (lockVerdict.locked) {
return v1ErrorResponseFromCode('SI_PAID_PERIOD_LOCKED', ctx.log, {
requestId: ctx.requestId,
details: {
reason: lockVerdict.reason,
fiscal_period_id: lockVerdict.fiscal_period_id,
payment_date: paymentDate,
},
})
}
const paymentAmount = bodyAmount != null
? Math.round(bodyAmount * 100) / 100
: Math.round(typed.remaining_amount * 100) / 100
if (paymentAmount <= 0) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'amount', message: 'amount must be positive.' },
})
}
// Reject overpayment up front. Without this, the silent `Math.max(0, ...)`
// clamp below would book the full payment_amount in the JE but only
// reduce the SI balance to 0 — the difference would be an unaccounted
// overpayment on the 2440 ledger. If a refund is genuinely due, the
// caller credits the SI (which reverses the obligation) and books the
// refund as a separate bank transaction. Half-öre tolerance allows
// legitimate rounding artefacts from FX-difference adjustments.
if (paymentAmount > typed.remaining_amount + 0.005) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'amount',
message:
'amount exceeds remaining_amount. Issue a credit note via :credit for over-billing, or book the refund through the transactions endpoints.',
attempted: paymentAmount,
remaining_amount: typed.remaining_amount,
},
})
}
const newRemaining = Math.max(
0,
Math.round((typed.remaining_amount - paymentAmount) * 100) / 100,
)
// Half-öre epsilon — same convention as v1 invoices.mark-paid.
const newStatus: 'paid' | 'partially_paid' = newRemaining <= 0.005 ? 'paid' : 'partially_paid'
const newPaidAmount = Math.round((typed.paid_amount + paymentAmount) * 100) / 100
// Settings fetch hoisted ahead of the dry-run branch so the FX-required
// check below fires in both preview and commit modes (and so dry-run can
// surface the requirement before a caller learns it the hard way).
const { data: settings } = await ctx.supabase
.from('company_settings')
.select('accounting_method')
.eq('company_id', ctx.companyId!)
.maybeSingle()
const accountingMethod = (settings as { accounting_method?: string } | null)?.accounting_method ?? 'accrual'
// FX-required validation. Under accrual the registration JE used the
// invoice's exchange rate to compute subtotal_sek; the payment JE has to
// book any rate delta to 3960 / 7960 (BAS) or AP will carry a stranded
// 2440 balance after the bank line clears. The pitfall docs warn about
// this — enforce it.
if (
typed.currency !== 'SEK' &&
accountingMethod === 'accrual' &&
exchangeRateDifference === undefined
) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: [{
field: 'exchange_rate_difference',
message:
'exchange_rate_difference (SEK delta vs the registration rate) is required when paying a non-SEK supplier invoice under faktureringsmetoden. Use 0 if there is no rate movement.',
}],
invoice_currency: typed.currency,
},
})
}
if (ctx.dryRun) {
// paid_at: the live UPDATE writes `new Date().toISOString()` (a full UTC
// timestamp). Mirror that shape here so callers validating dry-run vs
// live against the same regex don't see surprises. payment_date stays
// ISO date because it represents the user-supplied calendar date.
return dryRunPreview(
{
...typed,
status: newStatus,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
paid_at: newStatus === 'paid' ? new Date().toISOString() : null,
payment_date: paymentDate,
payment_amount: paymentAmount,
would_create_payment_journal_entry: true,
},
{ requestId: ctx.requestId, log: ctx.log },
)
}
const pickSupplier = (s: SI['supplier']): SupplierObj | null => {
if (!s) return null
return Array.isArray(s) ? (s[0] ?? null) : s
}
const supplierRow = pickSupplier(typed.supplier)
// Strict-mode: book the JE FIRST. Failure aborts before any SI mutation.
let journalEntryId: string | null = null
try {
if (accountingMethod === 'cash') {
const entry = await createSupplierInvoiceCashEntry(
ctx.supabase,
ctx.companyId!,
ctx.userId,
typed as unknown as SupplierInvoice,
(typed.items ?? []) as SupplierInvoiceItem[],
paymentDate,
supplierRow?.supplier_type ?? 'swedish_business',
supplierRow?.name,
)
journalEntryId = entry?.id ?? null
} else {
const entry = await createSupplierInvoicePaymentEntry(
ctx.supabase,
ctx.companyId!,
ctx.userId,
typed as unknown as SupplierInvoice,
paymentAmount,
paymentDate,
exchangeRateDifference,
supplierRow?.name,
)
journalEntryId = entry?.id ?? null
}
if (!journalEntryId) {
// Engine returned null (no open fiscal period). Strict-mode abort.
return v1ErrorResponseFromCode('SI_PAID_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { reason: 'no_fiscal_period', payment_date: paymentDate },
})
}
} catch (err) {
if (isBookkeepingError(err)) {
return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
}
ctx.log.error('supplier-invoice mark-paid JE creation failed', err as Error, {
invoiceId,
companyId: ctx.companyId,
})
return v1ErrorResponseFromCode('SI_PAID_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
})
}
// Step 2: optimistic-lock SI update. The .in() filter guards against
// concurrent calls (or a credit/mark-paid race) flipping the status
// between our pre-flight and write.
const { data: updated, error: updateErr } = await ctx.supabase
.from('supplier_invoices')
.update({
status: newStatus,
remaining_amount: newRemaining,
paid_amount: newPaidAmount,
paid_at: newStatus === 'paid' ? new Date().toISOString() : null,
payment_journal_entry_id: journalEntryId,
})
.eq('company_id', ctx.companyId!)
.eq('id', invoiceId)
.in('status', PAYABLE_STATUSES as unknown as string[])
.select(SI_PAID_RESPONSE_COLUMNS)
.maybeSingle()
if (updateErr) {
ctx.log.error('supplier-invoice mark-paid update failed — attempting storno of orphaned JE', updateErr, {
invoiceId,
companyId: ctx.companyId,
userId: ctx.userId,
journalEntryId,
})
// The payment JE is already posted but the SI update failed — without a
// storno, the AP ledger would carry a 2440/1930 entry with no matching
// SI status change (BFL 5 kap 5 § integrity violation). reverseEntry()
// takes the entry id directly (no pre-fetch needed), matching the CAS-
// race branch immediately below.
try {
await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, journalEntryId, paymentDate)
} catch (revErr) {
ctx.log.error('orphan JE storno failed after SI update error — manual reconciliation required', revErr as Error, {
invoiceId,
companyId: ctx.companyId,
userId: ctx.userId,
journalEntryId,
})
}
return v1ErrorResponseFromCode('SI_PAID_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { reason: 'si_update_failed', journal_entry_id: journalEntryId },
})
}
if (!updated) {
// CAS race: the SI moved out of a payable state between pre-flight and
// write. The JE we just posted is now orphaned. Storno it.
ctx.log.warn('supplier-invoice mark-paid race — JE was orphaned, attempting storno', {
invoiceId,
companyId: ctx.companyId,
userId: ctx.userId,
journalEntryId,
})
try {
await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, journalEntryId, paymentDate)
} catch (revErr) {
ctx.log.error('orphan JE storno failed — manual reconciliation required', revErr as Error, {
invoiceId,
companyId: ctx.companyId,
userId: ctx.userId,
journalEntryId,
})
}
return v1ErrorResponseFromCode('SI_PAID_ALREADY', ctx.log, {
requestId: ctx.requestId,
details: { reason: 'race' },
})
}
// Step 3: record the payment row (non-blocking — its only consumer is the
// dashboard's "payment history" tab, and the JE is the source of truth).
const { error: paymentErr } = await ctx.supabase
.from('supplier_invoice_payments')
.insert({
user_id: ctx.userId,
company_id: ctx.companyId!,
supplier_invoice_id: invoiceId,
payment_date: paymentDate,
amount: paymentAmount,
currency: typed.currency,
exchange_rate_difference: exchangeRateDifference ?? 0,
journal_entry_id: journalEntryId,
notes: bodyNotes ?? null,
})
if (paymentErr) {
ctx.log.warn('supplier_invoice_payments insert failed (non-blocking)', paymentErr, {
invoiceId,
})
}
try {
await eventBus.emit({
type: 'supplier_invoice.paid',
payload: {
supplierInvoice: typed as unknown as SupplierInvoice,
paymentAmount,
companyId: ctx.companyId!,
userId: ctx.userId,
},
})
} catch (err) {
ctx.log.warn('supplier_invoice.paid emit failed', err as Error)
}
return ok(updated, { requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)
@@ -0,0 +1,307 @@
/**
* /api/v1/companies/{companyId}/supplier-invoices/{id} — detail + update.
*
* GET — full record. ?expand=supplier,items,payments embeds related rows.
* PATCH — partial update. Only allowed on `registered` status (mirrors the
* dashboard: an approved/paid SI is effectively immutable from the
* caller's perspective; for those, use the action verbs or :credit).
* Idempotent (mandatory Idempotency-Key). Dry-runnable.
*
* No DELETE — supplier-invoice withdrawal is via :credit (mirrors v1 invoices).
* The credit verb keeps both originals AND credit notes in the audit trail per
* BFL 5 kap 5 § (corrections via reversing entries).
*/
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'
import { UpdateSupplierInvoiceSchema } from '@/lib/api/schemas'
// V1-only strict variant. The shared `UpdateSupplierInvoiceSchema` is also
// consumed by the dashboard, where unknown keys are silently stripped — fine
// for a UI that controls its own payload. The public API treats unknown keys
// as a contract violation: if a future schema iteration ever adds a
// protected field (status, company_id, user_id), `.strict()` makes the
// mass-assignment vector structurally impossible regardless of whether the
// downstream allowlist iteration catches it.
const V1PatchSupplierInvoiceSchema = UpdateSupplierInvoiceSchema.strict()
const SI_DETAIL_COLUMNS =
'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, received_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, reverse_charge, payment_reference, paid_at, paid_amount, remaining_amount, is_credit_note, credited_invoice_id, registration_journal_entry_id, payment_journal_entry_id, transaction_id, document_id, notes, reversed_at, created_at, updated_at'
const SI_ITEM_COLUMNS =
'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount'
const SI_PAYMENT_COLUMNS =
'id, payment_date, amount, currency, exchange_rate, exchange_rate_difference, journal_entry_id, transaction_id, notes, created_at'
const SUPPLIER_DETAIL_COLUMNS_EXPAND =
'id, name, supplier_type, email, org_number, vat_number, default_payment_terms, default_currency, bankgiro, plusgiro, iban, bic, default_expense_account, archived_at'
const SupplierInvoiceDetail = z.object({
id: z.string().uuid(),
supplier_id: z.string().uuid(),
arrival_number: z.number().int(),
supplier_invoice_number: z.string(),
invoice_date: z.string(),
due_date: z.string(),
received_date: z.string(),
delivery_date: z.string().nullable(),
status: z.string(),
currency: z.string(),
exchange_rate: z.number().nullable(),
subtotal: z.number(),
vat_amount: z.number(),
total: z.number(),
vat_treatment: z.string(),
reverse_charge: z.boolean(),
paid_amount: z.number(),
remaining_amount: z.number(),
is_credit_note: z.boolean(),
credited_invoice_id: z.string().uuid().nullable(),
registration_journal_entry_id: z.string().uuid().nullable(),
payment_journal_entry_id: z.string().uuid().nullable(),
notes: z.string().nullable(),
created_at: z.string(),
updated_at: z.string(),
})
const ALLOWED_EXPAND = ['supplier', 'items', 'payments'] as const
registerEndpoint({
operation: 'supplier-invoices.get',
method: 'GET',
path: '/api/v1/companies/:companyId/supplier-invoices/:id',
summary: 'Retrieve a single supplier invoice by id.',
description:
'Returns the full supplier-invoice record. Pass ?expand=supplier,items,payments to embed the related rows in the same response.',
useWhen:
'You need the full record before approving, paying, or crediting it — or for audit trail / reconciliation.',
doNotUseFor:
'Listing supplier invoices (use the list endpoint). Customer-invoice lookups (different resource).',
pitfalls: [
'Credit notes return is_credit_note=true and a credited_invoice_id pointing at the original.',
'registration_journal_entry_id and payment_journal_entry_id let you trace the SI to its bokföring rows; they are null when no JE has been posted (e.g. on a kontantmetoden SI before payment).',
],
example: {
response: {
data: {
id: '0e9c…',
supplier_id: 'a8f1…',
arrival_number: 42,
supplier_invoice_number: '2026-1234',
status: 'registered',
currency: 'SEK',
subtotal: 1000,
vat_amount: 250,
total: 1250,
remaining_amount: 1250,
is_credit_note: false,
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'suppliers:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: SupplierInvoiceDetail },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'supplier-invoices.get',
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: 'Supplier-invoice id must be a UUID.' },
})
}
const invoiceId = idParse.data
const url = new URL(request.url)
const expandResult = parseExpand(url, ALLOWED_EXPAND)
if (!expandResult.ok) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'expand',
invalidKeys: expandResult.invalidKeys,
allowed: expandResult.allowed,
},
})
}
const expand = expandResult.expand
const parts: string[] = [SI_DETAIL_COLUMNS]
if (expand.has('supplier')) parts.push(`supplier:suppliers(${SUPPLIER_DETAIL_COLUMNS_EXPAND})`)
if (expand.has('items')) parts.push(`items:supplier_invoice_items(${SI_ITEM_COLUMNS})`)
if (expand.has('payments')) parts.push(`payments:supplier_invoice_payments(${SI_PAYMENT_COLUMNS})`)
const selectClause = parts.join(', ')
const { data, error } = await ctx.supabase
.from('supplier_invoices')
.select(selectClause)
.eq('company_id', ctx.companyId!)
.eq('id', invoiceId)
.maybeSingle()
if (error) {
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
}
if (!data) {
ctx.log.warn('supplier-invoices.get: not found', { invoiceId, companyId: ctx.companyId })
return v1ErrorResponseFromCode('SI_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
}
return ok(data, { requestId: ctx.requestId })
},
)
// ──────────────────────────────────────────────────────────────────
// PATCH — partial update (registered-only)
// ──────────────────────────────────────────────────────────────────
registerEndpoint({
operation: 'supplier-invoices.update',
method: 'PATCH',
path: '/api/v1/companies/:companyId/supplier-invoices/:id',
summary: 'Update a registered supplier invoice.',
description:
'Patches a supplier invoice with the supplied fields. Only allowed on `registered` status — once approved, paid, or credited, the record is effectively immutable from the API\'s perspective. Idempotent (mandatory Idempotency-Key). Dry-runnable.',
useWhen:
'You need to fix a typo in supplier_invoice_number, adjust dates, or attach a payment reference / notes to a registered SI before approval. Use dry-run to confirm the merged state first.',
doNotUseFor:
'Editing line items (immutable — credit the SI and register a new one). Changing status (use action verbs). Approved/paid/credited SIs (returns 400 SI_NOT_DRAFT).',
pitfalls: [
'Returns 400 SI_NOT_DRAFT when current status !== "registered".',
'invoice_date / due_date changes do not re-post the registration JE; if the entry date needs to change, credit the SI and re-register.',
],
example: {
request: { payment_reference: 'OCR-1234567890' },
response: {
data: { id: '0e9c…', payment_reference: 'OCR-1234567890' },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'suppliers:write',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: V1PatchSupplierInvoiceSchema },
response: { success: SupplierInvoiceDetail },
})
export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'supplier-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: 'Supplier-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 = V1PatchSupplierInvoiceSchema.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 [
'supplier_invoice_number',
'invoice_date',
'due_date',
'delivery_date',
'payment_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.' },
})
}
// Status guard before doing anything else.
const { data: existing, error: fetchErr } = await ctx.supabase
.from('supplier_invoices')
.select(SI_DETAIL_COLUMNS)
.eq('company_id', ctx.companyId!)
.eq('id', invoiceId)
.maybeSingle()
if (fetchErr) {
return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId })
}
if (!existing) {
return v1ErrorResponseFromCode('SI_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
}
if ((existing as { status: string }).status !== 'registered') {
return v1ErrorResponseFromCode('SI_NOT_DRAFT', ctx.log, {
requestId: ctx.requestId,
details: { current_status: (existing as { status: string }).status },
})
}
if (ctx.dryRun) {
return dryRunPreview({ ...existing, ...updateData }, { requestId: ctx.requestId, log: ctx.log })
}
const { data, error } = await ctx.supabase
.from('supplier_invoices')
.update(updateData)
.eq('company_id', ctx.companyId!)
.eq('id', invoiceId)
// Race guard: another request may have approved / paid between the
// pre-flight status check and this update.
.eq('status', 'registered')
.select(SI_DETAIL_COLUMNS)
.maybeSingle()
if (error) {
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
}
if (!data) {
return v1ErrorResponseFromCode('SI_NOT_DRAFT', ctx.log, {
requestId: ctx.requestId,
details: { reason: 'race' },
})
}
return ok(data, { requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,821 @@
/**
* /api/v1/companies/{companyId}/supplier-invoices — list + register endpoints.
*
* GET — list with filters (status, supplier_id, currency, invoice_date range).
* Cursor pagination on (invoice_date DESC, id DESC).
* POST — register a new supplier invoice. Idempotent (mandatory Idempotency-Key).
* Dry-runnable.
*
* Lifecycle: a fresh SI is created in `registered` status. Under
* faktureringsmetoden the registration JE (Debit expense + Debit 2641 / Credit
* 2440) is posted in the same call — failure aborts and the SI row is rolled
* back to avoid orphaning a half-baked AP balance.
*
* Under kontantmetoden no JE is posted at registration; recognition is
* deferred to :mark-paid.
*
* `arrival_number` (ankomstnummer) is an internal counter; it does NOT carry
* the BFL/ML 17 kap löpnummer obligation that customer invoices do. The
* supplier-invoice number (`supplier_invoice_number`) is the seller's own
* series and is preserved verbatim.
*/
import { z } from 'zod'
import type { SupabaseClient } from '@supabase/supabase-js'
import { created, paginated } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import {
decodeDefaultCursor,
encodeDefaultCursor,
parsePaginationParams,
} from '@/lib/api/v1/pagination'
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 { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { eventBus } from '@/lib/events'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
const SupplierInvoiceStatus = z.enum([
'registered',
'approved',
'paid',
'partially_paid',
'overdue',
'disputed',
'credited',
'reversed',
])
const SupplierInvoiceSummary = z.object({
id: z.string().uuid(),
supplier_id: z.string().uuid(),
supplier_name: z.string(),
arrival_number: z.number().int(),
supplier_invoice_number: z.string(),
invoice_date: z.string(),
due_date: z.string(),
status: SupplierInvoiceStatus,
currency: z.string(),
subtotal: z.number(),
vat_amount: z.number(),
total: z.number(),
paid_amount: z.number(),
remaining_amount: z.number(),
is_credit_note: z.boolean(),
paid_at: z.string().nullable(),
created_at: z.string(),
})
const SupplierInvoicesListResponse = z.object({
supplier_invoices: z.array(SupplierInvoiceSummary),
})
// Explicit projection.
const SI_SUMMARY_COLUMNS =
'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, status, currency, subtotal, vat_amount, total, paid_amount, remaining_amount, is_credit_note, paid_at, created_at'
const SUPPLIER_NAME_ONLY_COLUMNS = 'id, name'
registerEndpoint({
operation: 'supplier-invoices.list',
method: 'GET',
path: '/api/v1/companies/:companyId/supplier-invoices',
summary: 'List supplier invoices for a company.',
description:
'Returns supplier invoices in most-recent-first order. Filters: status, supplier_id, currency, date_from / date_to (filter by invoice_date).',
useWhen:
'You need to enumerate registered supplier invoices for an AP dashboard, a payment run, or a leverantörsreskontra reconciliation.',
doNotUseFor:
'Fetching a single supplier invoice — use GET /supplier-invoices/{id}. Listing customer invoices (different resource).',
pitfalls: [
'Credit notes (is_credit_note=true) appear in the same list as the originals; filter by status=credited or check the flag to separate.',
'remaining_amount is the unpaid portion; a partially_paid SI has remaining_amount > 0.',
'arrival_number is internal book-keeping, not the seller\'s invoice number — use supplier_invoice_number for matching to received documents.',
],
example: {
response: {
data: [
{
id: '0e9c…',
supplier_id: 'a8f1…',
supplier_name: 'Office Depot AB',
arrival_number: 42,
supplier_invoice_number: '2026-1234',
invoice_date: '2026-05-10',
due_date: '2026-06-09',
status: 'registered',
currency: 'SEK',
subtotal: 1000,
vat_amount: 250,
total: 1250,
paid_amount: 0,
remaining_amount: 1250,
is_credit_note: false,
paid_at: null,
created_at: '2026-05-13T15:00:00Z',
},
],
meta: { request_id: 'req_…', api_version: '2026-05-12', next_cursor: null },
},
},
scope: 'suppliers:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: SupplierInvoicesListResponse },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'supplier-invoices.list',
async (request, ctx) => {
const url = new URL(request.url)
const { limit, cursor } = parsePaginationParams(url)
const decoded = decodeDefaultCursor(cursor)
const FiltersSchema = z.object({
status: SupplierInvoiceStatus.optional(),
supplier_id: z.string().uuid().optional(),
currency: z.string().regex(/^[A-Z]{3}$/, 'currency must be a 3-letter ISO-4217 code').optional(),
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'date_from must be ISO YYYY-MM-DD').optional(),
date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'date_to must be ISO YYYY-MM-DD').optional(),
})
const filtersResult = FiltersSchema.safeParse({
status: url.searchParams.get('status') ?? undefined,
supplier_id: url.searchParams.get('supplier_id') ?? undefined,
currency: url.searchParams.get('currency') ?? undefined,
date_from: url.searchParams.get('date_from') ?? undefined,
date_to: url.searchParams.get('date_to') ?? undefined,
})
if (!filtersResult.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: filtersResult.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
const filters = filtersResult.data
let query = ctx.supabase
.from('supplier_invoices')
.select(`${SI_SUMMARY_COLUMNS}, supplier:suppliers(${SUPPLIER_NAME_ONLY_COLUMNS})`)
.eq('company_id', ctx.companyId!)
.order('invoice_date', { ascending: false })
.order('id', { ascending: false })
.limit(limit + 1)
if (filters.status) query = query.eq('status', filters.status)
if (filters.supplier_id) query = query.eq('supplier_id', filters.supplier_id)
if (filters.currency) query = query.eq('currency', filters.currency)
if (filters.date_from) query = query.gte('invoice_date', filters.date_from)
if (filters.date_to) query = query.lte('invoice_date', filters.date_to)
if (decoded) {
// Keyset on (invoice_date DESC, id DESC).
query = query.or(
`invoice_date.lt.${decoded.ts},and(invoice_date.eq.${decoded.ts},id.lt.${decoded.id})`,
)
}
const { data, error } = await query
if (error) {
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
}
type SupplierObj = { id: string; name: string } & Record<string, unknown>
type Row = {
id: string
supplier_id: string
arrival_number: number
supplier_invoice_number: string
invoice_date: string
due_date: string
status: string
currency: string
subtotal: number
vat_amount: number
total: number
paid_amount: number
remaining_amount: number
is_credit_note: boolean
paid_at: string | null
created_at: string
supplier: SupplierObj | SupplierObj[] | null
} & Record<string, unknown>
const rows = ((data ?? []) as unknown) as Row[]
const trimmed = rows.slice(0, limit)
const hasMore = rows.length > limit
const pickSupplier = (s: Row['supplier']): SupplierObj | null => {
if (!s) return null
return Array.isArray(s) ? (s[0] ?? null) : s
}
const supplier_invoices = trimmed.map((r) => {
const s = pickSupplier(r.supplier)
return {
id: r.id,
supplier_id: r.supplier_id,
supplier_name: s?.name ?? '',
arrival_number: r.arrival_number,
supplier_invoice_number: r.supplier_invoice_number,
invoice_date: r.invoice_date,
due_date: r.due_date,
status: r.status,
currency: r.currency,
subtotal: r.subtotal,
vat_amount: r.vat_amount,
total: r.total,
paid_amount: r.paid_amount,
remaining_amount: r.remaining_amount,
is_credit_note: r.is_credit_note,
paid_at: r.paid_at,
created_at: r.created_at,
}
})
const last = trimmed[trimmed.length - 1]
const nextCursor = hasMore && last
? encodeDefaultCursor({ id: last.id, created_at: last.invoice_date })
: null
return paginated(supplier_invoices, {
requestId: ctx.requestId,
nextCursor: nextCursor ?? undefined,
})
},
)
// ──────────────────────────────────────────────────────────────────
// POST — register supplier invoice
// ──────────────────────────────────────────────────────────────────
const SI_RESPONSE_COLUMNS =
'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, received_date, delivery_date, status, currency, exchange_rate, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, reverse_charge, payment_reference, paid_amount, remaining_amount, is_credit_note, credited_invoice_id, registration_journal_entry_id, payment_journal_entry_id, notes, created_at, updated_at'
const SI_ITEMS_RESPONSE_COLUMNS =
'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount'
const SupplierInvoiceCreated = z.object({
id: z.string().uuid(),
supplier_id: z.string().uuid(),
arrival_number: z.number().int(),
supplier_invoice_number: z.string(),
invoice_date: z.string(),
due_date: z.string(),
status: z.string(),
currency: z.string(),
subtotal: z.number(),
vat_amount: z.number(),
total: z.number(),
remaining_amount: z.number(),
is_credit_note: z.boolean(),
registration_journal_entry_id: z.string().uuid().nullable(),
created_at: z.string(),
})
registerEndpoint({
operation: 'supplier-invoices.create',
method: 'POST',
path: '/api/v1/companies/:companyId/supplier-invoices',
summary: 'Register a new supplier invoice.',
description:
'Creates a supplier invoice in `registered` status and posts the registration journal entry under faktureringsmetoden (Debit expense + Debit 2641 Ingående moms / Credit 2440 Leverantörsskulder). Under kontantmetoden no JE is posted at this stage. Idempotent (mandatory Idempotency-Key). Dry-runnable.',
useWhen:
'You\'re registering an incoming leverantörsfaktura. Use dry-run first to validate VAT calculations + period-lock state before committing.',
doNotUseFor:
'Marking an existing SI as paid (use POST /:id/mark-paid). Issuing a credit note (use POST /:id/credit). Customer invoices (different resource).',
pitfalls: [
'Idempotency-Key is mandatory.',
'invoice_date must fall within an open fiscal period — a date covered by a locked period or the company-wide bookkeeping lock returns 400 PERIOD_LOCKED.',
'Under faktureringsmetoden the registration JE is posted atomically with the SI row. JE failure aborts the whole call and no SI row is left behind (strict-mode).',
'supplier_id must reference an existing, non-archived supplier in the same company — 404 SUPPLIER_NOT_FOUND otherwise.',
'Duplicate (supplier_id, supplier_invoice_number) returns 409 SI_CREATE_DUPLICATE_INVOICE_NUMBER. Use the credit flow on the original instead of re-registering with a tweaked number.',
],
example: {
request: {
supplier_id: 'a8f1…',
supplier_invoice_number: '2026-1234',
invoice_date: '2026-05-10',
due_date: '2026-06-09',
items: [
{ description: 'Office supplies', amount: 1000, account_number: '5410', vat_rate: 0.25 },
],
},
response: {
data: {
id: '0e9c…',
supplier_id: 'a8f1…',
arrival_number: 42,
supplier_invoice_number: '2026-1234',
status: 'registered',
total: 1250,
registration_journal_entry_id: '7b3a…',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'suppliers:write',
risk: 'medium',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: CreateSupplierInvoiceSchema },
response: { success: SupplierInvoiceCreated },
})
interface ComputedItem {
sort_order: number
description: string
quantity: number
unit: string
unit_price: number
line_total: number
account_number: string
vat_code: string | null
vat_rate: number
vat_amount: number
}
// Swedish VAT rates per ML 2 kap 1 § + Skatteverket's 2026 satser. Allow
// 0 (export / undantag / reverse charge), 6 (livsmedel / kultur), 12 (food
// service / hotel), 25 (default). A misstated rate flows straight into the
// registration JE → momsdeklaration Ruta 48 + INK2R, so reject anything
// else at the surface rather than silently book a wrong figure.
const ALLOWED_SV_VAT_RATES = new Set<number>([0, 0.06, 0.12, 0.25])
function computeItemsAndTotals(input: z.infer<typeof CreateSupplierInvoiceSchema>):
| { ok: true; items: ComputedItem[]; subtotal: number; vatAmount: number; total: number }
| { ok: false; field: string; message: string; attempted_rate: number; index: number } {
const items: ComputedItem[] = []
for (let index = 0; index < input.items.length; index++) {
const item = input.items[index]
const vatRate = item.vat_rate ?? 0.25
if (!ALLOWED_SV_VAT_RATES.has(vatRate)) {
return {
ok: false,
field: `items[${index}].vat_rate`,
message: 'vat_rate must be one of 0, 0.06, 0.12, or 0.25 (ML 2 kap 1 §).',
attempted_rate: vatRate,
index,
}
}
const lineTotal = item.amount != null
? Math.round(item.amount * 100) / 100
: Math.round((item.quantity ?? 1) * (item.unit_price ?? 0) * 100) / 100
const vatAmount = Math.round(lineTotal * vatRate * 100) / 100
items.push({
sort_order: index,
description: item.description,
quantity: item.amount != null ? 1 : (item.quantity ?? 1),
unit: item.amount != null ? 'st' : (item.unit || 'st'),
unit_price: item.amount != null ? lineTotal : (item.unit_price ?? 0),
line_total: lineTotal,
account_number: item.account_number,
vat_code: item.vat_code || null,
vat_rate: vatRate,
vat_amount: vatAmount,
})
}
const subtotal = items.reduce((sum, i) => sum + i.line_total, 0)
const vatAmount = items.reduce((sum, i) => sum + i.vat_amount, 0)
const total = Math.round((subtotal + vatAmount) * 100) / 100
return {
ok: true,
items,
subtotal: Math.round(subtotal * 100) / 100,
vatAmount: Math.round(vatAmount * 100) / 100,
total,
}
}
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'supplier-invoices.create',
async (request, ctx) => {
if (!z.string().uuid().safeParse(ctx.companyId).success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'companyId', message: 'companyId must be a UUID.' },
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = CreateSupplierInvoiceSchema.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
// Supplier lookup. Scoped to company; deny soft-archived.
const { data: supplier, error: supplierErr } = await ctx.supabase
.from('suppliers')
.select('id, name, supplier_type, archived_at')
.eq('company_id', ctx.companyId!)
.eq('id', body.supplier_id)
.maybeSingle()
if (supplierErr) {
return v1ErrorResponse(supplierErr, ctx.log, { requestId: ctx.requestId })
}
if (!supplier || supplier.archived_at) {
return v1ErrorResponseFromCode('SUPPLIER_NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
})
}
// Application-layer period-lock check on invoice_date. The DB trigger
// remains authoritative; this is for ergonomics so agents get a
// structured PERIOD_LOCKED instead of a generic 500.
const lockVerdict = await checkPeriodLock(ctx.supabase, ctx.companyId!, body.invoice_date)
if (lockVerdict.locked) {
return v1ErrorResponseFromCode('PERIOD_LOCKED', ctx.log, {
requestId: ctx.requestId,
details: {
reason: lockVerdict.reason,
fiscal_period_id: lockVerdict.fiscal_period_id,
},
})
}
const totalsResult = computeItemsAndTotals(body)
if (!totalsResult.ok) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: [{ field: totalsResult.field, message: totalsResult.message }],
attempted_rate: totalsResult.attempted_rate,
allowed_rates: [0, 0.06, 0.12, 0.25],
},
})
}
const { items, subtotal, vatAmount, total } = totalsResult
const exchangeRate = body.exchange_rate ?? null
const subtotalSek = exchangeRate ? Math.round(subtotal * exchangeRate * 100) / 100 : null
const vatAmountSek = exchangeRate ? Math.round(vatAmount * exchangeRate * 100) / 100 : null
const totalSek = exchangeRate ? Math.round(total * exchangeRate * 100) / 100 : null
// Derive a sensible default for vat_treatment + reverse_charge from the
// supplier_type. EU/non-EU suppliers default to reverse-charge unless the
// caller explicitly overrides; Swedish suppliers default to standard 25%.
// The engine looks at `invoice.reverse_charge` (boolean) for the actual
// booking choice — `vat_treatment` is recorded as metadata. Keeping the
// two in sync prevents momsdeklaration Ruta 30 / 48 misclassification on
// EU-supplier rows that omit both fields.
const foreignSupplier =
supplier.supplier_type === 'eu_business' || supplier.supplier_type === 'non_eu_business'
const reverseCharge = body.reverse_charge ?? foreignSupplier
// Force `vat_treatment` to track the resolved `reverse_charge` flag.
// Otherwise a caller could pass `vat_treatment: 'standard_25'` explicitly
// and have it co-exist with `reverse_charge=true` (driven by
// supplier_type), producing inconsistent metadata: the engine books via
// `reverse_charge` (Ruta 30 / 48) but a downstream momsdeklaration
// export reading `vat_treatment` would mis-classify. Normalisation here
// keeps the two fields in lock-step; an explicit override only sticks
// when it agrees with the boolean flag.
const vatTreatment = reverseCharge
? 'reverse_charge'
: (body.vat_treatment ?? 'standard_25')
// Cross-field constraint for reverse-charge invoices: the Swedish supplier
// does not charge VAT, the buyer self-assesses (ML 1 kap 2§ p.4b /
// 16 kap 6 § / 16 kap 13 §). All item vat_rates MUST be 0 — otherwise the
// engine will mis-book ingående moms in Ruta 30 / 48 (BAS 2614 / 2645 /
// 2641). Reject up front rather than booking a phantom VAT line.
if (reverseCharge) {
const offending = items.findIndex((it) => it.vat_rate !== 0)
if (offending !== -1) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: `items[${offending}].vat_rate`,
message:
'reverse_charge invoices must have vat_rate=0 on every line item — the buyer self-assesses VAT.',
attempted_rate: items[offending].vat_rate,
reverse_charge: true,
},
})
}
}
// Dry-run preview — no arrival_number is allocated (would burn a sequence
// number on a non-commit).
if (ctx.dryRun) {
return dryRunPreview(
{
supplier_id: body.supplier_id,
supplier_invoice_number: body.supplier_invoice_number,
invoice_date: body.invoice_date,
due_date: body.due_date,
delivery_date: body.delivery_date ?? null,
status: 'registered',
currency: body.currency ?? 'SEK',
exchange_rate: exchangeRate,
vat_treatment: vatTreatment,
reverse_charge: reverseCharge,
subtotal,
subtotal_sek: subtotalSek,
vat_amount: vatAmount,
vat_amount_sek: vatAmountSek,
total,
total_sek: totalSek,
remaining_amount: total,
is_credit_note: false,
notes: body.notes ?? null,
items,
// Indicate what the live commit would do; the actual JE row is not
// staged in pending_operations because the SI write path is
// orchestrated here, not via the staging substrate.
would_create_registration_journal_entry: true,
},
{ requestId: ctx.requestId, log: ctx.log },
)
}
// Allocate arrival_number (atomic, per-company sequence).
const { data: arrivalNum, error: arrivalErr } = await ctx.supabase
.rpc('get_next_arrival_number', { p_company_id: ctx.companyId! })
if (arrivalErr || arrivalNum == null) {
ctx.log.error('arrival_number allocation failed', (arrivalErr as Error) ?? new Error('null arrival_number'))
return v1ErrorResponseFromCode('SI_CREATE_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { step: 'arrival_number' },
})
}
// Insert SI row.
const { data: invoice, error: invoiceErr } = await ctx.supabase
.from('supplier_invoices')
.insert({
user_id: ctx.userId,
company_id: ctx.companyId!,
supplier_id: body.supplier_id,
arrival_number: arrivalNum,
supplier_invoice_number: body.supplier_invoice_number,
invoice_date: body.invoice_date,
due_date: body.due_date,
delivery_date: body.delivery_date ?? null,
status: 'registered',
currency: body.currency ?? 'SEK',
exchange_rate: exchangeRate,
vat_treatment: vatTreatment,
reverse_charge: reverseCharge,
payment_reference: body.payment_reference ?? null,
subtotal,
subtotal_sek: subtotalSek,
vat_amount: vatAmount,
vat_amount_sek: vatAmountSek,
total,
total_sek: totalSek,
remaining_amount: total,
notes: body.notes ?? null,
})
.select(SI_RESPONSE_COLUMNS)
.single()
if (invoiceErr || !invoice) {
const pgErr = invoiceErr as { code?: string; message?: string } | null
const isDuplicateNumber =
pgErr?.code === '23505' &&
(pgErr.message || '').includes('idx_supplier_invoices_company_supplier_number')
if (isDuplicateNumber) {
return v1ErrorResponseFromCode('SI_CREATE_DUPLICATE_INVOICE_NUMBER', ctx.log, {
requestId: ctx.requestId,
details: {
supplier_id: body.supplier_id,
supplier_invoice_number: body.supplier_invoice_number,
},
})
}
ctx.log.error('supplier invoice insert failed', invoiceErr, {
companyId: ctx.companyId,
pgCode: pgErr?.code,
})
return v1ErrorResponseFromCode('SI_CREATE_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { pg_code: pgErr?.code },
})
}
const invoiceId = (invoice as { id: string }).id
// Insert items; rollback the parent on failure.
const itemInserts = items.map((item) => ({ supplier_invoice_id: invoiceId, ...item }))
const { error: itemsErr } = await ctx.supabase
.from('supplier_invoice_items')
.insert(itemInserts)
if (itemsErr) {
// items_insert fires before any engine call — no JE could exist.
await rollbackSupplierInvoice(ctx.supabase, invoiceId, ctx.companyId!, ctx.log, 'items_insert', false)
return v1ErrorResponseFromCode('SI_CREATE_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { step: 'items_insert', pg_code: (itemsErr as { code?: string }).code },
})
}
// Determine accounting method — registration JE is only posted under accrual.
const { data: settings } = await ctx.supabase
.from('company_settings')
.select('accounting_method')
.eq('company_id', ctx.companyId!)
.maybeSingle()
const accountingMethod = (settings as { accounting_method?: string } | null)?.accounting_method ?? 'accrual'
let registrationJournalEntryId: string | null = null
if (accountingMethod === 'accrual') {
try {
const entry = await createSupplierInvoiceRegistrationEntry(
ctx.supabase,
ctx.companyId!,
ctx.userId,
invoice as unknown as SupplierInvoice,
itemInserts as unknown as SupplierInvoiceItem[],
supplier.supplier_type,
supplier.name,
)
if (entry) {
registrationJournalEntryId = entry.id
const { error: linkErr } = await ctx.supabase
.from('supplier_invoices')
.update({ registration_journal_entry_id: entry.id })
.eq('id', invoiceId)
.eq('company_id', ctx.companyId!)
if (linkErr) {
// The JE is posted but the SI denormalised back-reference failed
// to update. Storno the orphan JE first, then roll back the SI row
// to preserve strict-mode atomicity (otherwise a subsequent GET
// would show registration_journal_entry_id=null with a live JE on
// the books). reverseEntry takes the entry id directly.
ctx.log.error('SI register: JE link update failed — stornoing JE and rolling back row', linkErr, {
invoiceId,
companyId: ctx.companyId,
userId: ctx.userId,
journalEntryId: entry.id,
})
try {
await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, entry.id, body.invoice_date)
} catch (revErr) {
ctx.log.error('JE storno failed after SI link-update error — manual reconciliation required', revErr as Error, {
invoiceId,
companyId: ctx.companyId,
userId: ctx.userId,
journalEntryId: entry.id,
})
}
// je_link_failed: the JE was posted (and we just stornoed it
// above). Soft-mark keeps the audit trail visible per BFL 5:5.
await rollbackSupplierInvoice(ctx.supabase, invoiceId, ctx.companyId!, ctx.log, 'je_link_failed', true)
return v1ErrorResponseFromCode('SI_CREATE_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { step: 'registration_journal_entry_link' },
})
}
} else {
// Engine returned null (no open fiscal period). Strict-mode: roll back.
// Engine returned null before posting — no JE exists.
await rollbackSupplierInvoice(ctx.supabase, invoiceId, ctx.companyId!, ctx.log, 'no_fiscal_period', false)
return v1ErrorResponseFromCode('SI_CREATE_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { step: 'registration_journal_entry', reason: 'no_fiscal_period' },
})
}
} catch (err) {
// Engine threw — conservatively assume the JE may have committed
// before the throw (createJournalEntry is the atomic write inside
// the engine; a throw after that point would still leave a posted
// JE). Soft-mark to preserve any half-committed audit trail.
await rollbackSupplierInvoice(ctx.supabase, invoiceId, ctx.companyId!, ctx.log, 'registration_journal_entry', true)
if (isBookkeepingError(err)) {
return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
}
ctx.log.error('supplier-invoice registration JE creation failed', err as Error, {
invoiceId,
companyId: ctx.companyId,
})
return v1ErrorResponseFromCode('SI_CREATE_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { step: 'registration_journal_entry' },
})
}
}
try {
await eventBus.emit({
type: 'supplier_invoice.registered',
payload: {
supplierInvoice: invoice as unknown as SupplierInvoice,
companyId: ctx.companyId!,
userId: ctx.userId,
},
})
} catch (err) {
ctx.log.warn('supplier_invoice.registered emit failed', err as Error)
}
// Refetch with the registration_journal_entry_id populated and items.
const { data: complete } = await ctx.supabase
.from('supplier_invoices')
.select(`${SI_RESPONSE_COLUMNS}, items:supplier_invoice_items(${SI_ITEMS_RESPONSE_COLUMNS})`)
.eq('company_id', ctx.companyId!)
.eq('id', invoiceId)
.maybeSingle()
return created(
complete ?? { ...invoice, items: itemInserts, registration_journal_entry_id: registrationJournalEntryId },
{ requestId: ctx.requestId },
)
},
{ requireIdempotencyKey: true },
)
async function rollbackSupplierInvoice(
supabase: SupabaseClient,
invoiceId: string,
companyId: string,
log: import('@/lib/logger').Logger,
reason: string,
journalEntryPosted: boolean,
) {
// BFL 5 kap 5 § applies once a verifikation has been committed to the
// books. A failed insert that never produced a JE (items_insert error,
// engine returning null because no fiscal period covers the date) is a
// failed insertion, not a bokföringspost — a row with status='reversed'
// and registration_journal_entry_id=null would be a dangling
// räkenskapsinformation entry harder to audit than a clean removal.
//
// So: soft-mark `reversed` ONLY when a JE existed at the point of
// failure. Pre-JE failures hard-delete (with explicit items wipe in case
// the items insert partially succeeded — which Postgres makes atomic for
// a single INSERT, but the defense is cheap).
if (!journalEntryPosted) {
await supabase.from('supplier_invoice_items').delete().eq('supplier_invoice_id', invoiceId)
const { error: parentErr } = await supabase
.from('supplier_invoices')
.delete()
.eq('id', invoiceId)
.eq('company_id', companyId)
if (parentErr) {
log.error('supplier-invoice hard-rollback failed — orphan row', parentErr, {
invoiceId,
companyId,
rollbackReason: reason,
})
} else {
log.warn('supplier-invoice hard-rolled back (no JE existed)', {
invoiceId,
companyId,
rollbackReason: reason,
})
}
return
}
// Post-JE soft-mark. Status='reversed' is the same flag the dashboard
// uses for "Ångra kreditering"; the row + items remain queryable, the
// already-stornoed verifikation pair stays visible on the JE side.
const { error: updateErr } = await supabase
.from('supplier_invoices')
.update({ status: 'reversed', reversed_at: new Date().toISOString() })
.eq('id', invoiceId)
.eq('company_id', companyId)
if (updateErr) {
log.error('supplier-invoice soft-rollback failed — manual reconciliation required', updateErr, {
invoiceId,
companyId,
rollbackReason: reason,
})
} else {
log.warn('supplier-invoice soft-rolled back (status=reversed)', {
invoiceId,
companyId,
rollbackReason: reason,
})
}
}
@@ -0,0 +1,536 @@
/**
* /api/v1/companies/{companyId}/suppliers/{id} — supplier detail + writes.
*
* GET — full record. ?expand=supplier_invoices embeds open supplier invoices.
* PATCH — partial update. Idempotent (mandatory Idempotency-Key). Dry-runnable.
* Setting archived_at: null un-archives the supplier.
* DELETE — soft-delete (sets archived_at). Idempotent. Dry-runnable. 204 on
* success. REFUSES to archive when the supplier has any open
* (registered / approved / partially_paid / overdue / disputed)
* supplier invoice — preserves the canonical seller name/address
* that BFL 7 kap requires the leverantörsfaktura to carry. Close
* (credit or mark paid) the open invoices first.
*/
import { z } from 'zod'
import { noContent, 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'
import { UpdateSupplierSchema } from '@/lib/api/schemas'
// v1-only extension: allow PATCH to set archived_at back to null to
// un-archive a supplier. Restricted to literal `null` so the caller can't
// fake an archive timestamp.
const V1PatchSupplierSchema = UpdateSupplierSchema.extend({
archived_at: z.null().optional(),
})
const SupplierDetail = z.object({
id: z.string().uuid(),
name: z.string(),
supplier_type: z.string(),
email: z.string().nullable(),
phone: z.string().nullable(),
address_line1: z.string().nullable(),
address_line2: z.string().nullable(),
postal_code: z.string().nullable(),
city: z.string().nullable(),
country: z.string(),
org_number: z.string().nullable(),
vat_number: z.string().nullable(),
bankgiro: z.string().nullable(),
plusgiro: z.string().nullable(),
bank_account: z.string().nullable(),
iban: z.string().nullable(),
bic: z.string().nullable(),
default_expense_account: z.string().nullable(),
default_payment_terms: z.number(),
default_currency: z.string(),
notes: z.string().nullable(),
archived_at: z.string().nullable(),
created_at: z.string(),
updated_at: z.string(),
})
const ALLOWED_EXPAND = ['supplier_invoices'] as const
// `disputed` is included so a held supplier invoice still blocks archive —
// the seller record may still be needed if the dispute resolves into a
// kreditfaktura or partial payment.
const OPEN_SUPPLIER_INVOICE_STATUSES = [
'registered',
'approved',
'partially_paid',
'overdue',
'disputed',
]
const SUPPLIER_DETAIL_COLUMNS =
'id, name, supplier_type, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, bankgiro, plusgiro, bank_account, iban, bic, default_expense_account, default_payment_terms, default_currency, notes, archived_at, created_at, updated_at'
const OPEN_SUPPLIER_INVOICE_COLUMNS =
'id, supplier_invoice_number, arrival_number, invoice_date, due_date, status, currency, total, remaining_amount'
registerEndpoint({
operation: 'suppliers.get',
method: 'GET',
path: '/api/v1/companies/:companyId/suppliers/:id',
summary: 'Retrieve a single supplier by id.',
description:
'Returns the full supplier record. Pass ?expand=supplier_invoices to embed any open supplier invoices (registered / approved / partially_paid / overdue / disputed) for the supplier in the same response.',
useWhen:
'You need the full supplier record — address, payment terms, banking details, default expense account — before booking a supplier invoice or syncing to an external AP system.',
doNotUseFor:
'Listing suppliers (use the list endpoint). Looking up customer or employee records (different resources).',
pitfalls: [
'archived_at is non-null when the supplier has been soft-deleted; the supplier is still queryable by id but excluded from default lists.',
'Banking fields (bankgiro / plusgiro / iban / bic) are stored as supplied; no Luhn or IBAN check is performed at this layer.',
],
example: {
response: {
data: {
id: 'a8f1…',
name: 'Office Depot AB',
supplier_type: 'swedish_business',
email: 'invoices@officedepot.example',
org_number: '556677-8899',
bankgiro: '123-4567',
default_expense_account: '5410',
default_payment_terms: 30,
default_currency: 'SEK',
archived_at: null,
created_at: '2026-04-12T08:30:00Z',
updated_at: '2026-04-30T11:22:09Z',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'suppliers:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: SupplierDetail },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'suppliers.get',
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: 'Supplier id must be a UUID.' },
})
}
const supplierId = idParse.data
const url = new URL(request.url)
const expandResult = parseExpand(url, ALLOWED_EXPAND)
if (!expandResult.ok) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'expand',
invalidKeys: expandResult.invalidKeys,
allowed: expandResult.allowed,
},
})
}
const expand = expandResult.expand
const { data: supplier, error } = await ctx.supabase
.from('suppliers')
.select(SUPPLIER_DETAIL_COLUMNS)
.eq('company_id', ctx.companyId!)
.eq('id', supplierId)
.maybeSingle()
if (error) {
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
}
if (!supplier) {
ctx.log.warn('suppliers.get: not found', { supplierId, companyId: ctx.companyId })
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'supplier' },
})
}
let supplier_invoices: unknown[] | undefined
const partialExpansions: string[] = []
if (expand.has('supplier_invoices')) {
const { data: invs, error: invErr } = await ctx.supabase
.from('supplier_invoices')
.select(OPEN_SUPPLIER_INVOICE_COLUMNS)
.eq('company_id', ctx.companyId!)
.eq('supplier_id', supplierId)
.in('status', OPEN_SUPPLIER_INVOICE_STATUSES)
.order('due_date', { ascending: true })
if (invErr) {
// Soft-degrade: log but still return the supplier. Same Postgres
// class-42 (auth/access) treatment as the customers expand handler.
const errMsg = (invErr as { code?: string; message?: string }).message ?? 'unknown'
const errCode = (invErr as { code?: string }).code ?? 'unknown'
const isPermissionError = typeof errCode === 'string' && errCode.startsWith('42')
if (isPermissionError) {
ctx.log.error('suppliers.get: open-invoices expansion permission denied', new Error(errMsg), {
errCode,
supplierId,
})
} else {
ctx.log.warn('suppliers.get: open-invoices expansion failed', { errCode, errMsg })
}
supplier_invoices = []
partialExpansions.push('supplier_invoices')
} else {
supplier_invoices = invs ?? []
}
}
return ok(
{ ...supplier, ...(supplier_invoices !== undefined ? { supplier_invoices } : {}) },
{
requestId: ctx.requestId,
partialExpansions: partialExpansions.length > 0 ? partialExpansions : undefined,
},
)
},
)
// ──────────────────────────────────────────────────────────────────
// PATCH — partial update
// ──────────────────────────────────────────────────────────────────
registerEndpoint({
operation: 'suppliers.update',
method: 'PATCH',
path: '/api/v1/companies/:companyId/suppliers/:id',
summary: 'Partially update a supplier.',
description:
'Patches the supplier with the supplied fields. All fields optional. Idempotent (mandatory Idempotency-Key). Dry-runnable.',
useWhen:
'You need to change a supplier\'s contact details, payment terms, banking info, default expense account, or VAT number. Use dry-run first to confirm the merged record before committing.',
doNotUseFor:
'Archiving a supplier (use DELETE — sets archived_at). Replacing the entire record (no PUT verb is exposed; PATCH is partial).',
pitfalls: [
'Idempotency-Key is mandatory; calls without it return 400.',
'org_number uniqueness is enforced at DB level — 23505 → 409 SUPPLIER_DUPLICATE_ORG_NUMBER.',
'Changing default_expense_account does not retroactively rebook prior supplier invoices — only future bookings pick up the new default.',
],
example: {
request: { default_payment_terms: 14, notes: 'New payment terms agreed 2026-05-12.' },
response: {
data: {
id: '0e9c…',
name: 'Office Depot AB',
default_payment_terms: 14,
notes: 'New payment terms agreed 2026-05-12.',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'suppliers:write',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: UpdateSupplierSchema },
response: { success: SupplierDetail },
})
export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'suppliers.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: 'Supplier id must be a UUID.' },
})
}
const supplierId = 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 = V1PatchSupplierSchema.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 [
'name',
'supplier_type',
'email',
'phone',
'address_line1',
'address_line2',
'postal_code',
'city',
'country',
'org_number',
'vat_number',
'bankgiro',
'plusgiro',
'bank_account',
'iban',
'bic',
'default_expense_account',
'default_payment_terms',
'default_currency',
'notes',
'archived_at',
] 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.' },
})
}
// Mirror is_active (legacy boolean) when archived_at changes. Keeps the
// dashboard's "show only active suppliers" filters working without
// backfill — every v1 archive/un-archive flows through here and the
// bulk-create.
if (Object.prototype.hasOwnProperty.call(updateData, 'archived_at')) {
updateData.is_active = updateData.archived_at === null
}
// Fetch the current row up front. Needed for the BFL 7 kap immutability
// check below — a supplier with `archived_at IS NOT NULL` is part of the
// räkenskapsinformation backing historical verifikationer and must not
// have its name / address / banking fields mutated. The single exception
// is un-archiving (PATCH archived_at: null).
const { data: current, error: currentFetchErr } = await ctx.supabase
.from('suppliers')
.select(SUPPLIER_DETAIL_COLUMNS)
.eq('company_id', ctx.companyId!)
.eq('id', supplierId)
.maybeSingle()
if (currentFetchErr) {
return v1ErrorResponse(currentFetchErr, ctx.log, { requestId: ctx.requestId })
}
if (!current) {
ctx.log.warn('suppliers.update: not found', { supplierId, companyId: ctx.companyId })
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'supplier' },
})
}
const currentArchivedAt = (current as { archived_at: string | null }).archived_at
const isUnArchiving = updateData.archived_at === null
if (currentArchivedAt && !isUnArchiving) {
// BFL 7 kap 1 § protects räkenskapsinformation — the identifying
// fields that historical verifikationer reference through their join
// to this row. Internal notes / payment-config don't qualify, so we
// only refuse the PATCH when an identifying field is in the update.
// The dashboard equivalent allows the same narrow exception.
const PROTECTED_FIELDS = new Set([
'name',
'supplier_type',
'org_number',
'vat_number',
'address_line1',
'address_line2',
'postal_code',
'city',
'country',
'bankgiro',
'plusgiro',
'bank_account',
'iban',
'bic',
])
const offendingFields = Object.keys(updateData).filter((k) => PROTECTED_FIELDS.has(k))
if (offendingFields.length > 0) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'archived_at',
message:
'Supplier is archived; identifying fields (name, address, banking, org/vat number) are räkenskapsinformation under BFL 7 kap 1 § and cannot be edited. Un-archive (PATCH archived_at: null) first if a correction is needed.',
archived_at: currentArchivedAt,
offending_fields: offendingFields,
},
})
}
// Non-identifying fields (notes, default_payment_terms,
// default_expense_account, default_currency, email, phone) are not
// räkenskapsinformation — fall through and allow the update.
}
if (ctx.dryRun) {
return dryRunPreview({ ...current, ...updateData }, { requestId: ctx.requestId, log: ctx.log })
}
const { data, error } = await ctx.supabase
.from('suppliers')
.update(updateData)
.eq('company_id', ctx.companyId!)
.eq('id', supplierId)
.select(SUPPLIER_DETAIL_COLUMNS)
.maybeSingle()
if (error) {
if (error.code === '23505') {
return v1ErrorResponseFromCode('SUPPLIER_DUPLICATE_ORG_NUMBER', ctx.log, {
requestId: ctx.requestId,
details: { field: 'org_number' },
})
}
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
}
if (!data) {
ctx.log.warn('suppliers.update: not found', { supplierId, companyId: ctx.companyId })
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'supplier' },
})
}
return ok(data, { requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)
// ──────────────────────────────────────────────────────────────────
// DELETE — soft-delete (sets archived_at)
// ──────────────────────────────────────────────────────────────────
registerEndpoint({
operation: 'suppliers.delete',
method: 'DELETE',
path: '/api/v1/companies/:companyId/suppliers/:id',
summary: 'Archive a supplier (soft-delete).',
description:
'Sets archived_at on the supplier; the record is preserved (supplier invoices and audit history remain intact) but excluded from default list responses. To un-archive, PATCH archived_at back to null. Idempotent — archiving an already-archived supplier is a no-op. Dry-runnable.',
useWhen:
'You want to remove a supplier from active rosters without losing their history. Idempotent: re-archiving is safe.',
doNotUseFor:
'Permanently deleting a supplier with all history — the public API does not expose hard-delete. GDPR erasure requests go through a dedicated workflow.',
pitfalls: [
'Idempotency-Key is mandatory.',
'A supplier with any open supplier invoice (registered / approved / partially_paid / overdue / disputed) cannot be archived — returns 409 SUPPLIER_HAS_INVOICES. Close the invoices first. This protects BFL 7 kap audit: the supplier record is the canonical source of seller name/address for invoice reissuance.',
'204 No Content is returned on success — there is no response body to parse.',
],
example: {
response: { data: null, meta: { request_id: 'req_…', api_version: '2026-05-12' } },
},
scope: 'suppliers:write',
risk: 'medium',
idempotent: true,
reversible: true,
dryRunSupported: true,
response: { success: z.object({}) },
})
export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'suppliers.delete',
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: 'Supplier id must be a UUID.' },
})
}
const supplierId = idParse.data
const { count: openInvoiceCount, error: openErr } = await ctx.supabase
.from('supplier_invoices')
.select('id', { count: 'exact', head: true })
.eq('company_id', ctx.companyId!)
.eq('supplier_id', supplierId)
.in('status', OPEN_SUPPLIER_INVOICE_STATUSES)
if (openErr) {
return v1ErrorResponse(openErr, ctx.log, { requestId: ctx.requestId })
}
if ((openInvoiceCount ?? 0) > 0) {
return v1ErrorResponseFromCode('SUPPLIER_HAS_INVOICES', ctx.log, {
requestId: ctx.requestId,
details: { open_invoice_count: openInvoiceCount },
})
}
if (ctx.dryRun) {
const { data: current, error: fetchErr } = await ctx.supabase
.from('suppliers')
.select(SUPPLIER_DETAIL_COLUMNS)
.eq('company_id', ctx.companyId!)
.eq('id', supplierId)
.maybeSingle()
if (fetchErr) {
return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId })
}
if (!current) {
ctx.log.warn('suppliers.delete dry-run: not found', { supplierId, companyId: ctx.companyId })
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'supplier' },
})
}
return dryRunPreview(
{ ...current, archived_at: new Date().toISOString() },
{ requestId: ctx.requestId, log: ctx.log },
)
}
const { data, error } = await ctx.supabase
.from('suppliers')
.update({ archived_at: new Date().toISOString(), is_active: false })
.eq('company_id', ctx.companyId!)
.eq('id', supplierId)
.select('id')
.maybeSingle()
if (error) {
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
}
if (!data) {
ctx.log.warn('suppliers.delete: not found', { supplierId, companyId: ctx.companyId })
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'supplier' },
})
}
return noContent({ requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)
@@ -0,0 +1,574 @@
/**
* Integration tests for the v1 suppliers vertical (Phase 4 PR-1).
*
* Mirrors the customers test pattern: a Proxy-backed Supabase mock returns
* whatever the route awaits, keyed by table name. Each suite focuses on
* outcome (status / body shape) rather than query mechanics — the wrapper
* already validates auth, scope, idempotency, and dry-run resolution.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`suppliers route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { GET as listSuppliers, POST as createSupplier } from '../route'
import {
GET as getSupplier,
PATCH as updateSupplier,
DELETE as deleteSupplier,
} from '../[id]/route'
import { POST as bulkCreateSuppliers } from '../bulk-create/route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
interface TableResp {
data?: unknown
error?: unknown
count?: number | null
}
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
// Per-table queue: TableResp[] consumes one entry per await, then sticks
// on the last entry. Plain TableResp is treated as a constant.
const queues = new Map<string, TableResp[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve(next)
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const SUPPLIER_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const USER_ID = 'user-1'
function makeRequest(url: string, init?: RequestInit): Request {
return new Request(url, {
...init,
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Idempotency-Key': 'b1aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
...(init?.headers ?? {}),
},
})
}
function companyParams(companyId: string) {
return { params: Promise.resolve({ companyId }) }
}
function detailParams(companyId: string, id: string) {
return { params: Promise.resolve({ companyId, id }) }
}
beforeEach(() => {
vi.clearAllMocks()
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['suppliers:read', 'suppliers:write'],
mode: 'live',
})
})
const SAMPLE_SUPPLIER = {
id: SUPPLIER_ID,
name: 'Office Depot AB',
supplier_type: 'swedish_business',
email: 'invoices@officedepot.test',
phone: null,
address_line1: null,
address_line2: null,
postal_code: null,
city: null,
country: 'SE',
org_number: 'TEST-0000-0001',
vat_number: 'SETEST00000001',
bankgiro: '123-4567',
plusgiro: null,
bank_account: null,
iban: null,
bic: null,
default_expense_account: '5410',
default_payment_terms: 30,
default_currency: 'SEK',
notes: null,
archived_at: null,
created_at: '2026-04-12T08:30:00Z',
updated_at: '2026-04-30T11:22:09Z',
}
describe('GET /api/v1/companies/:companyId/suppliers', () => {
it('returns paginated suppliers, excluding archived by default', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
suppliers: { data: [SAMPLE_SUPPLIER], error: null },
}),
)
const res = await listSuppliers(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers`),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toHaveLength(1)
expect(body.data[0].name).toBe('Office Depot AB')
expect(body.data[0].default_currency).toBe('SEK')
})
it('rejects unknown filter values with 400 VALIDATION_ERROR', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
suppliers: { data: [], error: null },
}),
)
const res = await listSuppliers(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers?supplier_type=individual`),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
})
describe('GET /api/v1/companies/:companyId/suppliers/:id', () => {
it('returns the supplier when found', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
suppliers: { data: SAMPLE_SUPPLIER, error: null },
}),
)
const res = await getSupplier(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/${SUPPLIER_ID}`),
detailParams(COMPANY_ID, SUPPLIER_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.id).toBe(SUPPLIER_ID)
})
it('returns 404 NOT_FOUND when missing', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
suppliers: { data: null, error: null },
}),
)
const res = await getSupplier(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/${SUPPLIER_ID}`),
detailParams(COMPANY_ID, SUPPLIER_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('NOT_FOUND')
})
it('rejects a non-UUID id with 400 VALIDATION_ERROR', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await getSupplier(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/not-a-uuid`),
detailParams(COMPANY_ID, 'not-a-uuid'),
)
expect(res.status).toBe(400)
})
})
describe('POST /api/v1/companies/:companyId/suppliers', () => {
it('creates a supplier (happy path)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
suppliers: { data: SAMPLE_SUPPLIER, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await createSupplier(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers`, {
method: 'POST',
body: JSON.stringify({
name: 'Office Depot AB',
supplier_type: 'swedish_business',
org_number: 'TEST-0000-0001',
}),
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(201)
const body = await res.json()
expect(body.data.name).toBe('Office Depot AB')
})
it('returns 409 SUPPLIER_DUPLICATE_ORG_NUMBER on a 23505', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
suppliers: { data: null, error: { code: '23505', message: 'duplicate' } },
idempotency_keys: { data: null, error: null },
}),
)
const res = await createSupplier(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers`, {
method: 'POST',
body: JSON.stringify({
name: 'Office Depot AB',
supplier_type: 'swedish_business',
org_number: 'TEST-0000-0001',
}),
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('SUPPLIER_DUPLICATE_ORG_NUMBER')
// GDPR Art.5(1)(c) defense-in-depth — error never echoes the value back.
expect(JSON.stringify(body.error)).not.toContain('TEST-0000-0001')
})
it('returns a dry-run preview without committing when ?dry_run=true', async () => {
const fromSpy = vi.fn()
mockServiceClient.mockReturnValue({
from: (table: string) => {
fromSpy(table)
return new Proxy({}, {
get(_t, prop) {
if (prop === 'then') {
const data = table === 'company_members'
? { company_id: COMPANY_ID, role: 'owner' }
: null
return (resolve: (v: unknown) => void) => resolve({ data, error: null })
}
return () => new Proxy({}, this!)
},
})
},
})
const res = await createSupplier(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers?dry_run=true`, {
method: 'POST',
body: JSON.stringify({
name: 'Office Depot AB',
supplier_type: 'swedish_business',
org_number: 'TEST-0000-0001',
}),
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('X-Dry-Run')).toBe('true')
// No supplier insert; only the membership check should have hit the DB.
expect(fromSpy).not.toHaveBeenCalledWith('suppliers')
})
it('returns 400 when Idempotency-Key is missing', async () => {
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}/suppliers`, {
method: 'POST',
headers: { Authorization: 'Bearer test' },
body: JSON.stringify({ name: 'X', supplier_type: 'swedish_business' }),
})
const res = await createSupplier(req, companyParams(COMPANY_ID))
expect(res.status).toBe(400)
})
})
describe('PATCH /api/v1/companies/:companyId/suppliers/:id', () => {
it('updates an existing supplier', async () => {
const updated = { ...SAMPLE_SUPPLIER, default_payment_terms: 14 }
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
suppliers: { data: updated, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await updateSupplier(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/${SUPPLIER_ID}`, {
method: 'PATCH',
body: JSON.stringify({ default_payment_terms: 14 }),
}),
detailParams(COMPANY_ID, SUPPLIER_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.default_payment_terms).toBe(14)
})
it('returns 400 VALIDATION_ERROR for an empty body', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await updateSupplier(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/${SUPPLIER_ID}`, {
method: 'PATCH',
body: JSON.stringify({}),
}),
detailParams(COMPANY_ID, SUPPLIER_ID),
)
expect(res.status).toBe(400)
})
it('refuses to edit identifying fields on an archived supplier (BFL 7 kap)', async () => {
const archived = { ...SAMPLE_SUPPLIER, archived_at: '2026-01-01T00:00:00Z' }
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
suppliers: { data: archived, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await updateSupplier(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/${SUPPLIER_ID}`, {
method: 'PATCH',
body: JSON.stringify({ name: 'Renamed AB' }),
}),
detailParams(COMPANY_ID, SUPPLIER_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.details.field).toBe('archived_at')
expect(body.error.details.archived_at).toBe('2026-01-01T00:00:00Z')
})
it('allows un-archive (archived_at: null) on an archived supplier', async () => {
const archived = { ...SAMPLE_SUPPLIER, archived_at: '2026-01-01T00:00:00Z' }
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
// Queue: pre-flight fetch (archived) → final update select (un-archived)
suppliers: [
{ data: archived, error: null },
{ data: { ...archived, archived_at: null }, error: null },
],
idempotency_keys: { data: null, error: null },
} as Record<string, TableResp | TableResp[]>),
)
const res = await updateSupplier(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/${SUPPLIER_ID}`, {
method: 'PATCH',
body: JSON.stringify({ archived_at: null }),
}),
detailParams(COMPANY_ID, SUPPLIER_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.archived_at).toBeNull()
})
it('allows non-identifying field edits (notes) on an archived supplier', async () => {
// BFL 7 kap protects räkenskapsinformation — internal notes are not
// referenced by any verifikation, so they remain editable.
const archived = { ...SAMPLE_SUPPLIER, archived_at: '2026-01-01T00:00:00Z' }
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
suppliers: [
{ data: archived, error: null },
{ data: { ...archived, notes: 'Updated internal note' }, error: null },
],
idempotency_keys: { data: null, error: null },
} as Record<string, TableResp | TableResp[]>),
)
const res = await updateSupplier(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/${SUPPLIER_ID}`, {
method: 'PATCH',
body: JSON.stringify({ notes: 'Updated internal note' }),
}),
detailParams(COMPANY_ID, SUPPLIER_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.notes).toBe('Updated internal note')
})
})
describe('DELETE /api/v1/companies/:companyId/suppliers/:id', () => {
it('archives a supplier with no open invoices (204)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
supplier_invoices: { data: null, error: null, count: 0 },
suppliers: { data: { id: SUPPLIER_ID }, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await deleteSupplier(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/${SUPPLIER_ID}`, {
method: 'DELETE',
}),
detailParams(COMPANY_ID, SUPPLIER_ID),
)
expect(res.status).toBe(204)
})
it('refuses to archive when open invoices exist (409 SUPPLIER_HAS_INVOICES)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
supplier_invoices: { data: null, error: null, count: 3 },
idempotency_keys: { data: null, error: null },
}),
)
const res = await deleteSupplier(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/${SUPPLIER_ID}`, {
method: 'DELETE',
}),
detailParams(COMPANY_ID, SUPPLIER_ID),
)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('SUPPLIER_HAS_INVOICES')
expect(body.error.details.open_invoice_count).toBe(3)
})
})
describe('POST /api/v1/companies/:companyId/suppliers/bulk-create', () => {
it('partial-success: returns per-item ok/error rows', async () => {
// First insert succeeds, second hits a 23505. The makeFlexibleSupabase
// proxy returns the same response for every `from('suppliers')` await, so
// for this case we wire a tiny custom client that flips on call count.
let calls = 0
mockServiceClient.mockReturnValue({
from: (table: string) => {
if (table !== 'suppliers') {
return new Proxy({}, {
get(_t, prop) {
if (prop === 'then') return (r: (v: unknown) => void) => r({ data: table === 'company_members' ? { company_id: COMPANY_ID, role: 'owner' } : null, error: null })
return () => new Proxy({}, this!)
},
})
}
const responses = [
{ data: { ...SAMPLE_SUPPLIER, id: 'first-id' }, error: null },
{ data: null, error: { code: '23505', message: 'duplicate' } },
]
return new Proxy({}, {
get(_t, prop) {
if (prop === 'then') {
const i = calls++
return (r: (v: unknown) => void) => r(responses[Math.min(i, responses.length - 1)])
}
return () => new Proxy({}, this!)
},
})
},
})
const res = await bulkCreateSuppliers(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/bulk-create`, {
method: 'POST',
body: JSON.stringify({
suppliers: [
{ name: 'A', supplier_type: 'swedish_business' },
{ name: 'B', supplier_type: 'swedish_business', org_number: 'TEST-DUP' },
],
}),
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.summary).toEqual({ total: 2, succeeded: 1, failed: 1 })
expect(body.data.results[1].error.code).toBe('SUPPLIER_DUPLICATE_ORG_NUMBER')
})
it('rejects all_or_nothing: true with 501 NOT_IMPLEMENTED', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await bulkCreateSuppliers(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/bulk-create`, {
method: 'POST',
body: JSON.stringify({
suppliers: [{ name: 'A', supplier_type: 'swedish_business' }],
all_or_nothing: true,
}),
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(501)
const body = await res.json()
expect(body.error.code).toBe('NOT_IMPLEMENTED')
})
})
@@ -0,0 +1,312 @@
/**
* POST /api/v1/companies/{companyId}/suppliers/bulk-create
*
* Bulk-create up to 50 suppliers in one call. Each item is validated and
* inserted independently — per-item failures don't roll back successes.
* Mirrors the shape of /customers/bulk-create exactly so agents only need
* to learn one bulk pattern.
*
* Response: `{ results: [{ ok, request_index, data?, error? }], summary }`.
* Idempotent over the whole batch. Dry-runnable.
*
* Unlike customers, suppliers do not run VIES validation on create — the
* vat_number is stored as supplied without an external check.
*/
import { z } from 'zod'
import type { SupabaseClient } from '@supabase/supabase-js'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { CreateSupplierSchema } from '@/lib/api/schemas'
import { eventBus } from '@/lib/events'
import type { Logger } from '@/lib/logger'
import type { Supplier } from '@/types'
const BulkCreateRequest = z.object({
suppliers: z.array(CreateSupplierSchema).min(1).max(50),
all_or_nothing: z.boolean().optional().default(false),
})
const BulkResultItem = z.object({
ok: z.boolean(),
request_index: z.number().int().nonnegative(),
data: z.unknown().optional(),
error: z
.object({
code: z.string(),
message: z.string(),
details: z.unknown().optional(),
})
.optional(),
})
const BulkCreateResponse = z.object({
results: z.array(BulkResultItem),
summary: z.object({
total: z.number().int(),
succeeded: z.number().int(),
failed: z.number().int(),
}),
})
const SUPPLIER_RESPONSE_COLUMNS =
'id, name, supplier_type, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, bankgiro, plusgiro, bank_account, iban, bic, default_expense_account, default_payment_terms, default_currency, notes, archived_at, created_at, updated_at'
registerEndpoint({
operation: 'suppliers.bulk-create',
method: 'POST',
path: '/api/v1/companies/:companyId/suppliers/bulk-create',
summary: 'Create up to 50 suppliers in one call (partial-success).',
description:
'Bulk-create endpoint mirroring /customers/bulk-create. Each supplier is validated and inserted independently — per-item failures do not roll back items that succeeded. Returns a results array plus a summary. Idempotent over the whole batch. Dry-runnable.',
useWhen:
'You\'re importing a roster of suppliers from another AP system, or seeding a fresh company with its existing vendor list. Use dry-run first to validate the batch.',
doNotUseFor:
'Updating existing suppliers — PATCH /suppliers/{id} once per supplier. Bulk uploads of > 50 suppliers — split into pages of 50. Transactional all-or-nothing imports — passing all_or_nothing: true returns 501 NOT_IMPLEMENTED.',
pitfalls: [
'Idempotency-Key is mandatory and covers the WHOLE batch. A retried bulk-create returns the cached full response — it does not retry only the failed items.',
'Passing all_or_nothing: true returns 501 NOT_IMPLEMENTED. Today only partial-success batches exist; omit the flag or pass false.',
'org_number uniqueness is enforced at the DB level — items with duplicates fail individually with SUPPLIER_DUPLICATE_ORG_NUMBER.',
'No VIES validation runs per item; vat_number is stored as supplied. Validate externally if your workflow requires it.',
],
example: {
request: {
suppliers: [
{ name: 'Office Depot AB', supplier_type: 'swedish_business', org_number: '556677-8899' },
{ name: 'Cloud Hosting GmbH', supplier_type: 'eu_business', vat_number: 'DE123456789' },
],
},
response: {
data: {
results: [
{ ok: true, request_index: 0, data: { id: '0e9c…', name: 'Office Depot AB' } },
{ ok: true, request_index: 1, data: { id: '4d2a…', name: 'Cloud Hosting GmbH' } },
],
summary: { total: 2, succeeded: 2, failed: 0 },
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'suppliers:write',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: BulkCreateRequest },
response: { success: BulkCreateResponse },
})
interface ResultItem {
ok: boolean
request_index: number
data?: unknown
error?: { code: string; message: string; details?: unknown }
}
async function createOneSupplier(
supabase: SupabaseClient,
companyId: string,
userId: string,
index: number,
input: z.infer<typeof CreateSupplierSchema>,
dryRun: boolean,
log: Logger,
): Promise<ResultItem> {
if (dryRun) {
return {
ok: true,
request_index: index,
data: {
preview: {
id: null,
name: input.name,
supplier_type: input.supplier_type,
email: input.email ?? null,
phone: input.phone ?? null,
address_line1: input.address_line1 ?? null,
address_line2: input.address_line2 ?? null,
postal_code: input.postal_code ?? null,
city: input.city ?? null,
country: input.country ?? 'SE',
org_number: input.org_number ?? null,
vat_number: input.vat_number ?? null,
bankgiro: input.bankgiro ?? null,
plusgiro: input.plusgiro ?? null,
bank_account: input.bank_account ?? null,
iban: input.iban ?? null,
bic: input.bic ?? null,
default_expense_account: input.default_expense_account ?? null,
default_payment_terms: input.default_payment_terms ?? 30,
default_currency: input.default_currency ?? 'SEK',
notes: input.notes ?? null,
archived_at: null,
created_at: null,
updated_at: null,
},
},
}
}
const { data, error } = await supabase
.from('suppliers')
.insert({
user_id: userId,
company_id: companyId,
name: input.name,
supplier_type: input.supplier_type,
email: input.email ?? null,
phone: input.phone ?? null,
address_line1: input.address_line1 ?? null,
address_line2: input.address_line2 ?? null,
postal_code: input.postal_code ?? null,
city: input.city ?? null,
country: input.country ?? 'SE',
org_number: input.org_number ?? null,
vat_number: input.vat_number ?? null,
bankgiro: input.bankgiro ?? null,
plusgiro: input.plusgiro ?? null,
bank_account: input.bank_account ?? null,
iban: input.iban ?? null,
bic: input.bic ?? null,
default_expense_account: input.default_expense_account ?? null,
default_payment_terms: input.default_payment_terms ?? 30,
default_currency: input.default_currency ?? 'SEK',
notes: input.notes ?? null,
})
.select(SUPPLIER_RESPONSE_COLUMNS)
.single()
if (error) {
if (error.code === '23505') {
return {
ok: false,
request_index: index,
error: {
code: 'SUPPLIER_DUPLICATE_ORG_NUMBER',
message: 'A supplier with this org_number already exists in this company.',
details: { field: 'org_number' },
},
}
}
log.error('bulk-create: supplier insert failed', error, {
request_index: index,
companyId,
pgCode: error.code,
})
return {
ok: false,
request_index: index,
error: {
code: 'SUPPLIER_CREATE_FAILED',
message: 'Supplier insert failed.',
details: { pg_code: error.code },
},
}
}
try {
await eventBus.emit({
type: 'supplier.created',
payload: {
supplier: {
...(data as Record<string, unknown>),
user_id: userId,
company_id: companyId,
} as unknown as Supplier,
companyId,
userId,
},
})
} catch (err) {
log.warn('bulk-create: supplier.created emit failed', err as Error, {
request_index: index,
})
}
return { ok: true, request_index: index, data }
}
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'suppliers.bulk-create',
async (request, ctx) => {
if (!z.string().uuid().safeParse(ctx.companyId).success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'companyId', message: 'companyId must be a UUID.' },
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = BulkCreateRequest.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
const body = parsed.data
if (body.all_or_nothing) {
return v1ErrorResponseFromCode('NOT_IMPLEMENTED', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'all_or_nothing',
message:
'all_or_nothing: true is not yet implemented. Omit the flag (or pass false) to use partial-success semantics.',
},
})
}
// Sequential processing matches /customers/bulk-create and /invoices/bulk-create.
// The 50-item cap bounds worst-case latency.
const results: ResultItem[] = []
for (let i = 0; i < body.suppliers.length; i++) {
const item = await createOneSupplier(
ctx.supabase,
ctx.companyId!,
ctx.userId,
i,
body.suppliers[i],
ctx.dryRun,
ctx.log,
)
results.push(item)
}
const summary = {
total: results.length,
succeeded: results.filter((r) => r.ok).length,
failed: results.filter((r) => !r.ok).length,
}
ctx.log.info('suppliers.bulk-create completed', {
companyId: ctx.companyId,
userId: ctx.userId,
...summary,
dryRun: ctx.dryRun,
})
if (ctx.dryRun) {
return dryRunPreview({ results, summary }, { requestId: ctx.requestId, log: ctx.log })
}
return ok({ results, summary }, { requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)
@@ -0,0 +1,432 @@
/**
* /api/v1/companies/{companyId}/suppliers — list + create supplier endpoints.
*
* GET — list with filters (supplier_type, search, include_archived).
* Cursor pagination on (created_at ASC, id ASC).
* POST — create. Idempotent (mandatory Idempotency-Key). Dry-runnable
* (?dry_run=true returns the validated would-be record without
* committing).
*
* VIES validation note: unlike customers, suppliers do not carry a
* `vat_number_validated` flag in the schema today. The vat_number is
* accepted as input but not auto-verified against VIES — a deliberate
* scope decision documented in the endpoint pitfalls.
*/
import { z } from 'zod'
import { created, paginated } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import {
decodeDefaultCursor,
encodeDefaultCursor,
parsePaginationParams,
} from '@/lib/api/v1/pagination'
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 { CreateSupplierSchema } from '@/lib/api/schemas'
import { eventBus } from '@/lib/events'
import type { Supplier } from '@/types'
const SupplierType = z.enum([
'swedish_business',
'eu_business',
'non_eu_business',
])
const SupplierSummary = z.object({
id: z.string().uuid(),
name: z.string(),
supplier_type: SupplierType,
email: z.string().nullable(),
org_number: z.string().nullable(),
vat_number: z.string().nullable(),
default_payment_terms: z.number(),
default_currency: z.string(),
archived_at: z.string().nullable(),
created_at: z.string(),
})
const SuppliersListResponse = z.object({
suppliers: z.array(SupplierSummary),
})
// Explicit projection — never SELECT *. Schema migrations adding columns
// must update this list before the field becomes visible on the public API.
const SUPPLIER_SUMMARY_COLUMNS =
'id, name, supplier_type, email, org_number, vat_number, default_payment_terms, default_currency, archived_at, created_at'
registerEndpoint({
operation: 'suppliers.list',
method: 'GET',
path: '/api/v1/companies/:companyId/suppliers',
summary: 'List suppliers for a company.',
description:
'Returns active suppliers in created-first order. Pass ?include_archived=true to include archived rows. Use ?search to match against name or org_number.',
useWhen:
'You need a supplier roster — for building a UI picker, resolving a supplier_id before registering a supplier invoice, or syncing an external AP system.',
doNotUseFor:
'Fetching a single supplier you already know the id of — use GET /api/v1/companies/{companyId}/suppliers/{id}. Customers are a separate resource.',
pitfalls: [
'Archived suppliers are hidden by default; the dashboard makes the same choice.',
'org_number identifies legal entities only — suppliers currently have no `individual` type, so the field is Bolagsverket public-record data when present.',
'vat_number is stored as supplied; unlike customers, suppliers are not auto-validated against VIES on create. Validate externally if the integration requires it.',
],
example: {
response: {
data: [
{
id: 'a8f1…',
name: 'Office Depot AB',
supplier_type: 'swedish_business',
email: 'invoices@officedepot.example',
org_number: '556677-8899',
vat_number: 'SE556677889901',
default_payment_terms: 30,
default_currency: 'SEK',
archived_at: null,
created_at: '2026-04-12T08:30:00Z',
},
],
meta: { request_id: 'req_…', api_version: '2026-05-12', next_cursor: null },
},
},
scope: 'suppliers:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: SuppliersListResponse },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'suppliers.list',
async (request, ctx) => {
const url = new URL(request.url)
const { limit, cursor } = parsePaginationParams(url)
const decoded = decodeDefaultCursor(cursor)
const FiltersSchema = z.object({
supplier_type: SupplierType.optional(),
search: z.string().min(1).max(200).optional(),
include_archived: z.enum(['true', 'false']).optional(),
})
const filtersResult = FiltersSchema.safeParse({
supplier_type: url.searchParams.get('supplier_type') ?? undefined,
search: url.searchParams.get('search') ?? undefined,
include_archived: url.searchParams.get('include_archived') ?? undefined,
})
if (!filtersResult.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: filtersResult.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
const filters = filtersResult.data
const includeArchived = filters.include_archived === 'true'
let query = ctx.supabase
.from('suppliers')
.select(SUPPLIER_SUMMARY_COLUMNS)
.eq('company_id', ctx.companyId!)
.order('created_at', { ascending: true })
.order('id', { ascending: true })
.limit(limit + 1)
if (!includeArchived) {
query = query.is('archived_at', null)
}
if (filters.supplier_type) {
query = query.eq('supplier_type', filters.supplier_type)
}
if (filters.search) {
// Two layers of escaping (matches the customers list):
// 1. PostgREST `.or()` filter syntax uses commas + parens as
// delimiters; strip them from the user-supplied term.
// 2. SQL LIKE treats `%` and `_` (and `\` as the default escape) as
// wildcards; escape them so '100%' matches the literal string.
const term = filters.search
.replace(/[,()]/g, '')
.replace(/[%_\\]/g, '\\$&')
query = query.or(`name.ilike.%${term}%,org_number.ilike.${term}%`)
}
if (decoded) {
query = query.or(
`created_at.gt.${decoded.ts},and(created_at.eq.${decoded.ts},id.gt.${decoded.id})`,
)
}
const { data, error } = await query
if (error) {
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
}
type Row = {
id: string
name: string
supplier_type: string
email: string | null
org_number: string | null
vat_number: string | null
default_payment_terms: number
default_currency: string
archived_at: string | null
created_at: string
} & Record<string, unknown>
const rows = ((data ?? []) as unknown) as Row[]
const trimmed = rows.slice(0, limit)
const hasMore = rows.length > limit
// GDPR Art.5(1)(c) defense-in-depth: SupplierType has no `individual`
// variant today (only swedish_business / eu_business / non_eu_business),
// so the org_number is Bolagsverket public-record data. Were a future
// schema iteration introduce a natural-person supplier type, the list
// endpoint should mask org_number/vat_number the same way the customer
// list does for `individual`. Leaving the hook (an empty INDIVIDUAL_TYPES
// set) makes that change a one-line edit and signals the design intent
// to anyone copying the file.
const INDIVIDUAL_TYPES = new Set<string>([])
const suppliers = trimmed.map((r) => {
const isIndividual = INDIVIDUAL_TYPES.has(r.supplier_type)
return {
id: r.id,
name: r.name,
supplier_type: r.supplier_type,
email: r.email,
org_number: isIndividual ? null : r.org_number,
vat_number: isIndividual ? null : r.vat_number,
default_payment_terms: r.default_payment_terms,
default_currency: r.default_currency,
archived_at: r.archived_at,
created_at: r.created_at,
}
})
const last = trimmed[trimmed.length - 1]
const nextCursor = hasMore && last
? encodeDefaultCursor({ id: last.id, created_at: last.created_at })
: null
return paginated(suppliers, {
requestId: ctx.requestId,
nextCursor: nextCursor ?? undefined,
})
},
)
// ──────────────────────────────────────────────────────────────────
// POST — create supplier
// ──────────────────────────────────────────────────────────────────
const SupplierCreated = z.object({
id: z.string().uuid().nullable(),
name: z.string(),
supplier_type: SupplierType,
email: z.string().nullable(),
phone: z.string().nullable(),
address_line1: z.string().nullable(),
address_line2: z.string().nullable(),
postal_code: z.string().nullable(),
city: z.string().nullable(),
country: z.string(),
org_number: z.string().nullable(),
vat_number: z.string().nullable(),
bankgiro: z.string().nullable(),
plusgiro: z.string().nullable(),
bank_account: z.string().nullable(),
iban: z.string().nullable(),
bic: z.string().nullable(),
default_expense_account: z.string().nullable(),
default_payment_terms: z.number(),
default_currency: z.string(),
notes: z.string().nullable(),
archived_at: z.string().nullable(),
created_at: z.string().nullable(),
updated_at: z.string().nullable(),
})
const SUPPLIER_RESPONSE_COLUMNS =
'id, name, supplier_type, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, bankgiro, plusgiro, bank_account, iban, bic, default_expense_account, default_payment_terms, default_currency, notes, archived_at, created_at, updated_at'
registerEndpoint({
operation: 'suppliers.create',
method: 'POST',
path: '/api/v1/companies/:companyId/suppliers',
summary: 'Create a supplier.',
description:
'Creates a new supplier for the company. Requires Idempotency-Key (UUID). Supports ?dry_run=true for input validation without committing — the dry-run response shows the would-be record minus id and timestamps.',
useWhen:
'You need to register a new supplier before booking supplier invoices against them. Use dry-run first to catch validation errors before committing.',
doNotUseFor:
'Updating an existing supplier (PATCH instead). Creating customers (different resource).',
pitfalls: [
'Idempotency-Key is mandatory — calls without it return 400 VALIDATION_ERROR.',
'org_number uniqueness is enforced at the database level; duplicate inserts return 409 SUPPLIER_DUPLICATE_ORG_NUMBER.',
'Unlike customers, suppliers carry no `vat_number_validated` flag — vat_number is stored as supplied without VIES verification. Validate externally if your workflow requires it.',
'default_expense_account is a BAS account number (e.g. "5410"); the value is stored as-is and used as the suggested debit account when supplier invoices are booked.',
],
example: {
request: {
name: 'Office Depot AB',
supplier_type: 'swedish_business',
email: 'invoices@officedepot.example',
org_number: '556677-8899',
bankgiro: '123-4567',
default_expense_account: '5410',
default_payment_terms: 30,
default_currency: 'SEK',
},
response: {
data: {
id: '0e9c…',
name: 'Office Depot AB',
supplier_type: 'swedish_business',
email: 'invoices@officedepot.example',
org_number: '556677-8899',
bankgiro: '123-4567',
default_expense_account: '5410',
default_payment_terms: 30,
default_currency: 'SEK',
archived_at: null,
created_at: '2026-05-13T15:00:00Z',
updated_at: '2026-05-13T15:00:00Z',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'suppliers:write',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: CreateSupplierSchema },
response: { success: SupplierCreated },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'suppliers.create',
async (request, ctx) => {
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 = CreateSupplierSchema.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
if (ctx.dryRun) {
return dryRunPreview(
{
id: null,
name: body.name,
supplier_type: body.supplier_type,
email: body.email ?? null,
phone: body.phone ?? null,
address_line1: body.address_line1 ?? null,
address_line2: body.address_line2 ?? null,
postal_code: body.postal_code ?? null,
city: body.city ?? null,
country: body.country ?? 'SE',
org_number: body.org_number ?? null,
vat_number: body.vat_number ?? null,
bankgiro: body.bankgiro ?? null,
plusgiro: body.plusgiro ?? null,
bank_account: body.bank_account ?? null,
iban: body.iban ?? null,
bic: body.bic ?? null,
default_expense_account: body.default_expense_account ?? null,
default_payment_terms: body.default_payment_terms ?? 30,
default_currency: body.default_currency ?? 'SEK',
notes: body.notes ?? null,
archived_at: null,
created_at: null,
updated_at: null,
},
{ requestId: ctx.requestId, log: ctx.log },
)
}
const { data, error } = await ctx.supabase
.from('suppliers')
.insert({
user_id: ctx.userId,
company_id: ctx.companyId!,
name: body.name,
supplier_type: body.supplier_type,
email: body.email ?? null,
phone: body.phone ?? null,
address_line1: body.address_line1 ?? null,
address_line2: body.address_line2 ?? null,
postal_code: body.postal_code ?? null,
city: body.city ?? null,
country: body.country ?? 'SE',
org_number: body.org_number ?? null,
vat_number: body.vat_number ?? null,
bankgiro: body.bankgiro ?? null,
plusgiro: body.plusgiro ?? null,
bank_account: body.bank_account ?? null,
iban: body.iban ?? null,
bic: body.bic ?? null,
default_expense_account: body.default_expense_account ?? null,
default_payment_terms: body.default_payment_terms ?? 30,
default_currency: body.default_currency ?? 'SEK',
notes: body.notes ?? null,
})
.select(SUPPLIER_RESPONSE_COLUMNS)
.single()
if (error) {
if (error.code === '23505') {
// Symmetric with customers: do NOT echo body.org_number — guards
// against accidentally leaking a natural-person identifier in the
// future if SupplierType ever gains an `individual` variant.
return v1ErrorResponseFromCode('SUPPLIER_DUPLICATE_ORG_NUMBER', ctx.log, {
requestId: ctx.requestId,
details: { field: 'org_number' },
})
}
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
}
try {
await eventBus.emit({
type: 'supplier.created',
payload: {
supplier: { ...(data as Record<string, unknown>), user_id: ctx.userId, company_id: ctx.companyId! } as unknown as Supplier,
companyId: ctx.companyId!,
userId: ctx.userId,
},
})
} catch (err) {
ctx.log.warn('supplier.created emit failed', err as Error)
}
return created(data, { requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)
+10
View File
@@ -44,4 +44,14 @@ import '@/app/api/v1/companies/[companyId]/transactions/batch-categorize/route'
import '@/app/api/v1/companies/[companyId]/reconciliation/bank/run/route'
import '@/app/api/v1/companies/[companyId]/reconciliation/bank/status/route'
// Phase 4 PR-1 — AP world: suppliers + supplier-invoices verticals.
import '@/app/api/v1/companies/[companyId]/suppliers/route'
import '@/app/api/v1/companies/[companyId]/suppliers/[id]/route'
import '@/app/api/v1/companies/[companyId]/suppliers/bulk-create/route'
import '@/app/api/v1/companies/[companyId]/supplier-invoices/route'
import '@/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route'
import '@/app/api/v1/companies/[companyId]/supplier-invoices/[id]/approve/route'
import '@/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route'
import '@/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route'
export {}
+18
View File
@@ -69,6 +69,24 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
'GET /api/v1/companies/:companyId/invoices/:id/pdf': 'invoices:read',
'POST /api/v1/companies/:companyId/customers/bulk-create': 'customers:write',
// Phase 4 PR-1 — Suppliers + Supplier-invoices verticals (AP world).
// Suppliers
'GET /api/v1/companies/:companyId/suppliers': 'suppliers:read',
'GET /api/v1/companies/:companyId/suppliers/:id': 'suppliers:read',
'POST /api/v1/companies/:companyId/suppliers': 'suppliers:write',
'PATCH /api/v1/companies/:companyId/suppliers/:id': 'suppliers:write',
'DELETE /api/v1/companies/:companyId/suppliers/:id': 'suppliers:write',
'POST /api/v1/companies/:companyId/suppliers/bulk-create': 'suppliers:write',
// Supplier invoices
'GET /api/v1/companies/:companyId/supplier-invoices': 'suppliers:read',
'GET /api/v1/companies/:companyId/supplier-invoices/:id': 'suppliers:read',
'POST /api/v1/companies/:companyId/supplier-invoices': 'suppliers:write',
'PATCH /api/v1/companies/:companyId/supplier-invoices/:id': 'suppliers:write',
// Note: no DELETE — supplier-invoice withdrawal is via :credit (mirrors v1 invoices).
'POST /api/v1/companies/:companyId/supplier-invoices/:id/approve': 'suppliers:write',
'POST /api/v1/companies/:companyId/supplier-invoices/:id/mark-paid': 'suppliers:write',
'POST /api/v1/companies/:companyId/supplier-invoices/:id/credit': 'suppliers:write',
// Phase 3 — transactions + reconciliation vertical.
// Reads
'GET /api/v1/companies/:companyId/transactions': 'transactions:read',
+22
View File
@@ -1127,6 +1127,28 @@ const SUPPLIER: Record<string, StructuredErrorEntry> = {
message_sv: 'Leverantören kunde inte tas bort.',
message_en: 'Failed to delete supplier.',
},
// v1 archive refusal — leverantörsfakturor pointing at this supplier still
// need its name/address for BFL 7 kap audit. Issue credit notes first.
SUPPLIER_HAS_INVOICES: {
httpStatus: 409,
message_sv:
'Leverantören kan inte arkiveras eftersom det finns öppna leverantörsfakturor som refererar till den.',
message_en:
'Supplier cannot be archived while open supplier invoices reference it.',
remediation: {
description:
'Close (credit / mark paid) every open supplier invoice before archiving the supplier. The dashboard exposes the same blocker.',
},
},
// v1 strict-mode: update / delete only allowed on `registered` SIs (the
// SI analogue of `draft`). Mirrors the dashboard internal route.
SI_NOT_DRAFT: {
httpStatus: 400,
message_sv:
'Leverantörsfakturan är inte längre i status "registrerad" och kan därför inte uppdateras eller tas bort.',
message_en:
'Supplier invoice is not in `registered` status and cannot be updated or deleted.',
},
}
const SUPPLIER_INVOICE_WAVE4: Record<string, StructuredErrorEntry> = {
@@ -0,0 +1,40 @@
-- Migration: archived_at + vat_number_validated_at for customers + archived_at for suppliers
--
-- Phase 4 PR-1 (AP world) introduces a soft-archive flow for suppliers
-- analogous to the customers vertical from Phase 2. Both v1 routes treat
-- `archived_at IS NULL` as the canonical "active" filter.
--
-- This migration also retroactively adds the two columns that the Phase 2
-- v1 customer routes (PR #451 / #452 / #460) already reference but that no
-- prior migration installed in production:
-- - customers.archived_at (soft-archive timestamp)
-- - customers.vat_number_validated_at (last successful VIES check)
--
-- `suppliers.is_active` (legacy boolean from migration 025) is preserved;
-- the v1 layer treats it as a back-compat companion. Archive sets
-- archived_at = now() AND is_active = false; un-archive flips both back.
--
-- BFL 7 kap 2 § (7-year retention) and ML 17 kap 24 § (invoice metadata
-- preservation) both want the archived row to remain queryable; this is a
-- soft-delete, never a hard one. Trigger-level retention protection on
-- documents and journal entries is unchanged.
ALTER TABLE public.customers
ADD COLUMN IF NOT EXISTS archived_at timestamptz,
ADD COLUMN IF NOT EXISTS vat_number_validated_at timestamptz;
ALTER TABLE public.suppliers
ADD COLUMN IF NOT EXISTS archived_at timestamptz;
-- Partial indexes on archived_at NULL — the list endpoint's default filter.
-- A partial index is roughly half the size of a full one and covers the
-- common case (active rows only).
CREATE INDEX IF NOT EXISTS idx_customers_company_active
ON public.customers (company_id, created_at)
WHERE archived_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_suppliers_company_active
ON public.suppliers (company_id, created_at)
WHERE archived_at IS NULL;
NOTIFY pgrst, 'reload schema';