Commit Graph

927 Commits

Author SHA1 Message Date
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 Wennberg 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 Wennberg 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 Wennberg 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 Wennberg 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 Wennberg 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 Wennberg 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 Wennberg 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 Wennberg 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
Mattsson 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 Wennberg 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 Wennberg 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 Wennberg 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 Wennberg 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 Wennberg 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 Wennberg 97db09a3ff feat(invoices): allocate-on-save, makulera flow, manual invoice picker (#405)
* feat(invoices): allocate-on-save, makulera flow, manual invoice picker

Three coordinated invoice changes:

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

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

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

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

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

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

Greptile P1 + Swedish compliance reviewer findings:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(invoices): InvoicePicker excludes proforma invoices

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

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

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

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

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

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

New error code MATCH_INVOICE_NOT_INVOICE_TYPE (400). Test added.

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

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

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:49:56 +02:00
Mattsson 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 Wennberg 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 Wennberg 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
Mattsson 5725c25bf1 Logs/improved logging (#398)
* feat(mcp): add create_transactions tool with /pending approval gate

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:12:02 +02:00
Jakob Wennberg 94f15b9c6c feat(invoice-inbox): pin documents to bank transactions + MCP tools (#397)
* feat(invoice-inbox): pin documents to bank transactions + MCP tools

Adds a first-class flow for attaching unmatched inbox documents to bank
transactions, separate from the existing supplier-invoice convert path:

- new transactions.document_id FK → document_attachments (ON DELETE SET NULL)
- POST/DELETE /api/transactions/[id]/attach-document
- categorize route propagates the link to journal_entry_id on commit
- three new MCP tools: gnubok_list_unmatched_documents,
  gnubok_get_document_content (5-min signed URL),
  gnubok_attach_document_to_transaction (staged via pending_operations)
- InvoiceInboxWorkspace gains a "Koppla till transaktion" picker dialog
  ranked by amount-match, plus a "Bilaga" badge in SwipeCategorizationView
- regex extraction unchanged; supplier-invoice convert flow unchanged

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

* fix(invoice-inbox): address PR #397 review findings

- categorize: destructure { error } from the document-link update so
  Supabase-level failures are logged instead of silently dropped (BFL 5 kap 6 §
  receipt-on-verifikation contract).
- list_unmatched_documents: emit next_cursor whenever the inbox query may have
  more rows, not only when the post-filter slice was full; switch to composite
  (created_at, id) cursor to avoid same-second collisions.
- DELETE /attach-document: return 404 when the tx isn't in the company; return
  409 when the linked document already has journal_entry_id set
  (räkenskapsinformation immutability).
- risk tier: attach_document_to_transaction medium (was low) — link becomes
  part of verifikation underlag once categorize propagates it.
- pg-real test: stop reusing $2 across uuid + text-concat contexts (Postgres
  couldn't deduce the parameter type).

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

* fix(migrations): break duplicate version 20260505120000 (Supabase Preview)

Two migrations on main share filename version 20260505120000:
  - 20260505120000_api_keys_refresh_token.sql (PR #392)
  - 20260505120000_drop_agent_auto_commit.sql (PR #394)

The schema_migrations primary key is (version), so any fresh DB doing
`supabase db push` over both files conflicts on the second insert. This is
why every PR with a migration since #394 has had Supabase Preview either
fail or skip.

Renaming _drop_agent_auto_commit to 20260505190027 — that matches the
timestamp recorded in prod schema_migrations from when apply_migration was
called for it, so future `db push` against prod sees the file as already-
applied (no re-run). The migration body is fully idempotent (IF EXISTS on
every drop) so a re-run would be a no-op anyway.

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

* fix(invoice-inbox): address PR #397 round-2 compliance review

Two BFL gaps the compliance bot flagged on the round-1 fix commit:

1. commitAttachDocumentToTransaction silently broke verifikation→underlag if
   the transaction was categorized between staging and approval. Now reads
   transactions.journal_entry_id at commit time and, if non-null, also writes
   document_attachments.journal_entry_id in the same commit so BFL 5 kap 6 §
   is satisfied regardless of order.

2. Application-layer DELETE check was racy (SELECT then UPDATE) and the FK
   ON DELETE SET NULL path could null transactions.document_id even for a
   document that is räkenskapsinformation. Added a BEFORE UPDATE OF
   document_id trigger on transactions that raises check_violation when the
   previously-attached document has document_attachments.journal_entry_id
   set. The app-layer guard stays for friendly Swedish messaging; the
   trigger is the DB-level safety net.

pg-real test extended to cover both directions of the trigger (block detach
+ block swap) and the happy-path detach when there's no JE link yet.

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

* fix(invoice-inbox): address PR #397 round-3 compliance review

Four findings from the round-2 update of the compliance bot. The first three
are genuine compliance gaps; the fourth (preview metadata distinguishing pre-
vs post-categorization overwrites) is a UX nicety left for follow-up.

1. transactions.document_id FK switched from ON DELETE SET NULL to RESTRICT
   (migration 20260506100000). Removes the "trigger ordering" concern: a doc
   that's pinned to any tx now cannot be deleted at all without explicit
   detach first. Belt-and-braces with block_document_deletion.

2. commitAttachDocumentToTransaction now does:
   - pre-check that mirrors the DELETE route's 409 when the existing pinned
     doc is räkenskapsinformation, so the same Swedish message is returned
     in both paths;
   - UPDATE…RETURNING journal_entry_id so the propagation decision uses the
     post-update state, closing the read-then-write race with concurrent
     categorize. Either ordering of attach-then-categorize or
     categorize-then-attach now lands at the same correct final state.

3. Both DELETE /attach-document and the MCP commit path catch the trigger's
   check_violation (SQLSTATE 23514) and translate to 409 with the Swedish
   underlag message. The trigger remains the DB-level safety net; the app
   layer is responsible only for friendly UX.

pg-real test rewritten for ON DELETE RESTRICT (blocks deletion of pinned doc;
detach-then-delete works). Unit coverage added for commitAttach: 404, two
distinct 409 paths (pre-check + trigger-translation), happy-path
uncategorized, and propagation when tx was categorized between staging and
commit.

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

* fix(invoice-inbox): address PR #397 round-4 compliance review

Three of five round-3 findings actioned:

1. commitAttachDocumentToTransaction: surface propagation failure rather
   than logging-and-continuing. If document_attachments.journal_entry_id
   can't be set after the transaction has been categorized, the op fails
   (status 500) with a Swedish message instructing retry. Retry is
   idempotent — same document_id on the tx, same propagate target.

2. Replace check_violation (23514) matching with a stable
   "BFL_DOCUMENT_IMMUTABILITY:" message prefix. The trigger now uses default
   P0001 + tagged message; both the route handler and the executor match on
   the prefix instead of the generic SQLSTATE. Future unrelated CHECK
   constraints on transactions can no longer accidentally surface as the
   räkenskapsinformation message.

3. gnubok_list_unmatched_documents now returns invoice currency alongside
   amount so an agent can FX-normalise before comparing to
   transactions.amount. Description updated to make the requirement
   explicit. Mirrored in the UI: AttachToTransactionDialog ranks
   same-currency rows by amount distance and pushes cross-currency rows to
   the bottom of the list.

Skipped:
- Two-migration window for FK action change is acknowledged as resolved by
  the bot; deploy-atomicity is an ops concern, not code.
- Period-lock check in attach/detach: realistic compliance concern is
  already covered by the existing immutability trigger (post-categorize) and
  by the engine's period-lock enforcement (categorize itself). A dedicated
  period check on pre-categorize attach would only guard against pinning a
  doc to a tx in a closed period — defensible defense-in-depth, but no
  active BFL violation. Left for a follow-up.

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

* fix(invoice-inbox): address PR #397 round-5 compliance review

Three of four findings actioned. The fourth (block_document_deletion
verification) is already covered by 20240101000017_enforcement_triggers.sql
which raises when document_attachments.journal_entry_id IS NOT NULL on a
posted/reversed entry — confirmed via grep, no code change needed.

1. categorize/route.ts: propagation no longer fires-and-forgets. If
   document_attachments.journal_entry_id can't be set after the JE has been
   committed, the response now carries a document_link_warning field with a
   Swedish retry message. The JE is already committed so we can't roll back,
   but the client can no longer mistake a partial attach for a clean
   categorize.

2. Rättelse audit trail (BFL 5 kap 5 §): both the REST POST handler and the
   MCP commit executor now append a TransactionDocumentReplaced event to
   processing_history whenever a non-null document_id is overwritten, with
   previous_document_id and new_document_id in the payload. Best-effort —
   logging failure must not roll back the (compliant) attach. The previous
   doc id is also returned in the response so callers see what was displaced.

3. MCP staging preview now exposes the existing doc's identity
   (existing_document_id, existing_document_file_name) plus an explicit
   existing_document_is_rakenskapsinformation flag, so a human approver sees
   "replaces X.pdf with Y.pdf" rather than just a will_overwrite_existing
   boolean. Mirrors BFL 5 kap 5 § informed-rättelse intent.

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

* fix(invoice-inbox): close trigger SELECT race (PR #397 round-6)

The enforce_transactions_document_immutability trigger SELECTed
document_attachments.journal_entry_id without a row lock. A concurrent
UPDATE setting journal_entry_id on that row could commit between the
trigger's SELECT and its RAISE, letting a detach slip through against a
document that just became räkenskapsinformation.

Add FOR SHARE to the SELECT inside the trigger. A concurrent journal_entry_id
write blocks on our share lock until our transaction commits, so either we
observe the propagation and raise, or we run first and the propagation
observes our committed detach (which is fine because journal_entry_id was
still null at that point).

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

* fix(invoice-inbox): bidirectional immutability + richer staging preview (PR #397 round-7)

Two of six round-6 findings actioned. The other four are recurring
architectural recommendations (atomic audit-log writes, background
reconciliation jobs, migration consolidation, anti-join via materialized
view) that are properly scoped as follow-up work.

1. document_attachments side of the immutability link (BFL 5 kap 6 § works
   in both directions). New trigger enforce_document_journal_entry_immutability
   blocks UPDATE OF journal_entry_id when going from non-null to NULL or to a
   different uuid. The original null→uuid path (initial propagation in the
   categorize / commitAttach flows) still works. Migration
   20260506130000.

2. gnubok_attach_document_to_transaction staging preview now joins on
   invoice_inbox_items.extracted_data and surfaces vendor/amount/currency/
   invoice_date alongside the existing doc filename/mime metadata. Gives the
   human approver the same hints the agent saw before choosing the
   attachment.

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 10:24:57 +02:00
Jakob Wennberg 2c03dac981 fix(reports): convert FX to SEK in supplier/AR ledger reconciliation (#396)
* fix(reports): convert FX to SEK in supplier/AR ledger reconciliation

The supplier and AR ledger reports were summing remaining_amount
directly without converting foreign-currency invoices, so a
EUR/USD invoice would land in the aging total at face value while
the corresponding 2440 / 1510 GL line was already posted in SEK.
This produced false reconciliation discrepancies (e.g. 496,25 kr
ledger vs 952,50 kr GL with four EUR/USD invoices).

Apply resolveSekAmount(remaining, null, currency, exchange_rate)
in supplier-ledger, supplier-reconciliation, ar-ledger, and
ar-reconciliation. Per-invoice detail rows on the AR ledger keep
the original currency for display; only aging buckets and totals
become SEK. Adds mixed-currency test cases to all four files.

Also bundles unrelated WIP from the working tree:
- bank-reconciliation: log the swallowed catch error and drop the
  fallback path for the deleted get_unlinked_bank_lines RPC, using
  get_unlinked_1930_lines directly.
- new GET /api/transactions list endpoint with unmatched/reconciled/
  currency/date filters and full route tests.

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

* fix(reports): address PR #396 review findings

Compliance and Greptile review identified four issues; all four are
addressed here.

- Reconciliation: surface unconverted_fx_count on ReconciliationResult
  and ARReconciliationResult. When > 0 the difference field may be a
  data gap (FX invoice with no exchange_rate) rather than a true
  reconciliation break. UI now renders a Swedish caveat below the
  Avstämd / Ej avstämd badge so users understand the cause. New tests
  assert the count is set on legacy FX rows.

- Reconciliation: document the invoice-date-rate assumption explicitly
  in the JSDoc of both reconciliation generators. Per ML 8 kap 21–23 §,
  the report uses each invoice's stored exchange_rate; partial payments
  settled at a different rate produce a delta correctly booked to
  3960/7960 as valutakursvinst/-förlust, but the GL will diverge from
  the report by that amount until a subledger-derived total is wired up
  (deferred follow-up).

- fetchUnlinkedGLLines: drop the misleading bankAccount parameter.
  It was advertised as configurable but the function silently returned
  [] for any value other than '1930'. Now the signature is honest:
  1930-only until proper multi-account support is built.

- /api/transactions: query MAX_ROWS+1 rows so the response can include
  has_more and limit fields. Callers can now detect truncation, which
  matters once a company crosses 500 unmatched transactions in the
  selected range. New test asserts has_more=true when 501 rows are
  returned by the DB and the response is sliced to MAX_ROWS.

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

* fix(reports): address PR #396 round-2 compliance review

Compliance bot v2 review surfaced four findings on the previous commit;
three are addressed here. The fourth (an fx_rate_diff_amount indicator
distinguishing real reconciliation breaks from correctly-booked
valutakursvinst/-förlust) requires a subledger-derived total against
3960/7960 — already documented as a deferred follow-up in the JSDoc.

Changes:

- Exclude unconvertible FX rows from SEK sums. resolveSekAmount's
  null-rate fallback returned the raw foreign amount, so a 100 EUR
  invoice with no rate was being added to a SEK total as if it were
  100 SEK. All four generators (supplier-ledger,
  supplier-reconciliation, ar-ledger, ar-reconciliation) now skip
  rows where currency != SEK and exchange_rate is missing/zero, and
  count them in unconverted_fx_count.

- Surface unconverted_fx_count on SupplierLedgerReport /
  ARLedgerReport (not just the reconciliation result). UI shows a
  Swedish caption beneath the "Totalt utestående" card whenever the
  count is positive, so users see the warning even if they don't
  enable the reconciliation panel.

- Add outstanding_sek: number | null to ARInvoiceDetail. The
  per-invoice detail row keeps `outstanding` in invoice currency for
  display, but now also exposes the converted SEK value (or null when
  unconvertible). Defensive against future callers that sum across
  customers — they should use outstanding_sek to avoid mixing
  currencies (a real momsdeklaration foot-gun otherwise).

- Add a 1930-only notice to BankReconciliationView. The reconciliation
  is scoped to account 1930; users with Plusgiro 1920, kreditkort 1940,
  or valutakonton now see a Swedish caption explaining those are
  reconciled separately.

Tests:

- supplier-ledger: previous "falls back to original amount" case
  flipped to assert the row is excluded and counted; assertion that
  the legacy supplier disappears from the list when their only row is
  unconvertible.
- supplier-reconciliation: previous "1 100 ledger vs 1 000 GL" case
  flipped to assert ledger=1 000 and is_reconciled=true with
  unconverted_fx_count=1.
- ar-reconciliation: same pattern.
- ar-ledger: existing FX-mix test extended to assert outstanding_sek
  on each detail row; new test covering the null-rate exclusion path
  asserts the detail row is still pushed (with outstanding_sek=null)
  but excluded from buckets and the grand total.

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

* fix(reports): address PR #396 round-3 compliance review

Compliance bot v3 surfaced four findings on the previous commit; two
are addressed here, two are deliberately skipped (rationale in JSDoc /
this message).

Addressed:

- is_reconciled now returns false whenever unconverted_fx_count > 0,
  even if the numeric difference is zero. Per BFL 5 kap, the
  reconciliation must cover all affärshändelser; if a row was
  excluded for a missing exchange rate, the calculation is
  incomplete by construction and the period cannot honestly be
  stamped Avstämd. The fix is for the user to fill in the missing
  rate, not for the system to claim balance on partial data.

- AR reconciliation now sums account 1510 + 1513 in the GL balance
  comparison. Forward-looking defense for ROT/RUT fakturamodellen
  invoices that split AR receivables across the customer portion
  (1510) and the Skatteverket claim (1513). Today no production code
  posts to 1513 so the value is unchanged in practice; once
  fakturamodellen invoicing is added, the reconciliation will
  continue to balance without requiring another fix. UI label
  updated to "Kundfordringar (1510 + 1513) saldo" so the inclusion
  is visible. Field name and shape are unchanged for back-compat
  with the supplier-side parallel.

Skipped:

- "Block is_reconciled=true when any FX invoice exists in an open
  period" — overly aggressive; would block reconciliation for any
  FX-using company even when their books are correctly matched. The
  proper solution is the deferred subledger-derived total against
  3960/7960 (already documented in JSDoc on both reconciliation
  generators), which can compute the *expected* FX rate difference
  and either subtract it from `difference` or expose it as
  `fx_rate_difference`. Not in scope for this PR.

- Plusgiro 1920 wording in BankReconciliationView — bot itself
  marked this "not a hard finding". The 1920 reference is accurate
  per BAS 2026.

Tests:

- supplier-reconciliation: existing v3 exclusion test updated —
  asserts is_reconciled=false despite numbers matching, with
  comment explaining BFL rationale.
- ar-reconciliation: same update + new test covering the 1510 + 1513
  sum (1 200 on 1510, 300 on 1513, ledger total 1 500 → reconciled).

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-05 22:30:40 +02:00
Mattsson e31e7567f4 feat(ar-ledger): enhance outstanding invoice calculations and add tests for credited invoices (#395) 2026-05-05 21:22:26 +02:00
Jakob Wennberg b05f0e59b9 fix(mcp): correct fiscal_periods column ref + remove agent auto-commit (#394)
* fix(mcp): correct fiscal_periods column ref + remove agent auto-commit

The MCP `gnubok_list_fiscal_periods` tool selected a non-existent
`fiscal_periods.status` column, causing `column fiscal_periods.status
does not exist` errors when agents called it. Fixed by selecting the
real columns (`is_closed`, `locked_at`, `closed_at`,
`opening_balances_set`) and deriving `status` in code.

Also removes the agent auto-commit feature (settings card, gating logic,
DB columns, tests). In its current shape only `create_customer` was
auto-commitable, so the toggle changed nothing meaningful in practice
while implying a level of agent autonomy that wasn't actually granted.
The risk-tier infrastructure on `pending_operations` (actor model,
risk_level) is kept since it's still used by the /pending UI filters.

Migration `20260505120000_drop_agent_auto_commit.sql` drops:
  - pending_operations.auto_commit_eligible
  - pending_operations.auto_committed_at
  - company_settings.agent_auto_commit_enabled
  - company_settings.agent_auto_commit_max_amount

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

* fix(mcp): explicitly pick fields in gnubok_list_fiscal_periods response

Greptile flagged that `...p` spreads raw DB columns (`is_closed`,
`locked_at`, `closed_at`) into the tool response alongside the
derived `status`. Drop the spread for an explicit field list so the
tool contract is the derived status only — agents don't need to
reason about raw columns, and future SELECT additions won't silently
leak.

Also drops `closed_at` from the SELECT since it wasn't read.

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-05 21:16:01 +02:00
Jakob Wennberg 5c52f24a49 feat(mcp): agent-native improvements — progressive discovery, widgets, skills, telemetry (#393)
* feat(mcp): agent-native improvements — progressive discovery, widgets, skills, telemetry

Four coordinated streams of MCP server improvements that move gnubok toward
agent-first design, grounded in Anthropic's Nov 2025 "Code execution with MCP"
article and the May 2026 MCP conference talk.

Context budget — minimize tools/list payload
- New gnubok_search_tools: progressive discovery with name|summary|full detail
  levels and scope filtering. Agents pull only the schemas they need.
- Trimmed all 50 tool descriptions from multi-paragraph blocks (avg ~500-1000
  chars) to one-sentence summaries (avg ~120 chars). Args/Returns/Examples
  blocks dropped — they duplicated inputSchema.
- outputSchema declared on every tool; structuredContent emitted on every
  successful tools/call (was previously only widget-tagged tools).
- protocolVersion bumped to 2025-06-18 (negotiates back to 2024-11-05).
- Workflow examples consolidated into initialize.instructions.
- Net effect: tools/list payload ~43 KB / ~10.8K tokens for 51+ tools, with
  headroom guard at 20K tokens.

MCP applications — server-shipped UI widgets
- New widgets/ directory with typed UiWidget contract; receipt-matcher moved
  out of widget-html.ts (which was deleted) into widgets/receipt-matcher.ts.
- New gnubok_vat_review_widget tool + interactive momsdeklaration widget
  (all 8 rutor with summary card, theme-aware light/dark, copy buttons).
- resources/list and resources/read iterate uiWidgets dynamically — adding
  the next widget is a single file drop.

Skills over MCP — domain-knowledge primitive
- 5 user-facing SKILL.md-style workflow guides authored from existing
  .claude/skills/swedish-* development skills:
  • month-end-close — book → reconcile → VAT (monthly filers) → lock
  • quarterly-vat-review — ruta-by-ruta map, deadlines, common errors
  • year-end-close — bokslut, bokslutstransaktioner, lock → year-end
    → opening balances → close (irreversible)
  • invoicing-rules — ML 17 kap. 24 §, customer types, ROT/RUT, Peppol
  • payroll-monthly — salary run → calculate → review → AGI XML
- gnubok_list_skills (with optional tag filter) + gnubok_load_skill(slug).
  Both unscoped — available to any authenticated key.
- Each skill also exposed as MCP resource at gnubok://skill/<slug>
  (text/markdown) for forward compatibility with a future native
  skills/list primitive.

Tool-call telemetry — measure before optimizing further
- Three new CoreEvent types (mcp.tool_called, mcp.tools_list_called,
  mcp.resource_read), all persisted to event_log (30-day TTL, RLS-scoped).
- Fire-and-forget emission from the dispatcher — never blocks JSON-RPC
  response, double-guarded against handler failures.
- tools/call instrumented at all four exit points (success, execution
  error, scope denied, unknown tool). Latency measured tightly around
  tool.execute() — excludes dispatcher overhead.
- tools/list logs returned tool count (informs progressive-discovery
  adoption); resources/read logs URI + kind discriminator (widget /
  skill / data / unknown).
- No PII or secret material in payloads — only metadata.

Out of scope (explicitly deferred):
- Code-mode SDK (no production code-mode hosts to consume it yet).
- Elicitations (require streamable HTTP transport — bigger architectural lift).
- DB lockdown / RPC funnel (foundational; should follow once telemetry tells
  us where writes actually flow).
- CRUD → intent endpoints (frontend coupling — multi-PR effort).

Tests: +37 new unit tests across search-tools, output-schema, payload-size,
vat-review-widget, skills, telemetry. Existing receipt-matcher test updated
for the new structuredContent contract. 2,615 unit tests passing.
Production build green. No new lint warnings or errors in changed files.

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

* fix(mcp): address PR #393 review findings

Greptile (P1 + P2) and the Swedish accounting compliance bot flagged 9 issues
across security, data correctness, and skill content. All addressed:

Security (P1)
- gnubok_search_tools: scope filter now fails closed when __keyScopes is
  absent. The earlier permissive default leaked the full tool inventory if
  the dispatcher's hard-coded name check ever silently broke. Marker presence
  is part of the contract — explicitly empty array also hides scoped tools.
  Two new test cases pin the fail-closed behaviour.

Compliance — VAT (data correctness)
- get_vat_report now aggregates 2614/2624/2634 (reverse-charge output VAT)
  and exposes them as ruta30/ruta31/ruta32 per SKV 4700. ruta48 also picks
  up 2647 (missing before). ruta49 formula corrected to
  (10+11+12+30+31+32) − 48. The widget renders the new rutor between the
  Utgående and Ingående sections.
- Widget ruta 05 sub-label updated from "3001+3002+3003" to "all momspliktig
  försäljning oavsett skattesats" — ruta 05 covers all domestic taxable
  supplies, not just direct-rate sales.
- Refactored: extracted computeVatReport() helper used by both
  gnubok_get_vat_report and gnubok_vat_review_widget. Removes the
  rename-fragile tools.find() lookup at runtime.

Compliance — payroll
- payroll-monthly skill: replaced "born 1958 or earlier = 10.21%" (the 2024
  formulation) with the statutory rule "age 66+ on 1 January of the income
  year (67+ from income year 2026)". Removed the unsourced "age 16–18:
  11.78%" row in favour of a current växa-stöd description with explicit
  Prop. 2025/26:34 reference and a "verify against current Skatteverket
  tables" caveat.

Compliance — skills text
- invoicing-rules: added explicit footnote on the 1 April 2026 livsmedel
  rate change. Restaurang/servering stays at 12 %; livsmedel sold in other
  forms drops to 6 %. Per Prop. 2025/26:55.
- year-end-close: clarified periodiseringsfond cap as "25 % of överskott
  before this year's avsättning" (IL 30 kap.), removing the ambiguous
  "skattemässigt resultat" phrasing that could be misread as a circular
  after-fond computation.

Schema correctness
- STAGED_OPERATION_SCHEMA.required gains "staged" — every path through
  stagePendingOperation returns the field, so the schema now matches the
  contract that MCP clients validate against.

Tests: 2,617 passing (+2 for the search-tools fail-closed cases).
Production build green.

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

* fix(mcp): address PR #393 round-2 compliance review

Round 2 of the swedish-compliance bot ran after the previous fix-push and
flagged four substantive items + a recommendation. All addressed:

VAT computation (data correctness)
- ruta05 expanded beyond 3001/3002/3003 to cover the common BAS taxable-
  revenue accounts (3001-3008, 3041-3048, 3051-3058, 3071-3078). Companies
  that book to 30xx alternates were previously under-reporting taxable
  turnover; now all standard BAS taxable-revenue numbering contributes.
- One-sided reverse-charge warning: when output VAT is booked on
  2614/2624/2634 (rutor 30/31/32 > 0) but the matching calculated input
  VAT (2645) is zero, computeVatReport now returns a Swedish-language
  warning string. ruta49 is inflated in this case — the warning surfaces
  the most common reverse-charge error per the swedish-vat skill. The
  widget renders warnings in a terracotta panel above the summary card.
- computeVatReport exported and a focused unit test added — exercises 2647
  inclusion in ruta48, reverse-charge balanced/unbalanced cases, and the
  expanded ruta05 mapping. Fills the gap that prior tools/call integration
  tests couldn't reach.

Skill content
- payroll-monthly Step 6: BAS journal-entry example no longer hard-codes
  31.42 % on the 7510/2730 lines. The avgift line is now described as
  "avgift_base × applicable_rate per employee" with explicit aggregation
  semantics for runs that mix full-rate and reduced-rate employees.
  Aligns with the Step-4 reduced-rate caveats already in place.
- invoicing-rules ROT/RUT block: replaced the bare "30 % / max 50 000 SEK"
  text with the full year-by-year picture — RUT 50 % / 75 000 max, ROT
  baseline 30 % / 50 000 max, 2024 H2 doubled ceiling, 2025 May–Dec
  enhanced 50 % rate. Defaults to "verify against current Skatteverket
  table" rather than a single hard-coded rate.

False positives in the round-2 review (no fix needed; documented for the
record):
- "Old vat_report path still computes ruta48 without 2647" — the old path
  was replaced by computeVatReport in the previous push; the bot was
  reading the diff hunk and conflating it with current behaviour.
- "Widget prose says ruta49 = (10+11+12) - 48" — no such prose exists in
  widgets/vat-review.ts. The skill body has the correct
  (10+11+12+30+31+32)-48 formula.
- "Including 'reversed' status entries in VAT aggregation may over-count
  cross-period storno" — current behaviour is correct per Skatteverket
  period-aligned filing: the reversed original stays in its own period,
  the matching storno (status 'posted') lands in the reversal period,
  and they net to zero across the full year. Adding code comment to
  document.

Tests: 2,623 passing (+6 for computeVatReport unit tests). Production
build green.

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

* fix(mcp): address PR #393 round-3 compliance review

Compliance bot re-ran after the round-2 push and flagged three items:

VAT computation
- One-sided reverse-charge warning previously only checked 2645 (EU
  acquisitions). For domestic reverse charge per ML 16:13 (byggtjänster,
  electronics > 100k SEK, etc.) the matching input lands on 2647 — a
  correctly-balanced 2614+2647 booking would have falsely fired the
  warning. Fixed: warning now triggers only when *both* 2645 and 2647
  are zero. Updated message text mentions both accounts. Added a test
  case asserting the no-warning path for 2647-only-input.

Skill documentation drift
- quarterly-vat-review skill body still showed ruta05 source as
  "3001 + 3002 + 3003" while the runtime computeVatReport sums 32 BAS
  taxable-revenue accounts. Updated the skill table to read
  "3001–3008, 3041–3048, 3051–3058, 3071–3078" so the auditor-facing
  docs match the implementation.

outputSchema upgrade
- gnubok_get_vat_report and gnubok_vat_review_widget previously declared
  outputSchema as the bare { type: 'object' }. Replaced with a shared
  VAT_REPORT_OUTPUT_SCHEMA constant declaring period, period_label, all
  11 rutor (with descriptions referencing source accounts), summary,
  and warnings. Modern MCP clients that validate structuredContent
  against outputSchema now have an accurate contract. Added a test
  asserting the schema is non-trivial and declares every ruta the
  runtime returns.

Tests: 2,625 passing (+2 for the 2647 warning path and the
outputSchema shape assertion). Production build green.

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

* fix(mcp): address PR #393 round-4 compliance review

The compliance bot re-ran after the round-3 push with a fresh batch.
Real findings fixed; false positives documented.

VAT computation
- Removed 3004 (Försäljning inom Sverige, momsfri / VAT-exempt) from
  RUTA_05_ACCOUNTS — round-2's expansion accidentally included it. Ruta 05
  is the *taxable* base; exempt sales must NOT contribute. New test pins
  the exclusion.
- Added 3106 (taxable EU goods supply, momspliktig) to RUTA_05_ACCOUNTS.
  Used when EU buyer's VAT number is invalid or buyer is private.
- Added ruta35 — EU intra-community goods supplies, momsfri (account
  3108). Previously omitted entirely from the rutor schema; SKV 4700
  has it as a distinct box separate from ruta 39 (services) and ruta 40
  (export outside EU). VatReportResult, VAT_REPORT_OUTPUT_SCHEMA, the
  widget table, the copy-summary block, and the quarterly-vat-review
  skill table all updated. New test covers 3108 → ruta35 mapping.
- Strengthened the comment on the posted+reversed status filter to
  document why current behavior is correct per ML 2023:200 and
  faktureringsmetoden (the bot's cross-period storno concern is a false
  positive — see commit message rationale below).

Skill content
- invoicing-rules: added explicit BFL 5 kap. 6–7 § / ML 17 kap. 22–23 §
  note that the kreditfaktura itself consumes a sequential number from
  the same (or dedicated KR-) fakturaserie. The KR- prefix is a display
  convention; the underlying löpnummer must be unbroken just like the
  regular series.
- payroll-monthly: added the missing "born 1937 or earlier → 0 %"
  cohort to the rate breakdown. Previously could lead a payroll run to
  over-pay avgifter on the oldest cohort.

False positives in the round-4 review (verified, not changed)
- 2644/2648 as reverse-charge inputs: verified against gnubok's actual
  BAS chart (lib/bookkeeping/bas-data/class-2-equity-liabilities.ts).
  2644 does not exist; 2648 is "Vilande ingående moms" (dormant input
  VAT for cash method), not RC at 6 %. Canonical RC inputs are 2645 (EU)
  and 2647 (domestic) — both already covered.
- Cross-period storno over-count: per ML 2023:200 + Skatteverket
  faktureringsmetoden, the original sale's VAT belongs to the invoice-
  date period; the kreditfaktura's reduction belongs to the storno-date
  period. Including 'reversed' status entries (which still have their
  original date) is therefore correct. *Excluding* them would
  under-report the original period and over-credit the reversal period.
  Added a multi-line comment in computeVatReport documenting this.

Tests: 2,627 passing (+2 for ruta35 mapping and 3004 exclusion).
Production 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-05 20:04:10 +02:00
Jakob Wennberg 432a8b60dc feat(skatteverket): rewrite AGI flow against real Skatteverket RAML (#391)
* feat(skatteverket): rewrite AGI flow against real Skatteverket RAML

The previous AGI client posted JSON to URL paths that don't exist on
Skatteverket's gateway and used invented field names. POST /underlag
actually accepts application/xml, and the lock/kvittenser operations
live on the separate hanteraredovisningsperiod API. Verified against
dev_docs/arbetsgivardeklaration-inlamning(1.7.7) and
arbetsgivardeklaration-hantera-redovisningsperiod(1.2.8) RAMLs.

- Replace fictional types with real schemas (kontrollresultat,
  granskningsunderlag, kvittenser, error envelope)
- Rewrite agi-client into 9 functions matching the documented flow:
  /underlag (XML) -> kontrollresultat -> spara -> skapaGranskningsunderlag
  -> kvittenser, plus las/lasUpp on the hantera API
- Drop agi-mappers entirely; lib/salary/agi/xml-generator.ts already
  produces schema-valid XML, so the extension just feeds
  agi_declarations.xml_content to POST /underlag
- Extend skvRequest with a contentType option so AGI can post XML
- AGIPanel state machine: underlag_submitted -> awaiting_signing ->
  signed, with kontrollresultat polling and normalized findings
- Add the agd OAuth scope (confirmed from SKV's Tjanstebeskrivning
  Arbetsgivardeklaration inlamning v1.7, section 4.1.2.2)
- Add Skatteverket connect step to NewUserChecklist alongside the
  existing SIE/old-system import and bank steps; track
  hasSkatteverketConnected in OnboardingProgress
- Update orchestrator route + tests to point at the new /agi/submit
  endpoint
- Declare new optional base-URL env vars in the manifest

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

* fix(skatteverket): address PR review findings on AGI flow

- Surface INCORRECT_DATA felrapport link in AGIPanel
  skapaGranskningsunderlag returns 409 with a felrapport URL when SKV
  rejects the underlag. The link was persisted as `signeringslank` with
  status `underlag_rejected`, but the render condition only fired for
  `awaiting_signing`, leaving the link unreachable. Add a distinct
  destructive-styled block so the user can open the felrapport in Mina
  Sidor.

- /agi/underlag DELETE clears local submission state
  Add optional `period` query param. When supplied, clear
  `agi_submission_{period}` directly. When not, fall back to scanning
  recent agi_submission_* keys for the matching inlamningId. Without
  this, an aborted underlag left a stale `underlag_submitted` entry in
  extension_data and the UI couldn't progress.

- Re-add salary-run status guard inside loadAGIXml
  The orchestrator at app/api/salary/runs/[id]/agi/submit/route.ts has
  this check, but the extension endpoint is also reachable directly
  from AGIPanel and must enforce it itself. Per BFL 5 kap and SFL
  26 kap, AGI must reflect finalised payroll data; submitting from a
  draft/cancelled run would emit incorrect figures.

- Move agi_declarations.status='exported' from /agi/submit to /agi/spara
  Setting status on underlag-ingest was wrong because a DONE_REJECTED
  kontrollresultat would leave the row falsely marked as exported. The
  transition now happens only after the spara call commits the underlag
  to Eget utrymme. /agi/spara accepts salaryRunId in the body for the
  fast path and falls back to scanning agi_submission_* state otherwise.

- Move salary_runs.agi_submitted_at stamp to kvittenser observation
  The orchestrator was stamping at underlag-ingest, but no later code
  updated the column on signing. Removed the orchestrator stamp; the
  /agi/kvittenser handler now stamps salary_runs.agi_submitted_at to
  kvittens.signeradTid (mirroring SKV's own timestamp) when it pins
  the receipt to the matching agi_declarations row.

- Tighten misleading JSDoc in agi-client.ts
  taBortSparadInlamning is on the inlämning API, not hantera; the old
  layout grouped it under a "hantera API" heading and tripped an
  automated reviewer. Restructured into separate "period management"
  and "cleanup" blocks.

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

* fix(skatteverket): address Swedish compliance review on AGI flow

Follow-up to review on https://github.com/erp-mafia/gnubok/pull/391.

- Migration adds 'pending_signature' to agi_declarations.status
  Reusing 'exported' for the spara→kvittens interval misstated the
  filing outcome — Eget utrymme is a staging area, not a filing — which
  conflicts with BFNAR 2013:2 kap 8 / BFL 5 kap 5§ behandlingshistorik
  faithfulness. /agi/spara now sets 'pending_signature'; /agi/kvittenser
  later promotes to 'submitted' when a uuidKvittens is observed.

- AGIPanel auto-polls /agi/kvittenser at 30s, 2 min and 5 min after the
  signing link is created
  Previously the kvittens (and therefore salary_runs.agi_submitted_at)
  was only stamped if the user manually returned to the panel and
  clicked "Hämta kvittens". Without that follow-up the audit trail
  showed a NULL submitted-at for an AGI that had actually been filed.
  Background polls capture the kvittens for the common case where the
  user signs in Mina Sidor and never returns to gnubok. Cleanup on
  unmount via useRef + useEffect.

- Distinct MISSING_SCOPE error code on 403 invalid_scope
  Existing tokens lack the new 'agd' scope and surface as a generic
  ACCESS_DENIED today. The compliance reviewer pointed out that
  operators may interpret this as a data error and submit a corrected
  AGI with altered figures. New SkatteverketAuthError code maps SKV's
  invalid_scope body to a clear "reconnect via Inställningar →
  Skatteverket" message; routes to 401 (token-level remediation).

- Refine deadline copy in AGIPanel
  The standard AGI deadline is the 12th regardless of company size; the
  17th only applies in January and August for employers with turnover
  ≤ 40 MSEK. Surface that nuance instead of saying just "12:e".

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

* fix(skatteverket): server-side kvittens reconciliation cron

Round 2 of compliance review on https://github.com/erp-mafia/gnubok/pull/391.

- /api/extensions/skatteverket/agi/kvittenser/cron
  Walks every agi_declarations row in 'pending_signature' status, fetches
  kvittenser via the matching token, and on a hit promotes the row to
  'submitted' + stamps salary_runs.agi_submitted_at. Authoritative source
  for the audit trail per BFNAR 2013:2 kap 8 / BFL 5 kap 5§ — the
  AGIPanel client-side timers from the previous round remain as the
  fast-path UX, but no longer carry the audit-trail responsibility on
  their own. Per-row errors are skipped, not abort-the-run. 50s budget.
  Scheduled every 2 hours in vercel.json.

- AGIStatus union now includes 'pending_signature'
  Without this update, downstream code reading the union would have
  rejected the new status as unknown. The migration extending the DB
  CHECK constraint shipped in the previous commit; this brings the type
  layer into sync.

- Stale comment update in AGIPanel.tsx
  Referred to status='exported' from before the rename. Now reads
  'pending_signature', matching the actual handler behavior.

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

* fix(skatteverket): close audit-trail gaps from compliance round 3

- Cron now writes submitted_by from the token-owning auth.users row
  Previously left NULL with a "system actor" comment. The token row was
  created when the operator authenticated with BankID, and the kvittens'
  signeradAv refers to the same person — so writing the user_id from
  skatteverket_tokens captures actor traceability without inventing a
  system identity. Closes the BFL 5 kap 6§ / BFNAR 2013:2 kap 8 gap on
  cron-reconciled rows.

- /agi/spara monotonicity guard
  Adds .in('status', ['generated', 'exported']) to the row update so a
  delayed /agi/spara call after the cron (or interactive /agi/kvittenser)
  has already promoted the row to 'submitted'/'accepted' won't silently
  regress it back to 'pending_signature'. behandlingshistorik must
  advance only.

- DONE_REJECTED / DONE_FAILED → status='rejected'
  /agi/kontrollresultat handler now flips the matching agi_declarations
  row to 'rejected' on a terminal SKV failure, using the same
  cached-submission-state lookup pattern /agi/spara already uses.
  Without this the row sat at 'generated' indefinitely even though SKV
  considered the underlag failed. Same monotonicity guard prevents
  regressing a successfully-filed row.

- Deadline criterion: lönesumma, not omsättning
  AGIPanel pendingText. SFL 26 kap's relaxed-deadline criterion (17:e
  in Jan/Aug) is the employer's total taxable wages, not turnover.
  Internal reference (.claude/skills/swedish-payroll/references/agi-filing.md)
  used the colloquial "turnover"; statutory wording is "lönesumma".

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

* fix(skatteverket): close round-4 audit-trail and UX gaps

- agi_submitted_at: NULL when signeradTid absent
  Both /agi/kvittenser handler and the kvittens cron previously
  fell back to new Date().toISOString() if SKV's kvittens lacked
  signeradTid. Substituting wall-clock now() falsifies the filing
  moment in behandlingshistorik (BFNAR 2013:2 kap 8 / BFL 5 kap 6§).
  Now leaves the column NULL and logs a warning. Status flip to
  'submitted' still happens — the audit gap was timing only.

- Proactive missing-agd-scope banner
  SkatteverketConnectPanel and AGIPanel now warn when the stored
  token lacks the agd scope. Tokens issued before the agd rollout
  would otherwise 403 with invalid_scope at submission time, often
  too close to the AGI deadline. SkatteverketConnectPanel mirrors
  the existing "skattekonto saknas" pattern; AGIPanel surfaces a
  banner in the connected state and links to /settings/skatteverket.

- Granskningsunderlag isError keys on tillstand only
  Previous check mixed HTTP 409 with the INCORRECT_DATA tillstand
  string. A future SKV addition like RECEIVING returned with HTTP
  200 would have slipped through as awaiting_signing. Now keys
  solely on tillstand: only LOCKED_FOR_SIGNING / UNLOCKED are
  treated as signable; everything else (INCORRECT_DATA, RECEIVING,
  CALCULATING, SIGNING) routes to underlag_rejected.

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

* fix(skatteverket): close round-5 audit-trail and recovery gaps

- agi_submitted_at: stamp with reconciliation time when signeradTid absent
  Round 4 left the column NULL on missing signeradTid to avoid falsifying
  the signing moment. Round 5 pointed out that NULL hides that the filing
  *occurred* — also a behandlingshistorik integrity violation. Resolution:
  presence of uuidKvittens proves SKV signed and accepted the AGI, so we
  stamp with signeradTid || now() and warn-log when fallback is used.
  Both /agi/kvittenser handler and the kvittens cron.

- Persist signeradAv + full kvittens in agi_declarations.response_data
  submitted_by is the auth.users UUID we have on hand (the polling /
  reconciling user). The legally load-bearing signer identity is
  kvittens.signeradAv (a personnummer) — which the token user_id does
  NOT necessarily match (e.g. bookkeeper vs deklarationsombud). The
  existing response_data jsonb column now holds the full kvittens record,
  preserving signeradAv for the audit trail (BFL 5 kap 6§ / BFNAR 2013:2
  kap 8) without a schema change. Cron path also marks reconciledBy='cron'.

- /agi/spara monotonicity: allow recovery from 'rejected'
  Previously .in('status', ['generated', 'exported']) excluded rejected
  rows, so a successful re-submission after a prior rejection couldn't
  promote the row to pending_signature — it silently stayed rejected.
  The xml-route reuses the same agi_declarations row when re-generating
  XML, so this is the realistic recovery path. Added 'rejected' to the
  allowed-from list. 'submitted'/'accepted' still blocked (no regression
  from filed states).

- Fix misleading agi-client.ts comment
  Claimed users could "fix the errors in Mina Sidor" after a
  DONE_REJECTED save. Mina Sidor doesn't expose in-place editing; the
  correct recovery is to regenerate XML and resubmit. Updated the
  agiSparaUnderlag JSDoc to describe the actual flow.

- Deadline copy: "vars sammanlagda lönesumma understiger 40 MSEK"
  Reads more cleanly than "≤ 40 MSEK" and matches the phrasing the
  compliance reviewer suggested.

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

* fix(skatteverket): tighten /agi/spara guard and clarify deadline copy (round 6)

- Drop 'exported' from /agi/spara allowed-from states
  Audit confirmed no code path writes status='exported' today; the value
  is preserved in the schema (and union) for the legacy manual-download
  path that no longer has a writer. Allowing the spara handler to flip
  an 'exported' row to 'pending_signature' would conflate two distinct
  filing attempts on a single row, weakening the chain of custody (BFL
  5 kap 6§). Tightened to .in(['generated', 'rejected']) — same recovery
  path for re-submission after rejection, no path for the dormant state.

- Deadline copy: explicit "per år" qualifier
  The 40 MSEK threshold is annual lönesumma, not per-payment. Adding
  "per år" closes the (admittedly thin) misread the compliance reviewer
  flagged.

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-05 17:08:29 +02:00
Mattsson c03582b5c7 Fix/percistent mcp connection (#392)
* feat(oauth): add support for refresh tokens in OAuth flow and update database schema

* feat(prompts): add MCP prompts and corresponding functionality for prompt retrieval

* feat(auth): enhance error handling for refresh token operations and validation
2026-05-05 13:48:53 +02:00
Mattsson fa7d4075cf Supp/invoice bfl errors (#390)
* feat(accounting): update accounting method validation and messaging for aktiebolag and enskild firma

* Remove AI subsystem and related code

- Deleted AI proposals and requests persistence logic from `lib/ai/proposals/persist.ts`.
- Removed re-validation logic for proposals in `lib/ai/proposals/re-validate.ts`.
- Cleaned up schemas related to AI flows in `lib/api/schemas.ts`.
- Removed AI-related fields from bookkeeping engine in `lib/bookkeeping/engine.ts`.
- Eliminated AI event types from `lib/events/types.ts`.
- Updated tests to reflect the removal of AI-related functionality in `lib/extensions/__tests__/sectors.test.ts`.
- Adjusted initialization logic in `lib/init.ts` to exclude AI proposal handler registration.
- Cleaned up transaction ingestion logic in `lib/transactions/ingest.ts` to remove AI flow checks.
- Updated helper functions in `tests/helpers.ts` to remove AI-related settings.
- Removed AI-related types and interfaces from `types/index.ts`.
- Added migration script to drop AI-related tables and settings from the database.

* fix(migrations): ensure foreign key constraint is dropped before removing AI tables

* feat(invoice-inbox): implement deterministic invoice field extraction and inbox provisioning

- Added `extract-invoice-fields.ts` for extracting fields from PDF invoices using regex and pdfjs-dist, replacing the previous AI classifier.
- Introduced `inbox-provisioning.ts` to manage company inbox addresses and rotation of inboxes using Supabase RPCs.
- Created `resend-inbound.ts` for handling inbound email events and attachments via the Resend API.
- Defined the extension manifest for the invoice inbox, specifying required environment variables and descriptions.
- Migrated database schema to remove AI-related columns and tighten the status enum in `invoice_inbox_items`.

* feat(invoice-inbox): remove AI-specific columns and tighten status enum

* fix(skattekonto): remove manual entry creation reference from transaction input

* fix(schemas): remove accounting method validation for aktiebolag in UpdateSettingsSchema
2026-05-05 09:53:37 +02:00
Jakob Wennberg f3fd4c0822 feat(salary, skatteverket): per-day absence + AGI Frånvarouppgift + skattekonto + hardening (#388)
* feat(salary): per-day absence tracking with calendar UX

Replace aggregated-day absence counts with per-day records so payroll
calculations can correctly enforce Swedish legal rules that depend on
actual dates: karensavdrag once per sjuklöneperiod, återinsjuknande
within 5 calendar days, allmänt högriskskydd cap of 10 karensavdrag per
rolling 12 months, day-8 läkarintyg flag, day-15 transition to
Försäkringskassan.

Adds:
- salary_absence_days table (RLS, dedup unique on employee+date+type)
- /api/salary/employees/[id]/absence CRUD route
- deriveAbsenceLineItems helper that walks per-day records into
  sjuklöneperioder and emits correctly-classified line items, with the
  existing absence-calculator formulas reused for VAB / parental
- Per-employee pay-spec detail page with month-grid AbsenceCalendar
- Calculate route now derives line items from the calendar before
  running the salary engine, replacing the prior sumQuantity model
- Salary run GET surfaces the formatted Skatteverket arbetsgivare ID
  so downstream UI can build extension URLs without a second round-trip
- GET /salary/runs/[id]/employees/[employeeId] for the detail page

Tests: 15 new unit tests covering segment merge, återinsjuknande
within 5 days, högriskskydd cap, FK transition flag, läkarintyg flag,
VAB/parental semesterlönegrundande ceilings.

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

* feat(skatteverket): harden API client + add NEXT_PUBLIC_SKATTEVERKET_ENABLED feature flag

Three hardening fixes from the prior audit, plus a runtime extension
toggle for phased rollout.

api-client.ts:
- Map 429 to a new SkatteverketAuthError code RATE_LIMITED with a
  Swedish user message. The 4 req/sec local rate limiter normally
  prevents this, but the per-consumer gateway quota can still hit.
- Extend the error union with TOKEN_CORRUPTED for the token-store fix
  below.

token-store.ts:
- Surface decryption failures instead of silently returning null. A
  rotated key or tampered ciphertext used to look like "not connected";
  callers now get TOKEN_CORRUPTED with a clear "anslut igen med BankID"
  message and a structured log line for ops.

Extension dispatcher (app/api/extensions/ext/[...path]/route.ts):
- Per-extension feature flag table. When NEXT_PUBLIC_SKATTEVERKET_ENABLED
  is not exactly "true", the dispatcher returns 503 with code
  EXTENSION_DISABLED, letting ops disable a single integration mid-
  rollout without redeploying or removing it from extensions.config.json.
  UI panels (SkatteverketPanel, AGIPanel) detect the 503 and render an
  empty state.

Tests: 7 api-client cases (401/403/403-Behörighet/429/5xx/200/auth-error
codes) + 2 token-store cases (no-row → null, corrupted → TOKEN_CORRUPTED).

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

* feat(salary): emit AGI Frånvarouppgift per SKV 4785, add AGIPanel for one-click submission

AGI XML upgrade:
- Emit <gem:Franvarouppgift> top-level blocks for VAB and parental
  leave events sourced from salary_absence_days, per SKV 4785 +
  technical doc. Element order matches the spec example file.
  TILLFALLIG_FORALDRAPENNING for VAB / FORALDRAPENNING for parental,
  with FranvaroTimmarTFP (FK825) or FranvaroTimmarFP (FK827) for
  hours. Stable 1-based specifikationsnummer per (employee, period),
  date-sorted. Skipped entirely for periods before 202501.
- Sick days are NOT emitted (they go to Försäkringskassan).
- FK499 TotalSjuklonekostnad now derived from sick_day2_14.quantity
  × dailyRate × 0.80 instead of Math.abs(amount). The line-item
  amount is the net deduction (lostPay − sjuklon), not the cost,
  so the prior formula understated by a factor of four.

AGI submission UI:
- New AGIPanel mirroring SkatteverketPanel's validate → draft → lock
  → BankID-sign → poll-submitted flow. Detects 503 EXTENSION_DISABLED
  and renders a clear empty state. Replaces the bare "Skicka till
  Skatteverket" button on /salary/runs/[id], keeping the AGI XML
  download as a sibling for archival / manual upload fallback.
- Salary run rows now link to the per-employee detail page added in
  the previous commit.

Tests: 14 new agi-xml cases covering element order, type↔hour-field
mapping, specifikationsnummer ordering, fractional-hour formatting,
range clamping (0.01-24.00), period guard at 202501 boundary,
placement after Blankett blocks, multi-employee date ordering,
required-fields invariant, omission when no events.

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

* feat(skatteverket): skattekonto integration — read-only saldo + transactions, daily sync, per-row bokför

Adds read-only Skattekonto v2.1 access via the existing BankID OAuth
flow (extends the OAuth scope with `skattekonto`). Daily background
sync pulls saldo + transactions, dedupes on (company_id, dedup_key),
and surfaces the data in a /skattekonto dashboard plus a settings
panel for connection management.

Backend:
- skattekonto-client.ts: GET /skattekonton/{omfragad}/saldo and
  /transaktioner. Felkod 1–5 mapped to Swedish messages via dedicated
  SkatteverketSkattekontoError.
- skattekonto-sync.ts: parallel saldo + transaktioner fetch, UPSERT
  on (company_id, dedup_key) so kommande rows graduate to tidigare
  in place. Dedup key uses transaktionsidentitet when available, else
  sha256 of (date|amount|text). Caches saldo snapshot in extension_data.
  Emits skattekonto.synced / balance.changed (sign flip) /
  transaction.upcoming (first appearance) / connection.expired.
- skattekonto-booking.ts: keyword→counter-account rules with AB/EF
  differentiation (2510 vs 2012 for preliminärskatt; 2731/2710/2650
  for arbetsgivaravgifter/avdragen skatt/moms; 8423/8313 for
  kostnads-/intäktsränta). Creates a draft journal entry against
  BAS 1630, leaves it for the user to review and commit. Throws
  NO_COUNTER_ACCOUNT instead of guessing when no rule matches.
- Daily cron at 0 4 * * * (Swedish 06:00). Double-gated by
  CRON_SECRET and NEXT_PUBLIC_SKATTEVERKET_ENABLED. Per-company
  cooldown of 1 hour, time budget 50s, distinct `expired` status
  for token-exhaustion separate from generic errors.

Database:
- skattekonto_transactions: company-scoped with RLS, unique
  (company_id, dedup_key), indexed on (company_id, date DESC) and
  (company_id, status). journal_entry_id FK with ON DELETE SET NULL
  so a row can be re-bokförd after entry deletion.

Frontend:
- /skattekonto/page.tsx: dashboard with saldo card, transactions list
  (booked + upcoming), per-row "Bokför" action.
- /settings/skatteverket: connection panel showing scope/expiry.
- Extension toggle in SettingsSidebar (gated by ENABLED_EXTENSION_IDS).

Tests: 9 booking-rule cases (counter-account guessing, AB/EF
divergence, no-match throw) + 7 mapper cases (dedup key stability,
sign convention, kommande→tidigare graduation).

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

* fix: address PR review findings

Build:
- Fix Next.js build failure: Zod refuses .partial() on a refined schema.
  Replace AbsenceRangeQuerySchema.partial().extend(...) in the absence
  DELETE handler with a fresh z.object that defines its own optional
  fields.

Greptile findings (PR #388):
- skattekonto_transactions UPDATE policy was missing WITH CHECK; without
  it a user could mutate company_id to one they don't belong to. Edit
  the original migration for fresh applies + add a follow-up migration
  that drops/recreates the policy with both clauses (already applied
  to prod via Supabase MCP).
- FK499 TotalSjuklonekostnad now reads sjuklonRate from
  run.calculation_params (snapshot taken at calc time) instead of a
  hardcoded 0.80, so an operator override (e.g. CBA-specific rate) is
  honored. Falls back to 0.80 for older runs without the snapshot.
- Rename NEXT_PUBLIC_SKATTEVERKET_ENABLED → SKATTEVERKET_ENABLED so the
  flag is server-side only. NEXT_PUBLIC_* vars are inlined into the
  client bundle at build time, which would create split-brain (server
  503 vs client still rendering enabled flow) on a flag flip without
  redeploy. UI panels detect 503 by response code, not by reading the
  env directly, so no client-visible change is needed.
- Add pg-real RLS smoke tests for both new tables (salary_absence_days
  and skattekonto_transactions): tenant SELECT isolation, UPDATE WITH
  CHECK enforcement, unique-constraint enforcement, cross-tenant dedup
  key allowed.

Swedish compliance review:
- Document the högriskskydd cap interpretation in
  derive-absence-line-items.ts. We count *sjuklöneperioder* in the
  rolling 12-month window, matching the law's plain reading
  ("från och med den 11:e sjukperioden ... görs inget karensavdrag").
  An alternative reading counts only periods that actually had karens
  deducted; that requires persisting per-period karens-deduction state,
  which gnubok doesn't yet do. The period-count reading can over-
  suppress, never under-suppress, so it's the safer default.

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

* fix(test): inline skattekonto fixtures so core-only CI runs without dev_docs

dev_docs/ is gitignored, so the skattekonto-mappers test failed in CI
when it tried to readFileSync from dev_docs/skattekonto(2.1.0)/examples/.
Inline the saldoResponse + transaktionerResponse fixtures verbatim
from the spec; the test still verifies our mappers + dedup-key logic
against the same shape.

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-04 19:01:21 +02:00
Mattsson bb855d2ddc Add/ai native supp (#385)
* feat(branding): implement dynamic branding in service worker and reports

* feat(auth): enhance API key scopes and add bookkeeping write scope

- Updated transaction write scope description to include additional tools.
- Enhanced reports read scope description to reflect new functionality.
- Introduced bookkeeping write scope with relevant description.
- Updated SCOPE_GROUPS to include bookkeeping domain.
- Modified TOOL_SCOPE_MAP to include new bookkeeping operations.
- Updated validateApiKey function to return api_key_id and api_key_name for better actor attribution.

feat(tests): add unit tests for MCP resource registry

- Created tests for data resources to ensure all required fields are present.
- Added tests for resource query parsing and retrieval.

feat(resources): implement MCP resources for company and accounting data

- Added capabilities resource to expose API key capabilities based on granted scopes.
- Implemented chart of accounts resource to retrieve active BAS chart.
- Created company current resource to fetch active company details.
- Developed active fiscal period resource to check posting eligibility.
- Implemented recent activity resource to fetch latest journal entries, invoices, and transactions.
- Added VAT treatments resource to provide available VAT rates per customer type.

feat(pending-operations): introduce risk tiers for operations

- Added risk level classification for pending operations to determine auto-commit eligibility.
- Implemented functions to classify operation risk levels and identify high-risk operations.

feat(migrations): add actor model and risk tier to pending operations

- Updated pending_operations table to include actor type and risk level columns.
- Enhanced audit_log to mirror actor information for compliance.
- Modified validate_and_increment_api_key function to return actor details.
- Expanded operation types in pending_operations to include new high-risk operations.

* feat: add auto-commit functionality for low-risk pending operations

- Implemented shouldAutoCommit function to determine eligibility for auto-commit based on operation type, actor type, and company settings.
- Created commitPendingOperation function to handle execution of pending operations with consistent status updates.
- Added tests for shouldAutoCommit to cover various scenarios including high-risk operations, user actors, company opt-in status, and monetary thresholds.
- Introduced new columns in company_settings for agent_auto_commit_enabled and agent_auto_commit_max_amount to allow companies to opt-in for auto-commit functionality.
- Added SQL migration to update the database schema for new auto-commit settings.

* feat(idempotency): implement idempotency key handling for safe retries and cleanup

* feat: expand API key scopes and pending operations for bookkeeping

- Added 'suppliers:write' scope to API key scopes for supplier invoice management.
- Updated SCOPE_GROUPS to include the new 'suppliers:write' scope.
- Introduced new pending operation types for bookkeeping: close_period, lock_period, run_year_end, set_opening_balances, run_currency_revaluation, explain_voucher_gap, uncategorize_transaction, approve_supplier_invoice, credit_supplier_invoice, and convert_invoice.
- Implemented corresponding commit functions for the new operations in the pending operations module.
- Enhanced PendingOperation type to include actor model and risk level attributes.
- Added tests for new functionality, ensuring proper behavior and constraints in the database.

* feat: implement unlockPeriod functionality and related tests

* feat: add agent auto-commit settings and related functionality

* feat: add attention resource with comprehensive summary of outstanding tasks

* feat: enhance pending operations with 'committing' status and immutability checks, improve idempotency handling, and add original voucher reference for credit notes
2026-05-04 11:12:29 +02:00
Mattsson 5e1b0f791d feat(branding): implement dynamic branding in service worker and reports (#383)
* feat(branding): implement dynamic branding in service worker and reports

* refactor(service-worker): remove push notification handling code

* feat(service-worker): implement dynamic branding in service worker and related scripts
2026-04-30 17:17:41 +02:00
Mattsson c86dbdc60d feat(branding): add hiddenNavHrefs to hide sidebar items via env var (#382)
Extends the branding service with a hiddenNavHrefs: string[] field so
forks can hide sidebar entries (e.g. /salary, /customers) without
patching DashboardNav.tsx. Configurable via NEXT_PUBLIC_BRANDING_HIDDEN_NAV
as a comma-separated list. Default is [] — vanilla gnubok unchanged.

Routes remain reachable; this is a nav-visibility switch only. Settings
sidebar is intentionally out of scope.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:53:34 +02:00
Mattsson 064fb7f7a9 Add/white label (#381)
* feat(branding): add BrandingService with default-preserving env layer

Introduce lib/branding/service.ts mirroring lib/email/service.ts. Defaults
match current gnubok values exactly, so production behaviour is unchanged
unless an env var (NEXT_PUBLIC_BRANDING_*, BRANDING_*) or extension override
(via registerBrandingService) is set.

Resolution order: defaults < env vars < extension override.

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

* feat(branding): route root layout, manifest, and PWA assets through branding service

- app/layout.tsx now reads title, description, themeColor, and apple-touch-icon
  from getBranding() instead of hardcoded values.
- public/manifest.json replaced by dynamic app/manifest.ts so PWA name,
  short_name, description, theme_color, background_color, and icon paths
  are resolved at request time.

The manifest now serves at /manifest.webmanifest (Next.js convention for
the metadata file route). The previous /manifest.json URL is no longer
populated; nothing in core references it after this commit.

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

* feat(branding): route email service and templates through branding service

- resend-service.ts: From line uses getBranding().appName instead of
  hardcoded "Gnubok" in both the with-fromName and bare cases.
- invite-templates.ts: subject, HTML header, body, plain text, and the
  team-invite variants all read from branding (sentence case in prose,
  uppercased for the styled <p> header).
- consent-notification-templates.ts: signature fallback (companyName ||
  branding) for both HTML and plain text variants.

Defaults preserve the exact current strings ("Gnubok", "GNUBOK", "gnubok"
in their respective contexts) so no email content changes for production.

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

* feat(branding): route OAuth consent page through branding service

The MCP OAuth consent page rendered for Claude Desktop / Claude.ai
connector flows now reads the app name from getBranding() for both the
HTML <title> and the body copy. Default still produces "gnubok" in
lowercase prose, matching current behaviour.

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

* feat(branding): route auth, dashboard, and onboarding text through branding service

Replace user-visible "gnubok" / "Gnubok" references with calls to
getBranding(). Touches:

- Auth pages (login, register, mfa/enroll): logo src/alt, MFA TOTP
  friendlyName.
- Onboarding (companies/new, invite, sandbox, WelcomeOnboarding,
  Step2CompanyDetails, NewUserChecklist, BankIdCompanyPicker,
  ArcimMigrationWorkspace): logo, headings, error/help text.
- Dashboard fallback (companyName="gnubok") and settings (backup copy,
  ApiKeysPanel MCP connector name + login note, CompanyDangerZone,
  retention-notice).
- API routes (support contact subject prefix, enable-banking consent
  email companyName fallback, AI inbox receipt-request appUrl,
  pain001 messageId prefix).
- MCP server "open the gnubok web app" review message.
- Salary/reports filings (AGI Programnamn, KU10 Programnamn,
  payslip footer, full-archive system metadata, SRU #PROGRAM line).

Internal identifiers (cookie names gnubok-company-id /
gnubok-invite-token, API key prefix gnubok_sk_, invite token prefix
gnubok_inv_, MCP tool names, npm package gnubok-mcp, GNUBOK_API_KEY
env name) are deliberately left unchanged — they're stable contracts
that whitelabels must not break.

Defaults match current behaviour exactly.

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

* feat(branding): support legal page field-level swaps for entity and contact

Privacy and DPA pages now interpolate appName, legalEntity, and
privacyEmail from the branding service instead of hardcoding "Gnubok",
"Arcim", and "privacy@gnubok.se". Page metadata uses generateMetadata()
so titles also reflect the brand.

lib/support.ts now falls back to getBranding().supportEmail when
SUPPORT_RECIPIENT_EMAIL is unset, so a single BRANDING_SUPPORT_EMAIL
env var configures both the support form recipient and the displayed
support address.

Whitelabels with a different legal jurisdiction or entirely different
DPA text should override the page route from an extension. Phase 1
intentionally only supports field-level swaps.

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

* docs(branding): add WHITELABEL.md and example branding extension

WHITELABEL.md: fork checklist, env var reference, the "do not change"
list (cookies, API key prefixes, invite token prefixes, MCP tool names,
gnubok-mcp npm package, GNUBOK_API_KEY env name), out-of-scope items,
the upstream sync workflow YAML to copy into a fork, conflict avoidance
guidance, and a verification checklist.

extensions/general/_example-branding/: copy-paste starter extension with
index.ts (commented placeholder values for registerBrandingService),
manifest.json, and README.md. Disabled by default (not added to
extensions.config.json); whitelabels cp the folder, edit, and enable.

sectors.test.ts: bumped expected extension count 12 -> 13 to account
for the new starter extension on disk. The generated registry is
unchanged because the example is disabled.

The sync workflow YAML is documented inline in WHITELABEL.md rather
than checked in as a workflow file. It's only meaningful in a fork --
gnubok itself has nothing to sync from.

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

* fix(branding): address PR review — lazy support email + escape brand in HTML/XML

Three issues from code review:

P1 — lib/support.ts: SUPPORT_RECIPIENT_EMAIL was a module-level const,
evaluated at import time before extensions register branding overrides
via ensureInitialized(). Convert to getSupportRecipientEmail() lazy
accessor; update the only caller in app/api/support/contact/route.ts.
Extension-supplied supportEmail values now route correctly.

P2 — app/api/mcp-oauth/authorize/route.ts: appName was interpolated
into the consent page HTML without escapeHtml(), inconsistent with
the existing escaping of companyName. Wrap appName.toLowerCase() in
escapeHtml() at use sites in <title> and the body paragraph.

P2 — lib/salary/agi/xml-generator.ts and lib/salary/ku/ku10-generator.ts:
appName placed inside <gem:Programnamn> / <Programnamn> XML elements
without escapeXml(), the helper already used for other admin-controlled
fields in the same files. Wrap accordingly to prevent malformed XML if
a brand name contains XML reserved characters.

All admin-controlled inputs only — no user-exploitable path. Defense in
depth, not a known incident.

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

* fix(branding): security follow-up — lazy metadata, SRU/email header sanitization

Self-audit after the PR review surfaced four more concerns. Fixes them
with the same defense-in-depth posture as the prior review fixes.

1. app/layout.tsx — same eager-evaluation class as P1 support.ts. The
   module-level `const branding = getBranding()` froze branding before
   extensions registered, so extension-based overrides for title,
   description, themeColor, and apple-touch-icon silently never applied.
   - Convert to generateMetadata() / generateViewport() (lazy, run per
     request, see extension-registered overrides).
   - Inline getBranding() inside RootLayout for the apple-touch-icon
     href so it picks up overrides too.
   - Add ensureInitialized() at module level so extensions are loaded
     before the first metadata call. Mirrors the API route pattern.

2. app/manifest.ts — same class. The dynamic manifest function reads
   getBranding() per request, but if the manifest is requested before
   any other module has triggered ensureInitialized(), extensions are
   still unloaded. Add ensureInitialized() at module level.

3. lib/reports/ink2/sru-generator.ts — appName interpolated into the
   SRU `#PROGRAM` directive without sanitization. SRU's reserved char
   is `#` (directive marker) and CRLF injects new directives. Wrap in
   the existing sanitizeString() helper to match the pattern used for
   other admin-controlled fields in this file (#NAMN, #ADRESS, etc.).

4. extensions/general/email/lib/resend-service.ts — appName and the
   user-controlled fromName both flow into the From header. Resend's
   API does its own validation, but defense in depth: strip CRLF and
   angle brackets via a small sanitizeHeaderPart() helper before
   building the header string. fromName was a pre-existing surface;
   appName is new with this whitelabel work.

All four are admin-controlled inputs (env vars or extension code),
not user-exploitable. No known incidents — defense in depth, and
correctness for extension-based whitelabels.

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-29 16:32:26 +02:00
Mattsson 2d36dedf34 fix(journal-entry): add error logging for delete operation in journal… (#379)
* fix(journal-entry): add error logging for delete operation in journal entries

* fix(journal-entry): enhance error logging and add tests for delete_last_voucher functionality

* fix(journal-entry): restore enforce_journal_entry_immutability function to handle DELETE and un-reversal updates

* fix(tests): refactor delete_last_voucher tests to use insertPostedEntryWithLines for consistency
2026-04-28 21:55:51 +02:00
Jakob Wennberg cd64c0e3fb feat(skatteverket): production-ready momsdeklaration submission (#380)
* feat(skatteverket): production-ready momsdeklaration submission

Brings the Skatteverket extension up to a state where it can ship moms
declaration submission to Vercel production. Verified end-to-end against
SKV's Komplett testtjänst — all 8 momsdeklaration operations tested
(kontrollera, spara/hämta/radera utkast, lås/lås upp, hämta inlämnade,
hämta beslutade) plus signing-link return.

Bundles three coherent changes:

1. Skatteverket extension (the main work)
   - extensions.config.json: enable `skatteverket`, drop `invoice-inbox`
     and `ai-agent` (those were enabled in config but lacked AWS env vars
     in prod, so they loaded but failed at runtime)
   - lib/reports/vat-declaration.ts: extend ACCOUNT_RUTA to populate
     Ruta 06 (uttag 3401–3403), Ruta 20–24 (reverse-charge bases from
     4xxx cost accounts), Ruta 50 (import 4545–4547), and Ruta 42
     (3404/3994/3980); delete the supplier-type heuristic that made
     Ruta 20 and Ruta 23 always 0
   - extensions/general/skatteverket/lib/token-store.ts: work around
     three real prod schema-drift issues — wrong column on read/delete
     (was `company_id`, schema only has `user_id`), missing
     UNIQUE(user_id) constraint that makes UPSERT fail (switched to
     DELETE+INSERT), missing RLS policies (switched to service-role
     client). Refresh path now reuses existing row's company_id when
     none is passed.
   - extensions/general/skatteverket/index.ts: 9 sites switched from
     ctx.companyId to ctx.userId for the token-store key; pass
     companyId from the OAuth callback
   - extensions/general/skatteverket/types.ts + components/reports/
     SkatteverketPanel.tsx: align field names with v1.0.24 RAML
     (signeringsLank/kontrollResultat/resultat/kod/status/beskrivning).
     Without this, the signing link never displayed.
   - SkatteverketPanel: add Lås upp + Radera utkast + Hämta utkast +
     Hämta beslut buttons so the full lifecycle is reachable from the UI
   - lib/reports/__tests__/vat-declaration.test.ts: rewritten to match
     the refactored calculator; new fixtures for cost-account-based
     reverse charge (Ruta 20/21/22/23/24), Ruta 50 import, Ruta 06
     uttag, Ruta 42 expansion; SKV §4.1.1.4 cross-field contract checks
   - supabase/migrations/20260428120000_skatteverket_tokens_user_id_unique.sql:
     idempotently adds the missing UNIQUE(user_id) constraint
   - scripts/*: dev-only helpers used during the prod-of-test
     verification (create test company, seed VAT data, inspect token
     state, etc.)

2. Journal-entries cancelled-status filter
   - app/api/bookkeeping/journal-entries/route.ts: when no status filter
     is supplied, exclude `cancelled` entries by default
   - supabase/migrations/20260428153500_journal_entries_with_related_exclude_statuses.sql

3. Swedish e-invoicing skill (reference docs only — no runtime code)
   - .claude/skills/swedish-e-invoicing/

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

* fix(skatteverket): address PR review findings

- panel: handleFetchDraft read `result.data?.last` (typo) — switched to
  `result.data?.locked` to match the field defined in
  SkatteverketUtkastResponse and the v1.0.24 RAML. The "(låst)" suffix on
  the success message would silently never appear before this fix.

- api-client: getValidToken had no concurrency guard, so two parallel
  SKV requests from the same user could both call /token with the same
  refresh_token. SKV rotates the refresh_token on first use, so the
  second call would 401 with REFRESH_EXHAUSTED-adjacent failures. With
  the new 6-button UI on SkatteverketPanel, rapid clicks made this a
  realistic trigger. Added an in-process Promise map keyed on userId
  that coalesces concurrent refresh attempts; cross-process races are
  mitigated by re-reading tokens inside the critical section before
  calling refreshAccessToken (if another process refreshed already, we
  use the newer token instead of burning the old refresh_token).

- migration 20260428120000: dedup query used `created_at < max(...)`,
  which failed to remove duplicates inserted in the same second. The
  subsequent ALTER TABLE … ADD CONSTRAINT would then abort. Switched
  to ctid (Postgres physical row identifier) to break timestamp ties.

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

* fix(skatteverket): throw on token-store SELECT error before destructive DELETE

The company_id pre-read in storeTokens used destructuring that discarded
the error field. If the service-role SELECT failed for any reason (network
blip, overloaded DB, transient permissions issue), `existing` became null,
`resolvedCompanyId` stayed undefined, and execution fell through to the
DELETE. The old row got deleted successfully, then the INSERT omitted
company_id and failed with the NOT NULL constraint violation — leaving
the user with no token row at all and forcing a fresh BankID handshake.

Now we capture the SELECT error and throw before the DELETE runs.

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-28 18:26:03 +02:00
Mattsson 0363aff1d4 fix(pdf): correct negative amount formatting in operational report PDF (#378) 2026-04-28 15:13:23 +02:00
Mattsson 2f49106f49 fix(balansrapport): adjust balance report to reflect correct sign con… (#375)
* fix(balansrapport): adjust balance report to reflect correct sign conventions and update PDF template

* fix(balansrapport): enforce strict negative balance convention for equity rows in balance report tests
2026-04-28 14:58:27 +02:00
Mattsson d1267c37af feat(invoice): add PDF archival for sent invoices and implement tests (#373)
* feat(invoice): add PDF archival for sent invoices and implement tests

* fix(invoice): add company_id filter when fetching original invoice
2026-04-28 13:03:53 +02:00