feat(enable-banking): per-account ledger mapping for multicurrency setups (#443) — rebase of #487 (#488)
* feat(enable-banking): per-account ledger mapping for multicurrency setups (#443) Today every bank account routes to BAS 1930. Multicurrency setups (Wise, SEB foreign-currency sub-accounts) get pooled into a single SEK ledger account, making year-end FX revaluation a mess. This change lets each bank account under a PSD2 consent map to its own BAS account (SEK→1930, EUR→1932, USD→1933, etc.). The mapping engine already honors IngestOptions.settlementAccount (lib/bookkeeping/mapping-engine.ts:55-57) so the wiring is small: - StoredAccount gains an optional ledger_account field (no migration — bank_connections.accounts_data is already JSONB). - syncAccountTransactions passes account.ledger_account through as settlementAccount so the bank-side leg routes to the right BAS account. - PATCH /accounts accepts account_mappings, validates 4-digit BAS format, and verifies each ledger_account exists in chart_of_accounts before persisting. Selection edits without account_mappings preserve existing values for back-compat. - AccountPickerDialog shows a per-account "Bokför till konto" combobox populated from the company's 19xx accounts, with currency-based defaults (SEK→1930, EUR→1932, USD→1933, GBP→1934) and a non-blocking warning when two enabled accounts route to the same BAS account with different currencies. Reconciliation still scans 1930 only — foreign-currency accounts skip auto-matching until follow-up PR 4 (filed in the plan). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(enable-banking): address review feedback on PR #487 Two real bugs flagged in review: 1. AccountPickerDialog row wrapped a Radix <Checkbox> (which renders as its own <button role="checkbox">) inside <button onClick={toggle}>. Browsers silently flatten nested interactive elements, the Checkbox lost its onCheckedChange handler, and the toggle interaction was effectively broken. Reverted to <label> + <Checkbox onCheckedChange> with the <Select> as a sibling outside the label so clicking it doesn't also toggle. 2. BAS_ACCOUNT_PATTERN was /^[0-9]{4}$/ — accepted 3001 (revenue), 2640 (input VAT), or any 4-digit account that happens to exist in the chart. Direct API calls would bypass the UI's 19% picker filter and silently misroute every bank-side journal-entry leg into the wrong BAS class, corrupting both the ledger and momsdeklaration. Tightened to /^19[0-9]{2}$/ (kassa/bank only). Tests updated to assert non-19xx rejection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(enable-banking): validate account_mappings UIDs against accounts_data A typo'd UID in account_mappings was silently dropped — the entry never landed in the resulting accounts_data while the response was still 200, leaving the client to believe the mapping was applied. Mirror the existing enabled_uids guard: reject unknown UIDs with 400 + unknown_uids in the body. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(enable-banking): catch syncPromise rejections to prevent process crash When the 60s timeout wins the Promise.race, the underlying Promise.all keeps running. A subsequent bank-API rejection has no registered handler, which surfaces as an unhandledRejection — Node 22 (the self-hosted Docker runtime) terminates the process by default on those. The cron self-heals via initial_sync_completed_at IS NULL, so a no-op catch is the right policy: drop the late rejection, let the cron retry. Caught by Greptile review on PR #488. 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
04f902fe8f
commit
722b22972d
@@ -37,12 +37,28 @@ interface AccountPickerDialogProps {
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
interface ChartAccount {
|
||||
account_number: string
|
||||
account_name: string
|
||||
}
|
||||
|
||||
const LOOKBACK_OPTIONS = [
|
||||
{ days: 90, label: 'Senaste 90 dagar (PSD2 standard, rekommenderas)' },
|
||||
{ days: 180, label: 'Senaste 180 dagar' },
|
||||
{ days: 365, label: 'Senaste 365 dagar' },
|
||||
] as const
|
||||
|
||||
// Suggested BAS account per currency. The mapping engine falls back to 1930
|
||||
// when ledger_account is unset, so the SEK case is just an explicit hint.
|
||||
// Foreign-currency accounts default to the BAS-recommended numbers; if the
|
||||
// company hasn't created them yet, the user must pick or seed them first.
|
||||
const CURRENCY_DEFAULTS: Record<string, string> = {
|
||||
SEK: '1930',
|
||||
EUR: '1932',
|
||||
USD: '1933',
|
||||
GBP: '1934',
|
||||
}
|
||||
|
||||
export function AccountPickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -64,16 +80,26 @@ export function AccountPickerDialog({
|
||||
const [lookbackDays, setLookbackDays] = useState<number>(90)
|
||||
const [sieLastDate, setSieLastDate] = useState<string | null>(null)
|
||||
const [showCustomLookback, setShowCustomLookback] = useState(false)
|
||||
const [chartAccounts, setChartAccounts] = useState<ChartAccount[]>([])
|
||||
const [ledgerByUid, setLedgerByUid] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Start the dialog reflecting the current state. Accounts without an
|
||||
// explicit enabled flag are treated as enabled (back-compat).
|
||||
const initial = new Set<string>(
|
||||
accounts.filter(a => a.enabled !== false).map(a => a.uid)
|
||||
)
|
||||
setSelected(initial)
|
||||
setShowCustomLookback(false)
|
||||
|
||||
// Pre-populate ledger picks from existing StoredAccount values, falling
|
||||
// back to currency-based suggestions for accounts the user hasn't mapped yet.
|
||||
const initialLedger: Record<string, string> = {}
|
||||
for (const a of accounts) {
|
||||
const fromStored = a.ledger_account
|
||||
const fromDefault = CURRENCY_DEFAULTS[a.currency] ?? ''
|
||||
initialLedger[a.uid] = fromStored ?? fromDefault
|
||||
}
|
||||
setLedgerByUid(initialLedger)
|
||||
}
|
||||
}, [open, accounts])
|
||||
|
||||
@@ -99,7 +125,6 @@ export function AccountPickerDialog({
|
||||
const fye = (data as { fiscal_year_end?: string } | null)?.fiscal_year_end || null
|
||||
setSieLastDate(fye)
|
||||
if (fye) {
|
||||
// Anchor lookback to (today - (fye + 1 day)), clamped to [30, 365].
|
||||
const dayAfter = new Date(fye)
|
||||
dayAfter.setDate(dayAfter.getDate() + 1)
|
||||
const days = Math.ceil((Date.now() - dayAfter.getTime()) / (24 * 60 * 60 * 1000))
|
||||
@@ -111,6 +136,24 @@ export function AccountPickerDialog({
|
||||
return () => { cancelled = true }
|
||||
}, [open, isInitialSelection, company?.id, supabase])
|
||||
|
||||
// Load 19xx accounts from the chart for the per-account ledger combobox.
|
||||
// Class 19 = bank/cash on the BAS chart.
|
||||
useEffect(() => {
|
||||
if (!open || !company?.id) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const { data } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name')
|
||||
.eq('company_id', company.id)
|
||||
.like('account_number', '19%')
|
||||
.order('account_number', { ascending: true })
|
||||
if (cancelled) return
|
||||
setChartAccounts((data as ChartAccount[] | null) || [])
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [open, company?.id, supabase])
|
||||
|
||||
const allSelected = accounts.length > 0 && selected.size === accounts.length
|
||||
const noneSelected = selected.size === 0
|
||||
|
||||
@@ -119,6 +162,22 @@ export function AccountPickerDialog({
|
||||
[accounts]
|
||||
)
|
||||
|
||||
// Detect cases where the user routed two enabled accounts with different
|
||||
// currencies to the same BAS account — usually a mistake, but allowed.
|
||||
const currencyConflicts = useMemo(() => {
|
||||
const byLedger = new Map<string, Set<string>>()
|
||||
for (const a of accounts) {
|
||||
if (!selected.has(a.uid)) continue
|
||||
const ledger = ledgerByUid[a.uid]
|
||||
if (!ledger) continue
|
||||
if (!byLedger.has(ledger)) byLedger.set(ledger, new Set())
|
||||
byLedger.get(ledger)!.add(a.currency)
|
||||
}
|
||||
return Array.from(byLedger.entries())
|
||||
.filter(([, currencies]) => currencies.size > 1)
|
||||
.map(([ledger, currencies]) => ({ ledger, currencies: Array.from(currencies) }))
|
||||
}, [accounts, selected, ledgerByUid])
|
||||
|
||||
function toggle(uid: string) {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
@@ -146,14 +205,34 @@ export function AccountPickerDialog({
|
||||
return
|
||||
}
|
||||
|
||||
// Block save when any enabled account has no ledger picked. The currency
|
||||
// defaults cover SEK/EUR/USD/GBP; other currencies require an explicit pick.
|
||||
const missingLedger = accounts.filter(a => selected.has(a.uid) && !ledgerByUid[a.uid])
|
||||
if (missingLedger.length > 0) {
|
||||
toast({
|
||||
title: 'Välj bokföringskonto',
|
||||
description: `Saknar bokföringskonto för: ${missingLedger.map(a => a.name || a.iban || a.uid).join(', ')}`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
// Send a mapping entry per selected account. Account_mappings doesn't
|
||||
// include disabled accounts — their existing ledger_account stays untouched.
|
||||
const account_mappings = Array.from(selected).map(uid => ({
|
||||
uid,
|
||||
ledger_account: ledgerByUid[uid] || null,
|
||||
}))
|
||||
|
||||
const response = await fetch('/api/extensions/ext/enable-banking/accounts', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
connection_id: connectionId,
|
||||
enabled_uids: Array.from(selected),
|
||||
account_mappings,
|
||||
...(isInitialSelection ? { initial_lookback_days: lookbackDays } : {}),
|
||||
}),
|
||||
})
|
||||
@@ -164,7 +243,6 @@ export function AccountPickerDialog({
|
||||
throw new Error(data.error || 'Kunde inte spara kontoval')
|
||||
}
|
||||
|
||||
// Surface the actual backfill result so the user sees what the bank returned.
|
||||
if (isInitialSelection && data.initial_sync) {
|
||||
const { imported, returned_min_date, returned_max_date } = data.initial_sync as {
|
||||
imported: number
|
||||
@@ -204,7 +282,6 @@ export function AccountPickerDialog({
|
||||
}
|
||||
}
|
||||
|
||||
// Day-after-SIE date for the callout
|
||||
const dayAfterSie = useMemo(() => {
|
||||
if (!sieLastDate) return null
|
||||
const d = new Date(sieLastDate)
|
||||
@@ -214,13 +291,13 @@ export function AccountPickerDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Välj konton att synka — {bankName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isInitialSelection
|
||||
? 'Banken har gett åtkomst till följande konton. Avmarkera de konton du inte vill synka transaktioner från. Inga transaktioner hämtas innan du sparar.'
|
||||
: 'Justera vilka konton som ska synkas. Konton du avmarkerar slutar synkas från nästa körning; redan importerade transaktioner ligger kvar.'}
|
||||
? 'Banken har gett åtkomst till följande konton. Avmarkera de konton du inte vill synka transaktioner från, och välj vilket bokföringskonto varje konto ska bokföras mot. Inga transaktioner hämtas innan du sparar.'
|
||||
: 'Justera vilka konton som ska synkas och vilka bokföringskonton de bokförs mot. Konton du avmarkerar slutar synkas från nästa körning; redan importerade transaktioner ligger kvar.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -318,38 +395,85 @@ export function AccountPickerDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currencyConflicts.length > 0 && (
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
Varning: samma bokföringskonto används för flera valutor —
|
||||
{currencyConflicts.map(c => ` ${c.ledger} (${c.currencies.join(', ')})`).join(';')}.
|
||||
Det fungerar tekniskt men gör årsskifte med valutaomvärdering svårare.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-border divide-y divide-border">
|
||||
{sortedAccounts.map(account => {
|
||||
const isChecked = selected.has(account.uid)
|
||||
const ledger = ledgerByUid[account.uid] || ''
|
||||
const ledgerExistsInChart = chartAccounts.some(c => c.account_number === ledger)
|
||||
return (
|
||||
<label
|
||||
<div
|
||||
key={account.uid}
|
||||
className="flex cursor-pointer items-center gap-3 p-3 hover:bg-muted/50"
|
||||
className="flex items-center gap-3 p-3 hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggle(account.uid)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{account.name || account.iban || 'Okänt konto'}
|
||||
</p>
|
||||
{account.iban && (
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
{account.iban.replace(/(.{4})/g, '$1 ').trim()}
|
||||
{/* Toggle area: label + Checkbox (a Radix Checkbox renders as
|
||||
its own <button role="checkbox">, so wrapping it in another
|
||||
<button> would be nested interactive elements — invalid HTML
|
||||
that browsers silently flatten and breaks event routing). */}
|
||||
<label className="flex flex-1 min-w-0 cursor-pointer items-center gap-3">
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggle(account.uid)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{account.name || account.iban || 'Okänt konto'}
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
{account.currency}
|
||||
</span>
|
||||
</p>
|
||||
{account.iban && (
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
{account.iban.replace(/(.{4})/g, '$1 ').trim()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{account.balance !== undefined && (
|
||||
<p className="text-sm font-medium tabular-nums shrink-0">
|
||||
{new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: account.currency,
|
||||
}).format(account.balance)}
|
||||
</p>
|
||||
)}
|
||||
</label>
|
||||
{/* Ledger picker is a sibling of the label, not inside it —
|
||||
otherwise clicking the Select would also toggle the checkbox. */}
|
||||
<div className="w-44 shrink-0">
|
||||
{isChecked && (
|
||||
<Select
|
||||
value={ledger}
|
||||
onValueChange={(v) => setLedgerByUid(prev => ({ ...prev, [account.uid]: v }))}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Välj konto…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* Surface a non-existent default so the user can see/correct it. */}
|
||||
{ledger && !ledgerExistsInChart && (
|
||||
<SelectItem value={ledger} disabled>
|
||||
{ledger} — finns ej i kontoplan
|
||||
</SelectItem>
|
||||
)}
|
||||
{chartAccounts.map(acc => (
|
||||
<SelectItem key={acc.account_number} value={acc.account_number}>
|
||||
<span className="tabular-nums">{acc.account_number}</span> {acc.account_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
{account.balance !== undefined && (
|
||||
<p className="text-sm font-medium tabular-nums shrink-0">
|
||||
{new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: account.currency,
|
||||
}).format(account.balance)}
|
||||
</p>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user