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:
co-authored by
Claude Fable 5
parent
1af5846adf
commit
d0640e0968
@@ -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
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user