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
co-authored by Claude Opus 4.8
parent 13be0c569a
commit 2c59c3633f
12 changed files with 962 additions and 88 deletions
@@ -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 21–23§ 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 21–23§)
// 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 6–7§; ML 8 kap 21–23§).
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
// 21–23§ obligation and must leave a trail on the verifikat's payment row
// (BFL 5 kap 6–7§ — 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 21–23§. 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 {