diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index 031a25a3..9e9389e0 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -370,24 +370,6 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i Antal rader {lines.length} - {hasForeignCurrency && ( - <> -
- Belopp i utländsk valuta - - {foreignTotal.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {foreignCurrency} - -
- {foreignExchangeRate && ( -
- Omräkningskurs - - 1 {foreignCurrency} = {foreignExchangeRate.toLocaleString('sv-SE', { minimumFractionDigits: 4, maximumFractionDigits: 4 })} SEK - -
- )} - - )} @@ -413,6 +395,33 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i + {/* Foreign-currency conversion audit chip */} + {hasForeignCurrency && foreignCurrency && ( + + +

+ Valutaomräkning +

+
+
+ Kurs + + {foreignExchangeRate + ? `1 ${foreignCurrency} = ${foreignExchangeRate.toLocaleString('sv-SE', { minimumFractionDigits: 4, maximumFractionDigits: 4 })} SEK` + : '—'} + +
+
+ Ursprungsbelopp + + {foreignTotal.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {foreignCurrency} + +
+
+
+
+ )} + {/* Lines table */} diff --git a/components/transactions/JournalEntryPreview.tsx b/components/transactions/JournalEntryPreview.tsx index 94ba055b..ca357267 100644 --- a/components/transactions/JournalEntryPreview.tsx +++ b/components/transactions/JournalEntryPreview.tsx @@ -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'} {formatAccountWithName(line.account)} - {formatCurrency(line.amount, currency)} + {formatCurrency(line.amount, 'SEK')} ))} diff --git a/components/transactions/QuickReviewDialog.tsx b/components/transactions/QuickReviewDialog.tsx index 032e8ea2..11598fbf 100644 --- a/components/transactions/QuickReviewDialog.tsx +++ b/components/transactions/QuickReviewDialog.tsx @@ -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({ )}
-

{transaction.description}

+

{transaction.description}

{formatDate(transaction.date)}

-

- {isIncome ? '+' : ''} - {formatCurrency(transaction.amount, transaction.currency)} -

+
+

+ {isIncome ? '+' : ''} + {formatCurrency(sekAmount, 'SEK')} +

+ {isForeign && ( +

+ {isIncome ? '+' : ''} + {formatCurrency(transaction.amount, transaction.currency)} +

+ )} +
{/* Template or Category */} @@ -260,7 +278,7 @@ export default function QuickReviewDialog({ {/* Journal entry preview */} {/* 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