diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index 5df5b4c6..ba431916 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -75,6 +75,20 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st const [invoice, setInvoice] = useState(null) const [reminders, setReminders] = useState([]) + // 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(null) const [originalInvoice, setOriginalInvoice] = useState(null) const [convertedFromInvoice, setConvertedFromInvoice] = useState(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 - {/* 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') && ( - - - {t('paid_card_title')} + + {invoice.status === 'paid' ? ( + + ) : ( + + )} + {invoice.status === 'paid' + ? t('paid_card_title') + : t('payment_status_card_title')} - -

- {t('paid_received_at', { date: formatDate(invoice.paid_at) })} -

- {invoice.paid_amount && ( -

- {formatCurrency(invoice.paid_amount, invoice.currency)} + + {/* Paid / remaining summary. Right-aligned tabular nums so + the two columns scan cleanly. Same SEK / invoice.currency + formatter as the items table. */} +

+
+

+ {t('payment_status_paid_label')} +

+

+ {formatCurrency(invoice.paid_amount ?? 0, invoice.currency)} +

+
+
+

+ {t('payment_status_remaining_label')} +

+

+ {formatCurrency( + invoice.remaining_amount ?? + Math.max(0, invoice.total - (invoice.paid_amount ?? 0)), + invoice.currency, + )} +

+
+
+ + {invoice.status === 'paid' && invoice.paid_at && ( +

+ {t('paid_received_at', { date: formatDate(invoice.paid_at) })}

)} + + {/* 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. */} +
+

+ {t('payment_status_payments_heading')} +

+ {payments.length === 0 ? ( +

+ {t('payment_status_empty')} +

+ ) : ( +
    + {payments.map((p) => { + const voucherLabel = + p.voucher_series && p.voucher_number != null + ? `${p.voucher_series}-${p.voucher_number}` + : null + return ( +
  • + + {formatDate(p.payment_date)} + + + {formatCurrency(p.amount, p.currency)} + + {p.journal_entry_id && voucherLabel ? ( + + {t('payment_status_view_voucher', { label: voucherLabel })} + + + ) : ( + + {t('payment_status_view_voucher_unlinked')} + + )} +
  • + ) + })} +
+ )} +
)} diff --git a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts index c4127491..bbcb7b04 100644 --- a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -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 } }>(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 () => { diff --git a/app/api/transactions/[id]/match-invoice/preview/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/preview/__tests__/route.test.ts new file mode 100644 index 00000000..c8fd883c --- /dev/null +++ b/app/api/transactions/[id]/match-invoice/preview/__tests__/route.test.ts @@ -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() + }) +}) diff --git a/app/api/transactions/[id]/match-invoice/preview/route.ts b/app/api/transactions/[id]/match-invoice/preview/route.ts index d78bed2b..a8770c95 100644 --- a/app/api/transactions/[id]/match-invoice/preview/route.ts +++ b/app/api/transactions/[id]/match-invoice/preview/route.ts @@ -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, }) }, ) diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index d072c90f..d4451a36 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -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 { diff --git a/components/transactions/InvoiceMatchDialog.tsx b/components/transactions/InvoiceMatchDialog.tsx index 061050c4..f7194787 100644 --- a/components/transactions/InvoiceMatchDialog.tsx +++ b/components/transactions/InvoiceMatchDialog.tsx @@ -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([]) + // 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('') // BAS accounts power the AccountCombobox suggestions in edit mode. Loaded // once on dialog open; same endpoint that PaymentBookingDialog uses. const [accounts, setAccounts] = useState([]) @@ -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 ( +
+
+ +
+

{t('fx_rate_unavailable_title')}

+

+ {t('fx_rate_unavailable_description', { + date: transaction ? formatDate(transaction.date) : '', + invoiceCurrency: fx.invoice_currency, + })} +

+
+
+ {/* 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). */} +
+ + setManualRate(e.target.value)} + placeholder={t('fx_manual_rate_placeholder')} + className="tabular-nums" + /> +
+
+ ) + } + + 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 ( +
+

{t('fx_title')}

+

+ {t('fx_rate_description', { + date: fx.rate_date, + invoiceCurrency: fx.invoice_currency, + rate: fx.rate.toFixed(4).replace('.', ','), + })} +

+
+
+

+ {t('fx_paid_in_invoice_currency', { amount: '' }).replace(': ', '')} +

+

+ {formatCurrency(paidInInvoice, fx.invoice_currency)} +

+
+
+

+ {t('fx_remaining_after', { amount: '' }).replace(': ', '')} +

+

+ {formatCurrency(remainingAfter, fx.invoice_currency)} +

+
+
+

+ {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') })} + + )} +

+
+ ) + })()} + {/* 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({ diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 87ce1ad8..55d7ce52 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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', diff --git a/lib/bookkeeping/__tests__/invoice-payment-lines.test.ts b/lib/bookkeeping/__tests__/invoice-payment-lines.test.ts index 5f2b8fb3..3c51833e 100644 --- a/lib/bookkeeping/__tests__/invoice-payment-lines.test.ts +++ b/lib/bookkeeping/__tests__/invoice-payment-lines.test.ts @@ -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 diff --git a/lib/bookkeeping/invoice-payment-lines.ts b/lib/bookkeeping/invoice-payment-lines.ts index 58b68b6e..7f8a3efb 100644 --- a/lib/bookkeeping/invoice-payment-lines.ts +++ b/lib/bookkeeping/invoice-payment-lines.ts @@ -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 4–5§ 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 4–5§ "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 } diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 1509d367..213ef635 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -346,12 +346,12 @@ const MATCH_INVOICE: Record = { 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, diff --git a/messages/en.json b/messages/en.json index 0149b673..1d4de9c4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/sv.json b/messages/sv.json index b8da9932..73b6b45b 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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.",