Commit Graph
6 Commits
Author SHA1 Message Date
Jakob WennbergandClaude Opus 4.7 cc351158f8 Invoicing & account-security polish bundle (#550)
* feat: invoicing & account-security polish bundle

Five independent improvements bundled to ship together:

- BankID/password lockout fix: BankID-only users could enroll MFA and
  brick themselves (Supabase requires AAL2 to change password or unenroll
  MFA, and AAL2 needs a password sign-in). New app_metadata.has_password
  flag tracks this; middleware gates /mfa/enroll behind it, /account/set-
  password is the unlock path, SecuritySettings shows a banner, and
  /api/account/password is the single write path that flips the flag.
  Backfill script for existing users.

- Swish invoice payment method: company_settings.swish + invoice_show_swish
  columns, validation in lib/api/schemas.ts (accepts 123XXXXXXX företag or
  07XXXXXXXX mobile, strips whitespace/hyphens), rendered on invoice PDFs.

- Send-reminders kill switch: per-company company_settings.send_invoice_
  reminders toggle in PdfPrintSettings/Automatisering. Reminder processor
  also tightened: positive status allowlist (sent + overdue) so terminal
  statuses can never match; skip when customer already responded via
  reminder link; race-window re-check before send.

- First-invoice logo prompt: one-shot dialog when creating the first
  invoice without a logo (issue #520). Self-limits via head-only count.

- SIE export opening-balance fallback: route IB through getOpeningBalances
  so the compute_prior_opening_balances RPC supplies #IB after multi-year
  imports where opening_balance_entry_id is intentionally NULL. Previously
  #IB silently went to zero and #UB collapsed to current-period movements.

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

* fix(account-polish): address PR review feedback

- BankID-link path (extensions/general/tic/index.ts): read-merge-write
  app_metadata instead of passing { bankid_linked: true } alone.
  updateUserById REPLACES app_metadata wholesale, so the previous code
  would have wiped has_password for any user who later linked BankID,
  causing the set-password banner to (incorrectly) reappear and blocking
  the standard MFA enrollment button. The comment is now corrected.

- Middleware (lib/supabase/middleware.ts): thread inner returnTo through
  the /mfa/enroll → /account/set-password redirect so the user lands on
  their original destination after the full chain completes, not on /.

- safeReturnTo helper (lib/auth/safe-return-to.ts): replace the
  starts-with-/-but-not-// guard on mfa/enroll and set-password pages.
  The previous guard let /\evil.com and /@evil.com through. The new
  helper parses against a synthetic base origin and verifies it matches.

- set-password page (app/(auth)/account/set-password/page.tsx): remove
  CLAUDE.md design system violations — bg-gradient-to-b on page bg,
  inline shadow-md style on the card, space-y-5, font-medium on the h1,
  rounded-xl on the card. Flat surface, hairline border, font-display
  h1 per the design tokens.

- Swish dedup (lib/payments/swish.ts): extract normaliseSwish() and
  isValidSwish() helpers and use them in lib/api/schemas.ts,
  components/settings/BankDetailsForm.tsx, and the invoicing settings
  page. Single source of truth for the regex.

- Password route (app/api/account/password/route.ts): emit a structured
  success log so the audit pipeline can detect password-set events, not
  just failures.

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-21 16:44:09 +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 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
Jakob Wennberg 9a7f067fdd Revert "fix(tic): call /enrichment before /collect to avoid session-consume bug (#367)" (#368)
This reverts commit 34955b0418.
2026-04-27 19:20:17 +02:00
Jakob WennbergandClaude Opus 4.7 34955b0418 fix(tic): call /enrichment before /collect to avoid session-consume bug (#367)
* fix(tic): call /enrichment before /collect to avoid session-consume bug

TIC support confirmed (2026-04-24) that reading session status via /poll or
/collect after the BankID session reaches `complete` marks the session as
"consumed" server-side, after which /api/v1/enrichment refuses the sessionId
with `error: "Session not completed"` (returned as HTTP 200, not 400).

Today's flow inside POST /api/extensions/ext/tic/bankid/complete called
collectBankIdResult and then requestEnrichment — exactly the order that
triggers the bug. Login still succeeded (collect itself returns OK), but
enrichment failed 100% of the time, leaving extension_data.bankid_enrichment
empty and /select-company's CompanyRoles picker blank.

Reorder so /enrichment runs first, then /collect. Per TIC, the consume flag
only blocks subsequent /enrichment calls — collect after enrichment is
harmless. Refactor the existing fetchAndStoreEnrichment helper into
fetchEnrichmentSafely (pure fetch, returns EnrichmentData | null) and
storeEnrichment (pure DB upsert), so the data can be captured before the
collect/user-creation logic and persisted afterward.

Enrichment stays non-blocking: any /enrichment failure still logs a warning
and lets login proceed, exactly as today.

Adds a regression test asserting requestEnrichment is invoked before
collectBankIdResult (via mock.invocationCallOrder) plus tests for both
failure modes — enrichment throwing and TIC returning the production
"Session not completed" body shape.

Ref: TIC session 90f7da27-84b2-4f09-afc4-47239eef57c1.

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

* fix(tic): drop redundant void _omit — _ prefix already silences lint

Per PR review: @typescript-eslint/no-unused-vars ignores `_`-prefixed
identifiers by default, so the void expression is dead 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-04-27 18:55:53 +02:00
Jakob WennbergandClaude Opus 4.7 a0485ca1c1 fix: BankID signup takeover + supplier invoice dedup + event_log RLS visibility (#358)
* fix(tic): reject BankID signup when email is already registered (CWE-287)

Signup previously linked BankID to any pre-existing profile matching the
submitted email. Because BankID proves identity but not email ownership,
an attacker who knew a victim's email could bind their own BankID to the
victim's account and then log in via BankID (which skips TOTP MFA).

Now signup returns 409 account_exists; the register page shows a Swedish
error toast and redirects to /login so the user can authenticate with
their password first and link BankID from settings (via the authenticated
/bankid/link route that already exists).

Covered by new bankid-complete.test.ts with an explicit regression test
asserting no side-effects occur on the account_exists path.

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

* fix(bookkeeping): prevent duplicate registration entries for supplier invoices

The invoice-inbox convert flow creates the registration journal entry
inline and then emits supplier_invoice.confirmed. The core handler was
also creating one, producing a second voucher on 2440/2641/expense and
overwriting registration_journal_entry_id on the row.

Two guards in the core handler:
- Payload guard: skip if supplierInvoice.registration_journal_entry_id
  is already set (fast path for callers that include it in the payload).
- DB re-fetch guard: re-read the row and skip if it has been linked
  since the payload was built (handles stale-payload callers).

The inbox extension now stamps registration_journal_entry_id onto the
in-memory invoice before emitting so the fast path trips.

Also fixes a latent bug where the handler filtered company_settings by
userId instead of companyId, which would have selected the wrong row
(or none) on multi-company accounts.

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

* fix(events): write company_id on event_log rows and refuse unscoped events

event_log has a company-scoped SELECT RLS policy, so rows written with
NULL company_id are invisible to every user — effectively silently
dropped from the automation feed. The handler now reads companyId from
the payload (all persisted event types mandate it in TS) and includes
it in both single and batch inserts.

If a caller ever bypasses the type system and emits without companyId,
the handler logs an error and skips persistence rather than writing a
poisoned row.

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

* fix: address PR review nits (event-log meta strip, redirect timer)

- event-log-handler: rename stripUserId → stripMetaFields and also drop
  companyId from the stored data JSONB so it isn't duplicated alongside
  its dedicated column.
- register page: drop the 1500ms setTimeout before router.push('/login')
  on the account_exists branch — the timer had no cleanup and fired a
  stale-closure push if the component unmounted first. The toast survives
  the route change via the root layout's Toaster.

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-27 12:51:56 +02:00