Commit Graph

11 Commits

Author SHA1 Message Date
Jakob Wennberg abb9f5868c 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>
2026-05-13 20:16:27 +02:00
Jakob Wennberg a9c98da243 feat(api): Phase 3 — transactions + reconciliation vertical (#464)
* feat(api): Phase 3 — transactions + reconciliation vertical

Closes out Phase 3 of the plan in one PR. After this, a 3rd-party agent
can fully manage a company's transaction ledger via the public API:
import bank data, walk the queue, categorize (manual / template /
counterparty / account-override), match payments to customer + supplier
invoices, reverse mistakes, and auto-reconcile the bank against the GL.

ENDPOINTS (12)

Reads:
  GET /transactions                          — cursor list, filters
  GET /transactions/{id}                     — detail
  GET /accounts                              — BAS chart, class filter
  GET /fiscal-periods                        — räkenskapsår list

Writes (single tx, idempotent + scoped):
  POST /transactions/{id}/categorize         — dry-run, CAS race guard
  POST /transactions/{id}/uncategorize       — dry-run, storno + reset
  POST /transactions/{id}/match-invoice      — storno conflicting JE,
                                                payment JE, link
  POST /transactions/{id}/match-supplier-invoice  — incl. FX diff handling

Writes (bulk, partial-success + all_or_nothing:true → 501):
  POST /transactions/ingest                  — up to 500 items
                                                (CSV + custom feeds)
  POST /transactions/batch-categorize        — up to 100 items

Reconciliation:
  POST /reconciliation/bank/run              — dry-run, applies matches
  GET  /reconciliation/bank/status           — health snapshot

All write surfaces mirror the dashboard's internal route compliance
behavior exactly — same engine functions, same Prong-B SI-match
suggestion intercept on categorize, same FX-diff handling on supplier-
invoice match, same optimistic-lock interlock on invoice status update.
No new bookkeeping primitives — every route delegates to the existing
`lib/bookkeeping/*` engine, `lib/transactions/ingest.ts`, and
`lib/reconciliation/bank-reconciliation.ts`.

SCOPES + ERRORS

Adds 12 entries to lib/auth/scopes.ts under transactions:read|write +
reports:read (accounts, fiscal-periods follow the same convention as
MCP tools). Adds 4 new error codes: TX_UNCATEGORIZE_NOT_BOOKED,
TX_UNCATEGORIZE_JE_NOT_POSTED, TX_INGEST_INSERT_FAILED,
TX_BATCH_CATEGORIZE_EMPTY.

TESTS

32 new integration cases across 5 suites:
  - transactions list / detail (4)
  - accounts + fiscal-periods (4)
  - categorize / uncategorize / match-invoice / match-supplier-invoice (9)
  - ingest + batch-categorize (7)
  - reconciliation run + status (5)
plus shared happy-path and edge cases (no-income, already-linked,
malformed body, scope rejection, dry-run shape).

Full suite green: 3270 passing (234 files). Build + lint clean.

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

* fix(api): address PR #464 review — Phase 3 hardening

Greptile P1 — cursor pagination broken in GET /transactions.
  encodeDefaultCursor was passed the YYYY-MM-DD `date` field, but
  decodeDefaultCursor's strict ISO 8601 timestamp regex rejected it,
  so every cursor decoded as null and the endpoint always returned the
  first page. Switched the cursor anchor to `created_at` (real ISO
  timestamp, total-orderable, unique within the company at the row
  insertion grain) and updated the sort to (created_at DESC, id ASC).
  The `date` column remains in every row + filterable via ?date_from /
  ?date_to. Updated the registry description to reflect the change.

Greptile P1 — JE soft-fall in match-invoice + match-supplier-invoice.
  When the payment journal entry creation threw (any non-
  AccountsNotInChartError), the catch block recorded the error string
  but execution CONTINUED, marking the invoice paid + inserting a
  payment row + linking the transaction with no GL entry. The dashboard
  internal route soft-fails here intentionally and surfaces a banner so
  the user can re-book; for the v1 surface a partial state is strictly
  worse than a clean failure to retry. Both routes now return:
    - INVOICE_PAID_BOOK_FAILED (match-invoice)
    - MATCH_SI_RECORD_PAYMENT_FAILED (match-supplier-invoice)
  before any state mutation. Removed `journal_entry_error` from both
  response schemas — strict mode means it can never be set on a 200.

Greptile P1 — `overdue` supplier invoices fail the optimistic lock.
  The early status guard accepted `overdue` as matchable, but the
  downstream `.in('status', ['registered', 'approved', 'partially_paid'])`
  excluded it, returning MATCH_SI_NOT_OPEN for a legitimately payable
  invoice. Added `overdue` to the optimistic-lock list.

Greptile P1 + Swedish-compliance — CAS-race orphan cancellation.
  Direct `.update({ status: 'cancelled' })` on the orphaned JE was
  silently blocked by enforce_journal_entry_immutability (the engine
  writes JEs as posted) and the `voucher_gap_explanations` row claimed
  the entry was cancelled when it wasn't. BFL 5 kap 5 § requires
  corrections via a reversing entry. Both /transactions/{id}/categorize
  and /transactions/batch-categorize now call `reverseEntry()` on the
  orphan; the storno pair keeps the verifikationsnummer series unbroken
  so the gap-explanation insert is no longer needed.

Greptile P2 + Swedish-compliance — hardcoded category on match-invoice.
  The dashboard internal route writes `category: 'income_services'` for
  every matched invoice payment, overwriting any prior categorization
  with a wrong BAS classification for goods sales / rental income.
  Fixed by preserving the existing transaction.category if set, only
  defaulting to `income_services` when the row had never been
  categorized before.

Compliance Swarm V2.4 — reconciliation date range guard.
  Added a 366-day cap on date_from / date_to via Zod refine. Longer
  reconciliations should be paged.

Greptile P2 — dry-run dedup limitation.
  Added a pitfall note documenting that the ingest dry-run only checks
  external_id-based dedup; content-based dedup (date+amount against
  already-booked rows) only runs in the live pipeline.

Swedish-compliance — BFL chapter typo on fiscal-periods registry.
  "BFL 6 kap" → "BFL 5 kap 2 §" (the löpande bokföring deadline).

Deferred (with rationale documented):
  - OWASP V8.2.1 cross-tenant via path: false positive — wrapper sets
    ctx.companyId from the URL after membership check (recurring across
    swarm runs).
  - OWASP V4.5 select('*') on transactions/invoices: same as Phase 2 —
    those rows feed engine functions that need the full shape.
  - OWASP V2.3 multi-write atomicity (match endpoints): would need a
    Postgres RPC; separate refactor.
  - Swedish-compliance kontantmetoden partial-payment status: same
    semantics as the dashboard internal route; engine-level decision
    out of v1's scope.
  - Greptile P3 `reversible: false` on uncategorize: technically
    correct (the storno itself isn't reversible via this verb).

Tests + build green: 3270 passing, lint clean.

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

* fix(import): distinguish network errors in the SIE upload step

Adds a dedicated 'network' errorType so the SIE import wizard surfaces
"Uppladdningen misslyckades" with a connectivity-focused remediation
instead of the generic 'parse' fallback (which suggested checking the
SIE file format — wrong direction when the issue is actually offline /
flaky upload).

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

* fix(api): address PR #464 swedish-compliance re-run findings

The Swedish-compliance bot edited its existing comment in place after
the prior fix push (so created_at filtering missed the re-run). The
re-run flagged 6 new substantive findings against the post-fix code.

Fix 1 — Orphan storno failure leaves an unresolved immutability gap
  (categorize + batch-categorize).
  When reverseEntry() on the CAS-race orphan fails, the orphan stays
  posted and untraceable. BFL 5 kap 5 § requires every correction be
  traceable. Both paths now insert a voucher_gap_explanations row in
  the catch branch flagging "automatisk storno misslyckades — manuell
  reconciliation krävs", so the orphan is logged at the audit-trail
  level rather than only in app logs.

Fix 2 — Period-lock pre-check (categorize + batch-categorize).
  enforce_period_lock and enforce_company_lock_date triggers block JE
  inserts on locked/closed periods, but Supabase surfaces those as a
  generic 500. Added a new lib/api/v1/check-period-lock.ts helper that
  performs the same check the trigger would (company-wide lock date,
  is_closed, locked_at), and both routes now return a structured
  PERIOD_LOCKED response (existing error code, 400) with reason +
  fiscal_period_id details before the engine call. Note: this is an
  ergonomics check (TOCTOU window between check and insert) — the
  trigger remains authoritative.

Fix 3 — Ingest dry-run now performs content-based dedup too.
  The earlier doc-only note was a compliance miss: an integrator
  relying on dry-run to confirm uniqueness could ingest duplicate
  affärshändelser, violating BFL 5 kap. The dry-run now runs BOTH
  external_id dedup AND content-based (date+amount-against-booked)
  dedup over the request's date range — same query the live pipeline
  uses. Pitfall doc updated accordingly.

Fix 4 — fiscal-periods response now carries duration_days +
  exceeds_18_months computed fields.
  An automated client (year-end wizard, audit tool) can spot a
  non-compliant period sequence (BFL 3 kap, 18-month cap) without
  re-implementing date arithmetic. 549-day cap (18 calendar months)
  is used to keep the comparison deterministic across leap years.
  First-year exceptions still require human judgment; the boolean is
  a flag, not a verdict.

Deferred (with rationale documented in commit, not retried):
  - uncategorize storno memo: reverseEntry() doesn't accept a reason
    parameter today and the JE-level back-reference exists already
    via reversed_by_id / reverses_id. Engine signature change is
    out of v1's scope.
  - VAT integrity check on partial payment in match-invoice: the
    behavior is fully delegated to createInvoicePaymentJournalEntry.
    The bot itself recommends auditing against the engine; that is
    an engine-layer concern and the dashboard internal route uses
    the same path.
  - 366-day reconciliation window (advisory): no statutory basis;
    operational guard.
  - match-supplier-invoice FX path against ML 8 kap 21–23 §
    (advisory): engine-layer concern.

Tests + build green: 3270 passing, lint clean. Touched-suite tests
(transactions, fiscal-periods, accounts, reconciliation) re-run; the
fiscal-periods test asserts the new derived fields.

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

* fix(api): PR #464 round-3 review fixes (re-run after period-lock + dedup)

Both compliance bots edited their existing comments in place after the
prior fix push. New findings against the post-fix code:

Fix — VAT account suppression too broad on account_override.
  categorize/route.ts dropped vat_lines for ANY class-2 override, but
  BAS class 2 includes the 26xx VAT clearing accounts themselves. Result:
  a user override TO a VAT account silently lost the auto-VAT line.
  Tightened to `account_class === 2 && !account_override.startsWith('26')`.
  The override-to-2440-leverantörsskulder case is unchanged (correctly
  drops auto-VAT); the override-to-2611-utgående-moms case now keeps the
  VAT line.

Fix — fiscal-periods 18-month cap uses calendar arithmetic.
  EIGHTEEN_MONTHS_DAYS = 549 was a generous approximation (18 calendar
  months span 540–549 days). Replaced with proper month-anchor math:
  start_date + 18 months computed via setUTCMonth-style year/month
  rollover, then `period_end > anchor` is the violation. Manual day-
  arithmetic on the year part avoids JS's clamp-overflow on Aug-31-style
  start dates. duration_days helper preserved for the response field.

Fix — match-invoice no longer hardcodes 'income_services'.
  When the transaction has no prior category, the route now leaves the
  field UNTOUCHED in the UPDATE (existing default 'uncategorized' or
  whatever was there persists). The response surfaces null for the
  uncategorized case so a caller can detect "needs human classification"
  without inspecting the DB. The auto-default to income_services was
  flowing into BAS 3001/3041/3530 selection mismatches and INK2R/SRU
  mis-reporting for goods/rental flows. Existing-category transactions
  still propagate their value.

Doc — accounts.ts BAS 5/6 description tightened.
  Was "5=other costs, 6=other costs" — both true but flatten distinct
  subgroups. Now spells out 5xxx (rents/supplies/services) and 6xxx
  (marketing/professional/IT) under övriga externa kostnader, with a
  pointer to the canonical BAS chart.

Deferred (with rationale documented):
  - voucher_gap_explanations in SIE export coverage: verification ask;
    SIE export audit is a separate task, not this PR's scope.
  - Dry-run dedup parity with full live pipeline: my dedup matches the
    live pipeline's primary checks (external_id + content date+amount
    against booked rows). Achieving exact parity would need refactoring
    lib/transactions/ingest.ts to expose a shared dedup helper.
  - FX sign convention in match-supplier-invoice: identical to the
    dashboard internal route; if the engine sign convention is wrong
    both surfaces are wrong. Engine-layer audit, not v1 surface.
  - OWASP V8.2.1 cross-tenant via path: recurring false positive — the
    wrapper sets ctx.companyId from the URL only AFTER company_members
    membership check.
  - V2.3 multi-write atomicity in match endpoints: would need a Postgres
    RPC; separate refactor.
  - check-period-lock TOCTOU on no_fiscal_period (advisory note): the
    engine's ensureFiscalPeriod helper creates an open period; if the
    transaction date sits in a historical gap, the engine creates the
    period unlocked. The trigger remains the authoritative gate.

Tests + build green: 3270 passing, lint clean.

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

* fix(api): PR #464 round-4 review fixes (compliance bot re-run)

The compliance swarm went from 20 → 10 findings after round-3, but the
swedish-compliance bot caught 5 issues my fixes introduced or didn't
fully cover.

Fix — VAT account suppression narrowed to BAS 2610–2649.
  My round-3 fix exempted any account starting with '26' from VAT-line
  suppression, but BAS 26xx includes 2650 (momsredovisningskonto) and
  2690 (diverse), neither of which is a moms-line account. Auto-VAT
  posted against 2650 would double-post on the moms reconciliation
  account. Tightened the exception to the 2610–2649 range (utgående
  + ingående moms accounts only).

Fix — exceedsEighteenMonths month-end overflow.
  My round-3 manual month math still passed `startD` raw to Date.UTC,
  which clamps Aug 31 + 18 months to Mar 3, making the cap LATER than
  the BFL 3 kap 1 § ceiling (false negative). Now clamps `startD` to
  the last valid day of the target month using `Date.UTC(year, m+1, 0)`.

Fix — ingest dry-run dedup float-key normalization.
  Built the content-dedup set from `${tx.date}|${tx.amount}` where
  amount is a JS number stringified directly — `-349.5` from JSON vs
  `-349.50` from a Postgres numeric round-trip miss-match. Normalized
  both sides to .toFixed(2). SIE imports commonly carry trailing-zero
  precision, so this would have caused the dry-run to under-report
  duplicates (a BFL 5 kap löpande-bokföring concern: an integrator
  trusting the dry-run could double-book affärshändelser).

Fix — CAS-race voucher_series fallback no longer files under 'A'.
  Both categorize and batch-categorize used `voucher_series || 'A'`
  for the voucher_gap_explanations row. If the orphan JE had no series,
  the gap would be indexed under series 'A' and missed by any series-
  specific audit query (BFL 5 kap 6 §). Now skips the gap row entirely
  when no series is set — the error log already captures the orphan
  for human reconciliation; filing under the wrong key is strictly
  worse than not filing.

Fix — match-invoice rejects kontantmetoden partial payments.
  Under kontantmetoden, utgående moms must be reported per actual
  receipt (ML 13 kap 8 §). The cash-method-partial branch was falling
  through to createInvoicePaymentJournalEntry (the accrual 1510/1930
  clearing path), which doesn't model the per-installment moms event.
  Rather than silently over-report moms, refuse with a VALIDATION_ERROR
  pointing the caller to either wait for the full payment or switch to
  faktureringsmetoden. Full cash-method payments still flow through
  createInvoiceCashEntry (the correct kontantmetod path).

Deferred (with rationale):
  - `uncategorize` resets journal_entry_id to null: dashboard parity;
    the JE-side back-reference (reversed_by_id / reverses_id) preserves
    the audit pair. Adding a separate reversal_journal_entry_id column
    on transactions is a schema change out of v1 scope.
  - OWASP V8.2.1 cross-tenant: recurring false positive.
  - OWASP V2.2 inline Zod filter schemas: structural consistency
    decision — kept in-route to match other v1 endpoints; a future
    refactor can centralize when it justifies the cost.
  - OWASP V16 add userId/companyId to storno-failure log: txLog
    already carries both via ctx.log.child; not changing call-site
    syntax for compliance theatre.
  - Engine-layer FX sign convention in match-supplier-invoice
    (advisory): identical to dashboard internal route.

Tests + build green: 3270 passing.

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

* fix(api): match-supplier-invoice storno conflicting JE before booking

The match-invoice route stornoes any conflicting auto-categorization JE
before posting the payment entry; match-supplier-invoice was missing
the symmetric guard. If a transaction was previously auto-categorized
(e.g. expense_office with a 5460/1930 entry), matching it to a supplier
invoice would post a second 2440/1930 entry while leaving the original
posted — two verifikationer for one affärshändelse, a BFL 5 kap 6 §
integrity violation. Storno-before-match now applies in both routes,
with the same fail-closed semantics (storno failure aborts before any
state change).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:22:57 +02:00
Jakob Wennberg 07a7964e8d fix(supplier-invoices): guard against duplicate payment when bank tx already booked (#461)
* fix(supplier-invoices): guard against duplicate payment when bank tx already booked

Two-pronged fix for a UX trap where a supplier invoice could be marked paid
even though the bank payment was already booked on 2440, creating a duplicate
verifikation.

Prong A — mark-paid duplicate guard: before booking, scan for an unlinked
outgoing bank transaction matching this supplier (merchant_name ILIKE) within
±2% / ±60 days. If found, return 409 SI_PAID_LIKELY_DUPLICATE with candidates
so the UI can offer "link existing" instead. Override via { force: true }.

Prong B — categorize match suggestion: when the user assigns 2440 directly on
a negative business transaction and an open supplier invoice from the same
supplier covers the same amount, return 409 TX_CATEGORIZE_SUGGEST_SI_MATCH
with candidates and route the user to match-supplier-invoice. Override via
{ confirm_no_match: true }.

Frontend dialogs added on the supplier-invoice detail page and the
transactions inbox. Partial payments skip the mark-paid guard (deliberate
action). Tests cover the 409 path, the override path, and the no-candidates
happy path on both routes.

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

* fix(supplier-invoices): apply PR review fixes to duplicate-payment guards

- Add `is_business = true` filter to mark-paid candidate query so private
  bank withdrawals don't surface as false-positive duplicates
- Escape LIKE wildcards (`%`, `_`, `\`) in both ILIKE patterns to avoid
  silent over-matching when a supplier/merchant name contains those chars
- Round paymentAmount and remaining_amount to 2 decimals before the
  partial-payment guard comparison to avoid float-equality fragility
- Require credit account to be in the 1xxx (bank/cash) series for the
  Prong B 2440 intercept so 2440 against clearing/equity accounts isn't
  misinterpreted as a supplier payment
- Extract DUPLICATE_AMOUNT_TOLERANCE_PCT (0.02) and
  DUPLICATE_DATE_WINDOW_DAYS (60) into a shared helper module with the
  LIKE-escape utility

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

* fix(supplier-invoices): broaden 244x match, audit log overrides, drop JE id from response

Second-round PR review fixes:

- Widen Prong B regex from /^2440$/ to /^244\d$/ so payments mapped to BAS
  sub-accounts (e.g. 2441 leverantörsskulder i utländsk valuta) also trigger
  the suggestion (swedish-invoice-compliance bot)
- Log a structured warning when force=true or confirm_no_match=true is honored,
  with the relevant context (amount, date, accounts) so the override is
  traceable per BFNAR 2013:2 kap 8 (behandlingshistorik)
- Drop journal_entry_id from the SI_PAID_LIKELY_DUPLICATE candidate response
  payload (data minimization, GDPR Art.5(1)(c)); the UI never rendered it

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

* fix(supplier-invoices): third-round PR review — date window on Prong B, length cap, VAT message

- Add the missing date window to the Prong B categorize candidate query
  (swedish-compliance bot): without it, an open invoice from years back can
  surface as a "match" for an unrelated bank transaction. Uses the shared
  DUPLICATE_DATE_WINDOW_DAYS against invoice_date.
- Cap supplier/merchant names to 200 chars before they enter escapeLikePattern
  (OWASP V1.2.5 / ISO A.8.28). Bounds DB work on pathological inputs.
- Log a structured warning when the mark-paid guard is skipped because the
  invoice has no resolved supplier name (BFL 5 kap 7 § — motpart should be
  identifiable; the absence is itself worth surfacing).
- Update the SI-match suggestion error and the matching UI copy to call out
  the actual compliance risk: a duplicate 244x posting double-deducts ingående
  moms (ML 8 kap 3 §), not just bookkeeping symmetry.
- Reword "Bokför på 2440 ändå" to "Bokför på leverantörsskulder ändå" now that
  the regex covers BAS sub-accounts 244x.

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

* fix(supplier-invoices): correct Prong B framing — duplicate verifikation, not VAT double-deduction

Latest swedish-compliance review correctly walked back the earlier
finding that asked for ML 8 kap 3 § VAT framing. Plain 244x
categorization via account_override does not include VAT lines (account
class 2), so the risk is a duplicate verifikation (BFL 5 kap 5 §), not
a double VAT deduction. Update both the structured error message and
the dialog body to reflect the actual mechanism.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:38:19 +02:00
Jakob Wennberg 01e99d3220 feat(api): v1 invoice PDF + customer bulk-create (Phase 2 PR-B-3) (#460)
Closes out the Phase 2 invoices+customers vertical. After this PR, every
write/read the dashboard does on these two resources is reachable via the
public API.

GET /api/v1/companies/{companyId}/invoices/{id}/pdf
  Read-only application/pdf endpoint. Mirrors the dashboard's internal
  /api/invoices/[id]/pdf so a downloaded PDF is byte-equivalent across
  surfaces. Drafts render with the "faktura-utkast-<id-slice>.pdf"
  filename (preview before send is a legitimate workflow); sent invoices
  use "faktura-<number>.pdf"; credit notes use "kreditfaktura-<number>.pdf"
  and embed the original invoice's löpnummer per ML 17 kap 22–23§
  back-reference; proforma + delivery notes get their own prefixes.

  Error codes: INVOICE_PDF_RENDER_FAILED (500, new),
  INVOICE_SEND_COMPANY_SETTINGS_MISSING (404, reused — same condition,
  same remediation).

POST /api/v1/companies/{companyId}/customers/bulk-create
  Mirrors /invoices/bulk-create exactly: same `{ results, summary }`
  shape, same all_or_nothing: true → 501 NOT_IMPLEMENTED contract, same
  50-item cap, same sequential processing. Per-item rollback isn't
  needed (customer insert is a single row), but per-item 23505 →
  CUSTOMER_DUPLICATE_ORG_NUMBER failure surfaces in the results array
  without echoing org_number (GDPR Art.5(1)(c): for sole traders
  org_number IS the personnummer). VIES validation runs per item,
  best-effort — a timeout leaves vat_number_validated=false but does
  NOT fail the item.

Registry: extended EndpointDefinition.response with an optional
  `contentType` field so the OpenAPI generator can emit
  `format: binary` schemas for non-JSON responses. The PDF endpoint is
  the first consumer; future binary endpoints (SIE export, ICS feeds)
  use the same hook.

Scope catalogue: added GET .../pdf → invoices:read,
  POST .../customers/bulk-create → customers:write.

Tests: 14 new integration cases (7 for PDF: sent / draft / credit-note
filename, 404, render-error 500, non-UUID 400, scope rejection; 7 for
customer bulk-create: happy path, dup-org error masking, max-50 cap,
all_or_nothing 501, dry-run preview, empty array, scope rejection).
Suite green: 3232 passing. Build + lint clean.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:53:51 +02:00
Jakob Wennberg 37ccda5cad feat(api): v1 invoice :send + bulk-create (Phase 2 PR-B-2b-3 + PR-B-2c) (#458)
* feat(api): v1 invoice :send + bulk-create action verbs (Phase 2 PR-B-2b-3 + PR-B-2c)

Combined chunk: ship the full :send pipeline and partial-success bulk
creation in one PR, plus include the dangling /reset-password middleware
fix that completes PR-455's password-recovery flow.

POST /api/v1/companies/:companyId/invoices/:id/send
  Full send pipeline mirroring the internal route, hardened for the public
  API surface: email-configured check, draft-only guard, cancelled /
  delivery-note / credit-note / missing-moms_ruta rejections, customer
  email check, company-settings fetch, F-series invoice-number allocation
  (atomic at :send per ML 17 kap 24§ p.2, not at draft create), preflight
  PDF render before number consumption, final PDF render, email send,
  point-of-no-return status flip, BFL 5 kap journal entry, document
  archival, invoice.sent event emit. Post-send failures (journal entry,
  archive) surface via a `warnings` array rather than failing the response
  — the invoice IS sent at that point. Dry-run validates the pipeline +
  preflight PDF without allocating a number or hitting the provider.
  Error codes: INVOICE_SEND_EMAIL_NOT_CONFIGURED (503),
  INVOICE_SEND_NO_CUSTOMER_EMAIL / _CANCELLED /
  _COMPANY_SETTINGS_MISSING (400), INVOICE_UPDATE_NOT_DRAFT (409),
  INVOICE_SEND_PDF_RENDER_FAILED / _NUMBER_ASSIGN_FAILED (500),
  INVOICE_SEND_PROVIDER_FAILED (502).

POST /api/v1/companies/:companyId/invoices/bulk-create
  Batch create up to 50 invoices in a single call, sequential processing,
  partial success: `{ results: [{ ok, request_index, data?, error? }],
  summary: { total, succeeded, failed } }`. Per-item rollback on items
  insert failure (delete the parent invoice row). Emits invoice.created
  per success. Dry-run wraps results in a preview without inserting.
  `all_or_nothing` is accepted but reserved for a future PR.

lib/supabase/middleware.ts
  Add /reset-password bypass before the authenticated-user redirect so
  password-recovery sessions don't bounce to '/'. This should have landed
  in PR-455 — the `git add 'app/(auth)'` filter missed the middleware
  file at lib/. Without this the recovery email link silently fails for
  the recipient.

Tests: 14 new integration tests across both routes (happy path,
provider failure, scope rejection, draft-only guard, dry-run shape,
bulk partial-success, max-50 enforcement, validation error). Full suite
green (3207 passing, 1 unrelated pre-existing pg-real failure).

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

* fix(api): address PR #458 review — :send + bulk-create hardening

Greptile P1: silent zero-row update after email delivery.
  PostgREST returns { error: null } on 0-row UPDATEs. The post-email
  status flip used `.eq('status', 'draft')` as an optimistic lock but
  never inspected the row count, so a concurrent state change (race,
  double-send from another session) would leave the DB row in 'draft'
  while the response claimed 'sent' and the email was already gone.
  Fix: `.select('id')` after the update and check `flipRows.length`;
  on 0-row miss, push STATUS_UPDATE_FAILED warning AND change the
  response status to 'draft' so the caller can reconcile.

Greptile P1: re-read error swallowed; invoice_number could vanish
  from the response.
  After ensureInvoiceNumber, the re-read query destructured only `data`,
  silently dropping `error`. A transient connection failure would leave
  `numbered` null and `finalInvoiceNumber` undefined; JSON serialization
  would then omit the field, violating the documented response schema.
  Fix: capture `reReadErr`, log a warning, fall back to typed.invoice_number
  (which was just written by the RPC and is authoritative in-memory).
  Apply the same fallback at the top-level `ok()` call.

Greptile P2 + Compliance Swarm V2.3 + Swedish-compliance kreditfaktura:
  reject credit notes from :send.
  The :credit endpoint creates credit notes atomically in 'sent' state
  with their own number — there is no v1 path that produces a draft
  credit note, so reaching :send with credited_invoice_id set is misuse
  or manual DB editing. Allowing it would assign an F-series number to
  a kreditfaktura (ML 17 kap 22–23§ require a distinct kreditfaktura
  series and a back-reference that this route would not enforce). Fix:
  reject with VALIDATION_ERROR pointing at /credit. Removes a stretch
  of dead code (originalInvoiceNumber lookup, kreditfaktura filename
  branch) that can never execute now.

Greptile P2: all_or_nothing: true silently treated as false.
  A caller asking for atomic semantics must not get partial-success
  behaviour with no runtime signal. Fix: reject with new
  NOT_IMPLEMENTED error (501) plus a details.field pointer. Schema
  still accepts the flag for forward compatibility once a DB-side RPC
  ships. New error code added to lib/errors/structured-errors.ts.

Tests: 3 new integration cases (credit-note rejection, status-flip
no-op warning, all_or_nothing 501). Suite green: 3218 passing.
Build clean, lint clean.

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

* fix(api): PR #458 follow-up — defense-in-depth + comment precision

OWASP V8.2.1 (bulk-create): use DB-returned customer.id at insert time
  instead of input.customer_id. The .eq() pair already enforces company
  scoping at fetch, but echoing the trusted value from the query makes
  the guarantee explicit at the call site and immune to refactoring
  drift. Same change in the dry-run preview shape.

Swedish-compliance wording: the credit-note rejection comment now
  spells out BOTH ML 17 kap 22–23§ requirements — distinct kreditfaktura
  series AND explicit back-reference to the original invoice's
  löpnummer — so any future v1 path that does support credit-note send
  starts from a complete spec.

Company-settings select: kept select('*') with an explanatory comment
  rather than enumerating columns. The InvoicePDF template consumes the
  full CompanySettings shape; a partial allow-list risks silently breaking
  rendering, and the table has no sensitive columns today (API tokens,
  billing data live in scoped tables). Documents the trade-off so the
  next reviewer doesn't re-litigate.

Deliberately not changed:
  - Math.round → Math.trunc on VAT öre: CLAUDE.md mandates Math.round
    project-wide; unilateral deviation here would diverge from the
    bookkeeping engine and POST /invoices.
  - 207 Multi-Status on partial post-email failures: gnubok's convention
    is warnings[] in the 200 envelope; a per-route status divergence
    would break the response contract clients rely on.
  - Wrapper membership double-check (OWASP V8.2.1 send route): the
    withApiV1 wrapper sets ctx.companyId from the URL after the
    membership check — recurring false positive in this swarm.

Tests + build + lint clean. 17/17 in the touched suites.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:23:42 +02:00
Jakob Wennberg e96cbe05d0 feat(api): v1 invoice draft writes (Phase 2 PR-B-2a) (#453)
* feat(api): v1 invoice draft writes (Phase 2 PR-B-2a)

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

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

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

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

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

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

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

Real fixes (all reviewers agreed):

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

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

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

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

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

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

3165/3165 vitest pass; build clean.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:58:56 +02:00
Jakob Wennberg 17c67fece0 Inbox UX overhaul + cross-currency supplier-invoice fixes (#444)
* feat(kpi): expense mix and top suppliers charts

Replace the single monthly-trend chart with two additional compact visuals
on /kpi: expense composition donut (BAS class 4-7) and top suppliers bar
(supplier_invoices sum_sek over the fiscal period). KPIReport gains
expenseComposition and topSuppliers fields, computed from the trial
balance and supplier_invoices rows already fetched in the API.

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

* feat(nav): swap Deadlines sidebar slot for Dokumentinkorg

Sidebar main-menu slot now points to the invoice-inbox extension. The
/deadlines page stays accessible via dashboard widgets and direct links —
only the prominent nav entry changes. Most users open gnubok to act on
incoming documents, not to read tax deadlines.

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

* fix(supplier-invoices): cross-currency totals, FX residual, review SEK display

Five fixes around foreign-currency supplier invoices:

- Form layout: move Valuta / Växelkurs / Reverse charge from collapsed
  "Övrigt" into a visible row above the line-item table. Auto-fetch the
  Riksbanken rate when switching to a non-SEK currency; never clobber a
  user-typed rate; clear it when switching back to SEK.

- Form submit: reset() the form on successful submit so the
  useUnsavedChanges hook detaches its beforeunload listener before the
  router.push, killing the "Are you sure you want to leave?" prompt that
  fired during Turbopack-mediated navigations.

- BankTransactionPicker: drop the strict currency filter that hid every
  SEK transaction when the invoice was in EUR/USD. Cross-currency rows
  fall to the bottom with an "Annan valuta" hint instead of producing a
  meaningless numeric diff.

- match-supplier-invoice route: when the bank transaction currency
  differs from the invoice currency, compute the FX diff against the
  AP-booked SEK and pass it to createSupplierInvoicePaymentEntry so
  7960/3960 catches the residual instead of leaving a permanent stub on
  2440. Fix also covers the "EUR transaction paying a SEK invoice" case
  that the first iteration missed.

- Review dialog: buildJournalPreview now multiplies amounts by the
  exchange rate so the "Verifikation som bokförs" table shows the actual
  SEK numbers that hit the DB, not the EUR magnitudes labelled with no
  unit. Header gains an "(i SEK)" hint when foreign currency.

Test coverage for the FX residual path covers SEK-SEK (no diff),
SEK-into-EUR-invoice (loss), SEK-into-EUR-invoice (gain), foreign-tx-
into-SEK-invoice, and the no-rate fallback.

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

* feat(inbox): rate limits, multi-file UX, onboarding, retry, supplier autolink

Big workspace pass on /e/general/invoice-inbox. Highlights:

Backend
- New table inbox_rate_counters + RPC check_and_increment_inbox_quota.
  Postgres-backed (no Upstash dep) per-company limit: 30/min, 500/day.
  Applied at /upload, /inbound, and /items/:id/retry-extraction.
- POST /items/:id/retry-extraction — re-runs the deterministic extractor
  on a stored document when the previous attempt errored.
- POST /items/:id/match-supplier — links a freshly-created supplier
  back to the inbox item so the next action prefills correctly.
- POST /api/transactions/create-from-document — creates an uncategorized
  manual transaction from an inbox item for the "I have a receipt, no
  bank transaction" case. The user categorizes through the normal flow.
- /inbound caps email at 20 attachments/email; truncated count goes to
  processing_history as AttachmentsTruncated. Rate-limit drops emit
  RateLimitedDropped and return 200 so Resend doesn't retry.
- attach-document side effect: when the document came from an inbox
  item, the inbox row's matched_transaction_id is updated so the UI can
  flip it to "Kopplad till transaktion" without a round-trip.

New migration: re-introduces matched_transaction_id on
invoice_inbox_items as a plain FK (the AI metadata that the previous
migration stripped doesn't come back).

Workspace UI
- Onboarding card replaces the thin empty-state with a 3-step
  checkmark guide (Aktivera adress → Ladda upp → Matcha eller bokför).
  Auto-hides when all three steps are done; localStorage-backed dismiss.
  Beta badge + link to gnubok.se/priser.
- Responsive layout: 3-pane at lg, 2-pane at md, master-detail toggle
  on phone (list xor detail with a back button).
- Filter pills (Alla / Behöver åtgärd / Bearbetade / Fel) + search
  input above the list — client-side over the existing items list.
- Multi-file upload queue with "Laddar X av N…" progress counter on
  the button. Sequential to avoid hammering pdfjs. Selection stays put
  during a batch (only single-file drops auto-jump the detail pane).
- Bulk select + delete with sticky action bar. Items linked to a
  supplier invoice are skipped with a count toast.
- Retry button in the FieldsRail error branch.
- "Skapa transaktion från underlag" CTA in the match dialog when no
  unmatched bank transactions exist. Prefills date/amount/description
  from the extracted data; user picks the sign.
- "Skapa leverantör" inline CTA when the extractor caught a supplier
  name with no match against existing suppliers. POSTs /api/suppliers
  with the extracted fields, then auto-links via /items/:id/match-supplier.
- Matched-state CTA renamed to "Bokför transaktionen" with link to
  /transactions?highlight=<id> so the categorize panel auto-opens.

Tests
- lib/rate-limits/__tests__/inbox.test.ts — RPC wrapper happy/error/scope
- app/api/transactions/create-from-document/__tests__/route.test.ts —
  auth, validation, 404/409/200/500, inbox-link failure tolerated
- extensions/general/invoice-inbox/__tests__/retry-extraction.test.ts —
  auth, rate limit, 404, 409, 400 no-doc, success, extraction failure
- attach-document tests extend coverage to the new inbox-link side
  effect (both success and best-effort failure paths)
- inbound-webhook test mocks the rate-limit module so the queued-mock
  sequence in each existing test doesn't have to know about it

CLAUDE.md gains a row for lib/rate-limits/ so the new helper is
discoverable.

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

* feat(transactions): paperclip indicator and highlight-row param

Close the feedback loop after a user attaches a receipt to a transaction
from the inbox: the row in /transactions now shows a paperclip icon
when transaction.document_id is set, with a click handler that fetches
a signed download URL and opens the document in a new tab. Works for
both uncategorized and history views.

When the inbox sends a user to /transactions?highlight=<id>, the page
now scrolls that row into view and auto-opens the categorize panel if
the transaction is still uncategorized. Behind a double-rAF so the row
DOM exists when scrollIntoView fires.

QuickReviewDialog no longer prompts to upload underlag when the
transaction already has a doc attached (which it does after the inbox
match flow). Shows "Underlag bifogat — Visa" instead, opening the
existing doc in a new tab.

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

* fix(pr-444): address review feedback (Greptile + compliance bots)

Migration rules
- New migration 20260512092423: adds updated_at trigger on
  inbox_rate_counters (CLAUDE.md rule 2) and explicit USING (false) RLS
  policies for the four DML verbs to make the SECURITY DEFINER-only
  intent explicit (rule 1).
- New pg-real test inbox-rate-limit.pg.test.ts covering happy path,
  minute-cap rejection, day-cap rejection, per-company isolation, and
  the updated_at trigger firing. CLAUDE.md mandates *.pg.test.ts for
  every new RPC because mocks pass on broken PL/pgSQL.

Bugs
- Stale exchange rate on currency switch (Greptile P1) —
  userTouchedRateRef was scoped per session, not per currency. Switching
  EUR (with a hand-edited rate) → USD kept the EUR rate. Now tracks the
  last fetched currency in a ref and resets the touched flag on
  currency change while still honoring manual edits within a single
  currency.
- topSuppliersResult.error silently swallowed (Greptile P2) — failed
  queries used to render an empty chart matching the no-data state.
  Logged now.
- Currency from extracted_data not validated (GDPR Art.25(2), OWASP V4.5,
  Swedish compliance bot) — extracted PDF currency was inserted into
  transactions.currency without sanitisation. Allowlisted against the
  six supported ISO 4217 codes; coerce to SEK otherwise.
- Idempotency gap on create-from-document (OWASP V2.3) — two concurrent
  POSTs with the same inbox_item_id could each pass the
  matched_transaction_id IS NULL read and insert duplicate transactions.
  UPDATE now includes .is('matched_transaction_id', null) as an
  optimistic-lock release and returns 409 with an orphan-transaction
  rollback when the predicate doesn't match.
- FX residual on cash-method match path (Swedish compliance bot) —
  createSupplierInvoiceCashEntry has no exchange_rate_difference path,
  so a cross-currency match would silently leave a 1930 reconciliation
  gap. Added a guard that returns MATCH_SI_CASH_FX_UNSUPPORTED (400)
  before the JE is created. Users on cash method can switch to accrual
  or book the FX diff manually.

Design system
- gap-y-1.5 / gap-1.5 in KPIExpenseMixChart — replaced with gap-y-2 /
  gap-2 (CLAUDE.md design tokens; 2.5/1.5/5/hardcoded pixels are
  forbidden spacing values).

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

* chore(migrations): rename to match applied versions

The mcp__plugin_supabase_supabase__apply_migration tool stamps its own
timestamp when it applies a migration to the live project, so the
version recorded in supabase_migrations.schema_migrations differs from
my local generation-time filenames. Renaming the local files so a
production CD run sees the migrations as already-applied (matching
versions) instead of trying to re-apply them — which would fail for
the trigger/RLS migration (CREATE TRIGGER and CREATE POLICY don't
support IF NOT EXISTS).

Follows the pattern from d854efcd ("chore(migration): rename to match
applied version").

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

* fix(create-from-document): scope orphan rollback DELETE by company_id

Defence in depth on the inbox-link race rollback. newTx.id is a fresh
UUID from a company-scoped insert two statements above, so the existing
single-key DELETE is already safe, but adding .eq('company_id', companyId)
makes the cross-company invariant explicit on every write — addresses
the OWASP ASVS V2.3 finding from the compliance swarm on PR #444.

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

* feat(nav): mark Dokumentinkorg with Beta badge

Same signal we use for Löner and Anställda — the inbox flow (AI
extraction, supplier autolink, manual transaction creation) is in
end-to-end customer testing.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:12:17 +02:00
Mattsson 81e9dd224e Add/csv import options (#420)
* feat(import): add customer and supplier parsing functionality

- Implemented customer file parsing in `lib/import/customers/parser.ts` with support for Excel and CSV formats.
- Created types for detected customer columns and parsed customer rows in `lib/import/customers/types.ts`.
- Added tests for customer classification logic in `lib/import/shared/__tests__/classify.test.ts`.
- Developed classification functions for customers and suppliers in `lib/import/shared/classify.ts`.
- Introduced shared column utility functions in `lib/import/shared/column-utils.ts`.
- Implemented supplier file parsing in `lib/import/suppliers/parser.ts` with validation for various fields.
- Created types for detected supplier columns and parsed supplier rows in `lib/import/suppliers/types.ts`.
- Added tests for supplier column detection and parsing in `lib/import/suppliers/__tests__/column-detector.test.ts` and `lib/import/suppliers/__tests__/parser.test.ts`.

* fix(labels): update 'Svenskt företag' to 'Svenskt företag eller organisation' for clarity

* feat(import): refactor encoding handling for Swedish files and add tests for character preservation

* feat(recapt): implement clearRecaptIdentity function and integrate into logout flow

* feat(bookkeeping): implement copy functionality and next voucher sequence retrieval

* feat(import): enhance customer and supplier import functionality with normalization and event handling
2026-05-08 15:42:06 +02:00
Jakob Wennberg 97db09a3ff feat(invoices): allocate-on-save, makulera flow, manual invoice picker (#405)
* feat(invoices): allocate-on-save, makulera flow, manual invoice picker

Three coordinated invoice changes:

1. Allocate F-series number when the draft is created (Fortnox-style),
   not at send time. Users can download a numbered draft and send it
   manually. If number allocation fails, the invoice + items are rolled
   back so no orphaned rows remain. Adds INVOICE_CREATE_NUMBER_ASSIGN_FAILED.

2. DELETE /api/invoices/[id] now soft-cancels (status='cancelled') instead
   of hard-deleting. The F-series number is retained, keeping the sequence
   gap-free per ML 17 kap 24§ and BFNAR 2013:2 — no voucher_gap_explanations
   needed. Sent/paid invoices stay immutable (credit note required). Adds
   "Makulerade" tab to the invoice list; cancelled invoices are hidden from
   "Alla" by default. PDF draft banner stays visible on numbered drafts and
   only clears when the invoice is marked sent.

3. New InvoicePicker component lets users manually match an income
   transaction to an open invoice from the booking dialog ("Matcha med
   faktura..."), complementing the existing auto-match flow.

Also: new-invoice review dialog reads accounting_method from settings and
shows a cash-vs-accrual warning so users know when the verification posts.
seed-demo-account adds year-end closing + opening balance helpers so
multi-year demo data is balanced.

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

* fix(invoices): address review feedback on PR #405

Greptile P1 + Swedish compliance reviewer findings:

- app/api/invoices/route.ts — replace hard-delete rollback on number-
  allocation failure with a soft-cancel (status='cancelled'). If
  generate_invoice_number bumped the sequence before failing to write
  the number back, hard-deleting would leave a permanent gap in the
  F-series in violation of ML 17 kap 24§. Re-fetch invoice_number
  first so any partially-written value is logged for operator follow-up.
  Log loudly if the cancel itself fails so an orphan row doesn't go
  unnoticed.

- app/api/invoices/[id]/route.ts — close TOCTOU race on the cancel
  update. The .eq('status','draft') guard prevented data corruption
  but Supabase returned error: null with 0 affected rows on a
  concurrent flip, and the handler reported success. Add .select('id')
  and return new INVOICE_CANCEL_RACE (409) when no row updated.

- components/transactions/InvoicePicker.tsx — memoize createClient()
  so the supabase reference is stable across renders. Without this,
  including supabase in the useEffect dep array fires the open-invoices
  fetch on every render.

- app/(dashboard)/transactions/page.tsx + match-invoice/route.ts —
  read category from the match-invoice response instead of hardcoding
  'income_services' client-side. Server now echoes the category it
  actually booked; client falls back to 'income_services' if absent.

- lib/invoices/pdf-template.tsx — add MAKULERAD banner for cancelled
  invoices (red, distinct from the yellow draft banner). A cancelled
  invoice PDF previously rendered with no warning if it had a number,
  or with the draft banner if it didn't — both could be mistaken for a
  valid faktura. Cancelled takes precedence over draft so the legacy
  un-numbered-cancelled case is also covered.

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

* fix(invoices): guard cancelled status on send + rollback symmetry

Two follow-up fixes from the second-round Swedish compliance review on
PR #405:

- app/api/invoices/[id]/send/route.ts — reject sending a cancelled
  invoice. The existing flow had no status guard before
  .update({ status: 'sent' }), so a cancelled invoice could be silently
  re-activated to sent and a "MAKULERAD"-watermarked PDF could be
  delivered to the customer as if it were a live faktura. New
  INVOICE_SEND_CANCELLED (400) returned at the top of the handler.

- app/api/invoices/route.ts — add .eq('status', 'draft') to the
  rollback-cancel update so the rollback is symmetric with the DELETE
  handler's only-drafts-may-be-cancelled rule. At the create flow's
  current shape the row can't realistically be anything other than
  draft, but the symmetry prevents a future caller adding a status flip
  between insert and number-allocation from accidentally cancelling a
  posted invoice.

mark-sent (rejects non-draft), mark-paid (only sent/overdue), and
convert (explicitly rejects cancelled proformas) already guard
correctly — no changes needed there.

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

* fix(invoices): InvoicePicker filters settled invoices; drop dead error code

Two cleanups from the third-round Swedish compliance review on PR #405:

- components/transactions/InvoicePicker.tsx — add .gt('remaining_amount', 0)
  defensively. The picker filtered by status IN (sent, overdue,
  partially_paid), but a stale 'sent' or 'overdue' row with
  remaining_amount=0 (data inconsistency) would otherwise be selectable
  here and could be matched a second time, double-booking the income —
  a direct BFL 5 kap accuracy violation.

- lib/errors/structured-errors.ts — remove INVOICE_DELETE_NUMBERED.
  The numbered-draft refusal was replaced by the soft-cancel path
  earlier in this PR; the entry has no remaining callers.

Verified-safe and not changed:
- Cancel-without-storno concern: createInvoiceJournalEntry only fires
  inside mark-sent (after the draft→sent guard) or send (after the
  cancelled-status reject). Drafts never have posted verifications, so
  cancelling a draft cannot leave an orphaned bokföringspost.
- Hardcoded category: 'income_services' in match-invoice is a
  pre-existing classification concern that warrants a larger refactor
  (derive from invoice's revenue accounts) rather than a one-line patch.

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

* fix(invoices): InvoicePicker excludes proforma invoices

Add .eq('document_type', 'invoice') to the open-invoice query. A
proforma is not a faktura per ML 17 kap 24§ — no VAT obligation, no
binding commercial document — and must never be matched against a
bank receipt. Without this guard a sent proforma could be selected
in the picker, triggering a payment booking and VAT-rate journal
entry that violates BFL 5 kap accuracy rules.

Other findings from the third-round Swedish compliance review were
verified-safe and not changed:

- Cancelled-invoice PDF download path: the MAKULERAD watermark added
  earlier in this PR is the safeguard. Blocking the download endpoint
  outright would prevent legitimate audit access; the visible banner
  prevents the doc being mistaken for a valid faktura.
- Cancel-without-storno: createInvoiceJournalEntry only fires inside
  mark-sent / send / pending-operations, all behind status guards.
  Drafts never carry a posted verifikation, so cancel can't orphan one.
- Allocate-on-save for proforma uses F-series: not true. The
  generate_invoice_number RPC (migration 20260427150100) routes
  document_type='proforma' to a separate 'PF-' prefix sequence; the
  F-series is untouched.
- closeYearForSeed 2099 → 2091 transfer: real demo-data correctness
  issue but a seed-script polish item — separate PR.

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

* fix(match-invoice): server-side document_type='invoice' guard

The InvoicePicker filter excluding proformas (added in the previous
commit) is client-only. A direct API call to /api/transactions/[id]/
match-invoice with a proforma id would otherwise still book a payment
journal entry against a document that has no VAT obligation per
ML 17 kap 24§. Add a defense-in-depth check after the invoice fetch.

New error code MATCH_INVOICE_NOT_INVOICE_TYPE (400). Test added.

Other findings from the latest compliance review were verified-safe and
not changed:

- Cancelled-invoice PDF download path: /api/invoices/[id]/pdf always
  re-renders through InvoicePDF, so the MAKULERAD banner is always
  present. The bot's "cached pre-cancellation PDF" scenario does not
  apply to this codebase.
- Proforma F-series allocation: the generate_invoice_number RPC routes
  document_type='proforma' to a separate 'PF-' prefix; the F-series is
  not polluted.
- Soft-cancel rollback gap when number not written: the RPC is a
  single-transaction PL/pgSQL function — sequence bump (UPDATE
  company_settings) and row write (UPDATE invoices) commit or roll
  back together. The "sequence advanced but row null" scenario the
  bot describes is impossible by construction; a thrown exception in
  the row-write step rolls back the bump.
- closeYearForSeed obeskattade reserver: seed-script demo accuracy,
  separate PR.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:49:56 +02:00
Mattsson ce3af4d17e Fix/multiple domain issue (#401)
* feat: enhance invoice management and immutability checks

- Update InvoiceDetailPage to prevent deletion of drafts with assigned invoice numbers, providing user feedback.
- Modify the invoice conversion API to ensure invoice number allocation occurs only after successful item insertion and proforma cancellation.
- Implement structured error responses for invoice deletion, ensuring only drafts without assigned numbers can be deleted.
- Add comprehensive tests for invoice deletion and conversion scenarios, including edge cases for draft invoices.
- Introduce immutability checks in the document management system to prevent unauthorized changes to linked documents.
- Create SQL migration to enforce document metadata immutability, ensuring compliance with accounting regulations.

* fix(invoice): prevent invoice number consumption on PDF render failure

* feat: add document journal entry immutability enforcement for delete_last_voucher RPC

* fix(invoice): implement rollback for orphan invoices on proforma cancel failure

* fix(document): extend immutability trigger to protect journal entry links
2026-05-06 14:08:38 +02:00
Mattsson 5725c25bf1 Logs/improved logging (#398)
* feat(mcp): add create_transactions tool with /pending approval gate

New MCP tool gnubok_create_transactions stages 1–10 transactions per call
as pending_operations of type create_transaction (risk: medium). Each item
becomes its own card on /pending; on confirm, the executor inserts the row
into transactions with import_source='mcp' so MCP-staged ingestion is
distinguishable from PSD2 sync. Designed for skill workflows that pull
external data (e.g., Airtable) and want the user to gate the writes.

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

* fix(bas): strip concatenated group headers from corrupted account names

A chart-data import bug had glued the next group's header onto the last
account in each preceding group across all eight bas-data class files
(e.g. account 2670 read "Utgående moms på försäljning inom EU, OSS 27
PERSONALENS SKATTER, AVGIFTER OCH LÖNEAVDRAG"). The corrupted names
surface in transaction dropdowns, ledgers, SIE exports and årsredovisning,
and risk VAT miscategorization on the OSS (2670) and blandad-verksamhet
(6999) accounts specifically.

- Cleans 69 account_name and 64 description fields across class-1..8 files
- Adds a regression test asserting no name contains a concatenated header
- Ships an idempotent safety-net migration that updates already-seeded
  chart_of_accounts rows, gated on the corrupted string so user
  customizations are preserved

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

* feat(errors): add structured error codes and handling for various operations

- Introduced a new structured error registry in `structured-errors.ts` to standardize error handling across the application.
- Added Swedish and English messages for various error scenarios, including validation, authorization, and bookkeeping errors.
- Implemented a client-side error toast in `use-error-toast.ts` to display user-friendly error messages with remediation hints.
- Created a wrapper for recording operation outcomes in `record-operation.ts`, enhancing audit capabilities for operations.
- Developed a provider call wrapper in `with-provider-call.ts` to handle external HTTP calls with structured logging and error mapping.
- Added a new SQL migration to extend the processing history with new event types and aggregate types for better operational telemetry.

* Refactor supplier API routes to use context-based logging and error handling

- Replaced direct Supabase client usage in GET and POST routes with context-based approach using `withRouteContext`.
- Enhanced error handling to provide structured error responses for supplier creation and listing.
- Updated logging to include request IDs for better traceability.
- Introduced new error codes for supplier-related operations.
- Refactored tax deadlines cron job to utilize context and improved error handling.
- Updated ESLint configuration to enforce logging practices across API and lib directories.
- Enhanced arcim migration extension with structured error handling and logging.
- Added classification for provider errors to improve user-facing error messages.
- Introduced request ID in extension context for better log correlation.

* fix(route-context): update DynamicParams type for improved type safety in route handlers

* feat(transactions): add 'create_transaction' operation to PendingOperationType

* fix(route): ensure companyId is non-nullable in loadAndDeriveAbsence function

* fix(route-context): ensure companyId is always non-null by short-circuiting with COMPANY_CONTEXT_MISSING

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:12:02 +02:00