fix(settings): clarify bankgiro source on Foretag tab, offer IBAN prefill from bank connection (#1695)

* fix(settings): stop registry bank data masquerading as a setting, offer IBAN from bank connection

User report: the Foretag tab shows a bankgiro from the Bolagsverket snapshot,
which reads as a configured setting while the field payment files and
invoices actually use (Fakturering) was empty.

- Note on the Foretag Bankuppgifter row: data is from Bolagsverket; the
  editable fields live under Installningar -> Fakturering.
- One-click IBAN prefill on the SEK payment account, sourced from the
  connected bank accounts (cash_accounts.iban). Deterministic: only offered
  when every connected account agrees on a single IBAN.
- Delete dead BankDetailsForm.tsx (unmounted since the settings
  restructure); its bank fields are edited via InvoicePaymentAccountsSettings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): only suggest IBAN from enabled, still-connected SEK accounts

Skeptic refutation on the initial PR state: cash_accounts keeps rows after
disconnect (bank_connection_id nulled) and the connect picker mirrors
deselected accounts with enabled=false, so an unfiltered read could offer a
closed or third-party IBAN as the invoice payee / pain.001 sender. Filter on
enabled=true, currency=SEK and a non-null bank_connection_id, matching the
enable-banking session-sharing invariant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-18 17:18:51 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 1af5846adf
commit d0640e0968
8 changed files with 151 additions and 235 deletions
+1
View File
@@ -1055,5 +1055,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-18] Defect record (compliance-swarm PI1.3, no risk_register.csv exists in repo so this log is the equivalent): Swish QR on invoice PDFs encoded the pre-deduction total on ROT/RUT invoices with the amount locked (editmask 0), risking customer overpayment by the full skattereduktion; impact window = since Swish QR + ROT/RUT coexisted; remediation PR #1685 = QR now encodes getAmountToPay().toPay on every send/download surface (dashboard send/preview-of-send/pdf, v1 send/pdf, MCP pending-operations commit, recurring, issue-and-book; the editor preview is tracked separately in #1686), v1 pdf/send projections gained the amount-path columns (deduction_total, ore_rounding), pinned by lib/api/v1/__tests__/invoice-columns.test.ts.
[2026-08-18] Generic CSV mapping (#1671): description guess now excludes clock-time columns (Time/Tid/Klockslag by label, HH:MM by values) and knows Lunar's Title/Titel label; Lunar detect() sniffs comma/semicolon/tab and matches header CELLS exactly (date, title|text, amount, balance) instead of substrings, aligned with what parse() resolves on. NOT changed: the 2026-08-13 generic_csv exemption from the parsed-0-rows auto-detect fallback stays; lifting it would route an explicit "Annan CSV" pick into a dedicated parser and remove the manual escape hatch. Not verified against the customer's actual file (Gmail thread not readable in-session): the semicolon/tab widening is the plausible detection miss, a Swedish-localized Lunar header is not confirmed to exist and was not special-cased in the Lunar detector (the generic path now maps it correctly anyway).
[2026-08-18] IBAN prefill reads only enabled, still-connected SEK cash_accounts: disconnect keeps rows (bank_connection_id nulled) and the connect picker mirrors deselected accounts, so an unfiltered read could offer a closed or third-party IBAN as invoice payee (skeptic refutation on PR #1695).
[2026-08-18] Editor PDF preview (#1686) recomputes ROT/RUT server-side from the posted lines with the same helpers as build-invoice-write.ts (computeDeduction / computeInvoiceDeductionTotal, base inkl. moms at the rendered rate, invoice-doc only) and resolves the masked personnummer the same way (typed value, else an individual customer's kundkort personnummer): the client is not trusted with the deduction math, and the preview must state the same avdrag row, info box and "Att betala" as the invoice that gets created. The editor now also posts deduction_personnummer / deduction_housing_designation to the preview route, only when a line claims a deduction (same privacy rule as buildInvoiceWritePayload). Swish QR amount in the preview follows buildSwishQrDataUrl, fixed separately in #1685.
[2026-08-18] Skatteverket read data is visible to every company member, no new role gate (#1673): token rows are per (user, company) but the fetched skattekonto/declaration data belongs to the company, and viewers already read `skattekonto_transactions` and the local snapshot with no role check; membership (dispatcher-resolved ctx.companyId + company-scoped SELECT policy on `skatteverket_tokens`) is the gate. Reads resolve the caller's own token first, then the most recently issued active token of any member (all rows ordered, never `.maybeSingle()`, which errored once two members had connected). Writes (moms utkast/las/submit, AGI submit/spara/las, connect/disconnect, /status) stay on the caller's own token: BankID signing is personal.
-227
View File
@@ -1,227 +0,0 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { BankNameCombobox } from '@/components/settings/BankNameCombobox'
import { validateBankgiroNumber, formatBankgiroNumber, validatePlusgiroNumber, formatPlusgiroNumber } from '@/lib/bankgiro/luhn'
import { normaliseSwish, isValidSwish } from '@/lib/payments/swish'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import type { CompanySettings } from '@/types'
interface BankDetailsFormProps {
settings: CompanySettings
}
export function BankDetailsForm({ settings }: BankDetailsFormProps) {
const t = useTranslations('settings_bank_details_form')
const [bankgiroError, setBankgiroError] = useState<string | null>(null)
const [plusgiroError, setPlusgiroError] = useState<string | null>(null)
const [clearingError, setClearingError] = useState<string | null>(null)
const [accountNumberError, setAccountNumberError] = useState<string | null>(null)
const [swishError, setSwishError] = useState<string | null>(null)
const [ibanError, setIbanError] = useState<string | null>(null)
const [bicError, setBicError] = useState<string | null>(null)
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
return (
<section className="space-y-4">
<h2 className="text-sm uppercase tracking-wider text-muted-foreground">
{t('heading')}
</h2>
<p className="text-xs text-muted-foreground -mt-2">
{t('subheading')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>{t('bank_label')}</Label>
<BankNameCombobox
defaultValue={settings.bank_name || ''}
enableBankingEnabled={hasBankingExtension}
/>
</div>
<div className="space-y-2">
<Label htmlFor="clearing_number">{t('clearing_label')}</Label>
<Input
id="clearing_number"
name="clearing_number"
inputMode="numeric"
placeholder="XXXX"
maxLength={5}
defaultValue={settings.clearing_number || ''}
onChange={(e) => {
e.target.value = e.target.value.replace(/\D/g, '')
}}
onBlur={(e) => {
const val = e.target.value.trim()
if (!val) { setClearingError(null); return }
setClearingError(!/^\d{4,5}$/.test(val) ? t('clearing_error') : null)
}}
/>
{clearingError && <p className="text-xs text-destructive">{clearingError}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="account_number">{t('account_number_label')}</Label>
<Input
id="account_number"
name="account_number"
inputMode="numeric"
placeholder="XXXXXXX"
maxLength={12}
defaultValue={settings.account_number || ''}
onChange={(e) => {
e.target.value = e.target.value.replace(/\D/g, '')
}}
onBlur={(e) => {
const val = e.target.value.trim()
if (!val) { setAccountNumberError(null); return }
setAccountNumberError(!/^\d{6,12}$/.test(val) ? t('account_number_error') : null)
}}
/>
{accountNumberError && <p className="text-xs text-destructive">{accountNumberError}</p>}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="bankgiro">{t('bankgiro_label')}</Label>
<Input
id="bankgiro"
name="bankgiro"
placeholder="XXX-XXXX"
defaultValue={settings.bankgiro || ''}
onBlur={(e) => {
const val = e.target.value.trim()
if (!val) { setBankgiroError(null); return }
if (validateBankgiroNumber(val)) {
e.target.value = formatBankgiroNumber(val)
setBankgiroError(null)
} else {
setBankgiroError(t('bankgiro_error'))
}
}}
/>
{bankgiroError && <p className="text-xs text-destructive">{bankgiroError}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="plusgiro">{t('plusgiro_label')}</Label>
<Input
id="plusgiro"
name="plusgiro"
placeholder="XXXXXX-X"
defaultValue={settings.plusgiro || ''}
onBlur={(e) => {
const val = e.target.value.trim()
if (!val) { setPlusgiroError(null); return }
if (validatePlusgiroNumber(val)) {
e.target.value = formatPlusgiroNumber(val)
setPlusgiroError(null)
} else {
setPlusgiroError(t('plusgiro_error'))
}
}}
/>
{plusgiroError && <p className="text-xs text-destructive">{plusgiroError}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="swish">{t('swish_label')}</Label>
<Input
id="swish"
name="swish"
placeholder={t('swish_placeholder')}
defaultValue={settings.swish || ''}
onBlur={(e) => {
const val = normaliseSwish(e.target.value)
if (!val) { setSwishError(null); e.target.value = ''; return }
if (isValidSwish(val)) {
e.target.value = val
setSwishError(null)
} else {
setSwishError(t('swish_error'))
}
}}
/>
{swishError && <p className="text-xs text-destructive">{swishError}</p>}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="iban">{t('iban_label')}</Label>
<Input
id="iban"
name="iban"
placeholder="SE00 0000 0000 0000 0000 0000"
defaultValue={settings.iban || ''}
onBlur={(e) => {
const val = e.target.value.replace(/\s/g, '').toUpperCase()
if (!val) { setIbanError(null); e.target.value = ''; return }
e.target.value = val
setIbanError(/^SE\d{22}$/.test(val) ? null : t('iban_error'))
}}
/>
{ibanError
? <p className="text-xs text-destructive">{ibanError}</p>
: <p className="text-xs text-muted-foreground">{t('iban_hint')}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="bic">{t('bic_label')}</Label>
<Input
id="bic"
name="bic"
placeholder={t('bic_placeholder')}
maxLength={11}
defaultValue={settings.bic || ''}
onBlur={(e) => {
const val = e.target.value.replace(/\s/g, '').toUpperCase()
if (!val) { setBicError(null); e.target.value = ''; return }
e.target.value = val
setBicError(/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/.test(val) ? null : t('bic_error'))
}}
/>
{bicError && <p className="text-xs text-destructive">{bicError}</p>}
</div>
</div>
</section>
)
}
/** Validate bank fields from FormData. Returns error messages or null. */
export function validateBankFields(formData: FormData): { field: string; message: string }[] {
const errors: { field: string; message: string }[] = []
const clearing = (formData.get('clearing_number') as string || '').trim()
const account = (formData.get('account_number') as string || '').trim()
const bankgiro = (formData.get('bankgiro') as string || '').trim()
const plusgiro = (formData.get('plusgiro') as string || '').trim()
const swish = normaliseSwish(formData.get('swish') as string)
const iban = (formData.get('iban') as string || '').replace(/\s/g, '').toUpperCase()
const bic = (formData.get('bic') as string || '').replace(/\s/g, '').toUpperCase()
if (clearing && !/^\d{4,5}$/.test(clearing)) {
errors.push({ field: 'clearing_number', message: 'Clearingnummer måste vara 4-5 siffror' })
}
if (account && !/^\d{6,12}$/.test(account)) {
errors.push({ field: 'account_number', message: 'Kontonummer måste vara 6-12 siffror' })
}
if (bankgiro && !validateBankgiroNumber(bankgiro)) {
errors.push({ field: 'bankgiro', message: 'Ogiltigt bankgironummer' })
}
if (plusgiro && !validatePlusgiroNumber(plusgiro)) {
errors.push({ field: 'plusgiro', message: 'Ogiltigt plusgironummer' })
}
if (swish && !isValidSwish(swish)) {
errors.push({ field: 'swish', message: 'Ogiltigt Swish-nummer (företagsnummer 123XXXXXXX eller mobilnummer 07XXXXXXXX)' })
}
if (iban && !/^SE\d{22}$/.test(iban)) {
errors.push({ field: 'iban', message: 'Ogiltigt IBAN (SE följt av 22 siffror)' })
}
if (bic && !/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/.test(bic)) {
errors.push({ field: 'bic', message: 'Ogiltig BIC/SWIFT (8 eller 11 tecken)' })
}
return errors
}
+17 -8
View File
@@ -162,14 +162,23 @@ export function CompanyProfileView({ snapshot }: { snapshot: SnapshotShape | nul
{Array.isArray(snapshot.bankAccounts) && snapshot.bankAccounts.length > 0 && (
<SettingsRow label="Bankuppgifter" align="baseline">
<ul className="w-full space-y-1">
{snapshot.bankAccounts.map((b, i) => (
<li key={`${b.type}-${b.accountNumber}-${i}`} className="text-sm tabular-nums">
<span className="text-muted-foreground">{b.type}:</span>{' '}
<span className="text-foreground">{b.accountNumber}</span>
</li>
))}
</ul>
{/* Registry read-out, not a setting: without the note this row makes
the bankgiro look configured while payment files and invoices
read the editable fields under Fakturering. */}
<div className="w-full space-y-1">
<ul className="space-y-1">
{snapshot.bankAccounts.map((b, i) => (
<li key={`${b.type}-${b.accountNumber}-${i}`} className="text-sm tabular-nums">
<span className="text-muted-foreground">{b.type}:</span>{' '}
<span className="text-foreground">{b.accountNumber}</span>
</li>
))}
</ul>
<SettingsRowNote className="block">
Enligt Bolagsverket. Fakturor och betalfiler använder bankuppgifterna
under Inställningar → Fakturering.
</SettingsRowNote>
</div>
</SettingsRow>
)}
@@ -16,6 +16,7 @@ import {
import { useToast } from '@/components/ui/use-toast'
import { useCompany } from '@/contexts/CompanyContext'
import { createClient } from '@/lib/supabase/client'
import { formatIbanGroups, uniqueConnectionIban } from '@/lib/company/connection-iban'
import { bankgiroFromTicSnapshot } from '@/lib/company/snapshot-bank'
import { formatBankgiroNumber, validateBankgiroNumber, validatePlusgiroNumber } from '@/lib/bankgiro/luhn'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -92,6 +93,12 @@ export function InvoicePaymentAccountsSettings({
// helper only suggests when the snapshot's orgNumber matches the company's
// org_number: stale fuzzy-matched snapshots can describe another entity.
const [snapshotBankgiro, setSnapshotBankgiro] = useState<string | null>(null)
// Same offer for IBAN, sourced from the bank connection instead. Only rows
// that still belong to a live connection count: disconnect keeps cash_accounts
// rows (bank_connection_id nulled) and the connect picker mirrors deselected
// accounts with enabled=false, so an unfiltered read could offer a closed or
// third-party account. Only offered when the remaining rows agree on one IBAN.
const [connectionIban, setConnectionIban] = useState<string | null>(null)
const legacySekAccount = useMemo(
() => legacySekInvoicePaymentAccount({
bank_name: settings.bank_name,
@@ -159,6 +166,18 @@ export function InvoicePaymentAccountsSettings({
if (cancelled) return
setSnapshotBankgiro(bankgiroFromTicSnapshot(data?.tic_snapshot, data?.org_number))
})
supabase
.from('cash_accounts')
.select('iban')
.eq('company_id', company.id)
.eq('enabled', true)
.eq('currency', 'SEK')
.not('bank_connection_id', 'is', null)
.not('iban', 'is', null)
.then(({ data }) => {
if (cancelled) return
setConnectionIban(uniqueConnectionIban(data))
})
return () => {
cancelled = true
}
@@ -528,6 +547,15 @@ export function InvoicePaymentAccountsSettings({
placeholder="SE00 0000 0000 0000 0000 0000"
className="tabular-nums"
/>
{activeCurrency === 'SEK' && !value(activeAccount, 'iban') && connectionIban && (
<button
type="button"
onClick={() => updateField('iban', connectionIban)}
className="text-xs text-muted-foreground underline underline-offset-2 transition-colors duration-150 hover:text-foreground"
>
{t('iban_prefill', { value: formatIbanGroups(connectionIban) })}
</button>
)}
</SettingsRow>
<SettingsRow
label={t('bic_label')}
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { formatIbanGroups, uniqueConnectionIban } from '../connection-iban'
const IBAN_A = 'SE3550000000054910000003'
const IBAN_B = 'SE4550000000058398257466'
describe('uniqueConnectionIban', () => {
it('returns null for non-array input', () => {
expect(uniqueConnectionIban(null)).toBeNull()
expect(uniqueConnectionIban(undefined)).toBeNull()
expect(uniqueConnectionIban('rows')).toBeNull()
expect(uniqueConnectionIban({ iban: IBAN_A })).toBeNull()
})
it('returns null for an empty result', () => {
expect(uniqueConnectionIban([])).toBeNull()
})
it('returns the IBAN when exactly one account carries one', () => {
expect(uniqueConnectionIban([{ iban: IBAN_A }])).toBe(IBAN_A)
})
it('normalises spacing and case', () => {
expect(uniqueConnectionIban([{ iban: 'se35 5000 0000 0549 1000 0003' }])).toBe(IBAN_A)
})
it('treats the same IBAN across several accounts as one', () => {
expect(
uniqueConnectionIban([
{ iban: IBAN_A },
{ iban: 'SE35 5000 0000 0549 1000 0003' },
]),
).toBe(IBAN_A)
})
it('returns null when accounts disagree: no guessing between banks', () => {
expect(uniqueConnectionIban([{ iban: IBAN_A }, { iban: IBAN_B }])).toBeNull()
})
it('skips rows without a usable IBAN', () => {
expect(
uniqueConnectionIban([
null,
'not-a-row',
{ iban: null },
{ iban: 42 },
{ iban: 'NOT-AN-IBAN' },
{ iban: IBAN_A },
]),
).toBe(IBAN_A)
})
it('returns null when only invalid IBANs exist', () => {
expect(uniqueConnectionIban([{ iban: 'SE12' }, { iban: '' }])).toBeNull()
})
})
describe('formatIbanGroups', () => {
it('groups the IBAN in blocks of four', () => {
expect(formatIbanGroups(IBAN_A)).toBe('SE35 5000 0000 0549 1000 0003')
})
it('leaves a trailing partial group intact', () => {
expect(formatIbanGroups('SE355000')).toBe('SE35 5000')
expect(formatIbanGroups('SE3550001')).toBe('SE35 5000 1')
})
})
+36
View File
@@ -0,0 +1,36 @@
/**
* Pick the IBAN to suggest for the SEK invoice payment account from the
* company's connected bank accounts (cash_accounts.iban). Like the
* Bolagsverket bankgiro suggestion, this is a suggestion only: the user
* still confirms and saves the value.
*
* The caller must pre-filter rows to accounts a live connection vouches for
* (enabled, non-null bank_connection_id, SEK): cash_accounts also holds rows
* for disconnected and picker-deselected accounts whose IBANs must never be
* offered as the company's own. This helper only enforces determinism: the
* IBAN is returned when every remaining row agrees on a single one. A
* company with accounts at two banks gets no suggestion rather than a guess
* at which one receives customer payments.
*
* Rows come from an unvalidated query result, so every level is checked
* defensively. Returns the compact uppercase IBAN, or null.
*/
export function uniqueConnectionIban(rows: unknown): string | null {
if (!Array.isArray(rows)) return null
const seen = new Set<string>()
for (const row of rows) {
if (!row || typeof row !== 'object') continue
const iban = (row as { iban?: unknown }).iban
if (typeof iban !== 'string') continue
const compact = iban.replace(/\s/g, '').toUpperCase()
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(compact)) continue
seen.add(compact)
if (seen.size > 1) return null
}
return seen.size === 1 ? [...seen][0] : null
}
/** Display form: groups of four, "SE12 3456 ...". Storage stays compact. */
export function formatIbanGroups(iban: string): string {
return iban.replace(/(.{4})/g, '$1 ').trim()
}
+1
View File
@@ -2177,6 +2177,7 @@
"plusgiro_label": "Plusgiro",
"swish_label": "Swish",
"iban_label": "IBAN",
"iban_prefill": "Use the IBAN from your bank connection: {value}",
"bic_label": "BIC/SWIFT",
"routing_number": "Routing number (ABA)",
"sort_code": "Sort code",
+1
View File
@@ -2177,6 +2177,7 @@
"plusgiro_label": "Plusgiro",
"swish_label": "Swish",
"iban_label": "IBAN",
"iban_prefill": "Hämta från bankkopplingen: {value}",
"bic_label": "BIC/SWIFT",
"routing_number": "Routing number (ABA)",
"sort_code": "Sort code",