Commit Graph
7 Commits
Author SHA1 Message Date
Jakob WennbergandClaude Fable 5 15300aa8e2 fix(invites): accept invite on BankID signup, recover missed invites on onboarding surfaces (#1157)
An invited user who registered via BankID was funneled into creating a
company instead of joining the one they were invited to: the register
page's BankID path never processed the gnubok-invite-token cookie
(unlike the login, MFA-verify, and auth-callback paths). Observed in
production 2026-07-24.

- register: BankID signup now accepts the pending invite before routing
  to /select-company, mirroring the login page's BankID path.
- lib/company/pending-invites: acceptPendingInviteByToken retries a
  missed acceptance from the cookie (pending + unexpired + email match,
  same rules as POST /api/team/accept); hasPendingInviteForEmail detects
  a stranded invitee whose cookie is gone.
- /onboarding and /select-company retry acceptance from the cookie and
  redirect to the dashboard on success, making the auth callback's
  long-promised fallback real; with no cookie but a pending invitation,
  both surfaces show a 'join via the link in the invitation email' hint
  instead of silently asking the invitee to create a company.
- No new accept path without the token: the hint deliberately points
  back to the mailed link, so mailbox possession stays required and no
  company name is leaked to unverified emails.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 19:36:09 +02:00
MattssonandClaude Opus 4.8 2c2743eb79 Check/salary bankid api (#892)
* fix(bankid): harden login/signup flow — polling, signup rollback, metadata merge, enrichment lookup

- middleware: read BankID enrichment from the bankid_enrichment table (the
  extension_data path has been dead since the multi-tenant refactor), so
  company-less BankID users land on /select-company instead of the manual wizard
- BankIdAuth: hard 6-min poll deadline; every failed poll counts toward the
  give-up limit; guard overlapping ticks so completion runs exactly once
  (a double /complete regenerated the magic link and invalidated the first,
  failing logins intermittently); retry clicks wait out the start cooldown
  instead of silently no-oping; Swedish messages for 429/unknown start errors
- bankid/complete: all-or-nothing signup — delete the created user when the
  identity insert, app_metadata update, or magic-link generation fails, so a
  retry starts clean instead of hitting account_exists with an unusable account
- bankid/unlink: read-merge-write app_metadata so has_password survives unlink
  (BankID-only users could otherwise strand themselves with no login method)
- login: BankID "create account" CTA now links to /register instead of
  dismissing the notice; sv.json: fix missing å/ä/ö in settings_bankid strings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: move secondary guides into docs/, delete dead root files

Move DOCKER.md, SELF-HOSTING.md, WHITELABEL.md and extensions.md
(renamed EXTENSIONS.md) into a new docs/ folder and update all path
references (README, setup.sh, .dockerignore image rules, docker-publish
workflow comment, _example-branding, lib/branding/service.ts).

Delete two dead root files: customer.json (stray API-test payload) and
findings.md (point-in-time swarm audit export, criticals already filed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(api): security & correctness hardening + withRouteContext MFA migration across API routes

Audit of ~100 app/api routes. Highlights:

Security
- agent/conversations: list leaked colleagues' titles + message previews
  (company-scoped RLS, no user filter) -> user-scoped
- calendar/feed PUT: raw body into .update() allowed feed_token fixation on a
  public unauthenticated URL -> strict schema, content toggles only
- bokslutsdispositioner: unbounded schablonintaktRate could inflate the
  IL 30 kap 25% periodiseringsfond cap base -> bounded
- agent profile/composer/onboarding: viewers could rewrite the agent profile
  while sibling /verify blocked them -> role-gated

Correctness
- account-totals / listAssets: unbounded queries silently truncated at 1000
  rows (under-counted money; skipped assets at year-end depreciation) ->
  fetchAllRows with stable order (+3 more pagination fixes)
- voucher-gaps: swallowed detect_voucher_gaps RPC errors (BFNAR gap view could
  show "no gaps" when the check never ran) -> surfaced
- 5 phantom-success writes (OK on zero matched rows) fixed
- assets K3 component-sum validated against stale acquisition_cost -> fixed
- invite silent email-send failure -> response carries email_sent;
  deadlines/calendar cast-then-check JSON crashes -> Zod

Convention
- ~44 legacy routes converted to withRouteContext (MFA); added Zod validation,
  corrected status codes, console.* -> lib/logger

Response shapes preserved for existing callers. ~110 new tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): save a booking as a reusable template from Bokför direkt

Add a "Spara som mall" action to the manual booking dialog so users can
capture a kontering they just worked out as a booking template — right
where they figured out how something should be booked.

- derive amount-parameterised template lines from the concrete booking
  (settlement = the non-VAT leg nearest the total, 26xx = a VAT line with
  its rate snapped to the nearest standard rate, the rest = business
  ratios; line labels come from the loaded BAS chart)
- extract the shared TemplateForm out of BookingTemplatesPanel so the
  booking dialog reuses the same editor, live preview and convertibility
  hints instead of duplicating them
- save via the existing POST /api/settings/booking-templates endpoint

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

* fix(bokslut): render arsredovisning RR/BR at ÅRL post level — no kontonummer

Bolagsverket rejected a user's filed årsredovisning with "Balansräkning
och resultaträkning ska inte innehålla kontonummer": the PDF built every
statement row as per-account "1930 Företagskonto" lines while the iXBRL
filing path already aggregated to statutory posts, so the two artifacts
diverged.

The PDF statements now derive from the same K2 risbs mapping the iXBRL
document uses (mapTrialBalancesToK2), via a new statement-rows.ts that
emits post-level rows in uppställningsform order for both the K2 and K3
templates. Also fixed along the way:

- Jämförelseår column (ÅRL 3:5 §) — previous-year trial balances now load
  and render; the old PDF had no comparatives at all.
- mapping.warnings (unmapped accounts, RR ≠ 2099, obalans, reclass
  nudges) flow into ArsredovisningData.warnings so the wizard flags a
  non-fileable document before download.
- Flerårsöversikt current/previous year overridden with the mapper's
  strict-3000–3799 Nettoomsattning, mirroring build-input's
  duplicate-fact rule, so the FB table ties to the RR.
- FB eget kapital-table is post-level and drops obeskattade reserver
  (never eget kapital); K3 equity-changes statement uses real prior-year
  opening balances with derived utdelning/nyemission residuals that tie
  the roll-forward exactly to booked UB.
- build-input dedupes warnings now that the PDF path runs the same
  mapping.

Regression test asserts no RR/BR label ever contains a four-digit
account number again.

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

* fix(reports): diagnose untransferred prior-year results behind balance-sheet differens

Prod incident (97 kr): a multi-year SIE migration lacked one year's
omforing av arets resultat; the residual corrupted every later derived
opening balance and Balansrakningen showed a bare "Differens: 97 kr"
with no explanation. Continuity checking cannot catch this failure mode
(prior-year UB and derived IB match per-account by construction) - the
invariant that actually breaks is per-year P&L = 0 for all non-latest
years.

- lib/reports/imbalance-diagnosis.ts: shared detector
  (findUntransferredResults + buildImbalanceDiagnosis)
- Balansrakning/Balansrapport attach imbalance_diagnosis when unbalanced,
  naming the exact culprit years; rendered in web views + PDF; MCP
  gnubok_get_balance_sheet inherits the field via spread
- SIE import: parse-time warning when a completed year's vouchers leave
  a P&L residual, plus a post-import DB walk surfacing culprits as
  warnings and structured details.untransferredResults; the Arcim
  migration workspace previously dropped result.warnings entirely and
  now renders them
- opening-balance/correct: pre-flight the company lock date and return
  409 OB_COMPANY_LOCK_DATE (retryable: false, lock date interpolated in
  the client message) instead of the retryable 500 that invited blind
  retries; catch-path maps a raced trigger rejection to the same code

Diagnosis runs only on unbalanced paths (zero cost when healthy) and
never fails the report or the import. No migration, nothing persisted.

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

* fix: production error remediation — FX rates, deadlines, log levels, correction relink

Batch of fixes for recurring Vercel runtime errors:

- Riksbanken FX rates: persistent read-through cache (exchange_rates
  table), one retry honoring Retry-After on 429/5xx, bounded ingest
  concurrency, and an honest fallback — most recent cached observation
  or null, never a hardcoded rate silently booked into amount_sek.
  Unrated transactions stay repairable via refresh-exchange-rate.
- Tax deadline regeneration inserts replacement rows before deleting
  the superseded set, so a failed insert no longer wipes a company's
  deadlines (the 23502 user_id regression did exactly that). Migration
  makes deadlines.user_id nullable for system-generated rows.
- Route wrappers + errorResponse log 4xx outcomes at warn so only
  genuine 5xx reach Vercel's runtime-error clustering; client-supplied
  /api/log telemetry demoted to warn as well.
- application/json documents (raw PSD2 responses archived per BFL)
  validate as parseable JSON with object/array root instead of always
  failing the magic-byte check.
- correctEntry surfaces document-relink failures to callers, and the
  BFL document-immutability trigger now allows relinking underlag from
  a reversed entry to its correction (migration + pg test).
- Middleware clears stale session cookies on /api requests too, using
  scope 'local' so cleanup doesn't re-trigger the failed token refresh.

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

* fix(skatteverket): persist token health and stop retrying dead consents

Terminal auth errors (SESSION_EXPIRED, REFRESH_EXHAUSTED, MISSING_SCOPE,
TOKEN_CORRUPTED) mark the token row needs_reconsent with the error code
and timestamp — SKV per-flow refresh tokens live 65 minutes, so once
expired nothing recovers without a fresh BankID consent. The AGI
kvittens and skattekonto sync crons skip flagged connections instead of
failing every night, and the settings panel prompts for re-consent
proactively. A successful reconnect resets the row to active.

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

* fix(banking): allocate distinct BAS ledger slots for PSD2 mirror accounts

A bank returning N same-currency accounts used to map them all onto the
currency default (1930/1932/1933/1934), tripping the UNIQUE
(company_id, ledger_account) constraint per-account — swallowed errors
left accounts silently unmirrored. allocatePsd2LedgerAccount now hands
out the currency default first, then free 1931–1959 sub-account slots,
skipping slots held by any existing row.

- Callback persists allocations to accounts_data so the picker pre-fills
  reality; reconnect reuses previously mirrored ledgers instead of
  re-deriving (a user remap to 1935 survives).
- Selection save resolves effective ledgers up front and rejects
  duplicates or cross-connection conflicts with a 400 instead of
  silently skipping the mirror.
- Bank error codes + psu_type are forwarded to the settings page for
  every OAuth error, keying the Handelsbanken corporate fullmakt
  guidance.

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

* fix(agent): stage exact journal lines on categorization previews

Categorization previews only carried debit/credit accounts, the GROSS
amount, and separate VAT rows — read together that looks like an
unbalanced 'gross on cost account + VAT debit' entry, and it misled
both users and agents into rejecting correct proposals. The MCP
preview and the pending-operation PATCH now materialize the exact
lines the commit executor will post (net cost line, VAT line, gross
bank line, SEK) via buildTransactionEntryLines, and PATCH re-derives
them from the new mapping instead of spreading stale staged lines.
ApprovalCard and /pending render the verifikat lines, falling back to
the legacy summary only for operations staged before this fix.

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

* feat(bookkeeping): prune unused imported accounts from the chart

SIE imports routinely bring in hundreds of accounts that were never
used and clutter the kontoplan. New account_usage_counts RPC (one
grouped query instead of a count per account) backs GET
/api/bookkeeping/accounts/usage, and POST /api/bookkeeping/accounts/prune
deletes zero-usage accounts — dry-run first, then an explicit account
list capped at 2000. Accounts with journal lines are skipped, never
deleted. The chart manager shows a usage column and a prune dialog
grouping custom accounts vs unused BAS-seeded ones.

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

* feat(api): carry dimensions through v1 invoice and supplier-invoice surfaces

Credit-note creation now copies default_dimensions and per-line
dimensions from the original, so the reversing journal entry nets
against the same dimension cells instead of dropping them. List/detail
responses expose the dimension fields, and the OpenAPI spec snapshot
follows.

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

* perf: batch serial Supabase round-trips on hot dashboard paths

Every dashboard render pays the layout's query chain, so serialized
awaits are direct wall-clock: the layout, chat conversation, invoice
detail, supplier detail, select-company, and agent-onboarding pages now
run their independent lookups in parallel batches, and
getCompanyCapabilities folds its disabled-config read into the same
round-trip. JournalEntryList hydrates the saved fiscal-year scope
optimistically instead of serializing the first entries fetch behind
the fiscal-periods request. The supplier detail page filters invoices
server-side via a new supplier_id query param instead of fetching the
whole company ledger, and the invoice editor (with its framer-motion
dependency) lazy-loads so it stops shipping with the invoice list
bundle.

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

* feat(salary): one-click runs, payslip delivery, payments settings, run cockpit

Salary P1 batch, driving the 20-click flow toward 3 clicks:

- One-click 'Starta lönekörning': POST /api/salary/runs accepts an
  empty body and resolves defaults server-side — period follows the
  latest non-corrected run, payment date from the new
  salary_pay_day setting, series from the per-source-type map. The
  separate /salary/runs/new page is gone.
- Run detail page rebuilt as a step-railed cockpit (progress rail,
  KPI cards, employee ledger, journal preview) on a deliberately
  wider canvas; components extracted to components/salary/run/.
- Payslip delivery: tokenized public payslip pages (/payslip/[token],
  backed by salary_payslip_links) plus per-employee email send with
  PDF — employees need no account, and the middleware exempts the
  route from auth redirects.
- Payments settings: salary pay day, default bank, and pain.001 vs
  Bankgirot Lön format with per-bank upload instructions and an LB
  sunset warning (banks retire LB during 2026).
- AGI panel: full submission status flows (stale drafts, signing
  links, kvittens polling, error reports); tax payment panel with
  skattekonto shortcut and mark-as-paid.
- Salary calendar bulk editing, employee benefits/tax-card polish,
  municipality tax-table lookup improvements.

messages/sv+en also carry the strings for the account-prune,
skatteverket-reconsent, and banking surfaces committed just before
this.

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

* chore: adopt Next 16 proxy.ts convention + repo housekeeping

- Rename middleware.ts to proxy.ts with the proxy() export (Next 16
  renamed the middleware convention; behavior unchanged).
- Exclude dev_docs/ from tsconfig so stray snippets in planning docs
  don't break the build type-check.
- Ratchet antipatterns-baseline down (raw-route-auth 165 → 119) to
  lock in the withRouteContext migration from 5cfd2b76.
- template-library uses roundOre() instead of inline rounding.
- database.md: drop account_balances from the key-tables list.

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

* fix(bookkeeping): robust service-role detection in correction document relink

relink_documents_to_correction() keyed its service-role branch on auth.role(),
which reads the singular request.jwt.claim.role GUC that PostgREST v10+ and the
pg-real harness no longer populate. Genuine service-role callers (pending-ops
executor / MCP approve) landed in the auth gate and could not relink underlag.
Read the role from the request.jwt.claims JSON directly, mirroring the canonical
link_voucher_rpcs_tenant_guard convention. Validated on staging.

Also: harden the salary run page's error paths (res.json().catch) against
non-JSON error bodies, and roll back the pg-real service-role case in finally so
an aborted transaction cannot poison a pooled connection for the next test.

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

* fix(documents): restore journal_entry_line_id link durability (BFL 7 kap)

Migration 20260704103000 rewrote enforce_document_journal_entry_immutability to
guard journal_entry_id but left journal_entry_line_id to the metadata trigger,
which exempts draft-linked docs -- and the entry-level trigger only fired on
UPDATE OF journal_entry_id, so a line-id-only UPDATE never invoked it at all.
That let a set journal_entry_line_id be cleared to NULL, breaking the "link
durable from first set" invariant (document-immutability.pg regression).

Widen the trigger to fire on journal_entry_line_id too and guard it with the
same uuid-durability rule as journal_entry_id (setting NULL -> uuid stays
allowed; clearing/re-pointing a set value is blocked, status-independent). The
correction-relink GUC path, which legitimately clears line_id when moving
underlag to the posted correction, stays exempt. Validated on staging.

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

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 03:05:09 +02:00
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00
Jakob WennbergandClaude Opus 4.8 c74b19df1b Accounted rebrand + swarm-skill cleanup + bank-reconciliation fixes (#643)
* feat(reconciliation): close the bank-feed loop on voucher links and re-tag mis-typed opening balances

Two related fixes to bank reconciliation correctness:

1. Auto-reconcile on voucher link. Linking an invoice or supplier invoice to
   an existing voucher previously advanced only the invoice — the bank
   transaction that paid it kept sitting in the Transactions inbox with a null
   journal_entry_id. linkInvoiceToVoucher / linkSupplierInvoiceToVoucher now
   call autoReconcileTransactionForLinkedVoucher (lib/reconciliation), which
   links the bank transaction to the same verifikat when exactly one unbooked
   line matches it. Best-effort and post-commit: a failure here never fails the
   link. The result surfaces reconciledTransactionId; the inbox row leaves the
   list and the UI shows link_success_tx_reconciled.

2. Re-tag mis-typed opening balances. getReconciliationStatus and the GL-line
   matching RPCs identify a cash account's ingående balans solely by
   journal_entries.source_type='opening_balance'. Companies migrated from other
   systems often booked the bank IB as an ordinary voucher (source_type
   'import' or 'manual'), so it was never excluded and surfaced as a phantom
   reconciliation difference equal to the opening balance. Adds:
   - migration mark_entry_as_opening_balance: a GUC-gated carve-out in the
     immutability trigger plus a SECURITY DEFINER RPC that validates the entry
     (balance-sheet lines only, dated on a fiscal-period boundary), flips the
     source_type, and writes an audit row — no blanket data sweep.
   - POST /api/reconciliation/bank/mark-opening-balance + MarkOpeningBalanceSchema.
   - BankReconciliationView action to trigger it from the IB diff.

The gnubok_create_voucher executor now accepts a typed is_opening_balance flag
and derives source_type='opening_balance' only after validating class 1/2 lines
on the period start, so new IBs land correctly typed.

Covered by lib/reconciliation auto-reconcile tests, voucher-executors tests,
and a mark-entry-as-opening-balance pg-real test.

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

* chore: rebrand gnubok → Accounted and prune swarm agent skills

Product rebrand and skills housekeeping. No runtime behaviour change.

Rebrand: replace user-visible "gnubok" with "Accounted" across docs, READMEs,
in-code comments, doc-site content, MCP skill/resource prose, and the
gnubok-mcp package description. The MCP resource URI scheme is moved gnubok://
→ Accounted:// consistently across resource registrations, the event-type
comment, and the resource/skill tests. Deliberately preserved as stable
identifiers (NOT rebranded): the gnubok-company-id cookie, gnubok_sk_ / gnubok_inv_
token prefixes, the gnubok-mcp npm bridge name, and the AGI <gem:Programnamn>
value (kept 'gnubok' per its source comment — it is the software identifier sent
to Skatteverket and must not churn across visual rebrands).

Skills: remove the 27 swarm-* agent SKILL.md atoms (no longer used; already
absent from the agent_atom_registry in prod), refresh the remaining skill docs,
add the .claude/rules/ path-scoped rule set, and regenerate the
seed_agent_atom_bodies migration + .skill-body-manifest.json via
`npm run skills:generate` so the DB-backed skill bodies match the trimmed set.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:52:01 +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
Jakob WennbergandClaude Opus 4.7 8fd3f112f8 fix: surface active TIC companies + block duplicate org numbers (#344)
* fix: surface active TIC companies + block duplicate org numbers

Three fixes from live-prod testing:

1. Enrichment filter hid the user's directorships. Now accepts both
   Completed and PartiallyCompleted status from TIC (tenants without
   CompanyRoles enabled still get SPAR) and the /select-company role
   filter no longer requires companyStatus === 'Aktivt' — real TIC
   payloads have been observed with different values, and positionEnd
   alone is the authoritative "currently a director" signal. Added
   PII-free diagnostic logs so the next shape-mismatch is debuggable
   from Vercel logs without a round trip.

2. Manual wizard silently allowed duplicate org numbers. Added:
   - findExistingCompanyByOrgNumber helper in actions.ts (service role,
     bypasses RLS to see cross-tenant rows)
   - Server-side guard in createCompanyFromOnboarding — returns
     'org_number_exists' before the create RPC so we don't leave ghost
     companies
   - New /api/company/check-org-number endpoint for debounced client
     checks
   - Warning + disabled submit in Step2CompanyDetails
   - Friendly error toasts in WelcomeOnboarding + BankIdCompanyPicker
   - Mirror cleaned org_number onto companies.org_number on creation so
     future duplicate checks and lookups are reliable

3. /onboarding ignored ?org_number= when the picker routed there as a
   fallback. Now reads searchParams and pre-fills settings; also fixed
   a latent bug where Step1's entity-type change wiped the pre-fill on
   *first* selection (it should only reset on a genuine change).

Tests: duplicate-org guard (with formatted-input normalization),
check-org-number route (auth + 400 + exists true/false +
normalization).

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

* fix: address PR review feedback on duplicate-org guard

Greptile P1 findings + swedish-compliance feedback:

- findExistingCompanyByOrgNumber now throws on Supabase error instead
  of silently returning null. Previously a DB outage or RLS
  misconfiguration would bypass the entire duplicate guard and allow
  duplicates through.
- createCompanyFromOnboarding catches the throw and returns a
  user-facing error ("Kunde inte verifiera organisationsnummer"),
  failing closed instead of open.
- companies.update({ org_number }) error is now checked and triggers a
  rollback. Silent failure would leave the company without an
  org_number, breaking all future duplicate checks for that entity.
- New normalizeOrgNumber helper validates 10- or 12-digit input,
  strips the century prefix for 12-digit personnummer form, and
  rejects anything else. Malformed input would have corrupted SIE4
  (#ORGNR) and SRU (INFO.SRU) exports downstream.
- /select-company now uses loose `== null` for positionEnd — TIC has
  been observed returning `undefined` for open-ended positions, which
  strict `=== null` would silently filter out. Documented the two
  downstream isCeased guards so future maintainers don't remove one
  without the other.
- createCompanyFromTicRole refuses to provision when lookup.isCeased
  (BFL 2 kap — bokföringsskyldighet ends at avregistrering).
  BankIdCompanyPicker surfaces this client-side too.
- WelcomeOnboarding + BankIdCompanyPicker recognise new error codes:
  org_number_invalid, company_ceased.

Tests: +4 cases covering malformed input rejection, fail-closed
behaviour on DB error, 12-digit personnummer normalization, and the
ceased-company refusal path. Full suite: 2306 passing.

Out of scope for this PR (follow-up):
- Partial unique index on companies(org_number) WHERE archived_at IS
  NULL. Closes the race-condition window but needs a migration plus
  any existing-duplicate cleanup — too risky for this hotfix.
- Rate limiting on /api/company/check-org-number. Endpoint is
  auth-gated so not an immediate concern.

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

* fix: add Luhn validation and extract org-number normalization

Third round of PR review feedback (swedish-compliance):

- Add Luhn-10 check-digit validation to normalizeOrgNumber. Rejects
  structurally invalid org_numbers (wrong check digit) at the boundary
  instead of letting them propagate into SIE4 #ORGNR and SRU INFO.SRU,
  where Skatteverket and receiving accounting systems would reject
  them later anyway. Reuses the existing luhnValidate helper from
  lib/bankgiro/luhn.ts (Bankgirot 10-modulen — same algorithm applies
  to both Bolagsverket org numbers and Swedish personnummer).

- Extract normalizeOrgNumber into lib/company-lookup/normalize-org-number.ts
  so the server action and /api/company/check-org-number use the same
  rule. Previously the API route only stripped hyphens/spaces, so a
  12-digit input would miss a stored 10-digit duplicate and mislead the
  client debounce check ("not a duplicate" → submit → server rejects).

- /api/company/check-org-number now returns exists=false for
  Luhn-invalid input rather than querying the DB. The submit-time
  server action surfaces org_number_invalid, which is the right place
  for the error.

Test coverage: dedicated normalize-org-number.test.ts (10 cases
covering both-lengths, Luhn, whitespace tolerance, garbage). Updated
existing tests to use Luhn-valid numbers (real Volvo 5560125790,
synthetic personnummer 8001011231). New failing-Luhn test in
actions.test.ts. New 12-digit-normalization and
luhn-invalid-returns-false tests in route.test.ts.

Full suite: 2315 passing.

Not fixed (out of scope for this hotfix):
- 10↔12 digit round-trip fragility for personnummer born 2000+. This
  is a codebase-wide architectural choice (see lib/skatteverket/format.ts
  which uses a two-digit-year heuristic to choose 19/20 at export).
  Migrating to 12-digit storage is a separate refactor.
- Server-side re-fetch of TIC /lookup for isCeased. The trust boundary
  here is user-to-their-own-onboarding, not adversarial; doubling TIC
  API cost isn't proportionate.

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-04-22 14:47:58 +02:00
Jakob WennbergandClaude Opus 4.7 f3a3d07ed3 feat: one-click company setup from BankID directorships (#309)
* feat: one-click company setup from BankID directorships

After BankID auth, surface Bolagsverket companies where the user is a
director and provision a fully-configured gnubok company with one click
instead of walking the 4-step wizard. Also exposed via CompanySwitcher's
"Lägg till företag" for returning users.

- New /select-company route merges gnubok memberships with TIC
  CompanyRoles; cards flag already-registered org numbers.
- createCompanyFromTicRole server action derives entity_type, f-skatt,
  VAT, moms_period, and SPAR address defaults, then delegates to
  createCompanyFromOnboarding for consistent provisioning.
- TIC /bankid/complete now requests enrichment on login too, so
  returning users see fresh CompanyRoles in the picker.
- Middleware routes zero-membership users to /select-company when
  enrichment is available, /onboarding otherwise.
- Inline enrichment picker removed from WelcomeOnboarding (wizard is
  now the manual fallback); SPAR address pre-fill preserved.
- Unit tests for mapEntityType helper and createCompanyFromTicRole
  defaults (VAT-AB, non-VAT EF, unmappable, unauth).

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

* fix: address PR review feedback on BankID company picker

Greptile P1 + swedish-compliance bot findings:

- Move enrichment row cleanup out of createCompanyFromOnboarding and
  into createCompanyFromTicRole. The manual wizard also goes through
  createCompanyFromOnboarding, and was wiping the enrichment row before
  the returning-user "Lägg till företag" flow could use it.
- Refuse to provision when TIC /lookup is missing. Silently defaulting
  vat_registered to false for a momsregistrerat bolag would create a
  company that issues invoices without moms (ML 17 kap violation). The
  picker now routes to the manual wizard with org_number pre-filled
  when the lookup fails, so the user confirms VAT/F-skatt manually.
- Default accounting_method by entity type: enskild firma → cash
  (K1/kontantmetoden per BFNAR 2013:2), aktiebolag → accrual (K2/K3).
- Document that moms_period='quarterly' is a provisional middle-tier
  default; Skatteverket's assigned period depends on turnover and the
  user can correct it in /settings/tax.
- Fix the misleading "re-fetch from BankID" comment — /select-company
  only reads the cached enrichment row; it's refreshed only on the next
  BankID auth.
- Extend test coverage: lookup-missing refusal, EF kontantmetoden default.

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

* fix: tighten entity-type mapping and clarify K1 threshold

Second round of PR review fixes (swedish-compliance bot):

- mapEntityType now uses explicit allow-lists instead of substring
  matches. "Enskild stiftelse" / "Enskild näringsverksamhet utan firma"
  no longer false-match as enskild_firma (would have provisioned with
  K1/kontantmetoden — ML/BFL risk). Regression guard test added.
- Publikt aktiebolag explicitly included (same K2/K3 regime as private
  AB); Bankaktiebolag / Försäkringsaktiebolag excluded (FFFS regime).
- Remove misleading claim that onboarding UI flags moms_period as
  provisional — no such UI exists by design (approved one-click UX).
- Expand accounting_method comment to cite the 3 MSEK K1→K3 threshold
  (BFNAR 2013:2 vs 2017:3) so the EF→cash default is honest about its
  scope.

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-04-22 13:21:55 +02:00