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:
co-authored by
Claude Opus 4.8
parent
13be0c569a
commit
2c59c3633f
@@ -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,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user