* feat(kpi): expense mix and top suppliers charts
Replace the single monthly-trend chart with two additional compact visuals
on /kpi: expense composition donut (BAS class 4-7) and top suppliers bar
(supplier_invoices sum_sek over the fiscal period). KPIReport gains
expenseComposition and topSuppliers fields, computed from the trial
balance and supplier_invoices rows already fetched in the API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(nav): swap Deadlines sidebar slot for Dokumentinkorg
Sidebar main-menu slot now points to the invoice-inbox extension. The
/deadlines page stays accessible via dashboard widgets and direct links —
only the prominent nav entry changes. Most users open gnubok to act on
incoming documents, not to read tax deadlines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(supplier-invoices): cross-currency totals, FX residual, review SEK display
Five fixes around foreign-currency supplier invoices:
- Form layout: move Valuta / Växelkurs / Reverse charge from collapsed
"Övrigt" into a visible row above the line-item table. Auto-fetch the
Riksbanken rate when switching to a non-SEK currency; never clobber a
user-typed rate; clear it when switching back to SEK.
- Form submit: reset() the form on successful submit so the
useUnsavedChanges hook detaches its beforeunload listener before the
router.push, killing the "Are you sure you want to leave?" prompt that
fired during Turbopack-mediated navigations.
- BankTransactionPicker: drop the strict currency filter that hid every
SEK transaction when the invoice was in EUR/USD. Cross-currency rows
fall to the bottom with an "Annan valuta" hint instead of producing a
meaningless numeric diff.
- match-supplier-invoice route: when the bank transaction currency
differs from the invoice currency, compute the FX diff against the
AP-booked SEK and pass it to createSupplierInvoicePaymentEntry so
7960/3960 catches the residual instead of leaving a permanent stub on
2440. Fix also covers the "EUR transaction paying a SEK invoice" case
that the first iteration missed.
- Review dialog: buildJournalPreview now multiplies amounts by the
exchange rate so the "Verifikation som bokförs" table shows the actual
SEK numbers that hit the DB, not the EUR magnitudes labelled with no
unit. Header gains an "(i SEK)" hint when foreign currency.
Test coverage for the FX residual path covers SEK-SEK (no diff),
SEK-into-EUR-invoice (loss), SEK-into-EUR-invoice (gain), foreign-tx-
into-SEK-invoice, and the no-rate fallback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(inbox): rate limits, multi-file UX, onboarding, retry, supplier autolink
Big workspace pass on /e/general/invoice-inbox. Highlights:
Backend
- New table inbox_rate_counters + RPC check_and_increment_inbox_quota.
Postgres-backed (no Upstash dep) per-company limit: 30/min, 500/day.
Applied at /upload, /inbound, and /items/:id/retry-extraction.
- POST /items/:id/retry-extraction — re-runs the deterministic extractor
on a stored document when the previous attempt errored.
- POST /items/:id/match-supplier — links a freshly-created supplier
back to the inbox item so the next action prefills correctly.
- POST /api/transactions/create-from-document — creates an uncategorized
manual transaction from an inbox item for the "I have a receipt, no
bank transaction" case. The user categorizes through the normal flow.
- /inbound caps email at 20 attachments/email; truncated count goes to
processing_history as AttachmentsTruncated. Rate-limit drops emit
RateLimitedDropped and return 200 so Resend doesn't retry.
- attach-document side effect: when the document came from an inbox
item, the inbox row's matched_transaction_id is updated so the UI can
flip it to "Kopplad till transaktion" without a round-trip.
New migration: re-introduces matched_transaction_id on
invoice_inbox_items as a plain FK (the AI metadata that the previous
migration stripped doesn't come back).
Workspace UI
- Onboarding card replaces the thin empty-state with a 3-step
checkmark guide (Aktivera adress → Ladda upp → Matcha eller bokför).
Auto-hides when all three steps are done; localStorage-backed dismiss.
Beta badge + link to gnubok.se/priser.
- Responsive layout: 3-pane at lg, 2-pane at md, master-detail toggle
on phone (list xor detail with a back button).
- Filter pills (Alla / Behöver åtgärd / Bearbetade / Fel) + search
input above the list — client-side over the existing items list.
- Multi-file upload queue with "Laddar X av N…" progress counter on
the button. Sequential to avoid hammering pdfjs. Selection stays put
during a batch (only single-file drops auto-jump the detail pane).
- Bulk select + delete with sticky action bar. Items linked to a
supplier invoice are skipped with a count toast.
- Retry button in the FieldsRail error branch.
- "Skapa transaktion från underlag" CTA in the match dialog when no
unmatched bank transactions exist. Prefills date/amount/description
from the extracted data; user picks the sign.
- "Skapa leverantör" inline CTA when the extractor caught a supplier
name with no match against existing suppliers. POSTs /api/suppliers
with the extracted fields, then auto-links via /items/:id/match-supplier.
- Matched-state CTA renamed to "Bokför transaktionen" with link to
/transactions?highlight=<id> so the categorize panel auto-opens.
Tests
- lib/rate-limits/__tests__/inbox.test.ts — RPC wrapper happy/error/scope
- app/api/transactions/create-from-document/__tests__/route.test.ts —
auth, validation, 404/409/200/500, inbox-link failure tolerated
- extensions/general/invoice-inbox/__tests__/retry-extraction.test.ts —
auth, rate limit, 404, 409, 400 no-doc, success, extraction failure
- attach-document tests extend coverage to the new inbox-link side
effect (both success and best-effort failure paths)
- inbound-webhook test mocks the rate-limit module so the queued-mock
sequence in each existing test doesn't have to know about it
CLAUDE.md gains a row for lib/rate-limits/ so the new helper is
discoverable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(transactions): paperclip indicator and highlight-row param
Close the feedback loop after a user attaches a receipt to a transaction
from the inbox: the row in /transactions now shows a paperclip icon
when transaction.document_id is set, with a click handler that fetches
a signed download URL and opens the document in a new tab. Works for
both uncategorized and history views.
When the inbox sends a user to /transactions?highlight=<id>, the page
now scrolls that row into view and auto-opens the categorize panel if
the transaction is still uncategorized. Behind a double-rAF so the row
DOM exists when scrollIntoView fires.
QuickReviewDialog no longer prompts to upload underlag when the
transaction already has a doc attached (which it does after the inbox
match flow). Shows "Underlag bifogat — Visa" instead, opening the
existing doc in a new tab.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(pr-444): address review feedback (Greptile + compliance bots)
Migration rules
- New migration 20260512092423: adds updated_at trigger on
inbox_rate_counters (CLAUDE.md rule 2) and explicit USING (false) RLS
policies for the four DML verbs to make the SECURITY DEFINER-only
intent explicit (rule 1).
- New pg-real test inbox-rate-limit.pg.test.ts covering happy path,
minute-cap rejection, day-cap rejection, per-company isolation, and
the updated_at trigger firing. CLAUDE.md mandates *.pg.test.ts for
every new RPC because mocks pass on broken PL/pgSQL.
Bugs
- Stale exchange rate on currency switch (Greptile P1) —
userTouchedRateRef was scoped per session, not per currency. Switching
EUR (with a hand-edited rate) → USD kept the EUR rate. Now tracks the
last fetched currency in a ref and resets the touched flag on
currency change while still honoring manual edits within a single
currency.
- topSuppliersResult.error silently swallowed (Greptile P2) — failed
queries used to render an empty chart matching the no-data state.
Logged now.
- Currency from extracted_data not validated (GDPR Art.25(2), OWASP V4.5,
Swedish compliance bot) — extracted PDF currency was inserted into
transactions.currency without sanitisation. Allowlisted against the
six supported ISO 4217 codes; coerce to SEK otherwise.
- Idempotency gap on create-from-document (OWASP V2.3) — two concurrent
POSTs with the same inbox_item_id could each pass the
matched_transaction_id IS NULL read and insert duplicate transactions.
UPDATE now includes .is('matched_transaction_id', null) as an
optimistic-lock release and returns 409 with an orphan-transaction
rollback when the predicate doesn't match.
- FX residual on cash-method match path (Swedish compliance bot) —
createSupplierInvoiceCashEntry has no exchange_rate_difference path,
so a cross-currency match would silently leave a 1930 reconciliation
gap. Added a guard that returns MATCH_SI_CASH_FX_UNSUPPORTED (400)
before the JE is created. Users on cash method can switch to accrual
or book the FX diff manually.
Design system
- gap-y-1.5 / gap-1.5 in KPIExpenseMixChart — replaced with gap-y-2 /
gap-2 (CLAUDE.md design tokens; 2.5/1.5/5/hardcoded pixels are
forbidden spacing values).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(migrations): rename to match applied versions
The mcp__plugin_supabase_supabase__apply_migration tool stamps its own
timestamp when it applies a migration to the live project, so the
version recorded in supabase_migrations.schema_migrations differs from
my local generation-time filenames. Renaming the local files so a
production CD run sees the migrations as already-applied (matching
versions) instead of trying to re-apply them — which would fail for
the trigger/RLS migration (CREATE TRIGGER and CREATE POLICY don't
support IF NOT EXISTS).
Follows the pattern from d854efcd ("chore(migration): rename to match
applied version").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(create-from-document): scope orphan rollback DELETE by company_id
Defence in depth on the inbox-link race rollback. newTx.id is a fresh
UUID from a company-scoped insert two statements above, so the existing
single-key DELETE is already safe, but adding .eq('company_id', companyId)
makes the cross-company invariant explicit on every write — addresses
the OWASP ASVS V2.3 finding from the compliance swarm on PR #444.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(nav): mark Dokumentinkorg with Beta badge
Same signal we use for Löner and Anställda — the inbox flow (AI
extraction, supplier autolink, manual transaction creation) is in
end-to-end customer testing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>