* fix(currency): use transaction-date FX rate and post mall bookings in SEK (#442) Three related defects in non-SEK transaction handling: 1. Ingest priced every transaction in a batch at today's rate because fetchMultipleRates() was called without a date. Switch to a per- (currency, date) fetch via fetchExchangeRate() + Promise.allSettled, so each row stores Riksbanken's rate as of its own transaction date. 2. buildMappingResultFromTemplate emitted VAT/reverse-charge/output-VAT lines from Math.abs(transaction.amount) — the source-currency amount. Mall bookings on USD/EUR transactions therefore posted the journal entry in the wrong currency. Resolve to SEK once via resolveSekAmount() and reuse for every line, matching what createTransactionJournalEntry already does for the settlement line. 3. The Granska bokföring modal and the journal-entry detail page lacked a clear audit trail. The verifikation preview now renders in SEK with a separate original-currency subtitle in the header; the detail page gains a dedicated "Valutaomräkning" card above Kontorader showing the rate and original total. Also: replace truncate with break-all on the modal description so long UUID-style strings (e.g. BALANCE_CASHBACK-…) wrap instead of forcing horizontal overscroll. Tests: 4 new booking-templates cases (USD 25%, EUR fallback via exchange_rate, EU reverse-charge on USD, output VAT on USD income) and 2 new ingest cases (per-pair fetch dedup; no fetch for SEK rows). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(currency): wire SEK conversion into SwipeCategorizationView (#442 follow-up) Address Greptile review: - SwipeCategorizationView rendered JournalEntryPreview without amountSek, so the swipe flow had the same defect QuickReviewDialog had — a USD transaction showed the verifikation in source currency labeled SEK. Now resolves SEK once via resolveSekAmount, passes it to the preview, and shows SEK as the headline with the original-currency subtitle (parallel to QuickReviewDialog). - Drop the now-dead `currency` prop from JournalEntryPreviewProps and its remaining call site in QuickReviewDialog — the component hardcodes 'SEK' on display, so the prop was misleading. - Correct the misleading inline comment in ingest.ts: network failures resolve inside fetchExchangeRate to getFallbackRate() (non-null), so the key only stays unset on empty-observation responses or outright rejections. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
4a6c473cc4
commit
cad83d180d
@@ -370,24 +370,6 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
<span className="text-muted-foreground">Antal rader</span>
|
||||
<span>{lines.length}</span>
|
||||
</div>
|
||||
{hasForeignCurrency && (
|
||||
<>
|
||||
<div className="border-t pt-2 mt-2 flex justify-between">
|
||||
<span className="text-muted-foreground">Belopp i utländsk valuta</span>
|
||||
<span className="tabular-nums font-medium">
|
||||
{foreignTotal.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {foreignCurrency}
|
||||
</span>
|
||||
</div>
|
||||
{foreignExchangeRate && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Omräkningskurs</span>
|
||||
<span className="tabular-nums">
|
||||
1 {foreignCurrency} = {foreignExchangeRate.toLocaleString('sv-SE', { minimumFractionDigits: 4, maximumFractionDigits: 4 })} SEK
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -413,6 +395,33 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Foreign-currency conversion audit chip */}
|
||||
{hasForeignCurrency && foreignCurrency && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground mb-2">
|
||||
Valutaomräkning
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex justify-between sm:block">
|
||||
<span className="text-muted-foreground">Kurs</span>
|
||||
<span className="tabular-nums sm:block">
|
||||
{foreignExchangeRate
|
||||
? `1 ${foreignCurrency} = ${foreignExchangeRate.toLocaleString('sv-SE', { minimumFractionDigits: 4, maximumFractionDigits: 4 })} SEK`
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between sm:block">
|
||||
<span className="text-muted-foreground">Ursprungsbelopp</span>
|
||||
<span className="tabular-nums sm:block">
|
||||
{foreignTotal.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {foreignCurrency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Lines table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -15,7 +15,13 @@ interface PreviewLine {
|
||||
|
||||
interface JournalEntryPreviewProps {
|
||||
amount: number
|
||||
currency?: string
|
||||
/**
|
||||
* SEK-equivalent of `amount` for foreign-currency transactions. When set,
|
||||
* all line calculations and the displayed totals use this value — the
|
||||
* verifikation must always be in SEK regardless of the source currency.
|
||||
* Falls back to `amount` when omitted (i.e. SEK transactions).
|
||||
*/
|
||||
amountSek?: number
|
||||
category?: TransactionCategory
|
||||
vatTreatment?: VatTreatment | 'none'
|
||||
accountOverride?: string
|
||||
@@ -33,7 +39,7 @@ interface JournalEntryPreviewProps {
|
||||
|
||||
export default function JournalEntryPreview({
|
||||
amount,
|
||||
currency = 'SEK',
|
||||
amountSek,
|
||||
category,
|
||||
vatTreatment,
|
||||
accountOverride,
|
||||
@@ -48,7 +54,9 @@ export default function JournalEntryPreview({
|
||||
}: JournalEntryPreviewProps) {
|
||||
const lines = useMemo(() => {
|
||||
const result: PreviewLine[] = []
|
||||
const absAmount = Math.abs(amount)
|
||||
// Use SEK-equivalent when provided; sign comes from `amount` (which
|
||||
// distinguishes income vs expense) but magnitude always comes from SEK.
|
||||
const absAmount = Math.abs(amountSek ?? amount)
|
||||
|
||||
// Multi-line counterparty template preview
|
||||
if (linePattern && linePattern.length > 0) {
|
||||
@@ -181,7 +189,7 @@ export default function JournalEntryPreview({
|
||||
}
|
||||
|
||||
return result
|
||||
}, [amount, category, vatTreatment, accountOverride, entityType, templateDebitAccount, templateCreditAccount, templateVatRate, templateVatTreatment, templateSupplierType, linePattern, settlementAccount])
|
||||
}, [amount, amountSek, category, vatTreatment, accountOverride, entityType, templateDebitAccount, templateCreditAccount, templateVatRate, templateVatTreatment, templateSupplierType, linePattern, settlementAccount])
|
||||
|
||||
if (lines.length === 0) return null
|
||||
|
||||
@@ -195,7 +203,7 @@ export default function JournalEntryPreview({
|
||||
{line.side === 'debet' ? 'Debet' : 'Kredit'}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{formatAccountWithName(line.account)}</span>
|
||||
<span className="flex-shrink-0 tabular-nums">{formatCurrency(line.amount, currency)}</span>
|
||||
<span className="flex-shrink-0 tabular-nums">{formatCurrency(line.amount, 'SEK')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp, AlertTriangle } from 'lucide-react'
|
||||
import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
|
||||
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
||||
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
import JournalEntryPreview from './JournalEntryPreview'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
@@ -117,6 +118,15 @@ export default function QuickReviewDialog({
|
||||
const isCounterpartyTemplate = !!(counterpartyLinePattern && counterpartyLinePattern.length > 0)
|
||||
const isTemplateBooking = !!templateId || isCounterpartyTemplate
|
||||
const isLiabilityAccount = accountOverride.startsWith('2')
|
||||
// For non-SEK transactions, the verifikation and the headline must show
|
||||
// the SEK-converted total — the mall/category booking always posts in SEK.
|
||||
const sekAmount = resolveSekAmount(
|
||||
transaction.amount,
|
||||
transaction.amount_sek,
|
||||
transaction.currency,
|
||||
transaction.exchange_rate
|
||||
)
|
||||
const isForeign = !!(transaction.currency && transaction.currency !== 'SEK')
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!category || !transaction) return
|
||||
@@ -192,13 +202,21 @@ export default function QuickReviewDialog({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{transaction.description}</p>
|
||||
<p className="font-medium text-sm break-all">{transaction.description}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatDate(transaction.date)}</p>
|
||||
</div>
|
||||
<p className={`font-medium text-sm flex-shrink-0 ${isIncome ? 'text-success' : ''}`}>
|
||||
{isIncome ? '+' : ''}
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</p>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className={`font-medium text-sm tabular-nums ${isIncome ? 'text-success' : ''}`}>
|
||||
{isIncome ? '+' : ''}
|
||||
{formatCurrency(sekAmount, 'SEK')}
|
||||
</p>
|
||||
{isForeign && (
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
{isIncome ? '+' : ''}
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template or Category */}
|
||||
@@ -260,7 +278,7 @@ export default function QuickReviewDialog({
|
||||
{/* Journal entry preview */}
|
||||
<JournalEntryPreview
|
||||
amount={transaction.amount}
|
||||
currency={transaction.currency}
|
||||
amountSek={sekAmount}
|
||||
{...(isCounterpartyTemplate
|
||||
? { linePattern: counterpartyLinePattern ?? undefined }
|
||||
: templateId && template
|
||||
|
||||
@@ -11,6 +11,7 @@ import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { checkExpenseWarnings } from '@/lib/tax/expense-warnings'
|
||||
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
|
||||
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
||||
import { isLibraryTemplateId } from '@/lib/bookkeeping/template-library'
|
||||
import TemplatePicker from './TemplatePicker'
|
||||
import JournalEntryPreview from './JournalEntryPreview'
|
||||
@@ -400,16 +401,33 @@ export default function SwipeCategorizationView({
|
||||
|
||||
<div className="flex-1 overflow-auto p-4 space-y-4">
|
||||
{/* Transaction summary */}
|
||||
<Card>
|
||||
<CardContent className="pt-4 space-y-1">
|
||||
<p className="font-medium">{currentTransaction.description}</p>
|
||||
<p className="text-sm text-muted-foreground">{formatDate(currentTransaction.date)}</p>
|
||||
<p className="font-display text-2xl font-medium tabular-nums mt-2">
|
||||
{currentTransaction.amount > 0 ? '+' : ''}
|
||||
{formatCurrency(currentTransaction.amount, currentTransaction.currency)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{(() => {
|
||||
const reviewSekAmount = resolveSekAmount(
|
||||
currentTransaction.amount,
|
||||
currentTransaction.amount_sek,
|
||||
currentTransaction.currency,
|
||||
currentTransaction.exchange_rate
|
||||
)
|
||||
const isForeign = !!(currentTransaction.currency && currentTransaction.currency !== 'SEK')
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-4 space-y-1">
|
||||
<p className="font-medium break-all">{currentTransaction.description}</p>
|
||||
<p className="text-sm text-muted-foreground">{formatDate(currentTransaction.date)}</p>
|
||||
<p className="font-display text-2xl font-medium tabular-nums mt-2">
|
||||
{currentTransaction.amount > 0 ? '+' : ''}
|
||||
{formatCurrency(reviewSekAmount, 'SEK')}
|
||||
</p>
|
||||
{isForeign && (
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
{currentTransaction.amount > 0 ? '+' : ''}
|
||||
{formatCurrency(currentTransaction.amount, currentTransaction.currency)}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Selected template or category */}
|
||||
<div>
|
||||
@@ -471,7 +489,12 @@ export default function SwipeCategorizationView({
|
||||
{/* Journal entry preview */}
|
||||
<JournalEntryPreview
|
||||
amount={currentTransaction.amount}
|
||||
currency={currentTransaction.currency}
|
||||
amountSek={resolveSekAmount(
|
||||
currentTransaction.amount,
|
||||
currentTransaction.amount_sek,
|
||||
currentTransaction.currency,
|
||||
currentTransaction.exchange_rate
|
||||
)}
|
||||
category={pendingCategory}
|
||||
vatTreatment={isLiabilityAccount ? 'none' : vatTreatment}
|
||||
accountOverride={accountOverride}
|
||||
|
||||
@@ -469,6 +469,74 @@ describe('buildMappingResultFromTemplate', () => {
|
||||
|
||||
expect(result.description).toBe('Drivmedel & Laddning: OKQ8 tankstation')
|
||||
})
|
||||
|
||||
// Foreign-currency transactions: the mall must always emit SEK amounts
|
||||
// (issue #442). Previously buildMappingResultFromTemplate used
|
||||
// Math.abs(transaction.amount) — which is in the source currency — to
|
||||
// compute VAT lines, producing a verifikation in mixed currencies.
|
||||
it('emits SEK amounts when transaction currency is USD (issue #442)', () => {
|
||||
const template = getTemplate('it_saas_subscription') // 25% input VAT
|
||||
const tx = makeTransaction({
|
||||
amount: -125, // -125 USD
|
||||
currency: 'USD',
|
||||
amount_sek: -1250, // pre-converted to SEK
|
||||
exchange_rate: 10,
|
||||
})
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
// VAT line debit must be 250 SEK (1250 * 0.25 / 1.25), not 25 USD
|
||||
expect(result.vat_lines).toHaveLength(1)
|
||||
expect(result.vat_lines[0].account_number).toBe('2641')
|
||||
expect(result.vat_lines[0].debit_amount).toBe(250)
|
||||
})
|
||||
|
||||
it('falls back to amount * exchange_rate when amount_sek is missing', () => {
|
||||
const template = getTemplate('it_saas_subscription')
|
||||
const tx = makeTransaction({
|
||||
amount: -100,
|
||||
currency: 'EUR',
|
||||
amount_sek: null,
|
||||
exchange_rate: 11.5,
|
||||
})
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
// 100 * 11.5 = 1150 SEK; 1150 * 0.25 / 1.25 = 230 SEK
|
||||
expect(result.vat_lines[0].debit_amount).toBe(230)
|
||||
})
|
||||
|
||||
it('emits SEK amounts for EU reverse-charge on a non-SEK transaction', () => {
|
||||
const template = getTemplate('it_saas_eu')
|
||||
const tx = makeTransaction({
|
||||
amount: -100,
|
||||
currency: 'USD',
|
||||
amount_sek: -1000,
|
||||
exchange_rate: 10,
|
||||
})
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
// Fiktiv-moms pair sized at 25% of 1000 SEK = 250, not 25 USD
|
||||
expect(result.vat_lines).toHaveLength(4)
|
||||
expect(result.vat_lines[0].debit_amount).toBe(250) // 2645
|
||||
expect(result.vat_lines[1].credit_amount).toBe(250) // 2614
|
||||
expect(result.vat_lines[2].debit_amount).toBe(1000) // 4535 basbelopp
|
||||
expect(result.vat_lines[3].credit_amount).toBe(1000) // 4598
|
||||
})
|
||||
|
||||
it('emits SEK amounts for output VAT on non-SEK income', () => {
|
||||
const template = getTemplate('revenue_standard_25')
|
||||
const tx = makeTransaction({
|
||||
amount: 100,
|
||||
currency: 'USD',
|
||||
amount_sek: 1000,
|
||||
exchange_rate: 10,
|
||||
})
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
// Output VAT at 25% on 1000 SEK = 200, not 20 USD
|
||||
expect(result.vat_lines).toHaveLength(1)
|
||||
expect(result.vat_lines[0].account_number).toBe('2611')
|
||||
expect(result.vat_lines[0].credit_amount).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
generateReverseChargeBasisLines,
|
||||
generateInputVatLine,
|
||||
} from './vat-entries'
|
||||
import { resolveSekAmount } from './currency-utils'
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
@@ -1577,6 +1578,17 @@ export function buildMappingResultFromTemplate(
|
||||
if (template.credit_account_ab) creditAccount = template.credit_account_ab
|
||||
}
|
||||
|
||||
// Always work in SEK. For non-SEK transactions, resolve the SEK-equivalent
|
||||
// (via amount_sek or amount * exchange_rate); for SEK rows this is a no-op.
|
||||
// Without this, VAT and reverse-charge lines would be emitted in the
|
||||
// original currency and the resulting verifikation would mix currencies.
|
||||
const absAmount = Math.abs(resolveSekAmount(
|
||||
transaction.amount,
|
||||
transaction.amount_sek,
|
||||
transaction.currency,
|
||||
transaction.exchange_rate
|
||||
))
|
||||
|
||||
// Generate VAT lines
|
||||
const vatLines: VatJournalLine[] = []
|
||||
if (isBusiness && template.vat_treatment && template.deductibility !== 'non_deductible') {
|
||||
@@ -1588,7 +1600,6 @@ export function buildMappingResultFromTemplate(
|
||||
// basbelopp pair populates momsdeklaration rutor 20–24; without it
|
||||
// Skatteverket rejects with FK004 ("ruta 30-32 utan motsvarande
|
||||
// basbelopp i 20-24" — ML 13 kap kräver båda sidor).
|
||||
const absAmount = Math.abs(transaction.amount)
|
||||
const supplierType = template.reverse_charge_supplier_type ?? 'eu_business'
|
||||
const isDomestic = supplierType === 'swedish_business'
|
||||
const rcRate = 0.25 // fiktiv moms rate; current templates are 25%
|
||||
@@ -1618,7 +1629,6 @@ export function buildMappingResultFromTemplate(
|
||||
}
|
||||
} else if (vatRate > 0 && isExpense) {
|
||||
// Input VAT deduction
|
||||
const absAmount = Math.abs(transaction.amount)
|
||||
const vatLine = generateInputVatLine(absAmount, vatRate)
|
||||
if (vatLine) {
|
||||
vatLines.push({
|
||||
@@ -1630,8 +1640,7 @@ export function buildMappingResultFromTemplate(
|
||||
}
|
||||
} else if (vatRate > 0 && !isExpense) {
|
||||
// Output VAT (income)
|
||||
const grossAmount = Math.abs(transaction.amount)
|
||||
const vatAmount = Math.round((grossAmount * vatRate / (1 + vatRate)) * 100) / 100
|
||||
const vatAmount = Math.round((absAmount * vatRate / (1 + vatRate)) * 100) / 100
|
||||
let vatAccount: string
|
||||
switch (template.vat_treatment) {
|
||||
case 'standard_25': vatAccount = '2611'; break
|
||||
|
||||
@@ -28,6 +28,11 @@ vi.mock('@/lib/invoices/invoice-matching', () => ({
|
||||
getBestInvoiceMatch: (...args: unknown[]) => mockGetBestInvoiceMatch(...args),
|
||||
}))
|
||||
|
||||
const mockFetchExchangeRate = vi.fn()
|
||||
vi.mock('@/lib/currency/riksbanken', () => ({
|
||||
fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queue-based Supabase mock
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -748,6 +753,60 @@ describe('ingestTransactions', () => {
|
||||
expect(result.imported).toBe(1)
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// FX rate fetching (issue #442)
|
||||
// Each non-SEK transaction must be priced at the rate of its OWN date,
|
||||
// not the import date and not a single batch-level rate.
|
||||
// -----------------------------------------------------------------------
|
||||
it('fetches an exchange rate per unique (currency, date) pair', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw1 = makeRaw({ amount: -100, currency: 'USD', date: '2026-05-07', external_id: 'usd-a' })
|
||||
const raw2 = makeRaw({ amount: -50, currency: 'USD', date: '2026-05-08', external_id: 'usd-b' })
|
||||
const raw3 = makeRaw({ amount: -200, currency: 'EUR', date: '2026-05-07', external_id: 'eur-a' })
|
||||
const raw4 = makeRaw({ amount: -300, currency: 'USD', date: '2026-05-07', external_id: 'usd-c' })
|
||||
|
||||
enqueue({ data: [], error: null }) // booked map
|
||||
enqueue({ data: [], error: null }) // unbooked enable_banking map
|
||||
enqueue({ data: [], error: null }) // supplier invoices
|
||||
enqueue({ data: [], error: null }) // batch external_id dedup
|
||||
enqueue({ data: makeTransaction({ id: 'tx-1' }), error: null })
|
||||
enqueue({ data: makeTransaction({ id: 'tx-2' }), error: null })
|
||||
enqueue({ data: makeTransaction({ id: 'tx-3' }), error: null })
|
||||
enqueue({ data: makeTransaction({ id: 'tx-4' }), error: null })
|
||||
|
||||
mockFetchExchangeRate.mockResolvedValue({ currency: 'USD', rate: 9.2, date: '2026-05-07' })
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw1, raw2, raw3, raw4])
|
||||
|
||||
// 3 unique pairs: USD/2026-05-07, USD/2026-05-08, EUR/2026-05-07.
|
||||
// raw4 reuses USD/2026-05-07 and must NOT trigger an extra fetch.
|
||||
expect(mockFetchExchangeRate).toHaveBeenCalledTimes(3)
|
||||
const pairs = mockFetchExchangeRate.mock.calls.map(([currency, date]) => ({
|
||||
currency,
|
||||
date: (date as Date).toISOString().split('T')[0],
|
||||
}))
|
||||
expect(pairs).toContainEqual({ currency: 'USD', date: '2026-05-07' })
|
||||
expect(pairs).toContainEqual({ currency: 'USD', date: '2026-05-08' })
|
||||
expect(pairs).toContainEqual({ currency: 'EUR', date: '2026-05-07' })
|
||||
})
|
||||
|
||||
it('does not fetch a rate for SEK transactions', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({ amount: -100, currency: 'SEK', date: '2026-05-07' })
|
||||
|
||||
enqueue({ data: [], error: null }) // booked
|
||||
enqueue({ data: [], error: null }) // unbooked
|
||||
enqueue({ data: [], error: null }) // suppliers
|
||||
enqueue({ data: [], error: null }) // dedup
|
||||
enqueue({ data: makeTransaction({ id: 'tx-sek' }), error: null })
|
||||
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
|
||||
expect(mockFetchExchangeRate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('continues normally when booked transaction map query fails', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({ amount: -200 })
|
||||
|
||||
+38
-14
@@ -4,7 +4,7 @@ import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-ent
|
||||
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching'
|
||||
import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matching'
|
||||
import { fetchMultipleRates } from '@/lib/currency/riksbanken'
|
||||
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import type { Transaction, RawTransaction, IngestResult, IngestOptions, SupplierInvoice, Currency, ExchangeRate } from '@/types'
|
||||
@@ -121,7 +121,9 @@ export async function ingestTransactions(
|
||||
// When rawInsertOnly is set (viewer imports), skip pre-fetching supplier
|
||||
// invoices and exchange rates — they are not used.
|
||||
let unpaidSupplierInvoices: SupplierInvoice[] = []
|
||||
let exchangeRates = new Map<Currency, ExchangeRate>()
|
||||
// Keyed by `${currency}|${date}` so each non-SEK transaction gets the
|
||||
// rate that was valid on its own transaction date, not the import date.
|
||||
const exchangeRatesByDate = new Map<string, ExchangeRate>()
|
||||
|
||||
if (!options?.rawInsertOnly) {
|
||||
// Pre-fetch unpaid supplier invoices for expense matching (non-critical)
|
||||
@@ -140,19 +142,41 @@ export async function ingestTransactions(
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-fetch exchange rates for non-SEK currencies (non-critical)
|
||||
// Pre-fetch exchange rates for each unique (currency, date) pair in the
|
||||
// batch. Riksbanken publishes a per-day rate; using one batched fetch with
|
||||
// no date stamps every row at today's rate, which is wrong for historical
|
||||
// imports (issue #442). fetchExchangeRate already falls back to the last
|
||||
// 7 days when the requested day is a weekend/holiday.
|
||||
if (!options?.rawInsertOnly) {
|
||||
try {
|
||||
const uniqueCurrencies = [...new Set(
|
||||
rawTransactions
|
||||
.map(t => t.currency)
|
||||
.filter((c): c is Currency => c != null && c !== 'SEK')
|
||||
)]
|
||||
if (uniqueCurrencies.length > 0) {
|
||||
exchangeRates = await fetchMultipleRates(uniqueCurrencies)
|
||||
const uniquePairs = new Map<string, { currency: Currency; date: string }>()
|
||||
for (const t of rawTransactions) {
|
||||
if (t.currency && t.currency !== 'SEK' && t.date) {
|
||||
const key = `${t.currency}|${t.date}`
|
||||
if (!uniquePairs.has(key)) {
|
||||
uniquePairs.set(key, { currency: t.currency as Currency, date: t.date })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uniquePairs.size > 0) {
|
||||
const pairs = Array.from(uniquePairs.entries())
|
||||
const settled = await Promise.allSettled(
|
||||
pairs.map(([, { currency, date }]) =>
|
||||
fetchExchangeRate(currency, new Date(date))
|
||||
)
|
||||
)
|
||||
for (let i = 0; i < pairs.length; i++) {
|
||||
const [key] = pairs[i]
|
||||
const outcome = settled[i]
|
||||
if (outcome.status === 'fulfilled' && outcome.value) {
|
||||
exchangeRatesByDate.set(key, outcome.value)
|
||||
}
|
||||
// Network failures resolve inside fetchExchangeRate to getFallbackRate()
|
||||
// (non-null, today's date), so they still populate the key. The key
|
||||
// only stays unset when the API returns an empty observation array
|
||||
// or the promise rejects outright — in that case amount_sek and
|
||||
// exchange_rate remain null on the inserted transaction.
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — amount_sek fields will stay null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +229,7 @@ export async function ingestTransactions(
|
||||
|
||||
// 2. Insert new transaction (with SEK conversion for foreign currencies)
|
||||
const rateInfo = raw.currency && raw.currency !== 'SEK'
|
||||
? exchangeRates.get(raw.currency as Currency)
|
||||
? exchangeRatesByDate.get(`${raw.currency}|${raw.date}`)
|
||||
: undefined
|
||||
const amountSek = rateInfo
|
||||
? Math.round(raw.amount * rateInfo.rate * 100) / 100
|
||||
|
||||
Reference in New Issue
Block a user