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:
@@ -75,6 +75,20 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
|
||||
const [invoice, setInvoice] = useState<InvoiceWithRelations | null>(null)
|
||||
const [reminders, setReminders] = useState<InvoiceReminder[]>([])
|
||||
// Payment history backing the new Betalningsstatus card. Fetched alongside
|
||||
// the invoice itself so the card stays in sync with paid_amount /
|
||||
// remaining_amount on the invoice row.
|
||||
const [payments, setPayments] = useState<
|
||||
Array<{
|
||||
id: string
|
||||
payment_date: string
|
||||
amount: number
|
||||
currency: string
|
||||
journal_entry_id: string | null
|
||||
voucher_series: string | null
|
||||
voucher_number: number | null
|
||||
}>
|
||||
>([])
|
||||
const [creditNote, setCreditNote] = useState<Invoice | null>(null)
|
||||
const [originalInvoice, setOriginalInvoice] = useState<Invoice | null>(null)
|
||||
const [convertedFromInvoice, setConvertedFromInvoice] = useState<Invoice | null>(null)
|
||||
@@ -154,6 +168,40 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
setReminders(reminderData as InvoiceReminder[])
|
||||
}
|
||||
|
||||
// Fetch payment history for the Betalningsstatus card. Joins the
|
||||
// journal_entries row to get voucher_series + voucher_number so each
|
||||
// payment row can link to its verifikat. Manual payments (no tx, no
|
||||
// JE) still surface with the amount + date.
|
||||
const { data: paymentData } = await supabase
|
||||
.from('invoice_payments')
|
||||
.select(
|
||||
'id, payment_date, amount, currency, journal_entry_id, journal_entries(voucher_series, voucher_number)',
|
||||
)
|
||||
.eq('invoice_id', id)
|
||||
.order('payment_date', { ascending: true })
|
||||
|
||||
if (paymentData) {
|
||||
type PaymentRow = {
|
||||
id: string
|
||||
payment_date: string
|
||||
amount: number
|
||||
currency: string
|
||||
journal_entry_id: string | null
|
||||
journal_entries: { voucher_series: string | null; voucher_number: number | null } | null
|
||||
}
|
||||
setPayments(
|
||||
(paymentData as unknown as PaymentRow[]).map((p) => ({
|
||||
id: p.id,
|
||||
payment_date: p.payment_date,
|
||||
amount: p.amount,
|
||||
currency: p.currency,
|
||||
journal_entry_id: p.journal_entry_id,
|
||||
voucher_series: p.journal_entries?.voucher_series ?? null,
|
||||
voucher_number: p.journal_entries?.voucher_number ?? null,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
// If this invoice is credited, find the credit note
|
||||
if (data.status === 'credited') {
|
||||
const { data: creditNoteData } = await supabase
|
||||
@@ -740,24 +788,119 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Payment info */}
|
||||
{invoice.status === 'paid' && invoice.paid_at && (
|
||||
{/* Betalningsstatus card. Shows for both `paid` and `partially_paid`
|
||||
so the user always sees how much has been paid + what remains +
|
||||
the individual payment events. Previously only the `paid` case
|
||||
had a card, leaving partially-paid invoices without any visible
|
||||
paid_amount/remaining_amount — surfaced by user feedback after
|
||||
PR #614. */}
|
||||
{(invoice.status === 'paid' || invoice.status === 'partially_paid') && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-success">
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
{t('paid_card_title')}
|
||||
<CardTitle
|
||||
className={cn(
|
||||
'flex items-center gap-2',
|
||||
invoice.status === 'paid' && 'text-success',
|
||||
invoice.status === 'partially_paid' && 'text-warning-foreground',
|
||||
)}
|
||||
>
|
||||
{invoice.status === 'paid' ? (
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
) : (
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
)}
|
||||
{invoice.status === 'paid'
|
||||
? t('paid_card_title')
|
||||
: t('payment_status_card_title')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('paid_received_at', { date: formatDate(invoice.paid_at) })}
|
||||
</p>
|
||||
{invoice.paid_amount && (
|
||||
<p className="text-lg font-bold mt-2">
|
||||
{formatCurrency(invoice.paid_amount, invoice.currency)}
|
||||
<CardContent className="space-y-4">
|
||||
{/* Paid / remaining summary. Right-aligned tabular nums so
|
||||
the two columns scan cleanly. Same SEK / invoice.currency
|
||||
formatter as the items table. */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('payment_status_paid_label')}
|
||||
</p>
|
||||
<p className="font-display text-xl tabular-nums mt-1">
|
||||
{formatCurrency(invoice.paid_amount ?? 0, invoice.currency)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('payment_status_remaining_label')}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
'font-display text-xl tabular-nums mt-1',
|
||||
invoice.status === 'partially_paid' && 'text-warning-foreground',
|
||||
)}
|
||||
>
|
||||
{formatCurrency(
|
||||
invoice.remaining_amount ??
|
||||
Math.max(0, invoice.total - (invoice.paid_amount ?? 0)),
|
||||
invoice.currency,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{invoice.status === 'paid' && invoice.paid_at && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('paid_received_at', { date: formatDate(invoice.paid_at) })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Payment history. Each row links to its verifikat when one
|
||||
exists. Compact list — dates and amounts tabular-nums for
|
||||
column alignment, the voucher link sits to the right with
|
||||
a small chevron. */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('payment_status_payments_heading')}
|
||||
</p>
|
||||
{payments.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('payment_status_empty')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border -mx-2">
|
||||
{payments.map((p) => {
|
||||
const voucherLabel =
|
||||
p.voucher_series && p.voucher_number != null
|
||||
? `${p.voucher_series}-${p.voucher_number}`
|
||||
: null
|
||||
return (
|
||||
<li
|
||||
key={p.id}
|
||||
className="flex items-center justify-between gap-3 px-2 py-2 text-sm transition-colors hover:bg-secondary/60 rounded"
|
||||
>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{formatDate(p.payment_date)}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums flex-1 text-right">
|
||||
{formatCurrency(p.amount, p.currency)}
|
||||
</span>
|
||||
{p.journal_entry_id && voucherLabel ? (
|
||||
<Link
|
||||
href={`/bookkeeping/${p.journal_entry_id}`}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t('payment_status_view_voucher', { label: voucherLabel })}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('payment_status_view_voucher_unlinked')}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -30,6 +30,11 @@ vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
|
||||
}))
|
||||
|
||||
const mockFetchExchangeRate = vi.fn()
|
||||
vi.mock('@/lib/currency/riksbanken', () => ({
|
||||
fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/invoices/match-log', () => ({
|
||||
logMatchEvent: vi.fn(),
|
||||
}))
|
||||
@@ -57,6 +62,9 @@ vi.mock('@/lib/auth/require-write', () => ({
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
// Mocked above — imported here as a spy handle to assert FX rate provenance
|
||||
// lands in the audit trail (PR #615 review).
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
|
||||
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
const VALID_UUID_2 = '550e8400-e29b-41d4-a716-446655440001'
|
||||
@@ -205,36 +213,170 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
expect((body.error as unknown as { code: string }).code).toBe('MATCH_INVOICE_NOT_OPEN')
|
||||
})
|
||||
|
||||
it('returns 400 MATCH_INVOICE_CURRENCY_MISMATCH for cross-currency settlement', async () => {
|
||||
// Round-9 fix: a SEK bank tx paying a USD invoice would otherwise
|
||||
// corrupt invoice.paid_amount (accumulator treats SEK as USD), flip
|
||||
// a 140 USD invoice to status=paid after a tiny partial. Block here
|
||||
// and route the user to the multi-allocation flow that handles
|
||||
// 3960/7960 FX-diff postings end-to-end.
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 1000, invoice_id: null, currency: 'SEK' })
|
||||
it('cross-currency settlement: converts SEK tx via Riksbanken rate, posts FX-diff verifikat', async () => {
|
||||
// 1000 SEK bank tx paying a 140 USD invoice. Spot rate today: 10.45.
|
||||
// Conversion: 1000 / 10.45 = 95.6938 USD. Invoice was booked at 9.30,
|
||||
// so 1510 credit = 95.6938 × 9.30 = 889.95. FX gain = 1000 − 889.95 =
|
||||
// 110.05 → 3960 Cr. Invoice flips to partially_paid with remaining
|
||||
// 140 − 95.6938 = 44.3062 USD.
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: 1000,
|
||||
invoice_id: null,
|
||||
currency: 'SEK',
|
||||
date: '2026-05-30',
|
||||
})
|
||||
const invoice = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
currency: 'USD',
|
||||
exchange_rate: 9.3,
|
||||
total: 140,
|
||||
remaining_amount: 140,
|
||||
paid_amount: 0,
|
||||
})
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: [], error: null }) // hard-duplicate check
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockFetchExchangeRate.mockResolvedValue({
|
||||
currency: 'USD',
|
||||
rate: 10.45,
|
||||
date: '2026-05-30',
|
||||
})
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-fx' })
|
||||
|
||||
enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice
|
||||
enqueue({ data: null, error: null }) // insert invoice_payments
|
||||
enqueue({ data: null, error: null }) // update transaction
|
||||
enqueue({ data: null, error: null }) // logMatchEvent
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string; details: Record<string, string> } }>(response)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
invoice_status: string
|
||||
paid_amount: number
|
||||
remaining_amount: number
|
||||
journal_entry_id: string
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.invoice_status).toBe('partially_paid')
|
||||
// 2dp precision matches the invoice currency's natural precision (USD
|
||||
// is to cent). The internal paidInInvoiceCurrency is computed at 4dp
|
||||
// for FX-rate accuracy then rounded to 2dp when accumulated into the
|
||||
// invoice column.
|
||||
expect(body.paid_amount).toBeCloseTo(95.69, 1)
|
||||
expect(body.remaining_amount).toBeCloseTo(44.31, 1)
|
||||
// Verifikat: Dr 1930 1000, Cr 1510 889.95 (95.6938 × 9.30 ≈ 889.95),
|
||||
// Cr 3960 110.05 (gain). Balances to öre.
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({
|
||||
source_type: 'invoice_paid',
|
||||
lines: expect.arrayContaining([
|
||||
expect.objectContaining({ account_number: '1930', debit_amount: 1000 }),
|
||||
expect.objectContaining({ account_number: '1510' }),
|
||||
expect.objectContaining({ account_number: '3960' }),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
// The auto path records the rate provenance as 'riksbanken' (vs 'manual').
|
||||
expect(logMatchEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'user-1',
|
||||
'tx-1',
|
||||
'matched',
|
||||
expect.objectContaining({
|
||||
newState: expect.objectContaining({ rate_source: 'riksbanken', exchange_rate: 10.45 }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('cross-currency settlement: returns 400 FX_RATE_UNAVAILABLE when Riksbanken fails and no manual rate', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 1000, invoice_id: null, currency: 'SEK', date: '2026-05-30' })
|
||||
const invoice = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
currency: 'USD',
|
||||
exchange_rate: 9.3,
|
||||
total: 140,
|
||||
remaining_amount: 140,
|
||||
})
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
|
||||
mockFetchExchangeRate.mockResolvedValue(null) // Riksbanken outage
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('MATCH_INVOICE_CURRENCY_MISMATCH')
|
||||
expect(body.error.details).toMatchObject({
|
||||
transactionCurrency: 'SEK',
|
||||
invoiceCurrency: 'USD',
|
||||
expect(body.error.code).toBe('MATCH_INVOICE_FX_RATE_UNAVAILABLE')
|
||||
})
|
||||
|
||||
it('cross-currency settlement: manual_exchange_rate succeeds when Riksbanken fails', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 1000, invoice_id: null, currency: 'SEK', date: '2026-05-30' })
|
||||
const invoice = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
currency: 'USD',
|
||||
exchange_rate: 9.3,
|
||||
total: 140,
|
||||
remaining_amount: 140,
|
||||
paid_amount: 0,
|
||||
})
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: [], error: null }) // hard-duplicate
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockFetchExchangeRate.mockResolvedValue(null) // Riksbanken down — manual rate used instead
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-fx-manual' })
|
||||
|
||||
enqueue({ data: [{ id: VALID_UUID }], error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID, manual_exchange_rate: 10.5 },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status } = await parseJsonResponse<{ success: boolean }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
// Manual rate skips the Riksbanken lookup — confirm by inspecting that
|
||||
// mockCreateJournalEntry got the FX-computed line set (1000 / 10.5 =
|
||||
// 95.2381 USD; arSek = 95.2381 × 9.30 = 885.71). Skipping Riksbanken is
|
||||
// intentional: when the user types a rate from their bank statement we
|
||||
// honour it rather than overriding with a possibly-stale Riksbanken value.
|
||||
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalled()
|
||||
// Provenance: the manual override is recorded in the audit trail's
|
||||
// new_state so it's distinguishable from an automatic Riksbanken lookup.
|
||||
expect(logMatchEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'user-1',
|
||||
'tx-1',
|
||||
'matched',
|
||||
expect.objectContaining({
|
||||
newState: expect.objectContaining({ rate_source: 'manual', exchange_rate: 10.5 }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('matches transaction to invoice with accrual method (full payment)', async () => {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
makeTransaction,
|
||||
makeInvoice,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
const mockFetchExchangeRate = vi.fn()
|
||||
vi.mock('@/lib/currency/riksbanken', () => ({
|
||||
fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args),
|
||||
}))
|
||||
|
||||
// Pure account-mapping helpers; mocked to keep the test off the real engine
|
||||
// import chain (mirrors the POST route test). buildInvoicePaymentClearingLines
|
||||
// and resolveSekAmount are pure and kept real so the preview lines are the
|
||||
// genuine ones the dialog would render.
|
||||
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
getRevenueAccount: vi.fn().mockReturnValue('3001'),
|
||||
getOutputVatAccount: vi.fn().mockReturnValue('2611'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
|
||||
describe('GET /api/transactions/[id]/match-invoice/preview', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
// PR #615 P1 regression (Greptile). A 1 000 SEK bank tx against a 140 USD
|
||||
// invoice is a PARTIAL payment (≈95.69 USD at 10.45). The preview must
|
||||
// convert to invoice currency BEFORE deciding fully-paid / cash-vs-clearing.
|
||||
// Before the fix, comparing the raw 1 000 SEK against 140 USD made
|
||||
// newRemaining go negative → is_fully_paid=true → a cash-method unbooked
|
||||
// invoice previewed a cash entry (Dr 1930 / Cr 30xx) while the POST — which
|
||||
// converts first — commits the clearing entry (Dr 1930 / Cr 1510). The user
|
||||
// approved one verifikat and a different one was booked.
|
||||
it('cross-currency partial under kontantmetoden previews a clearing entry, not a cash entry', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: 1000,
|
||||
currency: 'SEK',
|
||||
date: '2026-05-30',
|
||||
invoice_id: null,
|
||||
})
|
||||
const invoice = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
currency: 'USD',
|
||||
exchange_rate: 9.3,
|
||||
total: 140,
|
||||
remaining_amount: 140,
|
||||
paid_amount: 0,
|
||||
// journal_entry_id left undefined → unbooked (kontantmetoden candidate)
|
||||
})
|
||||
enqueue({ data: tx, error: null }) // transactions
|
||||
enqueue({ data: invoice, error: null }) // invoices
|
||||
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }) // company_settings
|
||||
|
||||
mockFetchExchangeRate.mockResolvedValue({ currency: 'USD', rate: 10.45, date: '2026-05-30' })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice/preview', {
|
||||
searchParams: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await GET(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
entry_type: string
|
||||
is_fully_paid: boolean
|
||||
lines: Array<{ account_number: string }>
|
||||
fx_conversion: { required: boolean; paid_in_invoice_currency?: number }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
// The crux of the bug: NOT a cash entry, NOT fully paid.
|
||||
expect(body.entry_type).toBe('clearing')
|
||||
expect(body.is_fully_paid).toBe(false)
|
||||
// Clearing lines clear 1510 and never recognise revenue on a 30xx account.
|
||||
const accounts = body.lines.map((l) => l.account_number)
|
||||
expect(accounts).toContain('1510')
|
||||
expect(accounts).not.toContain('3001')
|
||||
// FX surfaced with the invoice-currency equivalent (1000 / 10.45 ≈ 95.69),
|
||||
// matching what the POST handler accumulates.
|
||||
expect(body.fx_conversion.required).toBe(true)
|
||||
expect(body.fx_conversion.paid_in_invoice_currency).toBeCloseTo(95.69, 1)
|
||||
})
|
||||
|
||||
// Guard the cash path the fix reorders around: a same-currency full payment
|
||||
// of an unbooked invoice under kontantmetoden still previews the cash entry,
|
||||
// and no Riksbanken lookup happens for a same-currency settlement.
|
||||
it('same-currency full payment under kontantmetoden still previews a cash entry', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-2',
|
||||
amount: 12500,
|
||||
currency: 'SEK',
|
||||
date: '2026-05-30',
|
||||
invoice_id: null,
|
||||
})
|
||||
const invoice = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
currency: 'SEK',
|
||||
total: 12500,
|
||||
remaining_amount: 12500,
|
||||
paid_amount: 0,
|
||||
})
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-2/match-invoice/preview', {
|
||||
searchParams: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await GET(request, createMockRouteParams({ id: 'tx-2' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
entry_type: string
|
||||
is_fully_paid: boolean
|
||||
fx_conversion: { required: boolean }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.entry_type).toBe('cash')
|
||||
expect(body.is_fully_paid).toBe(true)
|
||||
expect(body.fx_conversion.required).toBe(false)
|
||||
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -20,7 +20,8 @@ import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
||||
import { getRevenueAccount, getOutputVatAccount } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines'
|
||||
import type { EntityType, Invoice, InvoiceItem } from '@/types'
|
||||
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||||
import type { Currency, EntityType, Invoice, InvoiceItem } from '@/types'
|
||||
import { z } from 'zod'
|
||||
|
||||
type PreviewLine = {
|
||||
@@ -83,14 +84,92 @@ export const GET = withRouteContext(
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
const paidAmount = transaction.amount
|
||||
// Cross-currency FX preview. When tx.currency !== invoice.currency we fetch
|
||||
// the Riksbanken spot rate for invoice.currency on the tx date and surface
|
||||
// the conversion to the dialog (the user sees the rate + invoice-currency-
|
||||
// equivalent before approving). The committed verifikat uses the same
|
||||
// numbers; the route POST handler re-runs the lookup so the rate at
|
||||
// commit time is authoritative.
|
||||
//
|
||||
// This MUST run BEFORE the paid / remaining / fully-paid math below.
|
||||
// invoice.remaining_amount and invoice.total are denominated in INVOICE
|
||||
// currency, so a SEK bank tx has to be converted first. Computing the
|
||||
// comparison from the raw SEK amount made a 1 000 SEK payment look like
|
||||
// it fully cleared a 140 USD invoice (newRemaining went negative →
|
||||
// isFullyPaid=true), which for a cash-method unbooked invoice previewed a
|
||||
// cash entry (Dr 1930 / Cr 30xx) that the POST — which converts first —
|
||||
// would never commit (it posts the clearing entry Dr 1930 / Cr 1510).
|
||||
//
|
||||
// Per ML 8 kap 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 {
|
||||
|
||||
@@ -28,12 +28,29 @@ interface PreviewLine {
|
||||
description: string
|
||||
}
|
||||
|
||||
// Cross-currency conversion info returned by the preview route. When
|
||||
// `required` is true the dialog surfaces a Valutaomräkning section so the
|
||||
// user sees the rate + invoice-currency-equivalent before approving. When
|
||||
// the Riksbanken lookup fails the dialog swaps in a manual-rate input.
|
||||
type FxConversion =
|
||||
| { required: false }
|
||||
| {
|
||||
required: true
|
||||
tx_currency: string
|
||||
invoice_currency: string
|
||||
rate: number
|
||||
rate_date: string
|
||||
paid_in_invoice_currency: number
|
||||
}
|
||||
| { required: true; error: 'rate_unavailable'; tx_currency: string; invoice_currency: string }
|
||||
|
||||
interface MatchPreview {
|
||||
entry_type: 'clearing' | 'cash'
|
||||
lines: PreviewLine[]
|
||||
invoice_already_booked: boolean
|
||||
accounting_method: 'accrual' | 'cash'
|
||||
is_fully_paid: boolean
|
||||
fx_conversion?: FxConversion
|
||||
}
|
||||
|
||||
// String-typed working copy of a line. The amount is a single value plus a
|
||||
@@ -57,6 +74,10 @@ export interface ConfirmOpts {
|
||||
credit_amount: number
|
||||
line_description?: string
|
||||
}>
|
||||
// Manual SEK-per-invoice-currency override used when Riksbanken's rate
|
||||
// for the payment date isn't available; the dialog asks the user to type
|
||||
// the rate from their bank statement. Same field flows to the route.
|
||||
manual_exchange_rate?: number
|
||||
}
|
||||
|
||||
interface InvoiceMatchDialogProps {
|
||||
@@ -109,6 +130,11 @@ export default function InvoiceMatchDialog({
|
||||
const [previewFailed, setPreviewFailed] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editLines, setEditLines] = useState<EditableLine[]>([])
|
||||
// Manual SEK-per-invoice-currency rate the user types when Riksbanken has
|
||||
// no rate for the payment date. Empty string = no override; on submit it
|
||||
// flows through ConfirmOpts.manual_exchange_rate to the route, which
|
||||
// re-runs the preview math with the supplied rate.
|
||||
const [manualRate, setManualRate] = useState<string>('')
|
||||
// BAS accounts power the AccountCombobox suggestions in edit mode. Loaded
|
||||
// once on dialog open; same endpoint that PaymentBookingDialog uses.
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
@@ -138,6 +164,7 @@ export default function InvoiceMatchDialog({
|
||||
setPreviewFailed(false)
|
||||
setIsEditing(false)
|
||||
setEditLines([])
|
||||
setManualRate('')
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
@@ -233,7 +260,19 @@ export default function InvoiceMatchDialog({
|
||||
}
|
||||
})
|
||||
: undefined
|
||||
onConfirm({ ...(opts ?? {}), ...(linesPayload ? { lines: linesPayload } : {}) })
|
||||
// Forward manual rate only when the preview indicated Riksbanken
|
||||
// failed AND the user typed a value. Same-currency settlements and
|
||||
// the auto-fetched cross-currency case both skip this field.
|
||||
const fx = preview?.fx_conversion
|
||||
const fxNeedsManualRate = fx?.required === true && 'error' in fx
|
||||
const manualRateNum = fxNeedsManualRate ? parseAmount(manualRate) : 0
|
||||
const manualRatePayload =
|
||||
fxNeedsManualRate && manualRateNum > 0 ? { manual_exchange_rate: manualRateNum } : {}
|
||||
onConfirm({
|
||||
...(opts ?? {}),
|
||||
...(linesPayload ? { lines: linesPayload } : {}),
|
||||
...manualRatePayload,
|
||||
})
|
||||
}
|
||||
|
||||
const resetEdits = () => {
|
||||
@@ -446,6 +485,112 @@ export default function InvoiceMatchDialog({
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Valutaomräkning section — only renders when the preview
|
||||
route flagged a cross-currency settlement. Shows the
|
||||
Riksbanken rate + invoice-currency-equivalent of the bank
|
||||
payment + the projected post-payment invoice state. When
|
||||
the rate lookup failed, swaps in a manual-rate input so
|
||||
the user can type the rate from their bank statement and
|
||||
retry. */}
|
||||
{preview?.fx_conversion?.required && (() => {
|
||||
const fx = preview.fx_conversion
|
||||
if (!fx?.required) return null
|
||||
const txAbs = transaction ? Math.abs(transaction.amount) : 0
|
||||
const invRemaining = transaction?.potential_invoice?.remaining_amount
|
||||
?? transaction?.potential_invoice?.total
|
||||
?? 0
|
||||
|
||||
if ('error' in fx) {
|
||||
// Riksbanken unavailable — show manual rate input.
|
||||
return (
|
||||
<div className="rounded-lg border border-warning/40 bg-warning/5 p-4 space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 text-warning-foreground flex-shrink-0" />
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="font-medium">{t('fx_rate_unavailable_title')}</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t('fx_rate_unavailable_description', {
|
||||
date: transaction ? formatDate(transaction.date) : '',
|
||||
invoiceCurrency: fx.invoice_currency,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* The typed rate flows through onConfirm.manual_exchange_rate
|
||||
and the route recomputes server-side, so the footer
|
||||
Confirm button is the trigger — no separate apply button.
|
||||
Confirm stays disabled until a positive rate is entered
|
||||
(see DialogFooter guard below). */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('fx_manual_rate_label')}
|
||||
</label>
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={manualRate}
|
||||
onChange={(e) => setManualRate(e.target.value)}
|
||||
placeholder={t('fx_manual_rate_placeholder')}
|
||||
className="tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const paidInInvoice = fx.paid_in_invoice_currency
|
||||
const remainingAfter = Math.max(0, Math.round((invRemaining - paidInInvoice) * 100) / 100)
|
||||
const willBeFullyPaid = remainingAfter <= 0
|
||||
// FX gain/loss for the kursvinst/kursförlust note: bankSek -
|
||||
// arSek, where arSek = paidInInvoice × invoice.exchange_rate.
|
||||
// Positive number = the SEK we received exceeded the SEK
|
||||
// value of the debt reduction (kursvinst).
|
||||
const invoiceRate = transaction?.potential_invoice?.exchange_rate ?? 0
|
||||
const arSek = invoiceRate > 0 ? Math.round(paidInInvoice * invoiceRate * 100) / 100 : 0
|
||||
const fxGain = invoiceRate > 0 ? Math.round((txAbs - arSek) * 100) / 100 : 0
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<p className="text-sm font-medium">{t('fx_title')}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('fx_rate_description', {
|
||||
date: fx.rate_date,
|
||||
invoiceCurrency: fx.invoice_currency,
|
||||
rate: fx.rate.toFixed(4).replace('.', ','),
|
||||
})}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm pt-1">
|
||||
<div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('fx_paid_in_invoice_currency', { amount: '' }).replace(': ', '')}
|
||||
</p>
|
||||
<p className="font-medium tabular-nums mt-0.5">
|
||||
{formatCurrency(paidInInvoice, fx.invoice_currency)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('fx_remaining_after', { amount: '' }).replace(': ', '')}
|
||||
</p>
|
||||
<p className="font-medium tabular-nums mt-0.5">
|
||||
{formatCurrency(remainingAfter, fx.invoice_currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{willBeFullyPaid ? t('fx_status_paid') : t('fx_status_partially_paid')}
|
||||
{Math.abs(fxGain) > 0.005 && (
|
||||
<>
|
||||
{' · '}
|
||||
{fxGain > 0
|
||||
? t('fx_gain_note', { amount: formatCurrency(fxGain, 'SEK') })
|
||||
: t('fx_loss_note', { amount: formatCurrency(Math.abs(fxGain), 'SEK') })}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Bookkeeping preview — editable. Read-only by default; user
|
||||
clicks "Redigera" to switch the rows to inputs. */}
|
||||
{(preview || previewFailed) && (
|
||||
@@ -624,7 +769,17 @@ export default function InvoiceMatchDialog({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleConfirm()}
|
||||
disabled={isConfirming || isCheckingDuplicate || (isEditing && !editValidation.isValid)}
|
||||
disabled={
|
||||
isConfirming ||
|
||||
isCheckingDuplicate ||
|
||||
(isEditing && !editValidation.isValid) ||
|
||||
// Block confirm when cross-currency lookup failed and the user
|
||||
// hasn't typed a manual rate yet. Same-currency and auto-rate
|
||||
// paths pass through unaffected.
|
||||
(preview?.fx_conversion?.required === true &&
|
||||
'error' in preview.fx_conversion &&
|
||||
parseAmount(manualRate) <= 0)
|
||||
}
|
||||
>
|
||||
{isConfirming ? t('confirming') : t('confirm_match')}
|
||||
</Button>
|
||||
|
||||
@@ -516,6 +516,17 @@ export const MatchInvoiceSchema = z
|
||||
credit_amount: nonNegativeAmount.default(0),
|
||||
line_description: z.string().optional(),
|
||||
})).min(2).optional(),
|
||||
// Optional caller-supplied SEK-per-invoice-currency rate for cross-currency
|
||||
// settlement. Used when the Riksbanken lookup returns nothing (rate not
|
||||
// published for that date) — the dialog surfaces an input so the user can
|
||||
// type the rate from their bank statement. Ignored when tx.currency ===
|
||||
// invoice.currency. The .max() is a sanity ceiling against pasted garbage /
|
||||
// scientific-notation input silently corrupting the FX-diff posting and
|
||||
// invoice_payments.amount — no supported currency's SEK rate approaches it
|
||||
// (USD~10.5, EUR~11.5, GBP~13.5). It is a guard rail, not a precise band;
|
||||
// the dialog's live preview (paid_in_invoice_currency + FX gain/loss) is
|
||||
// what catches a plausible-but-wrong decimal-shift typo before confirm.
|
||||
manual_exchange_rate: z.number().positive().max(100000).optional(),
|
||||
})
|
||||
.refine((v) => !v.force || !!v.expected_journal_entry_id, {
|
||||
message: 'expected_journal_entry_id is required when force=true',
|
||||
|
||||
@@ -90,6 +90,67 @@ describe('buildInvoicePaymentClearingLines', () => {
|
||||
expect(result.lines).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('partial cross-currency WITH paidInInvoiceCurrency: posts proportional 1510 credit + FX-diff line', () => {
|
||||
// The proper-FX path (round-10): caller supplies the invoice-currency
|
||||
// equivalent of the bank payment, computed at today's Riksbanken rate.
|
||||
// The helper credits 1510 by that × invoice.exchange_rate (the
|
||||
// booking rate) and posts the FX-diff line so the verifikat balances.
|
||||
//
|
||||
// Scenario: 1000 SEK bank tx, invoice 140 USD @ 9.30 (booked). Today
|
||||
// Riksbanken rate: 10.45. paidInInvoiceCurrency = 1000/10.45 = 95.6938.
|
||||
// arSek = 95.6938 × 9.30 = 889.95. fxDiff = 889.95 - 1000 = -110.05
|
||||
// (negative → gain → 3960 Cr 110.05).
|
||||
const result = buildInvoicePaymentClearingLines(
|
||||
{ amount: 1000, amount_sek: null, currency: 'SEK', exchange_rate: null },
|
||||
{ currency: 'USD', exchange_rate: 9.3, remaining_amount: 140, total: 140, paid_amount: 0 },
|
||||
'Inbetalning kundfaktura',
|
||||
95.6938, // paidInInvoiceCurrency
|
||||
)
|
||||
expect(result.bankSek).toBe(1000)
|
||||
expect(result.arSek).toBeCloseTo(889.95, 1)
|
||||
expect(result.fxDiffSek).toBeCloseTo(-110.05, 1)
|
||||
expect(result.lines).toHaveLength(3)
|
||||
expect(result.lines[0]).toMatchObject({ account_number: '1930', debit_amount: 1000 })
|
||||
expect(result.lines[1]).toMatchObject({ account_number: '1510' })
|
||||
expect(result.lines[2]).toMatchObject({
|
||||
account_number: '3960',
|
||||
line_description: 'Valutakursvinst',
|
||||
})
|
||||
const debit = result.lines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
const credit = result.lines.reduce((s, l) => s + l.credit_amount, 0)
|
||||
expect(Math.abs(debit - credit)).toBeLessThanOrEqual(0.005)
|
||||
})
|
||||
|
||||
it('cross-currency WITH paidInInvoiceCurrency, bank < booked: loss to 7960', () => {
|
||||
// Mirror of the gain case above with the opposite sign — guards the
|
||||
// kursförlust branch the route's gain-only assertion never reaches
|
||||
// (Swedish compliance review, PR #615). Invoice 100 USD booked at 9.30
|
||||
// (930 SEK on 1510); the 100 USD settlement only fetched 900 SEK at the
|
||||
// weaker 9.00 payment-date rate. arSek = 100 × 9.30 = 930.
|
||||
// fxDiff = 930 − 900 = +30 (positive → kursförlust → 7960 Dr 30).
|
||||
const result = buildInvoicePaymentClearingLines(
|
||||
{ amount: 900, amount_sek: null, currency: 'SEK', exchange_rate: null },
|
||||
{ currency: 'USD', exchange_rate: 9.3, remaining_amount: 100, total: 100, paid_amount: 0 },
|
||||
'Inbetalning kundfaktura',
|
||||
100, // paidInInvoiceCurrency (full settlement at today's 9.00 rate)
|
||||
)
|
||||
expect(result.bankSek).toBe(900)
|
||||
expect(result.arSek).toBeCloseTo(930, 2)
|
||||
expect(result.fxDiffSek).toBeCloseTo(30, 2)
|
||||
expect(result.lines).toHaveLength(3)
|
||||
expect(result.lines[0]).toMatchObject({ account_number: '1930', debit_amount: 900 })
|
||||
expect(result.lines[1]).toMatchObject({ account_number: '1510', credit_amount: 930 })
|
||||
expect(result.lines[2]).toMatchObject({
|
||||
account_number: '7960',
|
||||
debit_amount: 30,
|
||||
line_description: 'Valutakursförlust',
|
||||
})
|
||||
// Balanced to the öre: Dr 900 + 30 = 930 = Cr 930.
|
||||
const debit = result.lines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
const credit = result.lines.reduce((s, l) => s + l.credit_amount, 0)
|
||||
expect(Math.abs(debit - credit)).toBeLessThanOrEqual(0.005)
|
||||
})
|
||||
|
||||
it('partial cross-currency payment defers FX: bank-leg = AR-leg = bankSek, no 3960/7960 line', () => {
|
||||
// Invoice 140 USD @ 15.30 (2142 SEK booked on 1510)
|
||||
// Bank receives 230 SEK — way below the 2142 remaining. If we credited
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -346,12 +346,12 @@ const MATCH_INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Endast fakturor kan matchas mot en transaktion. Proforma och följesedel saknar momsskyldighet.',
|
||||
message_en: 'Only invoices may be matched to a transaction; proforma and delivery notes have no VAT obligation.',
|
||||
},
|
||||
MATCH_INVOICE_CURRENCY_MISMATCH: {
|
||||
MATCH_INVOICE_FX_RATE_UNAVAILABLE: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Transaktionens och fakturans valuta måste vara samma. För valutaomräkning, använd flerfaktura-matchningen som hanterar valutakursdifferenser på 3960/7960.',
|
||||
'Kunde inte hämta valutakurs från Riksbanken för betalningsdatumet. Ange kursen manuellt från ditt bankutdrag (fältet manual_exchange_rate).',
|
||||
message_en:
|
||||
'Transaction and invoice currency must match. For cross-currency settlement, use the multi-invoice allocation flow which posts FX-diff lines on 3960/7960.',
|
||||
'Could not retrieve an exchange rate from Riksbanken for the payment date. Provide the rate manually from your bank statement (manual_exchange_rate field).',
|
||||
},
|
||||
MATCH_INVOICE_ALREADY_PAID: {
|
||||
httpStatus: 409,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user