fix(currency): transaction-date FX rate + mall bookings in SEK (#442) (#483)

* 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:
Jakob Wennberg
2026-05-14 15:00:12 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 4a6c473cc4
commit cad83d180d
8 changed files with 276 additions and 58 deletions
@@ -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>
+24 -6
View File
@@ -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}