From d0640e096837e993944055c8090878e22f59a0c6 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:18:51 +0200 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + components/settings/BankDetailsForm.tsx | 227 ------------------ components/settings/CompanyProfileView.tsx | 25 +- .../InvoicePaymentAccountsSettings.tsx | 28 +++ lib/company/__tests__/connection-iban.test.ts | 67 ++++++ lib/company/connection-iban.ts | 36 +++ messages/en.json | 1 + messages/sv.json | 1 + 8 files changed, 151 insertions(+), 235 deletions(-) delete mode 100644 components/settings/BankDetailsForm.tsx create mode 100644 lib/company/__tests__/connection-iban.test.ts create mode 100644 lib/company/connection-iban.ts diff --git a/DECISIONS.md b/DECISIONS.md index 55b9cb00..ad2e5b7e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1055,5 +1055,6 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/components/settings/BankDetailsForm.tsx b/components/settings/BankDetailsForm.tsx deleted file mode 100644 index edb847ab..00000000 --- a/components/settings/BankDetailsForm.tsx +++ /dev/null @@ -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(null) - const [plusgiroError, setPlusgiroError] = useState(null) - const [clearingError, setClearingError] = useState(null) - const [accountNumberError, setAccountNumberError] = useState(null) - const [swishError, setSwishError] = useState(null) - const [ibanError, setIbanError] = useState(null) - const [bicError, setBicError] = useState(null) - const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking') - - return ( -
-

- {t('heading')} -

-

- {t('subheading')} -

- -
-
- - -
-
- - { - 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 &&

{clearingError}

} -
-
- - { - 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 &&

{accountNumberError}

} -
-
- -
-
- - { - 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 &&

{bankgiroError}

} -
- -
- - { - 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 &&

{plusgiroError}

} -
- -
- - { - 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 &&

{swishError}

} -
-
- -
-
- - { - 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 - ?

{ibanError}

- :

{t('iban_hint')}

} -
- -
- - { - 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 &&

{bicError}

} -
-
-
- ) -} - -/** 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 -} diff --git a/components/settings/CompanyProfileView.tsx b/components/settings/CompanyProfileView.tsx index ba8ecf25..b2811306 100644 --- a/components/settings/CompanyProfileView.tsx +++ b/components/settings/CompanyProfileView.tsx @@ -162,14 +162,23 @@ export function CompanyProfileView({ snapshot }: { snapshot: SnapshotShape | nul {Array.isArray(snapshot.bankAccounts) && snapshot.bankAccounts.length > 0 && ( -
    - {snapshot.bankAccounts.map((b, i) => ( -
  • - {b.type}:{' '} - {b.accountNumber} -
  • - ))} -
+ {/* 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. */} +
+
    + {snapshot.bankAccounts.map((b, i) => ( +
  • + {b.type}:{' '} + {b.accountNumber} +
  • + ))} +
+ + Enligt Bolagsverket. Fakturor och betalfiler använder bankuppgifterna + under Inställningar → Fakturering. + +
)} diff --git a/components/settings/InvoicePaymentAccountsSettings.tsx b/components/settings/InvoicePaymentAccountsSettings.tsx index 5eb0338a..966c6d63 100644 --- a/components/settings/InvoicePaymentAccountsSettings.tsx +++ b/components/settings/InvoicePaymentAccountsSettings.tsx @@ -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(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(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 && ( + + )} { + 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') + }) +}) diff --git a/lib/company/connection-iban.ts b/lib/company/connection-iban.ts new file mode 100644 index 00000000..cad2382b --- /dev/null +++ b/lib/company/connection-iban.ts @@ -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() + 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() +} diff --git a/messages/en.json b/messages/en.json index 7dd7674c..5e9ccc47 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index e3a91a51..00c5c1b0 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",