fix(salary): make pain.001 betalfil generatable (company IBAN + BIC) (#950)
The ISO 20022 pain.001 salary payment file could never be generated: the route required company_settings.iban/bic, but no settings screen wrote those columns, so every request returned 400. The specific reason was also swallowed by getErrorMessage (isSwedishUserMessage did not know "krävs"/"saknar"), surfacing only the generic "Förfrågan innehåller ogiltiga uppgifter" (issue #945). - Add IBAN + BIC inputs to Settings > Fakturering > Bankuppgifter. BIC auto-derives from the clearing number / bank already entered, so in practice only the IBAN is typed. Validated client- and server-side. - Route requires the company IBAN (canonical debtor form every Swedish bank accepts) and derives the BIC, with clear actionable errors. - Employees are unchanged: domestic clearing + account (BBAN), which is what Swedish payroll collects. Only the company (debtor) uses IBAN. - getErrorMessage recognizes "krävs"/"saknar" so payment-file reasons surface instead of the generic 400. Fixes #945 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
bacc5914af
commit
c1ea0d9bf2
@@ -21,6 +21,8 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
|
||||
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 (
|
||||
@@ -146,6 +148,45 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -158,6 +199,8 @@ export function validateBankFields(formData: FormData): { field: string; message
|
||||
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' })
|
||||
@@ -174,5 +217,11 @@ export function validateBankFields(formData: FormData): { field: string; message
|
||||
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
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ export function InvoicingSettingsContent() {
|
||||
? formatPlusgiroNumber((formData.get('plusgiro') as string).trim())
|
||||
: null,
|
||||
swish: normaliseSwish(formData.get('swish') as string) || null,
|
||||
iban: (formData.get('iban') as string || '').replace(/\s/g, '').toUpperCase() || null,
|
||||
bic: (formData.get('bic') as string || '').replace(/\s/g, '').toUpperCase() || null,
|
||||
invoice_prefix: (formData.get('invoice_prefix') as string) || null,
|
||||
next_invoice_number: parseInt(formData.get('next_invoice_number') as string) || 1,
|
||||
next_arrival_number: parseInt(formData.get('next_arrival_number') as string) || 1,
|
||||
|
||||
Reference in New Issue
Block a user