- {isIncome ? '+' : ''}
- {formatCurrency(transaction.amount, transaction.currency)}
-
+
{/* Transaction summary */}
-
-
- {currentTransaction.description}
- {formatDate(currentTransaction.date)}
-
- {currentTransaction.amount > 0 ? '+' : ''}
- {formatCurrency(currentTransaction.amount, currentTransaction.currency)}
-
-
-
+ {(() => {
+ const reviewSekAmount = resolveSekAmount(
+ currentTransaction.amount,
+ currentTransaction.amount_sek,
+ currentTransaction.currency,
+ currentTransaction.exchange_rate
+ )
+ const isForeign = !!(currentTransaction.currency && currentTransaction.currency !== 'SEK')
+ return (
+
+
+ {currentTransaction.description}
+ {formatDate(currentTransaction.date)}
+
+ {currentTransaction.amount > 0 ? '+' : ''}
+ {formatCurrency(reviewSekAmount, 'SEK')}
+
+ {isForeign && (
+
+ {currentTransaction.amount > 0 ? '+' : ''}
+ {formatCurrency(currentTransaction.amount, currentTransaction.currency)}
+
+ )}
+
+
+ )
+ })()}
{/* Selected template or category */}
@@ -471,7 +489,12 @@ export default function SwipeCategorizationView({
{/* Journal entry preview */}
{
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)
+ })
})
// ============================================================
diff --git a/lib/bookkeeping/booking-templates.ts b/lib/bookkeeping/booking-templates.ts
index 0f5c881d..ee262d7b 100644
--- a/lib/bookkeeping/booking-templates.ts
+++ b/lib/bookkeeping/booking-templates.ts
@@ -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
diff --git a/lib/transactions/__tests__/ingest.test.ts b/lib/transactions/__tests__/ingest.test.ts
index 6f744079..bd7f4e66 100644
--- a/lib/transactions/__tests__/ingest.test.ts
+++ b/lib/transactions/__tests__/ingest.test.ts
@@ -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 })
diff --git a/lib/transactions/ingest.ts b/lib/transactions/ingest.ts
index aee81dcd..df8a9318 100644
--- a/lib/transactions/ingest.ts
+++ b/lib/transactions/ingest.ts
@@ -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()
+ // 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()
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()
+ 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