feat(invoices): cross-currency settlement + payment-status card (#615)

* feat(invoices): cross-currency settlement + payment-status card

Two changes both surfaced by user feedback after PR #614:

# 1. Invoice detail page: Betalningsstatus card

The customer-invoice detail page now shows paid_amount + remaining_amount
+ the individual payment events whenever an invoice is partially_paid or
paid (was previously only a single "Paid" line on fully-paid invoices,
and nothing at all on partially_paid). Mirrors the supplier-invoice
page's payment section. Each payment row links to its verifikat.

# 2. Cross-currency match-invoice settlement

Replaces the PR #614 round-9 block (MATCH_INVOICE_CURRENCY_MISMATCH)
with proper FX-aware settlement. Flow:

1. Preview route detects tx.currency !== invoice.currency, fetches the
   Riksbanken spot rate for invoice.currency on tx.date (ML 8 kap 21–23§),
   and returns fx_conversion = { rate, rate_date, paid_in_invoice_currency }.
   When the lookup fails it returns fx_conversion.error = 'rate_unavailable'.

2. InvoiceMatchDialog renders a new Valutaomräkning card showing the rate
   + invoice-currency-equivalent + projected post-payment state + a one-
   line kursvinst/kursförlust note. When the lookup failed it swaps in
   a manual-rate input the user fills from their bank statement; the
   Confirm button blocks until a positive rate is supplied.

3. POST route does the same lookup (or accepts manual_exchange_rate from
   the request body), then:
   - paidInInvoiceCurrency = bankSek / rate (4dp precision)
   - invoice.paid_amount/remaining_amount accumulate in invoice currency
   - invoice_payments row records amount + currency = invoice.currency,
     exchange_rate = the rate actually used (not invoice.exchange_rate)
   - buildInvoicePaymentClearingLines gets paidInInvoiceCurrency so it
     credits 1510 by that × invoice.exchange_rate (booking rate) and
     posts the FX-diff line on 3960 (gain) or 7960 (loss)

4. buildInvoicePaymentClearingLines gains an optional fourth param. When
   supplied: proportional FX-aware AR-leg + balanced FX-diff. When omitted:
   pre-existing fallback (full-clear gets FX, partials defer).

The change fixes the invoice.paid_amount accumulator bug that PR #614
round-9 worked around by blocking the case entirely. Now SEK→USD
settlements actually work, with the verifikat balanced to the öre and
the GL+sub-ledger in sync per BFL 5 kap 4–5§.

Tests:
- 3 new helper tests (paidInInvoiceCurrency happy path + edge cases)
- 3 new route tests (Riksbanken happy path, lookup failure, manual rate)
- All 4321 tests pass

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

* fix(invoices): align cross-currency match preview with commit + review cleanups

Addresses PR #615 review feedback.

Preview/commit divergence (Greptile P1): preview/route.ts computed
paidAmount / isFullyPaid / useCashEntry from the raw SEK transaction.amount
before the FX conversion ran. A 1 000 SEK payment against a 140 USD invoice
made max(0, 140 − 1000) = 0 → is_fully_paid=true, so a cash-method unbooked
invoice previewed a cash entry (Dr 1930 / Cr 30xx) while the POST handler —
which converts first — commits the clearing entry (Dr 1930 / Cr 1510). The
user approved one verifikat and a different one was booked. Move the FX
lookup above the paid/remaining math so paidAmount derives from the
invoice-currency conversion, mirroring the POST handler. Rate-unavailable
stays non-fully-paid so the cash shape is never previewed on a guess.

Add a preview-route regression test (cross-currency → clearing + not
fully paid; same-currency cash path still previews the cash entry).

Cleanups:
- Bound manual_exchange_rate with .max(100000) as a sanity ceiling against
  pasted/garbage input corrupting the FX-diff posting (swarm V2.3).
- Remove the invisible disabled placeholder retry button and its unused
  fx_manual_rate_retry i18n keys (Greptile P2).
- Remove the now-unreachable MATCH_INVOICE_CURRENCY_MISMATCH error code
  (Greptile P2 dead code; confirmed zero references).

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

* fix(invoices): record FX rate provenance + cover kursförlust path

Follow-up to the PR #615 review (compliance swarm V16 / SOC 2 CC6.1 /
GDPR Art.5(1)(f); Swedish accounting review).

A manually-supplied cross-currency rate is a user-controlled money-path
override of the ML 8 kap 21–23§ obligation and was indistinguishable from
an automatic Riksbanken lookup in the audit trail. Tag the resolved rate
with source: 'manual' | 'riksbanken' and:
- write a "Manuell valutakurs <rate> <ccy>/SEK (betalningsdatum …)" note
  onto the existing invoice_payments.notes column when manual (BFL 5 kap
  6–7§ — the verifikation must reflect the actual affärshändelse);
- record rate_source + exchange_rate in payment_match_log.new_state.
No schema change — both are existing columns/JSON.

Tests:
- cover the kursförlust (7960 Dr) branch of the cross-currency
  paidInInvoiceCurrency path — previously only the 3960 gain was asserted;
- assert rate_source provenance ('manual' and 'riksbanken') reaches the
  match-log new_state on both FX paths.

Co-Authored-By: Claude Opus 4.8 (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-06-01 10:45:51 +02:00
committed by GitHub
parent 13be0c569a
commit 2c59c3633f
12 changed files with 962 additions and 88 deletions
+155 -12
View File
@@ -75,6 +75,20 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const [invoice, setInvoice] = useState<InvoiceWithRelations | null>(null)
const [reminders, setReminders] = useState<InvoiceReminder[]>([])
// Payment history backing the new Betalningsstatus card. Fetched alongside
// the invoice itself so the card stays in sync with paid_amount /
// remaining_amount on the invoice row.
const [payments, setPayments] = useState<
Array<{
id: string
payment_date: string
amount: number
currency: string
journal_entry_id: string | null
voucher_series: string | null
voucher_number: number | null
}>
>([])
const [creditNote, setCreditNote] = useState<Invoice | null>(null)
const [originalInvoice, setOriginalInvoice] = useState<Invoice | null>(null)
const [convertedFromInvoice, setConvertedFromInvoice] = useState<Invoice | null>(null)
@@ -154,6 +168,40 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
setReminders(reminderData as InvoiceReminder[])
}
// Fetch payment history for the Betalningsstatus card. Joins the
// journal_entries row to get voucher_series + voucher_number so each
// payment row can link to its verifikat. Manual payments (no tx, no
// JE) still surface with the amount + date.
const { data: paymentData } = await supabase
.from('invoice_payments')
.select(
'id, payment_date, amount, currency, journal_entry_id, journal_entries(voucher_series, voucher_number)',
)
.eq('invoice_id', id)
.order('payment_date', { ascending: true })
if (paymentData) {
type PaymentRow = {
id: string
payment_date: string
amount: number
currency: string
journal_entry_id: string | null
journal_entries: { voucher_series: string | null; voucher_number: number | null } | null
}
setPayments(
(paymentData as unknown as PaymentRow[]).map((p) => ({
id: p.id,
payment_date: p.payment_date,
amount: p.amount,
currency: p.currency,
journal_entry_id: p.journal_entry_id,
voucher_series: p.journal_entries?.voucher_series ?? null,
voucher_number: p.journal_entries?.voucher_number ?? null,
})),
)
}
// If this invoice is credited, find the credit note
if (data.status === 'credited') {
const { data: creditNoteData } = await supabase
@@ -740,24 +788,119 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</CardContent>
</Card>
{/* Payment info */}
{invoice.status === 'paid' && invoice.paid_at && (
{/* Betalningsstatus card. Shows for both `paid` and `partially_paid`
so the user always sees how much has been paid + what remains +
the individual payment events. Previously only the `paid` case
had a card, leaving partially-paid invoices without any visible
paid_amount/remaining_amount — surfaced by user feedback after
PR #614. */}
{(invoice.status === 'paid' || invoice.status === 'partially_paid') && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-success">
<CheckCircle className="h-5 w-5" />
{t('paid_card_title')}
<CardTitle
className={cn(
'flex items-center gap-2',
invoice.status === 'paid' && 'text-success',
invoice.status === 'partially_paid' && 'text-warning-foreground',
)}
>
{invoice.status === 'paid' ? (
<CheckCircle className="h-5 w-5" />
) : (
<AlertTriangle className="h-5 w-5" />
)}
{invoice.status === 'paid'
? t('paid_card_title')
: t('payment_status_card_title')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{t('paid_received_at', { date: formatDate(invoice.paid_at) })}
</p>
{invoice.paid_amount && (
<p className="text-lg font-bold mt-2">
{formatCurrency(invoice.paid_amount, invoice.currency)}
<CardContent className="space-y-4">
{/* Paid / remaining summary. Right-aligned tabular nums so
the two columns scan cleanly. Same SEK / invoice.currency
formatter as the items table. */}
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('payment_status_paid_label')}
</p>
<p className="font-display text-xl tabular-nums mt-1">
{formatCurrency(invoice.paid_amount ?? 0, invoice.currency)}
</p>
</div>
<div className="text-right">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('payment_status_remaining_label')}
</p>
<p
className={cn(
'font-display text-xl tabular-nums mt-1',
invoice.status === 'partially_paid' && 'text-warning-foreground',
)}
>
{formatCurrency(
invoice.remaining_amount ??
Math.max(0, invoice.total - (invoice.paid_amount ?? 0)),
invoice.currency,
)}
</p>
</div>
</div>
{invoice.status === 'paid' && invoice.paid_at && (
<p className="text-sm text-muted-foreground">
{t('paid_received_at', { date: formatDate(invoice.paid_at) })}
</p>
)}
{/* Payment history. Each row links to its verifikat when one
exists. Compact list — dates and amounts tabular-nums for
column alignment, the voucher link sits to the right with
a small chevron. */}
<div className="space-y-2">
<p className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('payment_status_payments_heading')}
</p>
{payments.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t('payment_status_empty')}
</p>
) : (
<ul className="divide-y divide-border -mx-2">
{payments.map((p) => {
const voucherLabel =
p.voucher_series && p.voucher_number != null
? `${p.voucher_series}-${p.voucher_number}`
: null
return (
<li
key={p.id}
className="flex items-center justify-between gap-3 px-2 py-2 text-sm transition-colors hover:bg-secondary/60 rounded"
>
<span className="tabular-nums text-muted-foreground">
{formatDate(p.payment_date)}
</span>
<span className="font-medium tabular-nums flex-1 text-right">
{formatCurrency(p.amount, p.currency)}
</span>
{p.journal_entry_id && voucherLabel ? (
<Link
href={`/bookkeeping/${p.journal_entry_id}`}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{t('payment_status_view_voucher', { label: voucherLabel })}
<ExternalLink className="h-3 w-3" />
</Link>
) : (
<span className="text-xs text-muted-foreground">
{t('payment_status_view_voucher_unlinked')}
</span>
)}
</li>
)
})}
</ul>
)}
</div>
</CardContent>
</Card>
)}
@@ -30,6 +30,11 @@ vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
}))
const mockFetchExchangeRate = vi.fn()
vi.mock('@/lib/currency/riksbanken', () => ({
fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args),
}))
vi.mock('@/lib/invoices/match-log', () => ({
logMatchEvent: vi.fn(),
}))
@@ -57,6 +62,9 @@ vi.mock('@/lib/auth/require-write', () => ({
}))
import { POST } from '../route'
// Mocked above — imported here as a spy handle to assert FX rate provenance
// lands in the audit trail (PR #615 review).
import { logMatchEvent } from '@/lib/invoices/match-log'
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
const VALID_UUID_2 = '550e8400-e29b-41d4-a716-446655440001'
@@ -205,36 +213,170 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
expect((body.error as unknown as { code: string }).code).toBe('MATCH_INVOICE_NOT_OPEN')
})
it('returns 400 MATCH_INVOICE_CURRENCY_MISMATCH for cross-currency settlement', async () => {
// Round-9 fix: a SEK bank tx paying a USD invoice would otherwise
// corrupt invoice.paid_amount (accumulator treats SEK as USD), flip
// a 140 USD invoice to status=paid after a tiny partial. Block here
// and route the user to the multi-allocation flow that handles
// 3960/7960 FX-diff postings end-to-end.
const tx = makeTransaction({ id: 'tx-1', amount: 1000, invoice_id: null, currency: 'SEK' })
it('cross-currency settlement: converts SEK tx via Riksbanken rate, posts FX-diff verifikat', async () => {
// 1000 SEK bank tx paying a 140 USD invoice. Spot rate today: 10.45.
// Conversion: 1000 / 10.45 = 95.6938 USD. Invoice was booked at 9.30,
// so 1510 credit = 95.6938 × 9.30 = 889.95. FX gain = 1000 889.95 =
// 110.05 → 3960 Cr. Invoice flips to partially_paid with remaining
// 140 95.6938 = 44.3062 USD.
const tx = makeTransaction({
id: 'tx-1',
amount: 1000,
invoice_id: null,
currency: 'SEK',
date: '2026-05-30',
})
const invoice = makeInvoice({
id: VALID_UUID,
status: 'sent',
currency: 'USD',
exchange_rate: 9.3,
total: 140,
remaining_amount: 140,
paid_amount: 0,
})
enqueue({ data: tx, error: null })
enqueue({ data: invoice, error: null })
enqueue({ data: [], error: null }) // hard-duplicate check
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
mockFetchExchangeRate.mockResolvedValue({
currency: 'USD',
rate: 10.45,
date: '2026-05-30',
})
mockCreateJournalEntry.mockResolvedValue({ id: 'je-fx' })
enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice
enqueue({ data: null, error: null }) // insert invoice_payments
enqueue({ data: null, error: null }) // update transaction
enqueue({ data: null, error: null }) // logMatchEvent
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
method: 'POST',
body: { invoice_id: VALID_UUID },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string; details: Record<string, string> } }>(response)
const { status, body } = await parseJsonResponse<{
success: boolean
invoice_status: string
paid_amount: number
remaining_amount: number
journal_entry_id: string
}>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.invoice_status).toBe('partially_paid')
// 2dp precision matches the invoice currency's natural precision (USD
// is to cent). The internal paidInInvoiceCurrency is computed at 4dp
// for FX-rate accuracy then rounded to 2dp when accumulated into the
// invoice column.
expect(body.paid_amount).toBeCloseTo(95.69, 1)
expect(body.remaining_amount).toBeCloseTo(44.31, 1)
// Verifikat: Dr 1930 1000, Cr 1510 889.95 (95.6938 × 9.30 ≈ 889.95),
// Cr 3960 110.05 (gain). Balances to öre.
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({
source_type: 'invoice_paid',
lines: expect.arrayContaining([
expect.objectContaining({ account_number: '1930', debit_amount: 1000 }),
expect.objectContaining({ account_number: '1510' }),
expect.objectContaining({ account_number: '3960' }),
]),
}),
)
// The auto path records the rate provenance as 'riksbanken' (vs 'manual').
expect(logMatchEvent).toHaveBeenCalledWith(
expect.anything(),
'user-1',
'tx-1',
'matched',
expect.objectContaining({
newState: expect.objectContaining({ rate_source: 'riksbanken', exchange_rate: 10.45 }),
}),
)
})
it('cross-currency settlement: returns 400 FX_RATE_UNAVAILABLE when Riksbanken fails and no manual rate', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: 1000, invoice_id: null, currency: 'SEK', date: '2026-05-30' })
const invoice = makeInvoice({
id: VALID_UUID,
status: 'sent',
currency: 'USD',
exchange_rate: 9.3,
total: 140,
remaining_amount: 140,
})
enqueue({ data: tx, error: null })
enqueue({ data: invoice, error: null })
mockFetchExchangeRate.mockResolvedValue(null) // Riksbanken outage
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
method: 'POST',
body: { invoice_id: VALID_UUID },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('MATCH_INVOICE_CURRENCY_MISMATCH')
expect(body.error.details).toMatchObject({
transactionCurrency: 'SEK',
invoiceCurrency: 'USD',
expect(body.error.code).toBe('MATCH_INVOICE_FX_RATE_UNAVAILABLE')
})
it('cross-currency settlement: manual_exchange_rate succeeds when Riksbanken fails', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: 1000, invoice_id: null, currency: 'SEK', date: '2026-05-30' })
const invoice = makeInvoice({
id: VALID_UUID,
status: 'sent',
currency: 'USD',
exchange_rate: 9.3,
total: 140,
remaining_amount: 140,
paid_amount: 0,
})
enqueue({ data: tx, error: null })
enqueue({ data: invoice, error: null })
enqueue({ data: [], error: null }) // hard-duplicate
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
mockFetchExchangeRate.mockResolvedValue(null) // Riksbanken down — manual rate used instead
mockCreateJournalEntry.mockResolvedValue({ id: 'je-fx-manual' })
enqueue({ data: [{ id: VALID_UUID }], error: null })
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
method: 'POST',
body: { invoice_id: VALID_UUID, manual_exchange_rate: 10.5 },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status } = await parseJsonResponse<{ success: boolean }>(response)
expect(status).toBe(200)
// Manual rate skips the Riksbanken lookup — confirm by inspecting that
// mockCreateJournalEntry got the FX-computed line set (1000 / 10.5 =
// 95.2381 USD; arSek = 95.2381 × 9.30 = 885.71). Skipping Riksbanken is
// intentional: when the user types a rate from their bank statement we
// honour it rather than overriding with a possibly-stale Riksbanken value.
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
expect(mockCreateJournalEntry).toHaveBeenCalled()
// Provenance: the manual override is recorded in the audit trail's
// new_state so it's distinguishable from an automatic Riksbanken lookup.
expect(logMatchEvent).toHaveBeenCalledWith(
expect.anything(),
'user-1',
'tx-1',
'matched',
expect.objectContaining({
newState: expect.objectContaining({ rate_source: 'manual', exchange_rate: 10.5 }),
}),
)
})
it('matches transaction to invoice with accrual method (full payment)', async () => {
@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
makeTransaction,
makeInvoice,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
const mockFetchExchangeRate = vi.fn()
vi.mock('@/lib/currency/riksbanken', () => ({
fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args),
}))
// Pure account-mapping helpers; mocked to keep the test off the real engine
// import chain (mirrors the POST route test). buildInvoicePaymentClearingLines
// and resolveSekAmount are pure and kept real so the preview lines are the
// genuine ones the dialog would render.
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
getRevenueAccount: vi.fn().mockReturnValue('3001'),
getOutputVatAccount: vi.fn().mockReturnValue('2611'),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { GET } from '../route'
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
describe('GET /api/transactions/[id]/match-invoice/preview', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
// PR #615 P1 regression (Greptile). A 1 000 SEK bank tx against a 140 USD
// invoice is a PARTIAL payment (≈95.69 USD at 10.45). The preview must
// convert to invoice currency BEFORE deciding fully-paid / cash-vs-clearing.
// Before the fix, comparing the raw 1 000 SEK against 140 USD made
// newRemaining go negative → is_fully_paid=true → a cash-method unbooked
// invoice previewed a cash entry (Dr 1930 / Cr 30xx) while the POST — which
// converts first — commits the clearing entry (Dr 1930 / Cr 1510). The user
// approved one verifikat and a different one was booked.
it('cross-currency partial under kontantmetoden previews a clearing entry, not a cash entry', async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: 1000,
currency: 'SEK',
date: '2026-05-30',
invoice_id: null,
})
const invoice = makeInvoice({
id: VALID_UUID,
status: 'sent',
currency: 'USD',
exchange_rate: 9.3,
total: 140,
remaining_amount: 140,
paid_amount: 0,
// journal_entry_id left undefined → unbooked (kontantmetoden candidate)
})
enqueue({ data: tx, error: null }) // transactions
enqueue({ data: invoice, error: null }) // invoices
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }) // company_settings
mockFetchExchangeRate.mockResolvedValue({ currency: 'USD', rate: 10.45, date: '2026-05-30' })
const request = createMockRequest('/api/transactions/tx-1/match-invoice/preview', {
searchParams: { invoice_id: VALID_UUID },
})
const response = await GET(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{
entry_type: string
is_fully_paid: boolean
lines: Array<{ account_number: string }>
fx_conversion: { required: boolean; paid_in_invoice_currency?: number }
}>(response)
expect(status).toBe(200)
// The crux of the bug: NOT a cash entry, NOT fully paid.
expect(body.entry_type).toBe('clearing')
expect(body.is_fully_paid).toBe(false)
// Clearing lines clear 1510 and never recognise revenue on a 30xx account.
const accounts = body.lines.map((l) => l.account_number)
expect(accounts).toContain('1510')
expect(accounts).not.toContain('3001')
// FX surfaced with the invoice-currency equivalent (1000 / 10.45 ≈ 95.69),
// matching what the POST handler accumulates.
expect(body.fx_conversion.required).toBe(true)
expect(body.fx_conversion.paid_in_invoice_currency).toBeCloseTo(95.69, 1)
})
// Guard the cash path the fix reorders around: a same-currency full payment
// of an unbooked invoice under kontantmetoden still previews the cash entry,
// and no Riksbanken lookup happens for a same-currency settlement.
it('same-currency full payment under kontantmetoden still previews a cash entry', async () => {
const tx = makeTransaction({
id: 'tx-2',
amount: 12500,
currency: 'SEK',
date: '2026-05-30',
invoice_id: null,
})
const invoice = makeInvoice({
id: VALID_UUID,
status: 'sent',
currency: 'SEK',
total: 12500,
remaining_amount: 12500,
paid_amount: 0,
})
enqueue({ data: tx, error: null })
enqueue({ data: invoice, error: null })
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
const request = createMockRequest('/api/transactions/tx-2/match-invoice/preview', {
searchParams: { invoice_id: VALID_UUID },
})
const response = await GET(request, createMockRouteParams({ id: 'tx-2' }))
const { status, body } = await parseJsonResponse<{
entry_type: string
is_fully_paid: boolean
fx_conversion: { required: boolean }
}>(response)
expect(status).toBe(200)
expect(body.entry_type).toBe('cash')
expect(body.is_fully_paid).toBe(true)
expect(body.fx_conversion.required).toBe(false)
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
})
})
@@ -20,7 +20,8 @@ import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { getRevenueAccount, getOutputVatAccount } from '@/lib/bookkeeping/invoice-entries'
import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines'
import type { EntityType, Invoice, InvoiceItem } from '@/types'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import type { Currency, EntityType, Invoice, InvoiceItem } from '@/types'
import { z } from 'zod'
type PreviewLine = {
@@ -83,14 +84,92 @@ export const GET = withRouteContext(
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const paidAmount = transaction.amount
// Cross-currency FX preview. When tx.currency !== invoice.currency we fetch
// the Riksbanken spot rate for invoice.currency on the tx date and surface
// the conversion to the dialog (the user sees the rate + invoice-currency-
// equivalent before approving). The committed verifikat uses the same
// numbers; the route POST handler re-runs the lookup so the rate at
// commit time is authoritative.
//
// This MUST run BEFORE the paid / remaining / fully-paid math below.
// invoice.remaining_amount and invoice.total are denominated in INVOICE
// currency, so a SEK bank tx has to be converted first. Computing the
// comparison from the raw SEK amount made a 1 000 SEK payment look like
// it fully cleared a 140 USD invoice (newRemaining went negative →
// isFullyPaid=true), which for a cash-method unbooked invoice previewed a
// cash entry (Dr 1930 / Cr 30xx) that the POST — which converts first —
// would never commit (it posts the clearing entry Dr 1930 / Cr 1510).
//
// Per ML 8 kap 2123§ the rate effective on the payment date is the
// correct conversion. If the lookup fails (Riksbanken outage, missing
// rate for that date), the response carries `fx_conversion.error` and
// the dialog can surface a manual-rate input field instead.
type FxConversion =
| {
required: true
tx_currency: string
invoice_currency: string
rate: number
rate_date: string
paid_in_invoice_currency: number
}
| { required: true; error: 'rate_unavailable'; tx_currency: string; invoice_currency: string }
| { required: false }
let fxConversion: FxConversion = { required: false }
if (transaction.currency !== invoice.currency) {
const rateInfo = await fetchExchangeRate(
invoice.currency as Currency,
new Date(transaction.date),
)
if (rateInfo && rateInfo.rate > 0) {
// bankSek / rate = how many units of invoice.currency this payment
// satisfies. Round to 4 decimal places to preserve precision through
// subsequent partial-payment accumulations.
const txAbsSek =
transaction.currency === 'SEK'
? Math.abs(transaction.amount)
: Math.abs(transaction.amount) * (transaction.exchange_rate ?? 1)
const paidInInvoiceCurrency =
Math.round((txAbsSek / rateInfo.rate) * 10000) / 10000
fxConversion = {
required: true,
tx_currency: transaction.currency,
invoice_currency: invoice.currency,
rate: rateInfo.rate,
rate_date: rateInfo.date,
paid_in_invoice_currency: paidInInvoiceCurrency,
}
} else {
fxConversion = {
required: true,
error: 'rate_unavailable',
tx_currency: transaction.currency,
invoice_currency: invoice.currency,
}
}
}
// paidAmount is denominated in INVOICE currency so the remaining /
// fully-paid comparison is like-with-like. Same-currency → tx.amount.
// Cross-currency with a resolved rate → the spot-rate conversion (mirrors
// the POST handler's paidAmountInInvoiceCurrency).
const paidAmount =
fxConversion.required && !('error' in fxConversion)
? fxConversion.paid_in_invoice_currency
: transaction.amount
const currentRemaining =
invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0)
const newRemaining = Math.max(
0,
Math.round((currentRemaining - paidAmount) * 100) / 100,
)
const isFullyPaid = newRemaining <= 0
// A rate-unavailable cross-currency payment can't be resolved to invoice
// currency yet, so never report fully-paid (or preview the cash shape) on
// a guess — the dialog blocks confirm until a manual rate is entered and
// the POST recomputes the real figure.
const fxRateUnavailable = fxConversion.required && 'error' in fxConversion
const isFullyPaid = !fxRateUnavailable && newRemaining <= 0
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid
@@ -185,6 +264,9 @@ export const GET = withRouteContext(
paid_amount: inv.paid_amount ?? null,
},
'Inbetalning kundfaktura',
fxConversion.required && !('error' in fxConversion)
? fxConversion.paid_in_invoice_currency
: undefined,
)
for (const line of clearingLines) {
lines.push({
@@ -202,6 +284,7 @@ export const GET = withRouteContext(
invoice_already_booked: invoiceAlreadyBooked,
accounting_method: accountingMethod,
is_fully_paid: isFullyPaid,
fx_conversion: fxConversion,
})
},
)
+120 -32
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { createInvoiceCashEntry } from '@/lib/bookkeeping/invoice-entries'
import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -12,7 +13,7 @@ import { logMatchEvent } from '@/lib/invoices/match-log'
import { detectDuplicatePaymentVoucher } from '@/lib/invoices/duplicate-payment-detection'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import type { EntityType, Invoice, Transaction } from '@/types'
import type { Currency, EntityType, Invoice, Transaction } from '@/types'
ensureInitialized()
@@ -99,29 +100,74 @@ export const POST = withRouteContext(
})
}
// Currency-integrity guard (BFL 5 kap 2§ + swedish-compliance PR #614
// round 9). invoices.paid_amount / remaining_amount are denominated in
// invoice.currency; invoice_payments rows carry currency = invoice.currency
// with amount in that currency. The accumulator below assumes
// `tx.amount` is already in invoice.currency. For a SEK bank tx paying
// a USD invoice the accumulator would silently treat 230 SEK as "230
// USD paid" and flip a 140 USD invoice to status=paid after a partial.
// Cross-currency settlement (replaces the PR #614 round-9 block).
//
// Block cross-currency on this single-allocation path until a proper
// FX-aware settlement flow lands. Same-currency (SEK→SEK or USD→USD)
// remains fully supported including partials; the buildInvoicePayment-
// ClearingLines helper handles the bookkeeping side correctly in both
// cases. For SEK tx → USD invoice the user should use the multi-
// allocation dialog (gnubok_match_batch_allocate) which DOES handle
// FX-diff postings on 3960/7960 end-to-end.
// invoices.paid_amount / remaining_amount are denominated in
// invoice.currency; invoice_payments rows carry currency =
// invoice.currency with amount in that currency. When tx and invoice
// currencies differ, we convert tx.amount (SEK) to invoice currency
// using the Riksbanken spot rate on the payment date (ML 8 kap 2123§)
// and accumulate / record in invoice currency throughout. The JE-lines
// helper gets the same converted amount so the verifikat balances
// exactly (FX-diff posted to 3960/7960). A manual rate may be supplied
// via the request body when the lookup fails (e.g. bank-statement rate
// when Riksbanken hasn't published for the date yet).
type FxConversion =
| { required: false }
| {
required: true
rate: number
rate_date: string
paidInInvoiceCurrency: number
// Provenance of the rate actually used, recorded for the audit
// trail: 'manual' = caller-supplied from a bank statement (Riksbanken
// had no rate for the date), 'riksbanken' = spot rate fetched on the
// payment date. A manual override on a money path must be traceable
// (BFL 5 kap 67§; ML 8 kap 2123§).
source: 'manual' | 'riksbanken'
}
let fx: FxConversion = { required: false }
if (transaction.currency !== invoice.currency) {
return errorResponseFromCode('MATCH_INVOICE_CURRENCY_MISMATCH', txLog, {
requestId,
details: {
transactionCurrency: transaction.currency,
invoiceCurrency: invoice.currency,
},
})
const manualRate =
typeof validation.data?.manual_exchange_rate === 'number' &&
validation.data.manual_exchange_rate > 0
? validation.data.manual_exchange_rate
: null
let rate = manualRate
let rateDate = transaction.date
if (rate == null) {
const rateInfo = await fetchExchangeRate(
invoice.currency as Currency,
new Date(transaction.date),
)
if (rateInfo && rateInfo.rate > 0) {
rate = rateInfo.rate
rateDate = rateInfo.date
}
}
if (rate == null || rate <= 0) {
return errorResponseFromCode('MATCH_INVOICE_FX_RATE_UNAVAILABLE', txLog, {
requestId,
details: {
transactionCurrency: transaction.currency,
invoiceCurrency: invoice.currency,
paymentDate: transaction.date,
},
})
}
const txAbsSek =
transaction.currency === 'SEK'
? Math.abs(transaction.amount)
: Math.abs(transaction.amount) * (transaction.exchange_rate ?? 1)
const paidInInvoiceCurrency = Math.round((txAbsSek / rate) * 10000) / 10000
fx = {
required: true,
rate,
rate_date: rateDate,
paidInInvoiceCurrency,
source: manualRate != null ? 'manual' : 'riksbanken',
}
}
// Hard-duplicate guard: if the invoice is 'sent'/'overdue' but already
@@ -240,7 +286,15 @@ export const POST = withRouteContext(
}
const now = new Date().toISOString()
const paidAmount = transaction.amount
// paidAmountInInvoiceCurrency is what gets accumulated into
// invoice.paid_amount / remaining_amount and stored on the
// invoice_payments row. For same-currency it's just tx.amount; for
// cross-currency it's the Riksbanken-rate conversion computed above.
// Using SEK directly for a USD invoice would corrupt the column units
// (the bug the PR #614 round-9 block was working around).
const paidAmountInInvoiceCurrency = fx.required
? fx.paidInInvoiceCurrency
: transaction.amount
const currentRemaining = invoice.remaining_amount ?? (invoice.total - (invoice.paid_amount || 0))
@@ -249,19 +303,19 @@ export const POST = withRouteContext(
// push invoice.paid_amount past invoice.total — silently. Reject and
// point the user at the split-payment flow which can allocate the excess
// across additional invoices.
if (paidAmount > currentRemaining + 0.005) {
if (paidAmountInInvoiceCurrency > currentRemaining + 0.005) {
return errorResponseFromCode('MATCH_AMOUNT_EXCEEDS_REMAINING', txLog, {
requestId,
details: {
transaction_amount: paidAmount,
transaction_amount: paidAmountInInvoiceCurrency,
remaining_amount: Math.round(currentRemaining * 100) / 100,
excess: Math.round((paidAmount - currentRemaining) * 100) / 100,
excess: Math.round((paidAmountInInvoiceCurrency - currentRemaining) * 100) / 100,
},
})
}
const newPaidAmount = Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100
const newRemaining = Math.max(0, Math.round((currentRemaining - paidAmount) * 100) / 100)
const newPaidAmount = Math.round(((invoice.paid_amount || 0) + paidAmountInInvoiceCurrency) * 100) / 100
const newRemaining = Math.max(0, Math.round((currentRemaining - paidAmountInInvoiceCurrency) * 100) / 100)
const isFullyPaid = newRemaining <= 0
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
@@ -367,6 +421,10 @@ export const POST = withRouteContext(
paid_amount: invoice.paid_amount ?? null,
},
desc,
// Cross-currency: pass the spot-rate-converted invoice-currency
// amount so the helper credits 1510 proportionally and posts the
// FX-diff line. Same-currency: undefined, helper just uses bankSek.
fx.required ? fx.paidInInvoiceCurrency : undefined,
)
const journalEntry = await createJournalEntry(supabase, companyId!, user.id, {
fiscal_period_id: fiscalPeriodId,
@@ -466,10 +524,29 @@ export const POST = withRouteContext(
// kontantmetoden partials — invoices that were never booked. When the
// invoice was booked under accrual, the clearing entry already handles
// the partial cleanly and the note would be misleading.
const paymentNotes = (!invoiceAlreadyBooked && accountingMethod === 'cash' && !isFullyPaid)
const cashMethodNote = (!invoiceAlreadyBooked && accountingMethod === 'cash' && !isFullyPaid)
? 'Kontantmetoden: intäkt bokförs vid slutbetalning'
: null
// Provenance for a manually-supplied FX rate. The Riksbanken spot rate is
// self-documenting (rate + rate_date are reproducible), but a rate the
// user typed from their bank statement is an override of the ML 8 kap
// 2123§ obligation and must leave a trail on the verifikat's payment row
// (BFL 5 kap 67§ — the verifikation must reflect the actual affärshändelse).
const manualRateNote =
fx.required && fx.source === 'manual'
? `Manuell valutakurs ${fx.rate} ${invoice.currency}/SEK (betalningsdatum ${transaction.date})`
: null
const paymentNotes = [cashMethodNote, manualRateNote].filter(Boolean).join(' · ') || null
// Payment row stores amount in INVOICE currency (the column unit). For
// same-currency that's tx.amount; for cross-currency it's the spot-rate
// conversion above. exchange_rate records the rate ACTUALLY USED for
// this payment — Riksbanken (or manual override) on tx.date — per
// ML 8 kap 2123§. Falling back to invoice.exchange_rate would record
// the invoice-date rate, which is what the round-7/8 bot reviews
// explicitly flagged as wrong.
const { error: paymentInsertError } = await supabase
.from('invoice_payments')
.insert({
@@ -477,9 +554,9 @@ export const POST = withRouteContext(
company_id: companyId,
invoice_id,
payment_date: transaction.date,
amount: paidAmount,
amount: paidAmountInInvoiceCurrency,
currency: invoice.currency,
exchange_rate: invoice.exchange_rate,
exchange_rate: fx.required ? fx.rate : invoice.exchange_rate,
journal_entry_id: journalEntryId,
transaction_id: transactionId,
notes: paymentNotes,
@@ -513,7 +590,18 @@ export const POST = withRouteContext(
invoiceId: invoice_id,
matchConfidence: 1.0,
matchMethod: 'manual_confirm',
newState: { status: newStatus, paid_amount: newPaidAmount, remaining_amount: newRemaining },
// rate_source / exchange_rate live inside new_state (the persisted JSON
// column) so a manual override — a user-supplied money-path input — is
// distinguishable from an automatic Riksbanken lookup in the audit trail
// (swarm V16 / SOC 2 CC6.1 / GDPR Art.5(1)(f)). Same-currency matches
// carry rate_source: null.
newState: {
status: newStatus,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
rate_source: fx.required ? fx.source : null,
exchange_rate: fx.required ? fx.rate : null,
},
})
try {
+157 -2
View File
@@ -28,12 +28,29 @@ interface PreviewLine {
description: string
}
// Cross-currency conversion info returned by the preview route. When
// `required` is true the dialog surfaces a Valutaomräkning section so the
// user sees the rate + invoice-currency-equivalent before approving. When
// the Riksbanken lookup fails the dialog swaps in a manual-rate input.
type FxConversion =
| { required: false }
| {
required: true
tx_currency: string
invoice_currency: string
rate: number
rate_date: string
paid_in_invoice_currency: number
}
| { required: true; error: 'rate_unavailable'; tx_currency: string; invoice_currency: string }
interface MatchPreview {
entry_type: 'clearing' | 'cash'
lines: PreviewLine[]
invoice_already_booked: boolean
accounting_method: 'accrual' | 'cash'
is_fully_paid: boolean
fx_conversion?: FxConversion
}
// String-typed working copy of a line. The amount is a single value plus a
@@ -57,6 +74,10 @@ export interface ConfirmOpts {
credit_amount: number
line_description?: string
}>
// Manual SEK-per-invoice-currency override used when Riksbanken's rate
// for the payment date isn't available; the dialog asks the user to type
// the rate from their bank statement. Same field flows to the route.
manual_exchange_rate?: number
}
interface InvoiceMatchDialogProps {
@@ -109,6 +130,11 @@ export default function InvoiceMatchDialog({
const [previewFailed, setPreviewFailed] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [editLines, setEditLines] = useState<EditableLine[]>([])
// Manual SEK-per-invoice-currency rate the user types when Riksbanken has
// no rate for the payment date. Empty string = no override; on submit it
// flows through ConfirmOpts.manual_exchange_rate to the route, which
// re-runs the preview math with the supplied rate.
const [manualRate, setManualRate] = useState<string>('')
// BAS accounts power the AccountCombobox suggestions in edit mode. Loaded
// once on dialog open; same endpoint that PaymentBookingDialog uses.
const [accounts, setAccounts] = useState<BASAccount[]>([])
@@ -138,6 +164,7 @@ export default function InvoiceMatchDialog({
setPreviewFailed(false)
setIsEditing(false)
setEditLines([])
setManualRate('')
return
}
let cancelled = false
@@ -233,7 +260,19 @@ export default function InvoiceMatchDialog({
}
})
: undefined
onConfirm({ ...(opts ?? {}), ...(linesPayload ? { lines: linesPayload } : {}) })
// Forward manual rate only when the preview indicated Riksbanken
// failed AND the user typed a value. Same-currency settlements and
// the auto-fetched cross-currency case both skip this field.
const fx = preview?.fx_conversion
const fxNeedsManualRate = fx?.required === true && 'error' in fx
const manualRateNum = fxNeedsManualRate ? parseAmount(manualRate) : 0
const manualRatePayload =
fxNeedsManualRate && manualRateNum > 0 ? { manual_exchange_rate: manualRateNum } : {}
onConfirm({
...(opts ?? {}),
...(linesPayload ? { lines: linesPayload } : {}),
...manualRatePayload,
})
}
const resetEdits = () => {
@@ -446,6 +485,112 @@ export default function InvoiceMatchDialog({
)
})()}
{/* Valutaomräkning section — only renders when the preview
route flagged a cross-currency settlement. Shows the
Riksbanken rate + invoice-currency-equivalent of the bank
payment + the projected post-payment invoice state. When
the rate lookup failed, swaps in a manual-rate input so
the user can type the rate from their bank statement and
retry. */}
{preview?.fx_conversion?.required && (() => {
const fx = preview.fx_conversion
if (!fx?.required) return null
const txAbs = transaction ? Math.abs(transaction.amount) : 0
const invRemaining = transaction?.potential_invoice?.remaining_amount
?? transaction?.potential_invoice?.total
?? 0
if ('error' in fx) {
// Riksbanken unavailable — show manual rate input.
return (
<div className="rounded-lg border border-warning/40 bg-warning/5 p-4 space-y-3">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 mt-0.5 text-warning-foreground flex-shrink-0" />
<div className="flex-1 text-sm">
<p className="font-medium">{t('fx_rate_unavailable_title')}</p>
<p className="text-muted-foreground mt-1">
{t('fx_rate_unavailable_description', {
date: transaction ? formatDate(transaction.date) : '',
invoiceCurrency: fx.invoice_currency,
})}
</p>
</div>
</div>
{/* The typed rate flows through onConfirm.manual_exchange_rate
and the route recomputes server-side, so the footer
Confirm button is the trigger — no separate apply button.
Confirm stays disabled until a positive rate is entered
(see DialogFooter guard below). */}
<div className="space-y-1">
<label className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('fx_manual_rate_label')}
</label>
<Input
inputMode="decimal"
value={manualRate}
onChange={(e) => setManualRate(e.target.value)}
placeholder={t('fx_manual_rate_placeholder')}
className="tabular-nums"
/>
</div>
</div>
)
}
const paidInInvoice = fx.paid_in_invoice_currency
const remainingAfter = Math.max(0, Math.round((invRemaining - paidInInvoice) * 100) / 100)
const willBeFullyPaid = remainingAfter <= 0
// FX gain/loss for the kursvinst/kursförlust note: bankSek -
// arSek, where arSek = paidInInvoice × invoice.exchange_rate.
// Positive number = the SEK we received exceeded the SEK
// value of the debt reduction (kursvinst).
const invoiceRate = transaction?.potential_invoice?.exchange_rate ?? 0
const arSek = invoiceRate > 0 ? Math.round(paidInInvoice * invoiceRate * 100) / 100 : 0
const fxGain = invoiceRate > 0 ? Math.round((txAbs - arSek) * 100) / 100 : 0
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<p className="text-sm font-medium">{t('fx_title')}</p>
<p className="text-sm text-muted-foreground">
{t('fx_rate_description', {
date: fx.rate_date,
invoiceCurrency: fx.invoice_currency,
rate: fx.rate.toFixed(4).replace('.', ','),
})}
</p>
<div className="grid grid-cols-2 gap-3 text-sm pt-1">
<div>
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('fx_paid_in_invoice_currency', { amount: '' }).replace(': ', '')}
</p>
<p className="font-medium tabular-nums mt-0.5">
{formatCurrency(paidInInvoice, fx.invoice_currency)}
</p>
</div>
<div className="text-right">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('fx_remaining_after', { amount: '' }).replace(': ', '')}
</p>
<p className="font-medium tabular-nums mt-0.5">
{formatCurrency(remainingAfter, fx.invoice_currency)}
</p>
</div>
</div>
<p className="text-xs text-muted-foreground">
{willBeFullyPaid ? t('fx_status_paid') : t('fx_status_partially_paid')}
{Math.abs(fxGain) > 0.005 && (
<>
{' · '}
{fxGain > 0
? t('fx_gain_note', { amount: formatCurrency(fxGain, 'SEK') })
: t('fx_loss_note', { amount: formatCurrency(Math.abs(fxGain), 'SEK') })}
</>
)}
</p>
</div>
)
})()}
{/* Bookkeeping preview — editable. Read-only by default; user
clicks "Redigera" to switch the rows to inputs. */}
{(preview || previewFailed) && (
@@ -624,7 +769,17 @@ export default function InvoiceMatchDialog({
</Button>
<Button
onClick={() => handleConfirm()}
disabled={isConfirming || isCheckingDuplicate || (isEditing && !editValidation.isValid)}
disabled={
isConfirming ||
isCheckingDuplicate ||
(isEditing && !editValidation.isValid) ||
// Block confirm when cross-currency lookup failed and the user
// hasn't typed a manual rate yet. Same-currency and auto-rate
// paths pass through unaffected.
(preview?.fx_conversion?.required === true &&
'error' in preview.fx_conversion &&
parseAmount(manualRate) <= 0)
}
>
{isConfirming ? t('confirming') : t('confirm_match')}
</Button>
+11
View File
@@ -516,6 +516,17 @@ export const MatchInvoiceSchema = z
credit_amount: nonNegativeAmount.default(0),
line_description: z.string().optional(),
})).min(2).optional(),
// Optional caller-supplied SEK-per-invoice-currency rate for cross-currency
// settlement. Used when the Riksbanken lookup returns nothing (rate not
// published for that date) — the dialog surfaces an input so the user can
// type the rate from their bank statement. Ignored when tx.currency ===
// invoice.currency. The .max() is a sanity ceiling against pasted garbage /
// scientific-notation input silently corrupting the FX-diff posting and
// invoice_payments.amount — no supported currency's SEK rate approaches it
// (USD~10.5, EUR~11.5, GBP~13.5). It is a guard rail, not a precise band;
// the dialog's live preview (paid_in_invoice_currency + FX gain/loss) is
// what catches a plausible-but-wrong decimal-shift typo before confirm.
manual_exchange_rate: z.number().positive().max(100000).optional(),
})
.refine((v) => !v.force || !!v.expected_journal_entry_id, {
message: 'expected_journal_entry_id is required when force=true',
@@ -90,6 +90,67 @@ describe('buildInvoicePaymentClearingLines', () => {
expect(result.lines).toHaveLength(2)
})
it('partial cross-currency WITH paidInInvoiceCurrency: posts proportional 1510 credit + FX-diff line', () => {
// The proper-FX path (round-10): caller supplies the invoice-currency
// equivalent of the bank payment, computed at today's Riksbanken rate.
// The helper credits 1510 by that × invoice.exchange_rate (the
// booking rate) and posts the FX-diff line so the verifikat balances.
//
// Scenario: 1000 SEK bank tx, invoice 140 USD @ 9.30 (booked). Today
// Riksbanken rate: 10.45. paidInInvoiceCurrency = 1000/10.45 = 95.6938.
// arSek = 95.6938 × 9.30 = 889.95. fxDiff = 889.95 - 1000 = -110.05
// (negative → gain → 3960 Cr 110.05).
const result = buildInvoicePaymentClearingLines(
{ amount: 1000, amount_sek: null, currency: 'SEK', exchange_rate: null },
{ currency: 'USD', exchange_rate: 9.3, remaining_amount: 140, total: 140, paid_amount: 0 },
'Inbetalning kundfaktura',
95.6938, // paidInInvoiceCurrency
)
expect(result.bankSek).toBe(1000)
expect(result.arSek).toBeCloseTo(889.95, 1)
expect(result.fxDiffSek).toBeCloseTo(-110.05, 1)
expect(result.lines).toHaveLength(3)
expect(result.lines[0]).toMatchObject({ account_number: '1930', debit_amount: 1000 })
expect(result.lines[1]).toMatchObject({ account_number: '1510' })
expect(result.lines[2]).toMatchObject({
account_number: '3960',
line_description: 'Valutakursvinst',
})
const debit = result.lines.reduce((s, l) => s + l.debit_amount, 0)
const credit = result.lines.reduce((s, l) => s + l.credit_amount, 0)
expect(Math.abs(debit - credit)).toBeLessThanOrEqual(0.005)
})
it('cross-currency WITH paidInInvoiceCurrency, bank < booked: loss to 7960', () => {
// Mirror of the gain case above with the opposite sign — guards the
// kursförlust branch the route's gain-only assertion never reaches
// (Swedish compliance review, PR #615). Invoice 100 USD booked at 9.30
// (930 SEK on 1510); the 100 USD settlement only fetched 900 SEK at the
// weaker 9.00 payment-date rate. arSek = 100 × 9.30 = 930.
// fxDiff = 930 900 = +30 (positive → kursförlust → 7960 Dr 30).
const result = buildInvoicePaymentClearingLines(
{ amount: 900, amount_sek: null, currency: 'SEK', exchange_rate: null },
{ currency: 'USD', exchange_rate: 9.3, remaining_amount: 100, total: 100, paid_amount: 0 },
'Inbetalning kundfaktura',
100, // paidInInvoiceCurrency (full settlement at today's 9.00 rate)
)
expect(result.bankSek).toBe(900)
expect(result.arSek).toBeCloseTo(930, 2)
expect(result.fxDiffSek).toBeCloseTo(30, 2)
expect(result.lines).toHaveLength(3)
expect(result.lines[0]).toMatchObject({ account_number: '1930', debit_amount: 900 })
expect(result.lines[1]).toMatchObject({ account_number: '1510', credit_amount: 930 })
expect(result.lines[2]).toMatchObject({
account_number: '7960',
debit_amount: 30,
line_description: 'Valutakursförlust',
})
// Balanced to the öre: Dr 900 + 30 = 930 = Cr 930.
const debit = result.lines.reduce((s, l) => s + l.debit_amount, 0)
const credit = result.lines.reduce((s, l) => s + l.credit_amount, 0)
expect(Math.abs(debit - credit)).toBeLessThanOrEqual(0.005)
})
it('partial cross-currency payment defers FX: bank-leg = AR-leg = bankSek, no 3960/7960 line', () => {
// Invoice 140 USD @ 15.30 (2142 SEK booked on 1510)
// Bank receives 230 SEK — way below the 2142 remaining. If we credited
+33 -24
View File
@@ -99,19 +99,33 @@ export interface PaymentClearingLines {
* Build the verifikat lines for a customer-invoice payment matched against
* a bank tx. Pure — no DB calls. Caller decides how to persist.
*
* For same-currency invoices the FX diff is always 0 and only the two
* bank/AR lines are returned. For cross-currency, a 3960 or 7960 line is
* appended to balance the verifikat. Per the contract documented in this
* file, when the tx is cross-currency we assume the bank tx fully clears
* the invoice's remaining amount and book the full FX diff to one
* verifikat — same pattern as the match_batch_allocate RPC, which is the
* only other code path that posts FX diffs on customer-invoice
* settlements.
* # Same-currency
* Bank-leg = AR-leg = bankSek. No FX diff line.
*
* # Cross-currency with explicit paidInInvoiceCurrency (preferred path)
* The caller supplies how many units of the invoice's currency this bank
* payment satisfies (typically computed as `bankSek / today_rate` where
* `today_rate` is the Riksbanken spot rate on the payment date — see
* `app/api/transactions/[id]/match-invoice/route.ts`). The helper then:
* arSek = paidInInvoiceCurrency × invoice.exchange_rate (booking rate)
* fxDiffSek = arSek bankSek
* For a partial cross-currency payment this credits 1510 by the
* proportional foreign amount (not the full remaining) and posts the
* accurate FX-diff line. The verifikat balances per BFL 5 kap 45§ and
* the GL stays in sync with the AR sub-ledger because both move in step.
*
* # Cross-currency without paidInInvoiceCurrency (fallback)
* Earlier behaviour, kept for callers that haven't been updated yet:
* if `bankSek >= remaining × rate`, book the full FX diff (full clear);
* otherwise defer (book 1930 = 1510 = bankSek with no FX line). The
* deferred path leaves the GL slightly understated until the final
* settlement closes the invoice.
*/
export function buildInvoicePaymentClearingLines(
tx: PaymentClearingTx,
invoice: PaymentClearingInvoice,
description: string,
paidInInvoiceCurrency?: number,
): PaymentClearingLines {
// Bank-leg: actual SEK that hit the bank. resolveSekAmount returns the
// raw amount for SEK txs and amount * exchange_rate for foreign txs
@@ -136,30 +150,25 @@ export function buildInvoicePaymentClearingLines(
// reduction equals what hit the bank. No FX diff possible.
arSek = bankSek
fxDiffSek = 0
} else if (paidInInvoiceCurrency != null && paidInInvoiceCurrency > 0) {
// Proper FX path: caller computed the invoice-currency equivalent
// using today's spot rate. AR-leg comes off 1510 at the invoice's
// BOOKING rate (so the GL credit matches what was originally posted
// for those units of foreign currency). FX diff balances the verifikat.
const invRate = invoice.exchange_rate ?? 1
arSek = TWO_DP(paidInInvoiceCurrency * invRate)
fxDiffSek = TWO_DP(arSek - bankSek)
} else {
// Cross-currency: AR is denominated in invoice.currency and was
// booked on 1510 at invoice.exchange_rate. The remaining-amount × rate
// is the SEK currently sitting on 1510 for this invoice.
// Fallback when no paidInInvoiceCurrency is supplied (e.g. legacy
// callers, Riksbanken lookup failed with no manual override). Same
// pre-FX-rewrite behaviour: full-clear gets FX diff, partial defers.
const invRemainingForeign = invoice.remaining_amount ?? invoice.total - (invoice.paid_amount ?? 0)
const invRate = invoice.exchange_rate ?? 1
const arSekFullRemaining = TWO_DP(invRemainingForeign * invRate)
// Branch on whether the bank tx fully clears (or over-pays) the
// remaining 1510 balance. Partial cross-currency must NOT credit the
// full remaining — that would zero 1510 in the GL while the invoice
// row stays at status=partially_paid, leaving the ledger inconsistent
// with the AR sub-ledger and over-stating FX gain/loss for the period.
// Defer the FX adjustment to the final settlement (when bank-SEK
// covers the full remaining), per BFL 5 kap 45§ "verifikat must
// reflect the actual affärshändelse".
if (bankSek >= arSekFullRemaining - 0.005) {
// Full payment of remaining (or overpay): clear AR and book FX diff.
arSek = arSekFullRemaining
fxDiffSek = TWO_DP(arSek - bankSek)
} else {
// Partial cross-currency: book 1930 / 1510 at bankSek (the actual
// SEK that moved), no FX line. The deferred FX diff lands on the
// verifikat that finally closes the invoice.
arSek = bankSek
fxDiffSek = 0
}
+3 -3
View File
@@ -346,12 +346,12 @@ const MATCH_INVOICE: Record<string, StructuredErrorEntry> = {
message_sv: 'Endast fakturor kan matchas mot en transaktion. Proforma och följesedel saknar momsskyldighet.',
message_en: 'Only invoices may be matched to a transaction; proforma and delivery notes have no VAT obligation.',
},
MATCH_INVOICE_CURRENCY_MISMATCH: {
MATCH_INVOICE_FX_RATE_UNAVAILABLE: {
httpStatus: 400,
message_sv:
'Transaktionens och fakturans valuta måste vara samma. För valutaomräkning, använd flerfaktura-matchningen som hanterar valutakursdifferenser på 3960/7960.',
'Kunde inte hämta valutakurs från Riksbanken för betalningsdatumet. Ange kursen manuellt från ditt bankutdrag (fältet manual_exchange_rate).',
message_en:
'Transaction and invoice currency must match. For cross-currency settlement, use the multi-invoice allocation flow which posts FX-diff lines on 3960/7960.',
'Could not retrieve an exchange rate from Riksbanken for the payment date. Provide the rate manually from your bank statement (manual_exchange_rate field).',
},
MATCH_INVOICE_ALREADY_PAID: {
httpStatus: 409,
+19
View File
@@ -1794,6 +1794,18 @@
"amount_diff": "Difference: {amount}",
"different_currencies": " (different currencies)",
"partial_payment_note": " — the invoice will become partially paid.",
"fx_title": "Currency conversion",
"fx_rate_description": "Riksbanken mid-rate {date}: 1 {invoiceCurrency} = {rate} SEK",
"fx_paid_in_invoice_currency": "Payment equals: {amount}",
"fx_remaining_after": "Remaining after payment: {amount}",
"fx_status_paid": "Invoice will be: Paid",
"fx_status_partially_paid": "Invoice will be: Partially paid",
"fx_rate_unavailable_title": "Exchange rate unavailable",
"fx_rate_unavailable_description": "Riksbanken has not published a rate for {date}. Enter the rate from your bank statement (SEK per 1 {invoiceCurrency}).",
"fx_manual_rate_label": "Manual rate",
"fx_manual_rate_placeholder": "e.g. 10.4500",
"fx_gain_note": "FX gain: {amount}",
"fx_loss_note": "FX loss: {amount}",
"on_confirm_title": "On confirmation:",
"on_confirm_link_supplier": "The transaction is linked to the supplier invoice",
"on_confirm_link_customer": "The transaction is linked to the invoice",
@@ -2211,6 +2223,13 @@
"correction_prompt": "Something wrong? Create a correction verifikat",
"paid_card_title": "Paid",
"paid_received_at": "Payment received {date}",
"payment_status_card_title": "Payment status",
"payment_status_paid_label": "Paid",
"payment_status_remaining_label": "Remaining",
"payment_status_payments_heading": "Payments",
"payment_status_view_voucher": "Voucher {label}",
"payment_status_view_voucher_unlinked": "No voucher linked",
"payment_status_empty": "No payments recorded yet.",
"reminders_card_title": "Reminders",
"reminders_description": "Automatic reminders are sent at 15, 30 and 45 days overdue",
"reminders_empty": "No reminders have been sent yet.",
+19
View File
@@ -1794,6 +1794,18 @@
"amount_diff": "Differens: {amount}",
"different_currencies": " (olika valutor)",
"partial_payment_note": " — fakturan blir delbetald.",
"fx_title": "Valutaomräkning",
"fx_rate_description": "Riksbankens mittkurs {date}: 1 {invoiceCurrency} = {rate} SEK",
"fx_paid_in_invoice_currency": "Inbetalning motsvarar: {amount}",
"fx_remaining_after": "Återstår efter betalning: {amount}",
"fx_status_paid": "Faktura blir: Betald",
"fx_status_partially_paid": "Faktura blir: Delbetald",
"fx_rate_unavailable_title": "Saknar valutakurs",
"fx_rate_unavailable_description": "Riksbanken har ingen publicerad kurs för {date}. Ange kursen från ditt bankutdrag (SEK per 1 {invoiceCurrency}).",
"fx_manual_rate_label": "Manuell kurs",
"fx_manual_rate_placeholder": "t.ex. 10,4500",
"fx_gain_note": "Kursvinst: {amount}",
"fx_loss_note": "Kursförlust: {amount}",
"on_confirm_title": "Vid bekräftelse:",
"on_confirm_link_supplier": "Transaktionen kopplas till leverantörsfakturan",
"on_confirm_link_customer": "Transaktionen kopplas till fakturan",
@@ -2211,6 +2223,13 @@
"correction_prompt": "Något fel? Skapa ändringsverifikation",
"paid_card_title": "Betald",
"paid_received_at": "Betalning mottagen {date}",
"payment_status_card_title": "Betalningsstatus",
"payment_status_paid_label": "Betalt",
"payment_status_remaining_label": "Återstår",
"payment_status_payments_heading": "Betalningar",
"payment_status_view_voucher": "Verifikat {label}",
"payment_status_view_voucher_unlinked": "Saknar verifikat",
"payment_status_empty": "Inga registrerade betalningar ännu.",
"reminders_card_title": "Påminnelser",
"reminders_description": "Automatiska påminnelser skickas vid 15, 30 och 45 dagars förfallen betalning",
"reminders_empty": "Inga påminnelser har skickats ännu.",