Commit Graph
345 Commits
Author SHA1 Message Date
Jakob WennbergandClaude Opus 4.7 cd96e5ec26 feat(api): v1 invoice :mark-paid + :credit action verbs (Phase 2 PR-B-2b combined) (#455)
* feat(api): v1 invoice :mark-paid + :credit action verbs (Phase 2 PR-B-2b combined)

Bigger PR per the user's request. Lands the remaining two journal-entry-
centric action verbs together — they share the same lifecycle pattern
established in :mark-sent (idempotent, dry-runnable, scope-gated,
warnings on partial-state failures).

POST /api/v1/companies/:companyId/invoices/:id/mark-paid
- Books a payment against a sent / overdue invoice. Updates status to
  paid (or partially_paid when remaining_amount > 0). Three booking paths:
  - Faktureringsmetoden (accrual default): Debit 1930 / Credit 1510 via
    createInvoicePaymentJournalEntry — settles AR.
  - Kontantmetoden (cash): Debit 1930 / Credit revenue + Credit VAT via
    createInvoiceCashEntry — revenue recognition happens HERE under cash.
  - Custom lines (partial payment): caller-supplied balanced journal lines
    via createJournalEntry directly. Validated for balance (sum debits ==
    sum credits, both > 0) → 400 INVOICE_PAID_LINES_UNBALANCED otherwise.
- Optional body: { payment_date?, exchange_rate_difference?, lines? }
- Race-condition guard: status update matches .in(['sent','overdue',
  'partially_paid']) so a concurrent payment returns 409 INVOICE_PAID_RACE.
- Emits invoice.paid (new event type, added to lib/events/types.ts with
  paymentAmount + paymentDate in the payload).

POST /api/v1/companies/:companyId/invoices/:id/credit
- Issues a kreditfaktura against a sent / paid / overdue invoice
  (ML 17 kap 22–23§). Creates a NEW invoice row with:
  - invoice_number = "KR-<original>"
  - credited_invoice_id = original id
  - status = 'sent'
  - All amounts negated (subtotal, vat_amount, total, items quantities/totals)
- Items mirror the original with negated values; inserted in a separate
  step with company-scoped rollback DELETE on failure.
- Flips original invoice to status='credited'. Warns ORIGINAL_NOT_FLIPPED
  if the flip fails (the credit note still exists; operator reconciles).
- Posts reverse journal entry via createCreditNoteJournalEntry (accrual
  only; cash basis defers to refund time).
- Emits credit_note.created (existing event in the bus).

Both endpoints:
- Use the established wrapper + Idempotency-Key + dry-run + warnings
  pattern from :mark-sent.
- Validate document_type (no delivery_notes), credited_invoice_id (no
  recursive credits), and status before any mutation.
- Use explicit column projections (no SELECT *).
- Sanitize pg_message from client responses (kept in logs).
- Emit error-level logs on partial-state failures + surface warnings to
  the caller via meta.warnings.

Event types union (lib/events/types.ts) gains invoice.paid; credit
uses the existing credit_note.created event.

URL convention: plain /verb subpaths (e.g. /invoices/:id/mark-paid),
consistent with :mark-sent. Stripe/QuickBooks pattern, not the
AIP-style :verb that Next.js routing fights.

17 new tests covering happy paths (accrual + cash for mark-paid),
custom-lines balance validation, dry-run preview, document-shape
guards, scope, idempotency, race conditions, and credit-of-credit /
delivery-note rejection.

3194/3194 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 #455 review + include password-recovery fixes

PR #455 review fixes:

- Greptile P1 (CLAUDE.md architecture rule): API routes that emit events
  via eventBus must call ensureInitialized() at module level to wire
  extension event handlers. Neither :mark-paid (invoice.paid) nor :credit
  (credit_note.created) had it — nor did the already-merged :mark-sent,
  POST /invoices, POST /customers, etc. Fixed once at the wrapper layer:
  ensureInitialized() now runs at module import of lib/api/v1/with-api-v1.ts,
  so EVERY v1 route gets the init at import time. Single source of truth
  prevents future routes from forgetting (idempotent guard makes the
  repeated call safe). Cleaner than per-route copy of the call.

- Swarm PI1.3 (low): 0.005 epsilon in mark-paid was undocumented. Added
  a comment explaining: after rounding to 2 decimals, newRemaining is in
  steps of 0.01; values ≤ half-an-öre only arise from float artefacts.

Pushing back (recurring triage, consistent with prior PRs):
- V8.2.1 + CC6.3 × 4 "ctx.companyId vs params.companyId mismatch" —
  impossible by construction. The wrapper sets ctx.companyId FROM the URL
  params after the membership check. They are guaranteed equal.
- V2.3 + A.8.15 + A.8.28 atomicity / floating-point / partial-failure
  alerts — same architectural / cross-surface deferred work as prior PRs;
  matches internal /api/invoices pattern precisely.
- V4.5 account_number allowlist — engine validates it.
- V2.4 idempotency TOCTOU — wrapper handles via DB unique constraint.
- Art.5(1)(f) PII in logs, A.8.11 dry-run preview scope, A.8.15 partial-
  failure naming, test scope coverage — all recurring triage.

Password-recovery flow fixes (included per request — pre-existing
working-tree changes the user authored):

- app/(auth)/auth/callback/route.ts: when the callback exchanges a
  recovery token (type='recovery' or next='/reset-password'), redirect
  directly to /reset-password instead of running onboarding/MFA/
  dashboard checks. Previously users clicking the password-reset email
  got bounced through onboarding.
- lib/supabase/middleware.ts: /reset-password no longer bounces
  authenticated users to / (the recovery flow lands here with a fresh
  session by design — the user is *supposed* to call updateUser({
  password }) on this page).
- app/(auth)/login/page.tsx: shows an error banner when ?error=auth_error
  is set (expired/used recovery link), with a button to request a new
  one. Wrapped the page in <Suspense> because useSearchParams() now
  forces dynamic rendering (Next.js 16 static-prerender bail-out
  otherwise).
- app/(auth)/auth/callback/__tests__/route.test.ts: new test file
  covering the recovery callback path.

3197/3197 vitest pass (3194 prior + 3 from the new auth-callback tests).
Build clean.

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

* fix(api): mark-paid uses remaining_amount as default payment, not total

Real correctness fix from Swedish-compliance review on PR #455. When no
customLines is supplied, mark-paid previously defaulted paymentAmount to
typed.total. Combined with the race-condition guard that allows the
status UPDATE to flip a partially_paid invoice to paid, this could
over-credit AR in a race scenario:

1. Invoice in 'sent' status, total=12500, remaining=12500.
2. Concurrent partial payment lands first → status='partially_paid',
   remaining=7500.
3. The full-payment request's pre-flight saw 'sent' and passed; its
   UPDATE matches partially_paid (race guard allows it). With the old
   logic the journal entry was for total=12500 against an AR balance
   of only 7500 — a 5000 over-credit.

Using remaining_amount as the default eliminates this. Same end state
in the common case (no prior partial); correct booking in the race.

3197/3197 vitest pass.

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 23:52:18 +02:00
Jakob WennbergandClaude Opus 4.7 e4186523f9 feat(api): v1 invoice :mark-sent action verb (Phase 2 PR-B-2b-1) (#454)
* feat(api): v1 invoice :mark-sent action verb (Phase 2 PR-B-2b-1)

First invoice action verb. Transitions a DRAFT invoice to 'sent' status —
intended for invoices delivered outside gnubok (Peppol, postal, custom
SMTP). The full :send pipeline (PDF + email) builds on top of this in
PR-B-2b-3.

URL convention: plain /verb subpath (e.g. /invoices/:id/mark-sent), not
the AIP-style :verb suffix the plan originally proposed. Next.js routes
don't support `:` in folder names, and the Stripe/QuickBooks idiom is
plain subpaths anyway. The agent-facing docs can still describe the
action however we want.

What happens on commit:
1. F-series invoice_number allocated atomically via the
   generate_invoice_number RPC (per the PR-B-2a design — drafts have
   invoice_number=null until this transition, preserving the unbroken
   löpnummer series required by ML 17 kap 24§ p.2).
2. Status flips draft → sent.
3. For accrual + real invoices, posts the invoice journal entry via
   createInvoiceJournalEntry (Debit AR 1510 / Credit revenue 3xxx /
   Credit output VAT 26xx). Cash basis skips this; booking happens at
   payment time.
4. Writes journal_entry_id back onto the invoice row.
5. Emits invoice.sent.

Race-condition guard: the status update matches .eq('status', 'draft'),
so a concurrent transition between pre-flight and update returns 409
INVOICE_UPDATE_NOT_DRAFT.

Dry-run: returns a preview of the post-send invoice state including a
would_create_journal_entry flag and the resolved accounting_method.
invoice_number can't be predicted exactly (atomic sequence allocation)
so the preview shows a marker rather than a fake number.

PDF archival is deliberately NOT in this PR. The internal route does
it, but PDF rendering + document upload is a meaningful surface area
that belongs with :send (PR-B-2b-3) where email + PDF land together.

Test infrastructure: the makeFlexibleSupabase mock now supports
per-table result QUEUES (array form returns results in order across
multiple calls; single value returns same result every time). Required
to mock the pre-flight read (status=draft) and post-update read
(status=sent) on the same `invoices` table inside one request.

9 new tests covering happy path, idempotency, scope, draft-only guard,
delivery-note rejection, 404, UUID validation, dry-run preview shape,
and cash-method skip. 3174/3174 vitest pass; build clean.

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

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

Real bugs / contract violations:

- Greptile P1 (guard order): the delivery_note guard ran AFTER the
  status check, so a sent delivery note returned 409 instead of the
  documented 400. Reordered: document-shape guards (delivery_note +
  credit_note + missing moms_ruta) now run before the status check.
- Greptile P1 (journal_entry_id write-back): Supabase returns
  { data, error } and never rejects on DB errors, so a write-back
  failure produced no log and left the invoice with a real journal
  entry but no pointer. Now destructured + escalated to error log AND
  surfaced as a warning in the response.
- Swedish: credit notes (credited_invoice_id !== null) were not
  rejected — they would have been posted via createInvoiceJournalEntry
  with the wrong sign (Debit AR / Credit revenue instead of the
  inverse). Now explicitly rejected; credit-note path goes through
  POST /:id/credit (PR-B-2b-4).
- Swedish: moms_ruta now validated in the pre-flight. A null value
  would silently default to 25% domestic in the journal-entry
  generator — wrong for reverse-charge / EU-service / zero-rated
  invoices. Real ML 17 kap 24§ concern.

Partial-state visibility (Swedish + Swarm V2.3 + A.8.15 + PI1.3):

The response now carries an optional `warnings: [{ code, message }]`
field when the status flip succeeded but a follow-up step failed
(journal entry creation, event emission, or journal_entry_id write-
back). Three warning codes:
  - JOURNAL_ENTRY_NOT_POSTED — verifikation missing; BFL 5 kap
    reconciliation required
  - JOURNAL_ENTRY_ID_WRITEBACK_FAILED — entry exists but invoice row
    has no pointer
  - EVENT_EMIT_FAILED — webhook subscribers may miss this transition
All three escalate to error-level logs. The architectural fix
(transactional Postgres RPC that bundles allocation + status flip
+ journal entry) is tracked as cross-surface compliance work; the
warnings field is the agent-facing signal until that lands.

The F-series race window (number allocated before status flip; a
concurrent transition can leave a consumed-but-orphaned number, ML
17 kap 24§ p.2 gap) is now explicitly documented in the route
docstring rather than hidden in implementation. Same residual issue
exists in the internal route; fix needs the transactional RPC.

Pushing back (consistent with prior triage):
- V8.2.1 explicit ownership check (wrapper handles — false positive)
- Cross-tenant IDOR test (duplicates wrapper test coverage)
- Pseudonymise IDs in logs (operational value > theoretical risk)
- Structured audit event sink (current ctx.log.info IS structured)
- Test fixture A.8.33 (NODE_ENV guard in place; Acme AB is canonical
  synthetic placeholder)
- Projection column narrowing (fields ARE used in the flow)
- company_settings hard-fail on miss (accrual default is normal)

3 new tests covering credit-note rejection, missing moms_ruta, and
the journal-entry-failed warnings path. Plus the delivery-note test
now asserts the guard ordering works for sent delivery notes too.
3177/3177 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 23:19:21 +02:00
Jakob WennbergandClaude Opus 4.7 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 WennbergandClaude Opus 4.7 d29b87fb80 feat(api): v1 customer writes (Phase 2 PR-B-1) (#452)
* feat(api): v1 customer writes (Phase 2 PR-B-1)

First slice of Phase 2 PR-B from the agent-native v1 plan. Customer writes
are the simplest write surface — no journal entries, no PDF, no email —
which makes them the right place to validate the dry-run + idempotency
pipeline before applying it to invoice flows in PR-B-2.

New endpoints:
- POST   /api/v1/companies/:companyId/customers       (idempotent, dry-runnable)
- PATCH  /api/v1/companies/:companyId/customers/:id   (idempotent, dry-runnable)
- DELETE /api/v1/companies/:companyId/customers/:id   (soft-delete via
        archived_at; idempotent re-archiving; dry-runnable; 204)

All three require customers:write scope (added to V1_ENDPOINT_SCOPES). All
three require Idempotency-Key (mandatory — wrapper option
requireIdempotencyKey: true). All three accept ?dry_run=true or
X-Dry-Run: true and return a 200 OK preview with X-Dry-Run header set.

New shared infrastructure:
- lib/api/v1/dry-run.ts — dryRunPreview() (validation-only, no staging) and
  dryRunStaged() (financial writes; populated in later phases). Defines the
  preview response shape that POST/PATCH/DELETE share. Future financial
  writes (invoices, journal entries) will reuse the staged variant.

Pre-existing PR-A bugs fixed:
- CustomerType enum in customers/route.ts had wrong values ('business',
  'eu_individual', 'non_eu'). Canonical enum is ['individual',
  'swedish_business', 'eu_business', 'non_eu_business']. Fixed.
- INDIVIDUAL_TYPES masking referenced non-existent 'eu_individual'.
  Only 'individual' refers to a natural person (Swedish sole trader where
  org_number = personnummer).

Wrapper bug fix:
- The idempotency body-hashing flow was consuming the original request's
  body before passing it to the handler. In Node's vitest environment the
  cloned request's body became empty, so the handler's request.json()
  returned {}. Fix: read body from a clone for hashing, leave the
  original intact for the handler.

VIES re-validation on PATCH preserves existing best-effort behaviour.
Customer.created event emission on POST so future webhook delivery
(Phase 2 PR-C) can subscribe.

23 new tests covering happy path, dry-run preview, idempotency-key
enforcement, scope checks, UUID validation, duplicate-org conflict,
soft-delete semantics. 3150/3150 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 #452 review (Greptile + swarm + Swedish compliance)

Real bugs (all reviewers agreed):
- POST registerEndpoint example used the old customer_type 'business'
  enum value that this PR was already fixing. Now uses 'swedish_business'
  consistently between code, schema, and docs.
- DELETE docstring claimed "Refuses to delete if open invoices remain"
  but the handler unconditionally archived. Swedish compliance flagged
  this as a real ML 17 kap 24§ concern (archived customer + open
  invoice can block kreditfaktura issuance). Added the pre-flight check:
  DELETE now returns 409 CUSTOMER_HAS_INVOICES with the open count when
  any open invoice (sent / partially_paid / overdue) references the
  customer. Docstring updated to match.
- PATCH advertised archived_at: null for un-archive but did not actually
  apply it (field missing from updateData iteration). New
  V1PatchCustomerSchema extends UpdateCustomerSchema with archived_at
  restricted to literal null — agents can un-archive but cannot fake
  archive timestamps. The PATCH allowlist now includes archived_at.

Defensive cleanups:
- POST: VIES validation now resolves BEFORE the insert so
  vat_number_validated is set atomically in the primary write. Eliminates
  the stale-response window where the response could show
  vat_number_validated=false even though the secondary update succeeded.
- PATCH: same pattern — VIES re-validation folded into the primary
  update payload. Single round-trip; response always reflects committed
  DB state.
- CUSTOMER_RESPONSE_COLUMNS dropped vat_number_validated_at (internal
  timestamp; not declared in the CustomerCreated or CustomerDetail Zod
  schemas; no documented consumer).

Pushing back on:
- V8.2.1 cross-tenant membership check — false positive. The wrapper
  performs the company_members check before invoking the handler
  (lib/api/v1/with-api-v1.ts ~232). The swarm read handlers in isolation.
- A.8.11 PATCH response masking for individual customer_types —
  deliberate detail-endpoint carve-out per PR-A. List masks, detail
  doesn't; that's the design.
- CC6.3 separate customers:delete scope — every accounting API
  (Stripe, QuickBooks, Fortnox) conflates write + archive. Splitting
  violates principle of least surprise.
- V4.5 schema allowlist enforcement — Zod already strips unknown keys;
  defense-in-depth at the DB-write layer is redundant.
- A.5.34 eu_individual masking documentation — the value never existed
  in canonical CustomerTypeSchema; PR-A's enum was a hallucination this
  PR corrects. Nothing to document beyond the code comment that's now
  in place.
- Swedish: country default 'Sweden' wrong for non-Swedish customer types —
  breaking schema change; defer.
- Swedish: VAT-format regex pre-check before VIES — internal
  /api/customers route doesn't either; consistency over micro-validation.
- Swedish: flag existing reverse-charge invoices when VIES turns
  vat_number_validated=false — substantial cross-resource workflow;
  defer to PR-B-2 or a dedicated compliance-tooling PR.
- CC7.2 customer.updated / customer.archived event emission — adding new
  event types touches lib/events/types.ts AND the event-log-handler
  allowlist; defer to PR-C where webhooks will be the consumer.

3 new tests cover: archive-blocked-by-open-invoices, PATCH archived_at
un-archive, PATCH archived_at rejects non-null. 3153/3153 vitest pass;
build clean.

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

* fix(api): second-pass review on PR #452 — defense-in-depth tweaks

Two small adjustments after the second compliance-swarm sweep (13 →
expected 11 findings, 0 blocking after this commit):

- GDPR Art.5(1)(c) defense-in-depth: re-add 'eu_individual' to the
  INDIVIDUAL_TYPES masking set in the customer LIST handler. The value
  is not in the canonical CustomerTypeSchema (so new customers can never
  have it), but the `customer_type` DB column carries no CHECK constraint,
  so legacy rows from earlier schema iterations could in principle hold
  it. Masking is free when the value never appears and protective if it
  ever does. Adding 'eu_individual' as a first-class customer_type for
  EU natural persons remains a separate product decision the Swedish
  compliance review surfaced.

- ISO 27001:2022 A.8.33: test bootstrap now asserts NODE_ENV === 'test'.
  Supabase clients are fully mocked, but if a future test refactor
  accidentally bypassed the mock, this guard fails the run rather than
  letting fixtures reach production.

Stale comments from prior sweep — no action needed, fixes already in
commit 5bb63489:
- Greptile P1 "DELETE doesn't check open invoices" — handler now does
  (lines ~430-440 of [id]/route.ts); the inline comment is pinned to
  the original file lines and hasn't auto-resolved.
- Greptile P1 "PATCH archived_at silently ignored" — V1PatchCustomerSchema
  now accepts archived_at: z.null().optional() and the field is in the
  iteration list.
- Greptile P1 "example uses old enum" — updated to 'swedish_business'.

False positive called out:
- OWASP V2.4 "dry-run DELETE skips the open-invoice pre-check" — the
  pre-flight runs BEFORE the dry-run branch ([id]/route.ts ~430-445),
  so dry-run DELETE on a customer with open invoices DOES return 409.

Pushing back (consistent with first-pass triage):
- V8.2.1 × 3 cross-tenant check — wrapper does it (with-api-v1.ts ~232);
  false positive.
- V2.3 / CC6.3 archive scope split — every accounting API conflates
  write + archive.
- V4.5 customer_type cross-field invariants — schema-level cross-field
  validation; defer.
- V16 transactional outbox — architectural; defer to PR-C webhooks.
- Art.25 + A.5.12 + A.5.34 PATCH/dry-run preview masking — single-record
  detail context, deliberate carve-out per PR-A.
- Swedish 'disputed' status — not in canonical InvoiceStatus enum.
- Swedish 3-state VIES validation, personnummer-format check, mandatory
  country for non-SE types — all schema-level / cross-field; defer.

3153/3153 vitest pass; build clean.

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

* fix(api): don't echo org_number in 409 conflict response (GDPR Art.5(1)(c))

For customer_type='individual', org_number IS the Swedish personnummer.
The 409 CUSTOMER_DUPLICATE_ORG_NUMBER error detail previously included
the submitted value, transmitting it through:
  - The HTTP response body
  - Server / observability logs
  - Any HTTP intermediary recording bodies

The caller already knows what they submitted; the error code + a
{ field: 'org_number' } hint is enough. Drops the value from the detail
in both POST /customers and PATCH /customers/:id.

3153/3153 tests still pass.

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:31:46 +02:00
Jakob WennbergandClaude Opus 4.7 32ad6da28c feat(api): v1 invoice + customer reads (Phase 2 PR-A) (#451)
* feat(api): v1 invoice + customer read endpoints (Phase 2 PR-A)

First slice of the Phase 2 invoices vertical. Read-only endpoints landing
in this PR; writes + webhooks land in PR-B and PR-C. After all three a
developer can ship an end-to-end invoicing integration.

New endpoints (all wrapped, scoped, cursor-paginated):
- GET /api/v1/companies/:companyId/invoices       — list, filters: status, customer_id, document_type, currency. Cursor on (invoice_date DESC, id DESC). Customer name embedded inline; ?expand=customer for full record, ?expand=items for line items.
- GET /api/v1/companies/:companyId/invoices/:id   — detail with embedded customer. ?expand=items,payments.
- GET /api/v1/companies/:companyId/customers      — list, filters: customer_type, search (name/org_number prefix), include_archived. Cursor on (created_at ASC, id ASC).
- GET /api/v1/companies/:companyId/customers/:id  — detail. ?expand=invoices embeds open invoices in a single round-trip.

Shared infra:
- lib/api/v1/expand.ts — parseExpand() validates ?expand=a,b,c against a per-endpoint allowlist; unknown keys yield VALIDATION_ERROR with the full invalid list and the allowlist (agent-friendly).
- All four routes register with the Zod schema registry so they show up in /api/v1/openapi.json with x-action-risk and use-when / do-not-use-for metadata.
- Compound keyset filter on both list endpoints (per Greptile review on PR #450) — no skipped or duplicated rows on page boundaries.

Tests:
- lib/api/v1/__tests__/expand.test.ts (8 tests)
- app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts (10 tests)
- app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts (8 tests)

Full repo suite green (3127/3127), build clean, lint clean on v1 paths.

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

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

- Greptile P1 (customers search) + OWASP V1.2.5: customer search term now
  escapes both PostgREST .or() delimiters (,()) AND SQL LIKE wildcards
  (% _ \). '100%' searches for the literal string instead of any
  customer containing '100'.
- OWASP V8.2.1 + V16.1: detail endpoints now UUID-validate the :id path
  param before touching the database, and no longer echo the raw id in
  the NOT_FOUND response details. Adds a structured warn log on 404 with
  the queried (id, companyId) for audit purposes.
- V4.5 + Art.25(1) + A.8.3 / A.8.11 + CC6.3 + PI1.3 (~12 findings): every
  select('*') replaced with explicit column lists per the documented Zod
  schemas. Includes joined sub-queries — customer:customers(...),
  items:invoice_items(...), payments:invoice_payments(...). Future
  schema migrations adding sensitive columns must now update these
  projections before the field becomes visible on the public API.
- A.8.5: hardcoded 'Bearer gnubok_sk_x' in test fixtures replaced with
  'Bearer test-fixture-not-a-real-key' to avoid false-positive secret
  scanner alerts. Fixture UUIDs upgraded to valid v4 format (Zod 4's
  .uuid() enforces version+variant digits).
- Art.5(1)(f): customer-invoices expansion soft-degrade now logs only
  the error code + message rather than the full Supabase error object.

Pushing back on:
- Art.5(1)(b) org_number in customer list — Bolagsverket-public data
  (same triage as PR #450; required by integration use case)
- Art.25(2) customer_name always-joined — denormalising via trigger is
  a real schema migration for a marginal data-flow gain
- A.8.15 _partial flag on soft-degrade — ?expand is documented as a hint

50/50 v1 tests; 3131/3131 full suite; build clean.

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

* fix(api): second-pass review on PR #451 — partial_expansions + fake fixtures

Address the residual compliance-swarm findings after the first fix round:

- CC6.1 (medium): the customer-detail handler now sets
  meta.partial_expansions=['invoices'] when the ?expand=invoices subquery
  fails, signalling the degraded response to the caller without escalating
  to error-level logs (alert fatigue). The primary resource still returns
  with an empty invoices array. New ResponseOptions.partialExpansions
  threaded through buildMeta(). 1 new test for the failure path, plus
  a happy-path assertion that the flag is absent.
- A.8.33 (low): SAMPLE_CUSTOMER fixture's org_number and vat_number
  replaced with 'TEST-0000-0001' / 'SETEST00000001' — cannot be confused
  with real Bolagsverket entries or pass external VIES validation.

Pushing back on:
- CC6.3 (medium) — separate scope for ?expand=items on invoices: every
  accounting API I know (Stripe, QuickBooks, Fortnox) treats line items
  as part of the invoice resource. Splitting would violate principle of
  least surprise for integrators; the plan deliberately treats
  invoices:read as covering the full invoice including items.

3132/3132 vitest pass; build clean.

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

* fix(api): third-pass review on PR #451 — PII minimisation refinements

Third compliance-swarm sweep (6 → 0 highs, 4 medium, 2 low). Addressed:

- Art.5(1)(c) personnummer leakage: customer LIST response now masks
  org_number AND vat_number for customer_type IN ('individual',
  'eu_individual') — for sole traders (enskild firma) org_number IS the
  personnummer. Business customers' Bolagsverket-public org_numbers stay
  visible. Detail endpoint (deliberate single-record fetch) unchanged.
- Art.5(1)(c) over-broad invoice-list expansion: ?expand=customer on
  the invoice LIST endpoint now uses a new CUSTOMER_LIST_CONTEXT_COLUMNS
  projection (id, name, customer_type, email, country, archived_at) —
  full address/phone/notes/vat_number stay on the customer DETAIL
  endpoint. Drops PII transmitted in bulk-list contexts by ~60%.
- A.8.15 permission-error differentiation: customer-detail soft-degrade
  for ?expand=invoices now bumps Postgres error class 42 (insufficient
  privilege, RLS denial) to error-level log so Sentry alerts on
  misconfigurations. Transient errors stay at warn.
- PI1.1 ISO-4217 currency: invoice list ?currency now requires
  /^[A-Z]{3}$/ instead of accepting any 3-8 char string. Two new tests.

Pushing back on:
- Art.5(1)(f) UUID logging on 404 — UUIDs have 122 bits of entropy; you
  cannot enumerate the space, so the "log scraping = enumeration" framing
  doesn't hold. Operational audit value > theoretical risk.
- Art.25(1) notes-by-default in customer DETAIL — kept inline. Detail
  is a deliberate single-record fetch; the dashboard shows notes inline;
  agents calling /customers/{id} reasonably expect them. Notes are
  already excluded from the LIST endpoint AND from the invoice-list
  ?expand=customer projection (above).

3135/3135 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 21:49:22 +02:00
Jakob WennbergandClaude Opus 4.7 db592d922d feat(api): v1 REST API foundation — auth wrapper, scopes, registry, smoke endpoints (#450)
* feat(api): v1 REST API foundation — auth wrapper, scopes, registry, smoke endpoints

Lay the substrate for the public REST API at /api/v1/*: Bearer-auth wrapper
that reuses the existing api_keys + idempotency machinery, an extended scope
catalogue (companies, events, webhooks, operations, documents, compliance),
v1 response envelopes (data + meta with request_id, api_version, audit block,
cursor pagination), an error envelope with recovery_hint / docs_url /
valid_alternatives derived from the existing structured-error registry, and
a Zod schema registry that generates the OpenAPI 3.1 spec with x-action-risk
/ x-idempotent / x-reversible / x-dry-run-supported extensions.

Ships discovery routes (/llms.txt, /.well-known/skills/index.json) and three
smoke endpoints (GET /api/v1/health, /api/v1/companies, /api/v1/openapi.json)
so the wrapper is exercised end-to-end. Includes the api_keys.mode (test|live)
migration and 41 unit tests covering auth, scope, company-membership,
idempotency replay, dry-run, pagination, response shape, and scope resolution.

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

* fix(api): harden v1 foundation — cursor validation, security headers, forensic logs

Address compliance-swarm findings on PR #450:

- OWASP V2.3: decodeDefaultCursor now validates the cursor's ts as ISO 8601
  and id as UUID. A crafted cursor previously could inject untyped strings
  into a query's .gt(field, value); PostgREST would have rejected them, but
  validating here keeps the failure mode predictable (stale cursor → reset)
  rather than 400-ing.
- OWASP V3.4: public discovery routes (llms.txt, .well-known/skills,
  openapi.json) now stamp X-Content-Type-Options: nosniff, Referrer-Policy,
  X-Frame-Options: DENY. New lib/api/v1/security-headers.ts helper.
- OWASP V16: security event logs (missing token, validation failure,
  insufficient scope, company-membership deny) now include source IP
  (x-forwarded-for / x-real-ip) and User-Agent for forensic correlation.
- OWASP V8.2.1 / ISO A.8.3: GET /api/v1/companies emits a warn log when the
  PostgREST archived_at filter unexpectedly returns a row with a null
  company join, surfacing silent data-integrity regressions instead of
  hiding them behind the existing pickCompany() === null filter.

Pushing back on (not changed):
- GDPR Art.32 cursor HMAC signing — cursors only paginate within a user's
  own user_id scope; cross-tenant probe surface doesn't exist yet.
- GDPR Art.25 org_number in list — Bolagsverket public-record data, removing
  forces N+1 fetches to make the response useful.
- SOC 2 CC6.3 service-role bypasses RLS — defense-in-depth IS the design;
  the wrapper's company_members membership check is the technical control.
- ISO A.8.12 public OpenAPI spec — intentional, mirrors Stripe/Twilio.

5 new pagination tests cover the cursor validators. 46/46 v1 tests pass.

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

* fix(auth): detect Supabase duplicate-signup obfuscation on register

Supabase obfuscates duplicate signups to prevent user enumeration: when an
email already belongs to a confirmed account, signUp returns data.user with
identities: [] and no error, and sends no email. Without detecting this case
we showed the "check your email" screen to the user, who then waited for a
mail that never arrived.

Detect the empty-identities response and surface it via duplicateEmail state
so the UI can branch on it.

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

* fix(api): second-pass hardening — CSP, IP truncation, cursor scope comment

Address the second compliance-swarm sweep on PR #450:

- OWASP V3.2: PUBLIC_SECURITY_HEADERS now includes Content-Security-Policy
  default-src 'none'; frame-ancestors 'none'. Free win for JSON/text-only
  public routes (no script, style, image, or form contexts).
- GDPR Art.5(1)(f): truncate IPs before logging — IPv4 to /24, IPv6 to /48.
  Preserves diagnostic value (ASN, abuse-pattern correlation, city-level
  geolocation) while eliminating point-of-presence identification. Standard
  pattern used by Google Analytics anonymize_ip. Exported truncateIp() so
  other surfaces can adopt it.
- OWASP V8.2.1: explicit comment in GET /api/v1/companies documenting that
  the cursor's joined_at is applied AFTER user_id filter, so a tampered
  cursor can only reorder rows the caller already owns. Cursors deliberately
  unsigned; trade-off documented.

Pushing back on second-pass findings (not changed):
- ISO A.8.12 / SOC 2 CC6.3 health/llms.txt/skills exposing service name +
  API version + MCP URL — these are intentional disclosures for a public
  3rd-party developer API; hiding them is theatre.
- GDPR Art.32 logging granted scopes on INSUFFICIENT_SCOPE — diagnostic
  value during incident response outweighs the theoretical privilege-profile
  leak; an attacker who already breached the log store has bigger problems.
- OWASP V2.2 route-level Zod for cursor — decodeDefaultCursor already
  validates strictly; route-level Zod is stylistic.
- GDPR Art.25(2) org_number/entity_type in list — Bolagsverket-public data;
  entity_type materially affects which API calls make sense.
- ISO A.8.15 x-forwarded-for trusted-proxy CIDR — overkill behind Vercel's
  edge which rewrites the leftmost value.

50/50 v1 tests pass (4 new for truncateIp). Build green.

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

* fix(api): third-pass hardening — Host header injection, anon client, HSTS

Address the third compliance-swarm sweep on PR #450:

- SOC 2 CC6.1 (3× high): llms.txt, openapi.json, and .well-known/skills
  built URLs from the inbound Host header. A spoofed Host could poison
  agent discovery with attacker-controlled endpoints. New
  lib/api/v1/base-url.ts centralises canonical base-URL derivation via
  NEXT_PUBLIC_APP_URL (already a required env var per CLAUDE.md).
- ISO A.8.2 / A.8.5 (2× high): the wrapper's public-scope code path now
  uses an anon-key Supabase client (RLS-respecting) instead of the
  service-role client. A future accidental DB call from a public handler
  is constrained to anon-accessible rows. Least-privilege at the
  infrastructure layer.
- OWASP V3.2 (medium): PUBLIC_SECURITY_HEADERS now includes
  Strict-Transport-Security: max-age=31536000; includeSubDomains.
- GDPR Art.5(1)(f) (medium): truncateIp now logs a warn when a non-empty
  x-forwarded-for / x-real-ip payload fails to parse, surfacing spoofed
  or unexpected proxy values to security monitoring instead of silently
  dropping them. The raw value is never logged.
- CC2.3 (low): llms.txt now links the SECURITY.md disclosure policy with
  the security@arcim.io reporting address so agents have a clear
  responsible-disclosure path.

Pushing back on third-pass findings (not changed):
- Cursor HMAC signing — user_id filter is the authorisation boundary;
  cursor scope is bounded to within-user rows. Documented in code.
- org_number in companies list — Bolagsverket public data; the swarm's
  "could be enskild firma personnummer" framing isn't accurate (enskild
  firma org_number IS the personnummer, but it's already in the public
  Bolagsverket business register).
- Health endpoint information disclosure — intentional for a public
  developer API; matches Stripe/Twilio convention.
- llms.txt / skills index MCP URL disclosure — that's the file's purpose.
- Cache-Control public on discovery routes — content is by definition
  public; getCanonicalBaseUrl() removes the previous spoof concern.
- Duplicate-email screen — user's own input; out of scope for this PR.

50/50 v1 tests pass; @supabase/supabase-js#createClient mocked so the
public-path tests don't need real env vars.

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

* fix(test): widen validateApiKey result assertions to include mode field

The core-only CI job failed on two pre-existing api-keys.test.ts assertions
that used strict toEqual matching against the old (userId, companyId, scopes)
shape. The wrapper migration in this PR widened that shape with mode,
apiKeyId, and apiKeyName.

Update both existing assertions to match the current shape and add a third
test that exercises the mode='test' path. 3027/3027 vitest tests now pass
locally.

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

* fix(api): fourth-pass hardening — env guards, IP range check, headers on wrapped routes

Address the fourth compliance-swarm sweep on PR #450:

- ISO A.5.17 / SOC 2 CC6.1 (high): createAnonClient now fails closed with
  an explicit Error if NEXT_PUBLIC_SUPABASE_URL or _ANON_KEY are missing,
  surfacing misconfiguration on the first request instead of throwing
  deeper in the handler with no context.
- GDPR Art.5(1)(f): truncateIp now rejects IPv4 with out-of-range octets
  (>255). '999.999.999.999' now returns undefined instead of a pseudo-IP
  that would pollute abuse-pattern analysis. Edge octets (0, 255) still
  accepted. 2 new tests.
- OWASP V3.2 / V3.3: the wrapper's stampHeaders step now applies the full
  security header set to every wrapped v1 response (CSP, HSTS, X-Frame,
  Referrer-Policy, X-Content-Type-Options) PLUS X-Robots-Tag: noai,
  noimageai so authenticated payloads are excluded from AI training sets.
  Public discovery routes (llms.txt, skills index, openapi.json)
  deliberately omit X-Robots-Tag — being AI-discoverable is the whole
  point of those surfaces.
- New WRAPPED_RESPONSE_HEADERS export separates the two contexts.

Pushing back on:
- SOC 2 CC6.1 medium "API key prefix in public docs aids brute force" —
  inverted logic. Every public API publishes its key prefix specifically
  so secret scanners (GitHub Advanced Security, GitLeaks) can detect
  leaks. Stripe (sk_live_), GitHub (ghp_), OpenAI (sk-) all do this.
- SOC 2 CC6.3 medium "formal risk register for unsigned cursors" — org
  -level documentation, outside this PR. Code-comment already documents
  the trade-off.
- SOC 2 CC2.3 low "llms.txt hardcodes security@arcim.io" — same address
  as SECURITY.md; no drift risk.

Flagged separately (not changed): the register-page duplicate-email
detection in this branch defeats Supabase's user-enumeration obfuscation
(GDPR Art.5(1)(c) × 2, ISO A.8.11). Substantive product decision: UX (no
infinite-wait for non-existent accounts) vs security (no enumeration).
GitHub and Stripe Atlas pick UX; some pick security. Owner's call.

3029/3029 vitest tests pass.

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

* fix(api): address Greptile review on PR #450

- P1 (companies/route.ts): keyset pagination was missing its tiebreaker.
  The cursor encoded (joined_at, id) but the filter only applied
  .gt('joined_at', ts) — same-joined_at rows on a page boundary could be
  skipped or duplicated. Also the encoded id was companies.id while the
  sort was on company_members, mismatched. Fixed: select + sort + encode
  on company_members.id, apply compound
  joined_at.gt.{ts} OR (joined_at.eq.{ts} AND id.gt.{cursor_id}) via .or().
  Side benefit — eliminates the broken-cursor-on-null-join case (#2)
  because company_members.id is always present, no null guard needed.
- P2 (registry.ts): ZodUnion branch had a dead ternary
  (['x','y','z','w'].length > 0 ? undefined : 'object') that always
  yielded undefined. Removed; emit { oneOf: [...] } without top-level
  type (correct JSON Schema for a union).
- P2 (with-api-v1.ts): public-endpoint path was short-circuiting before
  Bearer-token validation, contradicting the JSDoc and PR description.
  Now opportunistically validates a supplied token for rate-limit
  attribution + key tracking; missing/invalid token silently falls back
  to anon (the route is public by definition, so we don't 401). Two
  new tests cover both branches.

3031/3031 vitest tests pass; build green.

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 20:47:13 +02:00
MattssonandClaude Opus 4.7 980f29dae8 Bug/momsdeklaration skv (#449)
* fix(salary): show birthdate in masked personnummer, hide the 4-digit suffix

Flip the personnummer display format from XXXXXXXX-NNNN to YYYYMMDD-XXXX so
the sensitive 4-digit suffix is hidden while the (public) birthdate stays
visible. Affects the employees list/detail, salary run, payslip PDF, payslip
email, and the MCP server tools (list_employees, get_salary_run). Each call
site now decrypts the stored personnummer before masking.

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

* fix(transactions): allow deleting unbooked transactions from "Alla transaktioner"

The history list only let users delete via the inbox card; once a category or
mall was picked but the verifikation hadn't been created, the row showed
"Ej bokförd" with no way to remove it. The API already permits delete while
journal_entry_id is null, so the gap was purely a missing UI affordance.

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

* fix(vat): populate ruta 20-24 for reverse charge + dishonest "Validera OK"

Three connected issues caused Skatteverket to reject momsdeklarationer
with FK004 even after our local "Validera"-knapp returned OK.

1. supplier-invoice-entries booked fiktiv moms (2614/2624/2634 + 2645/2647)
   on reverse-charge invoices but never the underlying basbelopp on 44xx/45xx.
   Ruta 30-32 filled up at SKV while ruta 20-24 stayed at 0 — SKV's FK004
   ("silent netting prohibited", ML 13 kap kräver båda sidor).

   Fix: generateReverseChargeBasisLines in vat-entries.ts emits parallel
   45xx/44xx debit + 4598 motkonto credit per rate group. Engine calls it
   from registration, cash, and credit-note paths. Skipped when the user
   booked the expense directly on a basis account to avoid double-counting.
   4598 added to BAS reference (no migration needed; account_number is
   plain text on journal_entry_lines).

2. rutorToMomsuppgift rounded each ruta independently but computed
   summaMoms from the unrounded ruta49. SKV recomputes the sum from
   integer rutor on their side, so fractional öres caused ±1 SEK drift
   and SKV rejected with FK009.

   Fix: derive summaMoms from the already-rounded VAT-amount rutor.

3. "Validera"-knappen only confirmed SKV's internal arithmetic — a
   declaration with ruta 30-32 populated and ruta 20-24 empty validated
   fine until /utkast hit FK004. Users got a false green light.

   Fix: vat-declaration-checks.ts runs locally before the SKV call,
   blocks Validera/Spara when ERROR-level findings exist, and surfaces
   them in a separate "Lokala kontroller"-section. Success message
   reworded so SKV's OK is no longer presented as filing-ready.

Tests: 4535/4536/4531/4425 lines + 4598 motkonto on EU/non-EU/byggtjänster
RC, credit-note reversal, fractional-öres summaMoms, all four pre-flight
codes (RC_BASIS_MISSING, RC_OUTPUT_MISSING, RC_INPUT_VAT_MISMATCH,
SUMMA_MOMS_DRIFT).

Backfill for already-posted entries follows in the next commit.

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

* feat: add skattekonto matching functionality

- Enhance TransactionInboxCard to display a warning for potential 1930↔1630 transfers.
- Implement match suggestions for skattekonto transactions in the backend.
- Create SkattekontoMatchDialog component for linking skattekonto rows to existing journal entries.
- Develop SkattekontoInboxCard component to handle skattekonto transactions in the inbox.
- Introduce skattekonto-match utility functions for candidate matching and linking.
- Update types to include match suggestions and enriched transaction responses.

* refactor: reorganize skattekonto types and implement bank counterpart matching logic

* docs: update CLAUDE.md to streamline integrations and clarify architecture details

* refactor: enhance reverse charge logic to handle non-basis accounts and prevent double-counting

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:04:48 +02:00
Jakob WennbergandClaude Opus 4.7 eb77ad50b5 feat(mcp): add create_voucher + correct_entry MCP tools (#448)
* feat(mcp): add create_voucher + correct_entry MCP tools

The MCP toolset had no way to post a journal entry outside the preset
workflows (categorize_transaction, create_invoice, …). That blocks
legitimate flows the engine already supports — K3 capitalization to BAS
1010, period-end accruals, FX adjustments, prepayments, and rättelseposter
for foreign reverse-charge VAT that landed on 2641 instead of 2614/2645.

create_voucher exposes the existing createJournalEntry() primitive:
arbitrary balanced lines, optional fiscal-period auto-resolution, staged
for human approval. correct_entry exposes correctEntry() (storno + new
corrected entry per BFL 5 kap 5§) so part of a posted verifikation can be
fixed without losing the legs that were right.

Both are HIGH risk in OPERATION_RISK_TIERS — the arbitrary account/amount/
period inputs make them compliance-critical despite being structurally
similar to uncategorize_transaction (medium). Approval flow unchanged; no
auto-commit, regardless of trust level.

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

* fix(mcp): address PR #448 review — voucher tools hardening

Greptile P1 + compliance bot findings, all in one pass.

commitCreateVoucher (commit.ts):
- Hardcode source_type to 'manual' instead of reading from params. A future
  direct-staging path or hand-inserted pending_operations row could
  otherwise inject 'bank_transaction'/'invoice_created'/etc. and corrupt
  the audit-trail origin.
- Re-validate balance defensively before reaching the engine, so a tampered
  params row surfaces a clean Swedish 400 instead of an opaque engine error.

gnubok_create_voucher (server.ts):
- Validate the explicit fiscal_period_id when supplied: confirm it exists,
  is open (is_closed = false), and that entry_date falls within its span.
  Without this, a closed/locked period was only caught at commit-time with
  a generic DB-trigger error.
- Throw at staging when any line targets an account that's missing from
  chart_of_accounts or marked inactive, rather than relying on the
  approver to spot the advisory flag.
- Remove source_type from the staged params blob entirely — the executor
  ignores it anyway, no point letting it travel through.
- Add a comment that the staging-time period-lock check is advisory and
  the executor is the authoritative guard, so future cleanup doesn't
  remove either as 'redundant'.

Descriptions:
- gnubok_correct_entry now explicitly notes that the storno + corrected
  entries land in the original period (defends against compliance bot's
  speculative "different period" concern recurring on future reviews).
- Both tools' tax_code field gets a note that the BAS account number
  drives momsdeklaration ruta mapping, not tax_code — guards against an
  LLM treating tax_code as the VAT-routing dial.

commitCorrectEntry (commit.ts):
- Add a comment pointing at storno-service.ts:99,102,195,198 to make the
  "uses original period and date" invariant explicit in this file.

Tests:
- +2 voucher-executors cases: source_type tampering is ignored, unbalanced
  params return 400.
- +10 new voucher-tools tests (MCP layer): unbalanced, closed explicit
  period, missing explicit period, entry_date outside period, unknown
  account, inactive account, happy path + correct_entry registration +
  unbalanced replacement.

720 tests pass in the impacted suites; full suite 2998/2998.

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 17:16:39 +02:00
Jakob WennbergandClaude Opus 4.7 ec06a2d04d fix(nav): render Beta/Dev/comingSoon badges for the main group (#447)
The Huvudmeny group has its own render loop separate from AR/AP/
Personal/Accounting; it only emitted icon + label and ignored the
betaBadge/devBadge/comingSoon flags. That's why Dokumentinkorg (group:
main, betaBadge: true) never showed the Beta tag even though Löner and
Anställda (group: personal) did. Adding the same badge precedence block
to both render paths (desktop sidebar + mobile drawer).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:47:55 +02:00
Jakob WennbergandClaude Opus 4.7 9ad646fa33 fix(inbox): dedupe onboarding card on empty inbox (#446)
* fix(inbox): dedupe onboarding card — show in preview pane only

When the inbox was empty, the OnboardingCard rendered in both the list
column (compact) and the preview pane (full size) at the same time, so
the "Så funkar dokumentinkorgen" explainer appeared twice on a fresh
inbox. Keep the preview-pane version (primary surface, more room) and
let the list column show the standard "Inkorgen är tom" empty state.

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

* fix(inbox): restore onboarding on mobile empty inbox

Greptile P1 on PR #446: on small screens the layout is a master-detail
toggle defaulting to list view. With items.length === 0 there's no row
to click, so setMobileView('detail') never fires and the user can't
reach the preview-pane onboarding. Result: mobile users on a fresh
inbox saw only "Inkorgen är tom." with no path forward.

Keep the compact list-column card on mobile only (md:hidden) and the
quiet empty-state line on desktop, so the preview pane stays the
single source of the explainer on md+.

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:25:48 +02:00
Jakob WennbergandClaude Opus 4.7 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 8f15f98687 Bug/employees creation (#440)
* Fix/employees page salary display logic and labels

* feat: add salary_worked_days table and related functionality

- Implemented the salary_worked_days table to track per-day worked hours for hourly employees.
- Established row-level security (RLS) policies to ensure tenant isolation for salary_worked_days.
- Created unique index on (employee_id, work_date) to enforce uniqueness.
- Added trigger to enforce a 24-hour cap across worked and absence days for the same employee and date.
- Developed tests to validate RLS, uniqueness, and 24-hour cap logic.

* Fix: update hourly_salary calculation and refresh logic in salary run processing
2026-05-12 01:10:01 +02:00
Mattsson dec920682f Fix/UI changes (#439)
* feat(bookkeeping): add preview for next voucher number in JournalEntryForm

* feat(encoding): implement U+FFFD recovery for Swedish text in encoding functions
2026-05-11 23:18:17 +02:00
MattssonandClaude Opus 4.7 a53a119a2e Fix/vat parent accounts (#438)
* feat(enable-banking): add support for account selection and syncing

- Updated StoredAccount interface to include an 'enabled' flag for account syncing preferences.
- Enhanced ensureFiscalPeriod function to handle overlapping fiscal periods with posted entries and opening balances.
- Added tests for fiscal period validation and account syncing logic.
- Implemented AccountPickerDialog component for user account selection.
- Created API routes for PATCH /accounts and POST /sync to manage account syncing.
- Introduced 'pending_selection' status for bank connections to allow user account selection before syncing.
- Updated database migration to support new connection status and backfill existing accounts with enabled=true.

* feat(enable-banking): implement account selection and consent event logging

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:12:00 +02:00
Jakob WennbergandClaude Opus 4.7 d0fbc2b616 refactor(ui): app-wide UI/UX consistency pass (#436)
* refactor(ui): app-wide UI/UX consistency pass

Net: +1,159 / −1,373 LOC across 77 files. No new features, no behavior
changes. Locks in a uniform design system across every dashboard surface.

What changed:

- **Foundation**: sidebar width 232→256px (md:w-64), spacing scale locked
  (Tailwind 1/2/3/4/6/8/10/12; 2.5/5 forbidden), card padding p-6 default
  (p-4 for compact metric cards), space-y-8 between page sections.

- **Tables unified**: all 33 thead blocks now share the Resultatrapport
  pattern via shadcn Table primitive (text-[11px] font-medium uppercase
  tracking-wider text-muted-foreground). Hand-rolled <table> instances
  converted where they were data tables; form/edit grids kept distinct.

- **Status badges unified**: every status indicator routes through
  shadcn <Badge variant>. Eliminated raw Tailwind colors
  (bg-amber-100, bg-emerald-500/10, bg-blue-100, bg-purple-100, etc.)
  in favor of the gnubok semantic palette (success=sage, warning=ochre,
  destructive=terracotta).

- **Empty states unified**: list pages migrated from hand-rolled
  "flex flex-col items-center py-12" divs to the EmptyState primitive.

- **Loading skeletons unified**: hand-rolled bg-muted rounded animate-pulse
  divs replaced with shadcn <Skeleton> across 15 files.

- **Touch targets**: 6 back-buttons + edit-pencil + inbox delete bumped
  from 24/32/36px to shadcn's 40px icon default. Added aria-labels on
  9 icon-only navigation buttons.

- **Date formatting**: formatDate() for accounting data (ISO yyyy-MM-dd,
  table-friendly) vs formatDateLong() for metadata (Swedish long form).
  Raw {x.invoice_date} renderings routed through formatDate() in 18 sites.

- **Toast titles**: eliminated 33 generic "Fel" titles. Each toast title
  now carries the action ("Kunde inte skapa lönekörning" etc.) with
  description carrying the error detail.

- **Page-level cleanups**:
  - Dashboard: dropped greeting hero + Snabbåtgärder/Att hantera nav
    duplicates + Visa detaljer collapsible.
  - Reports: 5-col mega-menu replaced with left-rail layout
    (new ReportsNav component).
  - Bookkeeping: fixed layout jump between Verifikationer/Ny verifikation
    tabs (moved FiscalYearSelector inside journal tab).
  - Bookkeeping: added voucher sort (A1 first / latest first) alongside
    existing date sort. Required matching API param sort_by.
  - KPI page: FiscalYearSelector instead of raw <select>; InfoTooltip
    instead of inline info-button toggle; bigger numbers.
  - Salary section: enum values translated to Swedish labels, mobile
    table collapses to Anställd+Netto on <md, KPI typography aligned
    with dashboard.
  - Invoice forms: styled RequiredMark + aria-required, tabular-nums
    on amount inputs.

- **CLAUDE.md**: new "Design System Tokens" subsection documents the
  locked spacing scale, primitives table, typography rules, date helpers,
  and forbidden patterns so future contributors don't drift.

Tests: 2,906 passing (unchanged). Lint: unchanged from main baseline.

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

* fix: address PR review feedback (Greptile + compliance bot)

- **formatDate / formatDateLong timezone fix**: switch from new Date() to
  parseISO. Bare yyyy-MM-dd strings are now parsed as local midnight rather
  than UTC midnight, eliminating the off-by-one display in west-of-UTC
  timezones flagged by Greptile.

- **DashboardContentProps cleanup**: removed unused firstName and settings
  fields from the interface, and the corresponding fetch (profiles table)
  + computation in app/(dashboard)/page.tsx. The greeting was dropped in
  the dashboard cleanup; these props were dead weight.

- **Voucher sort behavior documented**: extended the comment in the journal
  entries API route to explain why voucher sort intentionally uses strict
  fiscal_period_id filtering (BFL 5 kap 6–7 §§ — voucher numbers are
  series-scoped within a fiscal year). The row-count delta between date
  sort and voucher sort is now a documented design choice.

- **delete_last_voucher migration + draft-delete test included**: the UI
  already shipped the "Radera utkast" path in the previous commit; this
  pulls in the backing RPC migration that allows draft deletes (with the
  full safety logic — drafts skip series/period checks since they have
  voucher_number=0, posted entries go through the existing unchanged
  path). This was originally meant for a separate PR but the UI shipped
  half the feature without it.

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

* chore(migration): rename to match applied version

The function delete_last_voucher is already applied to the production DB
under version 20260509103736 (verified via pg_get_functiondef — exact
byte-for-byte match to file content). The previous file timestamp
20260509120000 would cause a fresh `supabase db push` to attempt re-applying
under a different version row. Renaming the file aligns local tracking
with what the database actually has.

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

* fix: address compliance bot findings (payroll label + VAT visibility)

- sick_karens label: drop "(första sjukdagen)" qualifier. Per sjuklönelagen
  6 §, karensavdrag is a single calculated amount (20% of one week's
  sjuklön) deducted from the first sick day's pay — not bounded to the
  first day. The qualifier could mislead users when the first sick day
  and return-to-work span a weekend. Swedish-payroll bot recommendation.

- Omvänd skattskyldighet badge: variant outline → warning. The reverse-
  charge indicator is compliance-critical (ML 16 kap) — missing it leads
  to incorrect input VAT deduction. Outline was too subtle; warning's
  ochre fill matches its semantic weight.

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-11 16:34:07 +02:00
MattssonandClaude Opus 4.7 ed9bdc4c00 Bug/transaction import (#435)
* fix(invoices): drop UTKAST banner on numbered invoices and preserve logo aspect ratio

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

* revert(invoices): restore UTKAST banner for drafts; keep logo aspect ratio fix

Numbered drafts intentionally surface UTKAST until manually marked sent.

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

* revert(invoices): drop logo objectFit change

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

* feat(enable-banking): implement transaction fetch strategy and update related logic

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:27:44 +02:00
Mattsson 75043632de Fix/compliance and invoices (#433)
* feat(invoices): implement inline membership checks for invoice-number RPCs and enhance error handling

* fix(invoices): enhance unpaid amount calculation to support currency-specific rounding

* fix(api-client): handle response.text() error for 403 status in skvRequest
2026-05-10 19:09:57 +02:00
Mattsson 1876b3b402 fix(agi): update AGI submission logic to reflect SKV's auto-persistence and adjust error handling (#432)
fix(pdf): align company logo and document title in invoice PDF layout
2026-05-10 15:37:19 +02:00
Mattsson c40e1114ca Bug/skv connection (#431)
* feat(invoices): implement öresavrundning logic and next invoice number preview

- Added `getDisplayTotal` utility to handle rounding for SEK invoices based on company settings.
- Updated `InvoicesPage` to utilize the new rounding logic when displaying totals.
- Introduced `peek_next_invoice_number` function to allow previewing the next invoice number without consuming the sequence.
- Modified invoice number generation to remove the year prefix and prevent truncation of numbers exceeding three digits.
- Enhanced tests for invoice number generation and rounding functionality to ensure correctness.
- Updated PDF template to reflect new rounding logic for totals and display appropriate values.
- Adjusted company switcher to hide options in sandbox mode.
- Improved error handling and logging in sandbox seeding process.

* fix(skatteverket): remove unused scope labels from SCOPE_LABELS and DEFAULT_SCOPES

* feat(skatteverket): add token revocation handling and disconnect functionality

* feat(skatteverket): implement PKCE support for OAuth2 flow to enhance security
2026-05-10 14:53:11 +02:00
Mattsson d7a7ddae2c Bug/skv connection (#430)
* feat(invoices): implement öresavrundning logic and next invoice number preview

- Added `getDisplayTotal` utility to handle rounding for SEK invoices based on company settings.
- Updated `InvoicesPage` to utilize the new rounding logic when displaying totals.
- Introduced `peek_next_invoice_number` function to allow previewing the next invoice number without consuming the sequence.
- Modified invoice number generation to remove the year prefix and prevent truncation of numbers exceeding three digits.
- Enhanced tests for invoice number generation and rounding functionality to ensure correctness.
- Updated PDF template to reflect new rounding logic for totals and display appropriate values.
- Adjusted company switcher to hide options in sandbox mode.
- Improved error handling and logging in sandbox seeding process.

* fix(skatteverket): remove unused scope labels from SCOPE_LABELS and DEFAULT_SCOPES

* feat(skatteverket): add token revocation handling and disconnect functionality
2026-05-10 14:40:16 +02:00
Mattsson 0ee5219b6c feat(invoices): implement öresavrundning logic and next invoice numbe… (#429)
* feat(invoices): implement öresavrundning logic and next invoice number preview

- Added `getDisplayTotal` utility to handle rounding for SEK invoices based on company settings.
- Updated `InvoicesPage` to utilize the new rounding logic when displaying totals.
- Introduced `peek_next_invoice_number` function to allow previewing the next invoice number without consuming the sequence.
- Modified invoice number generation to remove the year prefix and prevent truncation of numbers exceeding three digits.
- Enhanced tests for invoice number generation and rounding functionality to ensure correctness.
- Updated PDF template to reflect new rounding logic for totals and display appropriate values.
- Adjusted company switcher to hide options in sandbox mode.
- Improved error handling and logging in sandbox seeding process.

* fix(skatteverket): remove unused scope labels from SCOPE_LABELS and DEFAULT_SCOPES
2026-05-10 14:21:26 +02:00
Mattsson c110d3ef99 Bug/stale skv connection (#428)
* fix(dashboard): enable salary features with "Beta" badge for testing

* fix(errors): enhance Swedish error message patterns for better user feedback

fix(salary): update Nordea Personkonto handling in account encoding logic

* fix(agi): implement feature flag for AGI transmission and update button states

* fix(swedish-payroll): update youth rate eligibility criteria and enhance documentation

* fix(agi-panel): remove feature flag for AGI transmission and simplify button states

* fix(agi-panel): handle stale session errors and improve reconnect flow

* fix(api-client): enhance error handling for 401 responses and clarify subscription issues
2026-05-10 00:39:51 +02:00
Mattsson efdd3a50ba Bug/stale skv connection (#427)
* fix(dashboard): enable salary features with "Beta" badge for testing

* fix(errors): enhance Swedish error message patterns for better user feedback

fix(salary): update Nordea Personkonto handling in account encoding logic

* fix(agi): implement feature flag for AGI transmission and update button states

* fix(swedish-payroll): update youth rate eligibility criteria and enhance documentation

* fix(agi-panel): remove feature flag for AGI transmission and simplify button states

* fix(agi-panel): handle stale session errors and improve reconnect flow
2026-05-10 00:14:55 +02:00
Mattsson 993b3f5962 Fix/remove disabled fields (#426)
* fix(dashboard): enable salary features with "Beta" badge for testing

* fix(errors): enhance Swedish error message patterns for better user feedback

fix(salary): update Nordea Personkonto handling in account encoding logic

* fix(agi): implement feature flag for AGI transmission and update button states

* fix(swedish-payroll): update youth rate eligibility criteria and enhance documentation

* fix(agi-panel): remove feature flag for AGI transmission and simplify button states
2026-05-09 23:55:50 +02:00
Mattsson 27969c5260 fix(dashboard): enable salary features with "Beta" badge for testing (#425) 2026-05-09 18:54:49 +02:00
Mattsson af11460f53 fix(dashboard): enable salary features with "Beta" badge for testing (#424) 2026-05-09 12:48:55 +02:00
Mattsson 7e81f661b2 Add/skv salary agi (#423)
* feat: add Bankgirot LB-fil support for salary payments and tax payments

- Implemented `generateBgLb` for salary batch payments, producing opening, payment, and closing records.
- Added tests for `generateBgLb` to ensure correct record generation and validation.
- Created `generateBankgiroPaymentBgLb` for single tax payments to Skatteverket, including validation and formatting.
- Added tests for `generateBankgiroPaymentBgLb` to verify record structure and data integrity.
- Introduced `generateSkattekontoOcr` for generating valid OCR references for Skattekonto payments, with tests for various input formats.
- Updated database schema to track payment file formats and timestamps for salary runs and AGI declarations.
- Created a new table for logging salary payslip deliveries to ensure compliance with audit requirements.

* feat: add write permission check and company ID validation for payment file generation

* feat: add write permission check for salary payment file generation
2026-05-09 12:41:16 +02:00
Jakob WennbergandClaude Opus 4.7 c238542596 fix(settings): label first-year period as "Första räkenskapsåret" (#422)
The FiscalPeriodEditor section is for adjusting the onboarding fiscal
year and always loads the oldest period. After a multi-year SIE import
the oldest period is no longer the company's current year, so the
"Nuvarande period:" label misled at least one user into thinking their
data had landed in the wrong year. Rename to match the section header.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:17:53 +02:00
Jakob WennbergandClaude Opus 4.7 11e07b34c3 fix(sie-import): reject overlap-but-not-contain fiscal periods (#421)
* fix(sie-import): reject overlap-but-not-contain fiscal periods

ensureFiscalPeriod previously fell back silently to any partially-overlapping
fiscal_period when no period fully contained the SIE file's #RAR range. That
stamped every imported voucher with a fiscal_period_id whose date window
didn't cover the voucher's own entry_date — breaking the SIE invariant that
#VER dates fall inside #RAR and BFL 5 kap. (verifikationsnummer i obruten
serie per räkenskapsår). Reproduced in production: a customer with a broken
fiscal year (Mar–Feb) had their previous-year SIE collapse into a calendar
2026 period, mixing 116 prior-year vouchers into the current period's
voucher sequence.

Now: fully-contained → reuse; partial overlap → reject with a clear Swedish
message pointing at the period dates that would need to be fixed; no overlap
→ create a new period as before.

Adds a defense-in-depth pre-check in executeImport that rejects with a
Swedish error if any individual #VER date falls outside the resolved fiscal
period — catches multi-year SIE files (which gnubok does not yet support)
without producing corrupted journal entries.

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

* fix(sie-import): fail closed on period-fetch error in voucher-date guard

Greptile correctly flagged that the voucher-date guard wrapped the period
fetch in `if (resolvedPeriod)` without checking the .single() error. A
transient network error or RLS failure returned null for data, the guard
body skipped, and importVouchers ran with no date validation — the same
data-corruption path the guard exists to close.

Now: surface the fetch error to the user (Swedish) and abort the import.

Also switches the date comparison from millisecond timestamps to YYYY-MM-DD
string compare. SIE per spec is date-only and our parser normalizes to
midnight, but string compare matches the underlying DATE columns exactly and
removes a latent off-by-one risk on the period's last day if a future
parser change ever attached a time component to v.date.

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-08 17:17:36 +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 WennbergandClaude Opus 4.7 6c5c49f588 chore(compliance): upgrade to compliancemaxx@v2 (#419)
* chore(compliance): upgrade to compliancemaxx@v2

Per-PR check switches from `mode: pr` (deterministic scanners) to
`mode: review` (LLM-only diff review, ~90s, ~$0.05/PR). The nightly
audit workflow continues to provide the deeper scanner-backed coverage,
so concrete-pattern findings (secrets, CVEs, IaC misconfigs) are still
caught — just within 24 hours instead of immediately.

Trade-off accepted: PR feedback becomes shorter and more reasoned
(LLM judgement) rather than longer and more pattern-matched (scanner
output). Easier to act on per-PR; deep scanner coverage retained nightly.

  - compliance-pr.yml: @v1 mode:pr → @v2 (default mode: review)
  - compliance-swarm.yml: @v1 mode:swarm → @v2 mode:audit

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

* chore: re-trigger to pick up v2.0.1 action.yml fix

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 23:07:54 +02:00
Jakob WennbergandClaude Opus 4.7 97989285c2 chore(compliance): consume erp-mafia/compliancemaxx@v1 (#418)
* chore(compliance): consume erp-mafia/compliancemaxx@v1

Adds the consumer side of the new compliancemaxx orchestrator:

  - .github/workflows/compliance-pr.yml — single-job advisory check
    that calls `uses: erp-mafia/compliancemaxx@v1` on every PR.
    Threshold + skill selection live in .compliance/config.yml.
    `fail-on-findings: false` keeps it strictly advisory while findings
    bed in; flip to true after triage.

  - .compliance/config.yml — bootstrap config: all 5 skills enabled
    (oss-license, owasp-asvs-v5, iso-27001-2022, soc2, gdpr),
    severity_threshold_to_block=critical, no suppressions yet.

The orchestrator code, skill manifests, and reference docs live in the
new public repo: https://github.com/erp-mafia/compliancemaxx

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

* chore: retrigger compliance check

* chore(compliance): add full-swarm workflow (nightly + dispatch + label)

Adds the LLM-powered deep_audit alongside the existing PR-mode advisory
check. Triggers:
  - 02:00 UTC nightly cron
  - workflow_dispatch (manual)
  - PRs labeled `compliance:full-audit`

Authenticates to AWS Bedrock via the existing AWS_ACCESS_KEY_ID/SECRET
repo secrets (same path swedish-compliance-review.yml already uses).
`fail-on-findings: false` keeps it observational while findings bed in.

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-07 22:11:33 +02:00
Jakob WennbergandClaude Opus 4.7 15d4f429f3 feat: consolidate /expenses into /supplier-invoices, dual-channel support feedback, compliance skills (#417)
* feat(supplier-invoices): consolidate /expenses into /supplier-invoices and add bank matching

Collapse the duplicate AP entry points by redirecting /expenses, /expenses/new,
and /expenses/[id] into the canonical /supplier-invoices routes, and absorb the
expense-entry flow into /supplier-invoices/new. Add a BankTransactionPicker so
a supplier invoice can be registered and matched to an existing outgoing bank
transaction in one step. Extend the transaction → invoice match dialog to
handle supplier invoices alongside customer invoices, including the new
/match-supplier-invoice endpoint and Swedish copy variants. Update the sidebar
to point Leverantörsfakturor at /supplier-invoices and hide the legacy entry.

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

* feat(support): send feedback to both Recapt and email channels

Previously submitFeedback used Recapt when present and only fell back to email
on failure, so feedback captured by the SDK never reached the support inbox.
Always POST to /api/support/contact in parallel with the Recapt call and
return the list of channels that succeeded; feedback is considered delivered
if either channel succeeds.

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

* chore(skills): add compliance skill references for GDPR, ISO 27001, OSS, OWASP ASVS, SOC 2

Add reference material for five compliance domains alongside the existing
.claude/skills/ set so future audits and CI gating work has a documented
mapping to controls, violation patterns, tool orchestration, and cross-framework
crosswalks.

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

* fix(supplier-invoices): address review issues in register-and-match flow

- BankTransactionPicker: move createClient() inside the effect and drop the
  supabase client from the deps array. Calling createClient() in the component
  body produced a fresh reference on every render, which combined with the
  setIsLoading(true) inside the effect to fire an infinite re-fetch loop while
  the dialog was open.
- /supplier-invoices/new: replace the submitMode useState with a useRef.
  setSubmitMode() in the button onClick and the read in the form onSubmit run
  in the same React event batch, so onSubmit always saw the previous render's
  value and the bank picker never opened on first use.
- /supplier-invoices/new: route AB companies through the existing review
  dialog before booking the register-and-match flow. handlePickTransaction now
  stores the picked transaction and opens the review dialog for AB; on
  confirm, handleConfirm posts the create and then matches the stored
  transaction. EF retains the one-step create+approve+match path.

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

* fix(transactions): populate potential_supplier_invoice so the match flow is reachable

fetchTransactions and loadMoreTransactions previously joined potential_invoice
from the invoices table but never looked up potential_supplier_invoice, so
TransactionInboxCard's hasSupplierInvoiceMatch check was always false and
handleConfirmInvoiceMatch's supplier branch was unreachable. Mirror the
existing customer-invoice pipeline: collect potential_supplier_invoice_id
values, batch-fetch the matching supplier_invoices (with their supplier),
build a map, and spread the result onto each TransactionWithInvoice in
parallel with the customer-invoice fetch.

Update uncategorizedTransactions sort and transactionsWithMatches filter to
also recognize supplier-invoice candidates so they bubble to the top of the
inbox and aren't excluded from match-driven views.

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-07 18:13:43 +02:00
Jakob WennbergandClaude Opus 4.7 8a1c7f0714 feat(invoice-inbox): loading state + review fixes + env-driven config (#416)
* feat(invoice-inbox): make Bedrock model + max_tokens env-overridable

Read BEDROCK_MODEL_ID and BEDROCK_MAX_TOKENS from process.env so ops can
swap models or raise token caps without a code deploy. Defaults align
with production: eu.anthropic.claude-sonnet-4-6 in eu-north-1, 8192
tokens (up from 4096 — enough headroom for 20+ line-item invoices that
were still occasionally truncating mid-JSON).

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

* fix(invoice-inbox): guard onFieldsUpdated, tighten env parsing, hoist afterAll

Three fixes from Greptile review on #416:

1. Stale-closure bug in onFieldsUpdated. setSelected used an
   unconditional spread, so a PATCH that completed after the user had
   navigated to a different item would silently overwrite the new
   selection's extracted_data with the old item's payload. Guard with
   prev?.id === selected.id (capturing selected.id outside the updater
   so it's stable through both setSelected and setItems).

2. BEDROCK_MAX_TOKENS env parser used `Number(...) || 8192`, which
   silently treats a deliberate `0` the same as unset. Use
   Number.isFinite(n) && n > 0 so an invalid configuration is not
   masked by the fallback.

3. Hoist `afterAll` import in the test file to the top alongside other
   vitest imports. Was working via ES-module hoisting but reads weird
   to humans and linters.

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-07 16:26:11 +02:00
Jakob WennbergandClaude Opus 4.7 9c13586b13 feat(invoice-inbox): AI extraction (Bedrock Sonnet 4.6) + editable fields (#415)
* feat(invoice-inbox): AI extraction via Bedrock + editable fields

Two big changes that go together:

1. Replace the regex extractor with Claude Sonnet 4.6 via AWS Bedrock.
   The PDF or image is sent directly to the model — no unpdf, no DOM
   stubs, no worker bundling. Sonnet handles English receipts (Anthropic,
   AWS, Stripe), USD/EUR currency symbols, scanned PDFs and image
   receipts, and Subtotal vs Total disambiguation that the regex layer
   couldn't. Output is JSON, validated with Zod; anything that doesn't
   parse falls back to an empty result so the inbox row still lands.

2. Make the extracted fields editable inline in the workspace. Each
   field is an Input bound to local draft state with debounced
   auto-save (800ms) to a new PATCH /items/:id/fields route. The route
   refuses edits once the item is converted to a supplier invoice. The
   parent updates both the selected-item view and the list rail when a
   field saves so the summary stays in sync.

Drops `unpdf`, adds `@anthropic-ai/bedrock-sdk`. Requires three env vars
in Vercel prod (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION) —
not yet set; when missing, the extractor logs a warning and returns
empty so the upload still works.

All 43 invoice-inbox unit tests pass. tsc clean.

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

* feat(invoice-inbox): loading state + #415 review fixes

Loading state for AI extraction:
- Optimistic placeholder row inserted into the workspace list the moment
  an upload starts. Shows the file name + a "Tolkar dokument med AI…"
  spinner instead of timestamp/amount.
- Document preview pane shows a centered loader + same caption while the
  placeholder is selected.
- Fields rail shows 6 skeleton inputs + the same caption.
- Action buttons are hidden until the real row arrives.
- Placeholder is removed on success or failure (toast on error).

PR #415 review follow-ups (Greptile + Swedish compliance bot):
- Bump max_tokens 1500 → 4096 so multi-line invoices don't silently
  truncate mid-JSON and land with all-null fields.
- Tighten NullableDate to range-checked regex + Date.parse refine —
  rejects 2026-13-45 / 2026-02-30 with a clean 400 instead of a 500.
- Make vatRate representation consistent: percent integer (25, 12, 6, 0)
  for both lineItems[].vatRate AND vatBreakdown[].rate. Previously the
  AI was instructed to emit decimals for one and integers for the other.
- EditableFieldsList re-seeds drafts when the parent passes a new data
  snapshot, but only on fields where the local draft still matches the
  previous server value — so a server-normalised currency upper-cases
  cleanly without clobbering an in-progress edit.
- 409 conflict (item already linked to a supplier invoice) shows the
  server's specific Swedish message ("Posten är låst") instead of the
  generic "Kunde inte spara".

Out of scope (separate tickets if needed): live updates for
email-arrived items, AWS_SESSION_TOKEN plumbing, 6 % livsmedel rate
transition validation, server-side totals consistency check.

All 43 invoice-inbox unit tests pass. tsc clean.

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

* fix(invoice-inbox): tighten extraction schema per compliance bot review

Three follow-ups from the Swedish compliance bot's second pass on #415:

1. lineItems[].vatRate and vatBreakdown[].rate now refine to 0–100,
   blocking AI hallucinations like 5000% or negative rates while still
   accepting non-Swedish rates (UK 20, DE 19) since gnubok stores foreign
   invoices for reference. The strict Swedish [0, 6, 12, 25] allowlist is
   not enforced here on purpose — that check belongs in the supplier-
   invoice-creation step where the data hits the ledger.

2. accountSuggestion is now coerced to null at parse time via .transform,
   eliminating the brief intermediate window where a hallucinated string
   could appear in the parsed object before the post-validation .map()
   nulled it. Removes the redundant .map() afterwards.

3. PATCH /items/:id/fields currency now requires ISO 4217 format
   (^[A-Z]{3}$). Previously accepted any 3–8 char string, which would
   flow into supplier-invoice creation and produce a faktura with an
   invalid currency.

Test fixture vatRate updated 0.25 → 25 to match the percent-integer
convention introduced in the previous commit.

All 43 invoice-inbox unit tests pass.

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-07 15:21:55 +02:00
Jakob WennbergandClaude Opus 4.7 8aff6dc684 fix(dashboard): container leaks after extension workspace navigation (#414)
* fix(dashboard): keep container in sync with route after extension navigation

The shared (dashboard)/layout.tsx picked between the centered max-w-5xl
chrome and the full-width extension wrapper based on a server-side
pathname header. App Router caches shared layouts across sibling-route
navigations, so whichever branch was rendered on the first server pass
stuck on subsequent client navigations until a hard reload.

In practice this only mattered because /e/* is the first route family
that opts out of the centered card — visiting the invoice-inbox
workspace and then clicking back to /, /transactions, etc. left the
unconstrained h-full wrapper in place, and the dashboard rendered
edge-to-edge.

Move the conditional into a small client component that subscribes to
usePathname(). The hook re-runs on every navigation, so the wrapper
className always tracks the current route.

The layout still reads x-pathname for the isNoCompanyAllowed redirect
check; that path only fires on the initial server render and isn't
subject to the soft-navigation issue.

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

* refactor(dashboard): drop redundant nullish guards on usePathname

Per Greptile review on #414. usePathname() in App Router always returns
a string, so the optional chaining and ?? false were dead.

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-07 14:19:11 +02:00
MattssonandClaude Opus 4.7 9e1e13d388 Fix/bank and onboarding (#413)
* fix(onboarding): validate org number at step 2 instead of final submit

Run normalizeOrgNumber (Luhn + 10/12-digit check) inside the Step 2
Zod schema and gate the duplicate-check and TIC lookup effects on it,
so users get an inline error on the field they just typed instead of
filling out two more steps and being bounced back from the server.
Server-side check stays as defense in depth. Also fixes the old regex
incorrectly rejecting valid 12-digit org numbers.

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

* fix(reconciliation): pass real userId to transaction.reconciled events

runReconciliation and manualLink fell back to companyId when no userId
was provided, causing FK violations on event_log.user_id (which references
auth.users). Make userId a required arg in both functions, drop the
fallback, and forward a real user id from every caller (user.id from
authed routes, connection.user_id from the cron path).

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

* refactor(enable-banking): use local companyId instead of reinlining ctx fallback

Three sites in the enable-banking sync handler reinline `ctx?.companyId ?? user.id`
instead of using the local `companyId` declared at the top of the block. Collapse
all three to the local for consistency and to remove drift risk if the fallback
expression ever changes.

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-07 13:32:06 +02:00
Mattsson dbe634d0aa Mcp/bulk approval (#412)
* feat(pending-operations): implement bulk commit functionality with UI support

* feat(pending-operations): add bulk action labels and warnings for confirmation dialogs

* feat(pending-operations): enhance bulk commit functionality with rejection handling and summary updates
2026-05-07 13:00:11 +02:00
Jakob WennbergandClaude Opus 4.7 a295b62bfb fix(csp): allow Supabase Storage in frame-src for PDF previews (#411)
* fix(csp): allow Supabase Storage URLs in frame-src for PDF previews

The invoice-inbox workspace renders attached PDFs in an <iframe> sourced
from a Supabase Storage signed URL. CSP frame-src was 'self' only, so
Firefox/Chrome blocked the embed with "Det här innehållet har blockerats"
even though extraction worked and the inbox row landed correctly.

Whitelist *.supabase.co alongside the existing self/Activepieces entries.
The signed URL is short-lived (Supabase default) so the exposure is
minimal — same trust boundary we already accept on connect-src.

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

* refactor(csp): scope frame-src to our Supabase project, not the wildcard

Per Greptile review on #411. supabaseUrl (NEXT_PUBLIC_SUPABASE_URL) is
already in scope and resolves to <project-ref>.supabase.co, which is
where our Storage signed URLs live. Using it instead of the *.supabase.co
wildcard prevents the app from being able to iframe content from any
other Supabase tenant.

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-07 12:02:15 +02:00
Jakob WennbergandClaude Opus 4.7 8676f10fdd fix(invoice-inbox): switch from pdfjs-dist to unpdf (#409)
* fix(invoice-inbox): switch from pdfjs-dist to unpdf for PDF text extraction

After three rounds of fighting pdfjs on Vercel (#407 stubbed DOM globals,
#408 tried to ship the worker file via outputFileTracingIncludes), text
extraction still failed in prod with "Setting up fake worker failed" —
Next's tracer can't reliably include pdfjs-dist's worker file when the
package is marked as a server external.

unpdf is a serverless-first wrapper around pdfjs (by unjs) that ships its
own bundled pdfjs build with no canvas/worker dependencies. Drop-in
replacement: extractText returns merged page text directly.

- Remove DOM stubs, serverExternalPackages, outputFileTracingIncludes
- Replace pdfjs-dist with unpdf (no transitive deps)
- Update test mock from getDocument → extractText

All 45 invoice-inbox unit tests pass.

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

* fix(invoice-inbox): make unpdf import static, rename pdfjs test labels

Per Greptile review on #409:

1. CRITICAL: tryExtractPdfText was still using await import('unpdf'), a
   dynamic import. CLAUDE.md forbids dynamic imports in extensions
   precisely because Next.js bundling can't reliably trace them — which
   is the same class of failure that caused the pdfjs prod bug. unpdf
   bundles statically (no canvas/worker), so a top-level static import
   is safe and correct.

2. NIT: two test descriptions still said "pdfjs" after the mock rename.

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-07 11:32:50 +02:00
Jakob WennbergandClaude Opus 4.7 3a74f0ab61 fix(invoice-inbox): ship pdfjs worker file to Vercel (#408)
* fix(invoice-inbox): include pdfjs worker file in function bundle

After #407 landed and the DOMMatrix error was gone, the next pdfjs failure
surfaced in prod logs:

  Setting up fake worker failed: "Cannot find module
  '/var/task/node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs'..."

pdfjs loads its worker module via dynamic import at runtime, which Next's
tracer can't see. With pdfjs-dist marked as a server external package the
main pdf.mjs ships but the worker file gets pruned. Force-include it via
outputFileTracingIncludes for any /api/extensions/ext/** route, since
that's where invoice-inbox dispatches.

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

* refactor: narrow pdfjs worker tracing to invoice-inbox sub-path

Per Greptile review on #408. Other extensions don't use pdfjs and
shouldn't pay the ~1 MB worker cost. In practice all extension routes
share one catch-all bundle, so this is more about clarity of intent than
bundle size today, but future-proofs against per-segment bundling.

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-07 11:13:23 +02:00
Jakob WennbergandClaude Opus 4.7 5f9f72959f fix(invoice-inbox): stub DOM globals so pdfjs runs on Vercel (#407)
* fix(invoice-inbox): stub DOM globals so pdfjs text extraction works on Vercel

pdfjs-dist's legacy build references DOMMatrix/ImageData/Path2D at module
load. On Vercel's Node runtime those globals don't exist, so the dynamic
import threw "DOMMatrix is not defined" before getDocument() ran — every
inbox item came back with empty extracted_data. We only call
getTextContent (no rendering), so empty-class stubs are sufficient.

Also mark pdfjs-dist as a server external package so Next.js doesn't try
to bundle it (the bundling step pulls in @napi-rs/canvas references that
aren't installed and fails silently with the same downstream error).

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

* refactor(invoice-inbox): hoist DOM stubs to module scope

Per Greptile review on #407: stubs run once at module load instead of
per-call. Same effect, clearer intent. Wrapped in a block to scope the
local variable.

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-07 10:53:45 +02:00
Jakob WennbergandClaude Opus 4.7 30f5877e57 fix(invoices): init remaining_amount on create + attach invoice PDF to payment JE (#406)
* fix(invoices): initialize remaining_amount on create

The invoices table column remaining_amount has DB default 0. The create
path never set it, so brand-new fakturor were stored with
remaining_amount=0 even though no payment had been received. The
InvoicePicker (and any future open-invoice query that filters on
remaining_amount > 0) treated these as fully settled and hid them from
match candidates — surfaced when a real user reported "Inga öppna
fakturor" despite having 5 sent invoices.

Set remaining_amount = total on insert for document_type='invoice'.
Proformas and delivery notes have no payment obligation, so they keep
the 0 default.

Backfill of the 46 existing rows across 20 companies (1.32M SEK in
orphaned receivables) ran separately as a one-shot UPDATE — restricted
to rows with paid_amount IS NULL OR 0 so any legitimately-paid invoice
with stale status was untouched (verified: 0 such rows).

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

* feat(match-invoice): attach invoice PDF as underlag for payment JE

The payment verifikation created on transaction match (debit 1930 /
credit 1510) had no document attachment. The invoice PDF was archived
on send and pinned to the AR-booking JE, but document_attachments
.journal_entry_id is one-to-one — the payment JE was left without
underlag, a BFL 7 kap audit-trail gap.

Cheapest fix: after the payment JE is created, look up the invoice's
existing document_attachment row and insert a parallel row that points
at the same storage_path with the new journal_entry_id. The original
WORM file is untouched (single storage object, two DB pointers); no
schema change. Wrapped in non-blocking try/catch so a document lookup
failure doesn't abort the payment match.

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

* fix(match-invoice): destructure document_attachments insert error

Supabase JS client returns { data, error } on Postgres-level failures
(unique constraint, RLS reject) instead of throwing. The surrounding
try/catch only caught thrown JS exceptions, so DB errors on the payment
JE document attachment were silently swallowed — the txLog.warn path
was unreachable for the most likely failure mode.

Destructure { error: attachErr } and log on error with both JE ids so
attachment failures are visible and reparable. Greptile P1 on PR #406.

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 23:41:03 +02:00
Jakob WennbergandClaude Opus 4.7 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 b89abf64b1 feat(database): expand pending_operations.operation_type to include new transaction types (#404) 2026-05-06 18:24:52 +02:00
Mattsson e77423d099 feat(transactions): add tool to list transactions without documents and update scope map (#403) 2026-05-06 17:14:59 +02:00
Jakob WennbergandClaude Opus 4.7 4131db2894 chore: MCP intent-tools, BankID enrichment table, multi-tenant fixes (#402)
* chore: MCP intent-tools, BankID enrichment table, multi-tenant fixes

MCP server gains six intent-shaped tools that collapse multi-call
agent flows into one: vat_close_check, query_journal, auto_match_period,
create_supplier_invoice_from_inbox, audit_package, year_end_readiness.
Tools wired into TOOL_SCOPE_MAP and OPERATION_RISK_TIERS as appropriate
(create_supplier_invoice_from_inbox at medium tier — reversible until
approve, but stages a leverantörsskuld).

BankID enrichment now persists to a dedicated bankid_enrichment table
keyed by user_id. extension_data has been company-scoped (NOT NULL
company_id) since the multi-tenant refactor, so every BankID signup has
silently been failing the enrichment upsert. Select-company picker reads
from the new table.

delete_last_voucher (BFNAR 2013:2) needs to clear
document_attachments.journal_entry_id before deleting the entry, but the
new document immutability trigger blocks that UPDATE. Added the same
gnubok.allow_delete transaction-scoped bypass pattern used by the
journal-entry/line/retention triggers. pg-real tests cover the happy
path, the unauthorized direct UPDATE, and the swap-to-different-entry
attempt under the bypass flag.

fiscal_periods.no_overlapping_fiscal_periods exclusion was scoped to
user_id from before multi-tenant — rebound to company_id so the same
user can have overlapping fiscal years across companies they own/are
member of.

Also adds scripts/seed-demo-account.ts for end-to-end demo seeding
(two companies, full FY2025, active FY2026 with mixed state).

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

* fix(pr-402): address review feedback

Migrations
- Drop 20260506140000_document_journal_entry_immutability_delete_bypass.sql:
  redundant with 20260506140000_document_journal_entry_immutability_bypass.sql
  that landed on main while this branch was open. Both share the same
  gnubok.allow_delete pattern; main's version is what the DB actually has.
- Rename 20260506150000_bankid_enrichment_table.sql →
  20260506160000_bankid_enrichment_table.sql to clear the timestamp clash
  with 20260506150000_protect_document_journal_link.sql on main (Supabase
  branch preview was failing on schema_migrations PK collision).

Tests
- Drop the swap-under-flag test from delete-last-voucher.pg.test.ts:
  main's bypass returns NEW unconditionally when gnubok.allow_delete='true',
  so the swap is permitted. Drop the duplicate happy-path test (already
  covered by 'clears journal_entry_id on attached documents and deletes
  the voucher'). Keep the unauthorized-direct-UPDATE test.
- Add bankid-enrichment.pg.test.ts covering the SELECT RLS policy:
  user reads own row, cannot read another user's row, INSERT denied for
  authenticated.

gnubok_query_journal
- amount_min/amount_max is applied post-fetch (PostgREST can't OR
  abs(debit) and abs(credit) cleanly), but PostgREST's count is computed
  pre-filter. Reporting that as total_lines mislead agents into
  paginating a tail that was already filtered out. When the amount
  filter is applied, anchor total_lines and truncated to the filtered
  set and surface db_matched_pre_amount_filter +
  amount_filter_applied_post_fetch separately.
- Escape `_` in the free-text LIKE filter so a search for "2_441"
  doesn't match "2X441".

VAT close check
- Reverse-charge blocker no longer fires on ruta 30 (seller-side
  domestic omvänd skattskyldighet) — the seller books no VAT, the buyer
  does, so missing ruta 48 is expected. Now scoped to ruta 31/32 (EU
  acquisition) where the buyer must book both calculated output (2615)
  and matching ingående moms (2645).
- High-value receipt threshold no longer reads journal_entries.total_amount
  (column doesn't exist; check silently never fired). Sums debits across
  the entry's lines, which equals the gross for ordinary purchase entries
  — comparing a gross figure against the BFL/ML 4 000 SEK threshold per
  ML 17 kap 26–28 §.

seed-demo-account.ts
- Require an explicit email argument; refuse to run with the previously
  hardcoded fallback that would silently target a real user. Ensure
  email is non-undefined for downstream typing.
- Type the supabase fiscal_periods insert result locally so tsc no longer
  reports 'fp implicitly any' from the loose untyped client.

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

* fix(test): adjust fiscal-period-start-day pg test for per-company overlap

The pg-real failure on PR #402 was a latent bug surfaced by this branch's
fiscal_periods exclusion constraint flip from user_id to company_id
(migration 20260506140100). The test was inserting periods that overlapped
seedCompany's default 2026-01-01..2026-12-31 period; the previous
constraint slipped past it because the test's INSERT didn't set user_id
(NULL escapes the WITH = match), so two same-company overlapping periods
silently coexisted.

Now that the constraint correctly fires per company, pick years that
don't overlap with the seeded 2026 period. The trigger's behavior under
test (allow mid-month start when no earlier period exists, allow
back-dated SIE imports, reject mid-month start when an earlier period
exists) is unchanged.

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

* fix(vat-close-check): correct reverse-charge/import blocker rutor

Rutor 30/31/32 are the buyer's calculated utgående moms on reverse-
charge purchases (domestic byggtjänster/electronics → 2614 → ruta 30;
EU goods → 2624 → ruta 31; EU services → 2634 → ruta 32). The buyer
must also book matching ingående moms (2647 inhemskt / 2645 utlandet
→ ruta 48). The previous fix removed ruta 30 on the basis that it was
seller-side; that's incorrect — domestic-RC sellers book no VAT at
all (they report only beskattningsunderlag on ruta 41), so 2614 only
sees buyer-side entries. Restore ruta 30.

Also extend the check to import rutor 60/61/62 (non-EU import VAT
declared via momsdeklaration since 2015 — 2615/2625/2635). Same
mechanic: importer books output VAT on these rutor and deducts the
input side via ruta 48. SaaS-from-AWS / OpenAI / Vercel companies hit
this path; without including 60/61/62 the blocker would silently miss
their misbookings.

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

* fix(mcp): expose ruta 60/61/62 (import VAT) on the local VatReportResult

The vat-close-check fix referenced vatReport.rutor.ruta60/61/62 but the
MCP server's local VatReportResult type only carries ruta 05-49. Build
broke on tsc.

Extend the MCP server's slim VAT report to also project import VAT —
2615 → ruta 60 (25%), 2625 → ruta 61 (12%), 2635 → ruta 62 (6%) — and
fold those into ruta 49 (att betala/återfå). Mirrors the BAS-to-Ruta
mapping in lib/reports/vat-declaration.ts. Output schema and required
list updated accordingly.

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 16:41:36 +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
Jakob WennbergandClaude Opus 4.7 b9a2ce522b chore: post-bankid redirect, recapt feedback, TIC SPAR enrichment (#400)
* chore: post-bankid redirect, recapt feedback, TIC SPAR enrichment

- BankID login + register now redirect to /select-company so the picker
  shows freshly enriched CompanyRoles from the current session.
- New lib/support/submit-feedback util prefers window.recapt feedback
  widget when present, falls back to /api/support/contact. SupportLink
  uses it and hides itself in sandbox companies via new isSandbox flag
  on CompanyContext (+ useCompanyOptional hook).
- TIC enrichment re-requests SPAR alongside CompanyRoles now that both
  types are enabled on the tenant; enrichment shape logged PII-free
  (booleans/counts only). Tests cover the SPAR+CompanyRoles path.
- Skatteverket api-client: 15s AbortSignal timeout on outbound requests.
- Swedish compliance review CI: bump REVIEW_MODEL to claude-opus-4-7.

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

* chore: keep compliance review model on sonnet-4-6

Reverts the opus-4-7 bump from the previous commit per request.

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

* fix(tic): don't persist SPAR PII to extension_data

The previous commit started requesting SPAR alongside CompanyRoles and
wrote the full enrichment payload (incl. personnummer, full name, home
address, birth date, gender) verbatim to extension_data.value — a plain
JSON column. Personnummer is already hashed + encrypted in
bankid_identities, so the extension_data row was an unencrypted PII
duplicate exposed to anyone with read access to the table.

No consumer (middleware, /select-company, createCompanyFromTicRole)
reads any SPAR field today; they only read companyRoles. Persist a
sanitized blob of { companyRoles, enrichedAtUtc } instead. SPAR is
still requested from TIC (and its shape logged PII-free) so enrichment
completes; if address pre-fill ships later, those fields should be
encrypted before storage.

Also drop the dead `null` branch from SubmitFeedbackResult.channel —
every code path returns 'recapt' or 'email'.

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 13:43:06 +02:00
MattssonandClaude Opus 4.7 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