Inbox UX overhaul + cross-currency supplier-invoice fixes (#444)

* feat(kpi): expense mix and top suppliers charts

Replace the single monthly-trend chart with two additional compact visuals
on /kpi: expense composition donut (BAS class 4-7) and top suppliers bar
(supplier_invoices sum_sek over the fiscal period). KPIReport gains
expenseComposition and topSuppliers fields, computed from the trial
balance and supplier_invoices rows already fetched in the API.

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

* feat(nav): swap Deadlines sidebar slot for Dokumentinkorg

Sidebar main-menu slot now points to the invoice-inbox extension. The
/deadlines page stays accessible via dashboard widgets and direct links —
only the prominent nav entry changes. Most users open gnubok to act on
incoming documents, not to read tax deadlines.

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

* fix(supplier-invoices): cross-currency totals, FX residual, review SEK display

Five fixes around foreign-currency supplier invoices:

- Form layout: move Valuta / Växelkurs / Reverse charge from collapsed
  "Övrigt" into a visible row above the line-item table. Auto-fetch the
  Riksbanken rate when switching to a non-SEK currency; never clobber a
  user-typed rate; clear it when switching back to SEK.

- Form submit: reset() the form on successful submit so the
  useUnsavedChanges hook detaches its beforeunload listener before the
  router.push, killing the "Are you sure you want to leave?" prompt that
  fired during Turbopack-mediated navigations.

- BankTransactionPicker: drop the strict currency filter that hid every
  SEK transaction when the invoice was in EUR/USD. Cross-currency rows
  fall to the bottom with an "Annan valuta" hint instead of producing a
  meaningless numeric diff.

- match-supplier-invoice route: when the bank transaction currency
  differs from the invoice currency, compute the FX diff against the
  AP-booked SEK and pass it to createSupplierInvoicePaymentEntry so
  7960/3960 catches the residual instead of leaving a permanent stub on
  2440. Fix also covers the "EUR transaction paying a SEK invoice" case
  that the first iteration missed.

- Review dialog: buildJournalPreview now multiplies amounts by the
  exchange rate so the "Verifikation som bokförs" table shows the actual
  SEK numbers that hit the DB, not the EUR magnitudes labelled with no
  unit. Header gains an "(i SEK)" hint when foreign currency.

Test coverage for the FX residual path covers SEK-SEK (no diff),
SEK-into-EUR-invoice (loss), SEK-into-EUR-invoice (gain), foreign-tx-
into-SEK-invoice, and the no-rate fallback.

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

* feat(inbox): rate limits, multi-file UX, onboarding, retry, supplier autolink

Big workspace pass on /e/general/invoice-inbox. Highlights:

Backend
- New table inbox_rate_counters + RPC check_and_increment_inbox_quota.
  Postgres-backed (no Upstash dep) per-company limit: 30/min, 500/day.
  Applied at /upload, /inbound, and /items/:id/retry-extraction.
- POST /items/:id/retry-extraction — re-runs the deterministic extractor
  on a stored document when the previous attempt errored.
- POST /items/:id/match-supplier — links a freshly-created supplier
  back to the inbox item so the next action prefills correctly.
- POST /api/transactions/create-from-document — creates an uncategorized
  manual transaction from an inbox item for the "I have a receipt, no
  bank transaction" case. The user categorizes through the normal flow.
- /inbound caps email at 20 attachments/email; truncated count goes to
  processing_history as AttachmentsTruncated. Rate-limit drops emit
  RateLimitedDropped and return 200 so Resend doesn't retry.
- attach-document side effect: when the document came from an inbox
  item, the inbox row's matched_transaction_id is updated so the UI can
  flip it to "Kopplad till transaktion" without a round-trip.

New migration: re-introduces matched_transaction_id on
invoice_inbox_items as a plain FK (the AI metadata that the previous
migration stripped doesn't come back).

Workspace UI
- Onboarding card replaces the thin empty-state with a 3-step
  checkmark guide (Aktivera adress → Ladda upp → Matcha eller bokför).
  Auto-hides when all three steps are done; localStorage-backed dismiss.
  Beta badge + link to gnubok.se/priser.
- Responsive layout: 3-pane at lg, 2-pane at md, master-detail toggle
  on phone (list xor detail with a back button).
- Filter pills (Alla / Behöver åtgärd / Bearbetade / Fel) + search
  input above the list — client-side over the existing items list.
- Multi-file upload queue with "Laddar X av N…" progress counter on
  the button. Sequential to avoid hammering pdfjs. Selection stays put
  during a batch (only single-file drops auto-jump the detail pane).
- Bulk select + delete with sticky action bar. Items linked to a
  supplier invoice are skipped with a count toast.
- Retry button in the FieldsRail error branch.
- "Skapa transaktion från underlag" CTA in the match dialog when no
  unmatched bank transactions exist. Prefills date/amount/description
  from the extracted data; user picks the sign.
- "Skapa leverantör" inline CTA when the extractor caught a supplier
  name with no match against existing suppliers. POSTs /api/suppliers
  with the extracted fields, then auto-links via /items/:id/match-supplier.
- Matched-state CTA renamed to "Bokför transaktionen" with link to
  /transactions?highlight=<id> so the categorize panel auto-opens.

Tests
- lib/rate-limits/__tests__/inbox.test.ts — RPC wrapper happy/error/scope
- app/api/transactions/create-from-document/__tests__/route.test.ts —
  auth, validation, 404/409/200/500, inbox-link failure tolerated
- extensions/general/invoice-inbox/__tests__/retry-extraction.test.ts —
  auth, rate limit, 404, 409, 400 no-doc, success, extraction failure
- attach-document tests extend coverage to the new inbox-link side
  effect (both success and best-effort failure paths)
- inbound-webhook test mocks the rate-limit module so the queued-mock
  sequence in each existing test doesn't have to know about it

CLAUDE.md gains a row for lib/rate-limits/ so the new helper is
discoverable.

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

* feat(transactions): paperclip indicator and highlight-row param

Close the feedback loop after a user attaches a receipt to a transaction
from the inbox: the row in /transactions now shows a paperclip icon
when transaction.document_id is set, with a click handler that fetches
a signed download URL and opens the document in a new tab. Works for
both uncategorized and history views.

When the inbox sends a user to /transactions?highlight=<id>, the page
now scrolls that row into view and auto-opens the categorize panel if
the transaction is still uncategorized. Behind a double-rAF so the row
DOM exists when scrollIntoView fires.

QuickReviewDialog no longer prompts to upload underlag when the
transaction already has a doc attached (which it does after the inbox
match flow). Shows "Underlag bifogat — Visa" instead, opening the
existing doc in a new tab.

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

* fix(pr-444): address review feedback (Greptile + compliance bots)

Migration rules
- New migration 20260512092423: adds updated_at trigger on
  inbox_rate_counters (CLAUDE.md rule 2) and explicit USING (false) RLS
  policies for the four DML verbs to make the SECURITY DEFINER-only
  intent explicit (rule 1).
- New pg-real test inbox-rate-limit.pg.test.ts covering happy path,
  minute-cap rejection, day-cap rejection, per-company isolation, and
  the updated_at trigger firing. CLAUDE.md mandates *.pg.test.ts for
  every new RPC because mocks pass on broken PL/pgSQL.

Bugs
- Stale exchange rate on currency switch (Greptile P1) —
  userTouchedRateRef was scoped per session, not per currency. Switching
  EUR (with a hand-edited rate) → USD kept the EUR rate. Now tracks the
  last fetched currency in a ref and resets the touched flag on
  currency change while still honoring manual edits within a single
  currency.
- topSuppliersResult.error silently swallowed (Greptile P2) — failed
  queries used to render an empty chart matching the no-data state.
  Logged now.
- Currency from extracted_data not validated (GDPR Art.25(2), OWASP V4.5,
  Swedish compliance bot) — extracted PDF currency was inserted into
  transactions.currency without sanitisation. Allowlisted against the
  six supported ISO 4217 codes; coerce to SEK otherwise.
- Idempotency gap on create-from-document (OWASP V2.3) — two concurrent
  POSTs with the same inbox_item_id could each pass the
  matched_transaction_id IS NULL read and insert duplicate transactions.
  UPDATE now includes .is('matched_transaction_id', null) as an
  optimistic-lock release and returns 409 with an orphan-transaction
  rollback when the predicate doesn't match.
- FX residual on cash-method match path (Swedish compliance bot) —
  createSupplierInvoiceCashEntry has no exchange_rate_difference path,
  so a cross-currency match would silently leave a 1930 reconciliation
  gap. Added a guard that returns MATCH_SI_CASH_FX_UNSUPPORTED (400)
  before the JE is created. Users on cash method can switch to accrual
  or book the FX diff manually.

Design system
- gap-y-1.5 / gap-1.5 in KPIExpenseMixChart — replaced with gap-y-2 /
  gap-2 (CLAUDE.md design tokens; 2.5/1.5/5/hardcoded pixels are
  forbidden spacing values).

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

* chore(migrations): rename to match applied versions

The mcp__plugin_supabase_supabase__apply_migration tool stamps its own
timestamp when it applies a migration to the live project, so the
version recorded in supabase_migrations.schema_migrations differs from
my local generation-time filenames. Renaming the local files so a
production CD run sees the migrations as already-applied (matching
versions) instead of trying to re-apply them — which would fail for
the trigger/RLS migration (CREATE TRIGGER and CREATE POLICY don't
support IF NOT EXISTS).

Follows the pattern from d854efcd ("chore(migration): rename to match
applied version").

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

* fix(create-from-document): scope orphan rollback DELETE by company_id

Defence in depth on the inbox-link race rollback. newTx.id is a fresh
UUID from a company-scoped insert two statements above, so the existing
single-key DELETE is already safe, but adding .eq('company_id', companyId)
makes the cross-company invariant explicit on every write — addresses
the OWASP ASVS V2.3 finding from the compliance swarm on PR #444.

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

* feat(nav): mark Dokumentinkorg with Beta badge

Same signal we use for Löner and Anställda — the inbox flow (AI
extraction, supplier autolink, manual transaction creation) is in
end-to-end customer testing.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-12 13:12:17 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 8f15f98687
commit 17c67fece0
33 changed files with 3158 additions and 169 deletions
+86 -13
View File
@@ -51,19 +51,32 @@ export async function GET(request: Request) {
(prefsData?.value as Partial<KPIPreferences>) ?? {}
)
const [incomeStatement, trialBalanceResult, arLedger, monthlyBreakdown, paidInvoicesResult] =
await Promise.all([
generateIncomeStatement(supabase, companyId, periodId),
generateTrialBalance(supabase, companyId, periodId),
generateARLedger(supabase, companyId),
generateMonthlyBreakdown(supabase, companyId, periodId),
supabase
.from('invoices')
.select('invoice_date, paid_at')
.eq('company_id', companyId)
.eq('status', 'paid')
.not('paid_at', 'is', null),
])
const [
incomeStatement,
trialBalanceResult,
arLedger,
monthlyBreakdown,
paidInvoicesResult,
topSuppliersResult,
] = await Promise.all([
generateIncomeStatement(supabase, companyId, periodId),
generateTrialBalance(supabase, companyId, periodId),
generateARLedger(supabase, companyId),
generateMonthlyBreakdown(supabase, companyId, periodId),
supabase
.from('invoices')
.select('invoice_date, paid_at')
.eq('company_id', companyId)
.eq('status', 'paid')
.not('paid_at', 'is', null),
supabase
.from('supplier_invoices')
.select('supplier_id, total_sek, total, supplier:suppliers(id, name)')
.eq('company_id', companyId)
.gte('invoice_date', period.period_start)
.lte('invoice_date', period.period_end)
.neq('status', 'credited'),
])
// Cash position — use account overrides if set
const cashOverrides = preferences.accountOverrides['cashPosition']
@@ -108,6 +121,59 @@ export async function GET(request: Request) {
paid_at: inv.paid_at as string,
}))
// Expense composition by BAS class (4-7). Expense accounts have a debit
// normal balance, so amount = closing_debit - closing_credit. Negative
// values (rare reclassifications) are clamped to 0 so the donut renders
// sensibly.
const expenseComposition = trialBalanceResult.rows.reduce(
(acc, r) => {
if (r.account_class < 4 || r.account_class > 7) return acc
const amount = r.closing_debit - r.closing_credit
if (amount <= 0) return acc
if (r.account_class === 4) acc.class4 += amount
else if (r.account_class === 5) acc.class5 += amount
else if (r.account_class === 6) acc.class6 += amount
else if (r.account_class === 7) acc.class7 += amount
return acc
},
{ class4: 0, class5: 0, class6: 0, class7: 0 }
)
// Top suppliers by spend within the fiscal period. Sum total_sek to avoid
// mixing currencies. Drop FX invoices without a SEK conversion (total_sek
// null) — they would otherwise inflate a supplier's total with raw
// foreign-currency amounts.
type SupplierInvoiceRow = {
supplier_id: string | null
total_sek: number | null
total: number | null
supplier: { id: string; name: string } | { id: string; name: string }[] | null
}
if (topSuppliersResult.error) {
// Surface the failure rather than silently rendering an empty chart that
// matches the legitimate "no supplier invoices" empty state.
console.error('[kpi] topSuppliersResult error:', topSuppliersResult.error)
}
const supplierTotals = new Map<string, { name: string; total: number }>()
for (const row of (topSuppliersResult.data ?? []) as SupplierInvoiceRow[]) {
if (!row.supplier_id) continue
const supplier = Array.isArray(row.supplier) ? row.supplier[0] : row.supplier
if (!supplier?.name) continue
const amount = row.total_sek ?? null
if (amount == null) continue
const existing = supplierTotals.get(row.supplier_id)
if (existing) existing.total += amount
else supplierTotals.set(row.supplier_id, { name: supplier.name, total: amount })
}
const topSuppliers = Array.from(supplierTotals.entries())
.map(([supplier_id, v]) => ({
supplier_id,
supplier_name: v.name,
total: Math.round(v.total * 100) / 100,
}))
.sort((a, b) => b.total - a.total)
.slice(0, 7)
const report: KPIReport = {
netResult: incomeStatement.net_result,
cashPosition,
@@ -122,6 +188,13 @@ export async function GET(request: Request) {
periodComplete: period.is_closed,
months: monthlyBreakdown.months,
period: { start: period.period_start, end: period.period_end },
expenseComposition: {
class4: Math.round(expenseComposition.class4 * 100) / 100,
class5: Math.round(expenseComposition.class5 * 100) / 100,
class6: Math.round(expenseComposition.class6 * 100) / 100,
class7: Math.round(expenseComposition.class7 * 100) / 100,
},
topSuppliers,
}
return NextResponse.json({ data: report })
@@ -82,7 +82,8 @@ describe('POST /api/transactions/[id]/attach-document', () => {
it('attaches when both rows exist', async () => {
enqueue({ data: { id: 'tx-1' }, error: null }) // tx fetch
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
enqueue({ data: null, error: null }) // update
enqueue({ data: null, error: null }) // transactions update
enqueue({ data: null, error: null }) // inbox-link best-effort update
const res = await POST(
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
createMockRouteParams({ id: 'tx-1' }),
@@ -92,6 +93,41 @@ describe('POST /api/transactions/[id]/attach-document', () => {
expect(body.data.transaction_id).toBe('tx-1')
expect(body.data.document_id).toBe('11111111-1111-4111-8111-111111111111')
})
it('attempts to update invoice_inbox_items.matched_transaction_id after successful attach', async () => {
// The side effect lets the inbox UI flip an item from "needs action" to
// "Kopplad till transaktion" without an extra round-trip.
enqueue({ data: { id: 'tx-1' }, error: null }) // tx fetch
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
enqueue({ data: null, error: null }) // transactions update
enqueue({ data: null, error: null }) // inbox-link update
await POST(
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
createMockRouteParams({ id: 'tx-1' }),
)
// Verify the inbox_items table was touched.
const fromCalls = mockSupabase.from.mock.calls.map((c) => c[0])
expect(fromCalls).toContain('invoice_inbox_items')
})
it('tolerates a failing inbox-link update — the document attach is the primary effect', async () => {
enqueue({ data: { id: 'tx-1' }, error: null }) // tx fetch
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
enqueue({ data: null, error: null }) // transactions update
enqueue({ data: null, error: { message: 'rls denied' } }) // inbox-link fails
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const res = await POST(
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
createMockRouteParams({ id: 'tx-1' }),
)
const { status, body } = await parseJsonResponse<{ data: { transaction_id: string } }>(res)
// Side-effect failure must not roll back the (compliant) document attach.
expect(status).toBe(200)
expect(body.data.transaction_id).toBe('tx-1')
spy.mockRestore()
})
})
describe('DELETE /api/transactions/[id]/attach-document', () => {
@@ -83,6 +83,22 @@ export async function POST(
return NextResponse.json({ error: 'Failed to attach document' }, { status: 500 })
}
// If this document came from an invoice_inbox_items row, mark that row
// as matched so the inbox UI can show it as "Kopplad" + link back to the
// transaction. Best-effort: a failure here must not roll back the
// (compliant) document attach.
try {
await supabase
.from('invoice_inbox_items')
.update({ matched_transaction_id: transactionId })
.eq('document_id', document_id)
.eq('company_id', companyId)
.is('matched_transaction_id', null)
.is('created_supplier_invoice_id', null)
} catch (linkErr) {
console.error('[attach-document] Failed to link inbox item:', linkErr)
}
// Rättelse audit trail (BFL 5 kap 5 §): record swaps where a non-null doc
// was replaced. Best-effort — a logging failure must not roll back the
// (compliant) attach.
@@ -0,0 +1,198 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createQueuedMockSupabase,
createMockRouteParams,
parseJsonResponse,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/invoices/match-log', () => ({
logMatchEvent: vi.fn(),
}))
vi.mock('@/lib/events/bus', () => ({
eventBus: { emit: vi.fn() },
}))
const mockCreatePaymentEntry = vi.fn()
const mockCreateCashEntry = vi.fn()
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
createSupplierInvoicePaymentEntry: (...args: unknown[]) => mockCreatePaymentEntry(...args),
createSupplierInvoiceCashEntry: (...args: unknown[]) => mockCreateCashEntry(...args),
}))
import { POST } from '../route'
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
mockCreatePaymentEntry.mockResolvedValue({ id: 'je-1' })
mockCreateCashEntry.mockResolvedValue({ id: 'je-1' })
})
const TX_UUID = '11111111-1111-4111-8111-111111111111'
const SI_UUID = '22222222-2222-4222-8222-222222222222'
function makeReq() {
return new Request(`http://localhost/api/transactions/${TX_UUID}/match-supplier-invoice`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ supplier_invoice_id: SI_UUID }),
})
}
function enqueueHappyPath(opts: {
transaction: { amount: number; currency: string; amount_sek?: number | null }
invoice: {
currency: string
exchange_rate?: number | null
remaining_amount?: number
paid_amount?: number
}
}) {
// 1. transactions fetch
enqueue({
data: {
id: TX_UUID,
company_id: 'company-1',
amount: opts.transaction.amount,
currency: opts.transaction.currency,
amount_sek: opts.transaction.amount_sek ?? null,
supplier_invoice_id: null,
date: '2026-05-12',
},
error: null,
})
// 2. supplier_invoices fetch
enqueue({
data: {
id: SI_UUID,
currency: opts.invoice.currency,
exchange_rate: opts.invoice.exchange_rate ?? null,
status: 'registered',
remaining_amount: opts.invoice.remaining_amount ?? 225,
paid_amount: opts.invoice.paid_amount ?? 0,
supplier: { supplier_type: 'eu_business' },
items: [],
},
error: null,
})
// 3. company_settings fetch
enqueue({ data: { accounting_method: 'accrual' }, error: null })
// 4. supplier_invoices update (CAS)
enqueue({ data: [{ id: SI_UUID }], error: null })
// 5. supplier_invoice_payments insert
enqueue({ data: null, error: null })
// 6. transactions update (link)
enqueue({ data: null, error: null })
}
describe('POST /api/transactions/[id]/match-supplier-invoice — FX residual', () => {
it('passes no exchangeRateDifference for a SEK transaction paying a SEK invoice', async () => {
enqueueHappyPath({
transaction: { amount: -2390, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 2390 },
})
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(mockCreatePaymentEntry).toHaveBeenCalledTimes(1)
const args = mockCreatePaymentEntry.mock.calls[0]
// (supabase, companyId, userId, invoice, paymentAmountSek, paymentDate, exchangeRateDifference?)
expect(args[4]).toBe(2390) // paymentAmountSek = actual bank SEK
expect(args[6]).toBeUndefined() // no FX diff
})
it('computes a loss when the SEK paid exceeds the AP booked SEK (EUR invoice)', async () => {
// Invoice: 225 EUR @ rate 10.6254 → AP booked at 2390.72 SEK.
// Bank: paid 2400 SEK out of a SEK account.
// → diff = 2390.72 − 2400 = −9.28 (loss, debit 7960).
enqueueHappyPath({
transaction: { amount: -2400, currency: 'SEK' },
invoice: { currency: 'EUR', exchange_rate: 10.6254, remaining_amount: 225 },
})
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const args = mockCreatePaymentEntry.mock.calls[0]
expect(args[4]).toBeCloseTo(2390.72, 2) // paymentAmountSek = originalBookedSek
expect(args[6]).toBeCloseTo(-9.28, 2) // exchangeRateDifference (loss)
})
it('computes a gain when the SEK paid is less than the AP booked SEK', async () => {
// Invoice: 100 EUR @ rate 11 → AP booked at 1100 SEK.
// Bank: paid 1080 SEK (rate had dipped) → diff = 1100 − 1080 = +20 (gain → credit 3960).
enqueueHappyPath({
transaction: { amount: -1080, currency: 'SEK' },
invoice: { currency: 'EUR', exchange_rate: 11, remaining_amount: 100 },
})
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const args = mockCreatePaymentEntry.mock.calls[0]
expect(args[4]).toBeCloseTo(1100, 2)
expect(args[6]).toBeCloseTo(20, 2)
})
it('uses transaction.amount_sek for a foreign-currency bank transaction', async () => {
// Reverse case: SEK invoice for 1000 kr, paid from a EUR card that
// showed amount_sek = 1063 (rate had moved).
// → diff = 1000 − 1063 = −63 (loss).
enqueueHappyPath({
transaction: { amount: -100, currency: 'EUR', amount_sek: -1063 },
invoice: { currency: 'SEK', remaining_amount: 1000 },
})
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const args = mockCreatePaymentEntry.mock.calls[0]
// SEK invoice: originalBookedSek = remaining = 1000, FX diff = 1000 - 1063 = -63
expect(args[4]).toBe(1000)
expect(args[6]).toBeCloseTo(-63, 2)
})
it('falls back to bank SEK when the invoice has no exchange_rate on file', async () => {
// Foreign-currency invoice but exchange_rate is null on the row.
// Without a rate we can't compute the AP-booked SEK precisely, so we
// pass the actual bank SEK and skip the FX diff.
enqueueHappyPath({
transaction: { amount: -239, currency: 'SEK' },
invoice: { currency: 'USD', exchange_rate: null, remaining_amount: 25 },
})
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const args = mockCreatePaymentEntry.mock.calls[0]
expect(args[4]).toBe(239)
expect(args[6]).toBeUndefined()
})
})
describe('POST /api/transactions/[id]/match-supplier-invoice — non-FX paths', () => {
it('returns 200 with the expected body shape on the happy path', async () => {
enqueueHappyPath({
transaction: { amount: -1000, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 1000 },
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{
success: boolean
paid_amount: number
remaining_amount: number
}>(res)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.paid_amount).toBe(1000)
expect(body.remaining_amount).toBe(0)
})
})
@@ -79,7 +79,59 @@ export const POST = withRouteContext(
})
}
const paymentAmount = Math.abs(transaction.amount)
const txAmountAbs = Math.abs(transaction.amount)
// Amount in the *invoice's* currency — used to update
// supplier_invoices.paid_amount/remaining_amount and the
// supplier_invoice_payments row (whose `currency` is the invoice's).
// When the bank transaction is in a different currency from the
// invoice (e.g. paying a USD invoice from a SEK account) we treat the
// match as a full payment of whatever remains, rather than storing the
// SEK number with the invoice's currency suffix — which would render
// as "Betalt 239 USD" on a 25 USD invoice.
const paymentAmountInvoiceCurrency =
transaction.currency === invoice.currency
? txAmountAbs
: invoice.remaining_amount
// Actual SEK leaving the bank — what really moved out of 1930. For a
// SEK transaction this is just the absolute amount; for a foreign-
// currency transaction we use the SEK conversion stored at import.
const actualBankSek =
transaction.currency === 'SEK'
? txAmountAbs
: (transaction.amount_sek != null
? Math.abs(transaction.amount_sek)
: txAmountAbs)
// SEK value that's actually sitting on 2440 for this payment portion:
// - SEK invoice: face value = paymentAmountInvoiceCurrency
// - Non-SEK invoice w/ exchange_rate: portion × rate
// - Non-SEK invoice w/o exchange_rate: can't compute precisely; fall
// back to actualBankSek (no FX diff, plain SEK booking)
// FX diff hits 7960/3960 so 2440 clears cleanly instead of leaving a
// residual. Triggered whenever bank-paid SEK differs from booked SEK —
// happens for any currency mismatch (SEK→EUR, EUR→SEK, EUR→USD), not
// just non-SEK invoices.
const invoiceFxRate = invoice.exchange_rate ?? null
const originalBookedSek =
invoice.currency === 'SEK'
? paymentAmountInvoiceCurrency
: invoiceFxRate && invoiceFxRate > 0
? Math.round(paymentAmountInvoiceCurrency * invoiceFxRate * 100) / 100
: actualBankSek
// Positive = gain (AP credited at more SEK than the bank actually paid).
// Negative = loss (bank paid more SEK than the AP we owed).
const exchangeRateDifference =
Math.round((originalBookedSek - actualBankSek) * 100) / 100
// `paymentAmountSek` is what we pass to the payment-entry builder. In
// the FX branch (non-zero exchangeRateDifference) it represents the
// ORIGINAL booked SEK on 2440; the builder then computes actualSekPaid
// as paymentAmountSek - exchangeRateDifference internally.
const paymentAmountSek = exchangeRateDifference !== 0 ? originalBookedSek : actualBankSek
const now = new Date().toISOString()
const { data: settings } = await supabase
@@ -90,6 +142,23 @@ export const POST = withRouteContext(
const accountingMethod = settings?.accounting_method || 'accrual'
// Cash method (kontantmetoden) collapses registration + payment into a
// single entry that credits 1930 at sum(expenses_SEK). It has no
// exchange_rate_difference path — if the actual bank SEK differs from
// the invoice's booked SEK, the 1930 credit won't match the bank
// transaction and we'd silently leave a reconciliation gap. Block the
// combination and ask the user to switch to accrual or do a manual JE.
if (accountingMethod === 'cash' && exchangeRateDifference !== 0) {
return errorResponseFromCode('MATCH_SI_CASH_FX_UNSUPPORTED', txLog, {
requestId,
details: {
exchangeRateDifference,
invoiceCurrency: invoice.currency,
transactionCurrency: transaction.currency,
},
})
}
let journalEntryId: string | null = null
let journalEntryError: string | null = null
@@ -105,7 +174,8 @@ export const POST = withRouteContext(
} else {
const journalEntry = await createSupplierInvoicePaymentEntry(
supabase, companyId, user.id, invoice as SupplierInvoice,
paymentAmount, transaction.date,
paymentAmountSek, transaction.date,
exchangeRateDifference !== 0 ? exchangeRateDifference : undefined,
)
if (journalEntry) journalEntryId = journalEntry.id
}
@@ -120,8 +190,8 @@ export const POST = withRouteContext(
}
}
const newRemaining = Math.max(0, Math.round((invoice.remaining_amount - paymentAmount) * 100) / 100)
const newPaidAmount = Math.round((invoice.paid_amount + paymentAmount) * 100) / 100
const newRemaining = Math.max(0, Math.round((invoice.remaining_amount - paymentAmountInvoiceCurrency) * 100) / 100)
const newPaidAmount = Math.round((invoice.paid_amount + paymentAmountInvoiceCurrency) * 100) / 100
const isFullyPaid = newRemaining <= 0
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
@@ -155,7 +225,7 @@ export const POST = withRouteContext(
company_id: companyId,
supplier_invoice_id,
payment_date: transaction.date,
amount: paymentAmount,
amount: paymentAmountInvoiceCurrency,
currency: invoice.currency,
journal_entry_id: journalEntryId,
transaction_id: transactionId,
@@ -0,0 +1,234 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
import { POST } from '../route'
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
const VALID_UUID = '11111111-1111-4111-8111-111111111111'
function makeReq(body: unknown) {
return new Request('http://localhost/api/transactions/create-from-document', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
function validBody(overrides: Partial<{
inbox_item_id: string
amount: number
transaction_date: string
description: string
}> = {}) {
return {
inbox_item_id: VALID_UUID,
amount: -100,
transaction_date: '2026-05-12',
description: 'Test supplier · INV-001',
...overrides,
}
}
describe('POST /api/transactions/create-from-document', () => {
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const res = await POST(makeReq(validBody()))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 400 when the body is invalid', async () => {
const res = await POST(makeReq({ inbox_item_id: 'not-a-uuid', amount: 0 }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(400)
})
it('returns 400 when amount is zero (schema refine)', async () => {
const res = await POST(makeReq(validBody({ amount: 0 })))
const { status } = await parseJsonResponse(res)
expect(status).toBe(400)
})
it('returns 404 when the inbox item is not in the user company', async () => {
enqueue({ data: null, error: null }) // inbox item lookup misses
const res = await POST(makeReq(validBody()))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(404)
expect(body.error).toBe('Inbox item not found')
})
it('returns 409 when the inbox item is already matched to a transaction', async () => {
enqueue({
data: {
id: VALID_UUID,
document_id: 'doc-1',
matched_transaction_id: 'tx-existing',
created_supplier_invoice_id: null,
extracted_data: null,
},
error: null,
})
const res = await POST(makeReq(validBody()))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(409)
expect(body.error).toMatch(/redan kopplad/)
})
it('returns 409 when the inbox item is already booked as a supplier invoice', async () => {
enqueue({
data: {
id: VALID_UUID,
document_id: 'doc-1',
matched_transaction_id: null,
created_supplier_invoice_id: 'si-existing',
extracted_data: null,
},
error: null,
})
const res = await POST(makeReq(validBody()))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(409)
expect(body.error).toMatch(/redan bokförd/)
})
it('creates the transaction and links the inbox item on the happy path', async () => {
enqueue({
data: {
id: VALID_UUID,
document_id: 'doc-1',
matched_transaction_id: null,
created_supplier_invoice_id: null,
extracted_data: { invoice: { currency: 'EUR' } },
},
error: null,
})
enqueue({ data: { id: 'new-tx-1' }, error: null }) // insert
enqueue({ data: [{ id: VALID_UUID }], error: null }) // inbox update — one row affected
const res = await POST(makeReq(validBody()))
const { status, body } = await parseJsonResponse<{
data: { transaction_id: string; inbox_item_id: string; document_id: string }
}>(res)
expect(status).toBe(200)
expect(body.data.transaction_id).toBe('new-tx-1')
expect(body.data.document_id).toBe('doc-1')
})
it('returns 409 and rolls back the orphan when a concurrent request linked first', async () => {
// Race scenario: both requests pass the matched_transaction_id IS NULL
// read; both insert their own transaction. The losing UPDATE matches
// zero rows because the .is('matched_transaction_id', null) predicate
// no longer holds. We delete the orphan and 409.
enqueue({
data: {
id: VALID_UUID,
document_id: 'doc-1',
matched_transaction_id: null,
created_supplier_invoice_id: null,
extracted_data: null,
},
error: null,
})
enqueue({ data: { id: 'orphan-tx' }, error: null }) // insert succeeds
enqueue({ data: [], error: null }) // inbox update affects zero rows — lost the race
enqueue({ data: null, error: null }) // rollback delete of the orphan
const res = await POST(makeReq(validBody()))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(409)
expect(body.error).toMatch(/parallell begäran/)
})
it('coerces an unrecognised extracted currency to SEK before insert', async () => {
// Defense-in-depth: the deterministic extractor can still emit garbage
// for malformed PDFs. We must not let arbitrary strings reach the
// transactions.currency column.
enqueue({
data: {
id: VALID_UUID,
document_id: null,
matched_transaction_id: null,
created_supplier_invoice_id: null,
extracted_data: { invoice: { currency: 'XYZ' } },
},
error: null,
})
enqueue({ data: { id: 'new-tx-3' }, error: null })
enqueue({ data: [{ id: VALID_UUID }], error: null })
const res = await POST(makeReq(validBody()))
const { status } = await parseJsonResponse(res)
expect(status).toBe(200)
})
it('returns 500 when the transaction insert fails', async () => {
enqueue({
data: {
id: VALID_UUID,
document_id: null,
matched_transaction_id: null,
created_supplier_invoice_id: null,
extracted_data: null,
},
error: null,
})
enqueue({ data: null, error: { message: 'db down' } }) // insert fails
// Silence the console.error
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const res = await POST(makeReq(validBody()))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(500)
expect(body.error).toMatch(/Kunde inte skapa transaktion/)
spy.mockRestore()
})
it('tolerates a failed inbox-link update — transaction exists, surface inbox_link_failed', async () => {
enqueue({
data: {
id: VALID_UUID,
document_id: 'doc-1',
matched_transaction_id: null,
created_supplier_invoice_id: null,
extracted_data: null,
},
error: null,
})
enqueue({ data: { id: 'new-tx-2' }, error: null }) // insert ok
enqueue({ data: null, error: { message: 'rls' } }) // link update fails
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const res = await POST(makeReq(validBody()))
const { status, body } = await parseJsonResponse<{
data: { transaction_id: string; inbox_link_failed?: boolean }
}>(res)
expect(status).toBe(200)
expect(body.data.transaction_id).toBe('new-tx-2')
expect(body.data.inbox_link_failed).toBe(true)
spy.mockRestore()
})
})
@@ -0,0 +1,139 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { CreateTransactionFromDocumentSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
/**
* POST /api/transactions/create-from-document
*
* Creates an uncategorized manual bank transaction prefilled from an
* invoice_inbox_items row, then attaches the inbox item's document to it
* and links the inbox item to the new transaction. The user categorizes
* the new transaction through the normal /transactions flow (which routes
* through the bookkeeping engine and respects period locks, etc.).
*
* Use case: receipt in the inbox has no matching bank transaction
* (cash purchase, personal-card expense, missed sync).
*/
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, CreateTransactionFromDocumentSchema)
if (!validation.success) return validation.response
const { inbox_item_id, amount, transaction_date, description } = validation.data
const { data: item, error: itemError } = await supabase
.from('invoice_inbox_items')
.select('id, document_id, matched_transaction_id, created_supplier_invoice_id, extracted_data')
.eq('id', inbox_item_id)
.eq('company_id', companyId)
.maybeSingle()
if (itemError || !item) {
return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 })
}
if (item.matched_transaction_id) {
return NextResponse.json(
{ error: 'Inkorgsposten är redan kopplad till en transaktion.' },
{ status: 409 },
)
}
if (item.created_supplier_invoice_id) {
return NextResponse.json(
{ error: 'Inkorgsposten är redan bokförd som leverantörsfaktura.' },
{ status: 409 },
)
}
// Allowlist the currency — extracted_data.invoice.currency comes from the
// (deterministic, but still untrusted) PDF extractor, so an arbitrary
// string like "XYZ" or '"SEK\'"' could otherwise be persisted directly to
// the transactions table and break later formatCurrency / journal-entry
// bookings (BFL 5 kap 6 §). Coerce anything outside the supported set
// to SEK; the user can change it manually on the transaction.
const ALLOWED_CURRENCIES = new Set(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'])
const extractedCurrency = (
item.extracted_data as { invoice?: { currency?: string } } | null
)?.invoice?.currency
const currency =
extractedCurrency && ALLOWED_CURRENCIES.has(extractedCurrency)
? extractedCurrency
: 'SEK'
const { data: newTx, error: insertError } = await supabase
.from('transactions')
.insert({
company_id: companyId,
user_id: user.id,
date: transaction_date,
description,
amount,
currency,
category: 'uncategorized',
is_business: null,
import_source: 'manual',
document_id: item.document_id,
})
.select('id')
.single()
if (insertError || !newTx) {
console.error('[create-from-document] Failed to insert transaction:', insertError)
return NextResponse.json({ error: 'Kunde inte skapa transaktion.' }, { status: 500 })
}
// Concurrency guard: the .is('matched_transaction_id', null) predicate +
// the rows-affected check turn this into an optimistic-lock release. If
// two requests with the same inbox_item_id race past the earlier
// matched_transaction_id check, only the first UPDATE will match a row
// here. The loser's transaction insert is then an orphan we proactively
// delete so the user doesn't get a duplicate uncategorized row.
const { data: linked, error: linkError } = await supabase
.from('invoice_inbox_items')
.update({ matched_transaction_id: newTx.id })
.eq('id', inbox_item_id)
.eq('company_id', companyId)
.is('matched_transaction_id', null)
.select('id')
if (linkError) {
console.error('[create-from-document] Failed to link inbox item:', linkError)
// Transaction was created; surface a 200 with a warning so the user can
// still find it under Transaktioner — the inbox-link orphan is recoverable.
return NextResponse.json({
data: { transaction_id: newTx.id, inbox_link_failed: true },
})
}
if (!linked || linked.length === 0) {
// Lost a race — another concurrent request linked the inbox item first.
// Roll back our newly-created transaction (only safe because we own it
// and it has no journal_entry_id yet) and return 409 so the client can
// refetch and reuse the winning transaction instead of creating a dupe.
// Re-assert company_id on the delete (defence in depth — newTx.id is a
// fresh UUID from a company-scoped insert above, but scoping the rollback
// makes the invariant explicit).
await supabase.from('transactions').delete().eq('id', newTx.id).eq('company_id', companyId)
return NextResponse.json(
{ error: 'Inkorgsposten kopplades av en parallell begäran. Försök igen.' },
{ status: 409 },
)
}
return NextResponse.json({
data: { transaction_id: newTx.id, inbox_item_id, document_id: item.document_id },
})
}