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:
Jakob Wennberg
2026-07-09 12:35:52 +02:00
committed by GitHub
parent bacc5914af
commit c1ea0d9bf2
12 changed files with 236 additions and 24 deletions
@@ -3,6 +3,7 @@ import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { generatePain001 } from '@/lib/salary/payment/pain001-generator'
import { effectiveNetPayout } from '@/lib/salary/payment/effective-net'
import { normalizeBankNumber, lookupBicByClearing, lookupBicByBankName } from '@/lib/salary/payment/bank-account'
import { getBranding } from '@/lib/branding/service'
import type { Pain001CompanyData, Pain001Employee } from '@/lib/salary/payment/pain001-generator'
@@ -47,7 +48,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
const { data: settings } = await supabase
.from('company_settings')
.select('company_name, iban, bic')
.select('company_name, iban, bic, clearing_number, bank_name')
.eq('company_id', companyId)
.single()
@@ -55,8 +56,31 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 })
}
if (!settings?.iban || !settings?.bic) {
return NextResponse.json({ error: 'IBAN och BIC krävs i företagsinställningar för betalfil' }, { status: 400 })
if (!settings) {
return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 400 })
}
// Debtor (company) account: the company's own IBAN, the canonical payer form
// every Swedish bank accepts. Set under Inställningar → Fakturering.
const senderIban = (settings.iban ?? '').replace(/\s/g, '').toUpperCase()
if (!senderIban) {
return NextResponse.json(
{ error: 'Företagets IBAN saknas i företagsinställningar. Fyll i det under Inställningar → Fakturering för att skapa betalfil (ISO 20022).' },
{ status: 400 },
)
}
// Debtor bank BIC: use the saved BIC, otherwise derive it from the clearing
// number (or bank name) the company already entered, so most users only need
// to fill in the IBAN. Required by the receiving bank.
const senderBic = settings.bic?.trim()
|| lookupBicByClearing(normalizeBankNumber(settings.clearing_number))
|| lookupBicByBankName(settings.bank_name)
if (!senderBic) {
return NextResponse.json(
{ error: 'Företagsbankens BIC saknas och kunde inte härledas. Fyll i BIC under Inställningar → Fakturering för att skapa betalfil.' },
{ status: 400 },
)
}
// Load employees
@@ -89,8 +113,8 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
// not the frozen onboarding companies.name.
name: settings.company_name || company.name,
orgNumber: company.org_number || '',
iban: settings.iban,
bic: settings.bic,
iban: senderIban,
bic: senderBic,
}
const employees: Pain001Employee[] = runEmployees
+49
View File
@@ -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,
+2 -2
View File
@@ -1363,8 +1363,8 @@ export const UpdateSettingsSchema = z.object({
)
.nullable()
.optional(),
iban: z.string().optional(),
bic: z.string().optional(),
iban: z.string().regex(/^SE\d{22}$/, 'Ogiltigt IBAN (SE följt av 22 siffror)').nullable().optional().or(z.literal('')),
bic: z.string().regex(/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/, 'Ogiltig BIC/SWIFT (8 eller 11 tecken)').nullable().optional().or(z.literal('')),
accounting_method: AccountingMethodSchema.optional(),
invoice_prefix: z.string().nullable().optional(),
next_invoice_number: z.number().int().positive().optional(),
@@ -147,6 +147,38 @@ describe('getErrorMessage: accumulated validation details', () => {
})
})
describe('getErrorMessage: payment-file route messages surface (issue #945)', () => {
// These specific { error: '...' } strings previously collapsed to the generic
// HTTP-400 message because isSwedishUserMessage did not recognize "krävs" /
// "saknar", so the user learned nothing about why the betalfil failed.
it('surfaces a "saknar bankkontouppgifter" message instead of the generic 400', () => {
const msg = getErrorMessage(
{ error: '2 anställd(a) saknar bankkontouppgifter' },
{ context: 'salary', statusCode: 400 },
)
expect(msg).toBe('2 anställd(a) saknar bankkontouppgifter')
expect(msg).not.toBe('Förfrågan innehåller ogiltiga uppgifter.')
})
it('surfaces a "... krävs ..." message instead of the generic 400', () => {
const msg = getErrorMessage(
{ error: 'Momsregistreringsnummer krävs när företaget är momsregistrerat (ML 11 kap. 8§)' },
{ context: 'settings', statusCode: 400 },
)
expect(msg).toContain('krävs')
expect(msg).not.toBe('Förfrågan innehåller ogiltiga uppgifter.')
})
it('surfaces the missing company bank-account message', () => {
const msg = getErrorMessage(
{ error: 'Företagets bankkonto (clearingnummer och kontonummer) saknas i företagsinställningar. Fyll i det under Inställningar → Fakturering för att skapa betalfil.' },
{ context: 'salary', statusCode: 400 },
)
expect(msg).toContain('Företagets bankkonto')
expect(msg).not.toBe('Förfrågan innehåller ogiltiga uppgifter.')
})
})
describe('getErrorMessage: existing patterns still work', () => {
it('regex match for "Entry date ... outside fiscal period" on plain string', () => {
const msg = getErrorMessage('Entry date 2024-06-15 is outside fiscal period "FY 2025"')
+2
View File
@@ -166,6 +166,8 @@ function isSwedishUserMessage(message: string): boolean {
/försök igen/i,
/ogiltigt?/i,
/saknas/i,
/saknar/i,
/krävs/i,
/måste/i,
/redan finns/i,
/gick fel/i,
+9 -1
View File
@@ -40,10 +40,18 @@ describe('generatePain001', () => {
const xml = generatePain001(company, employees, options)
expect(xml).toContain('<Nm>Test AB</Nm>')
expect(xml).toContain('SE1234567890123456789012')
expect(xml).toContain('ESSESESS')
})
it('identifies the debtor (company) by its own IBAN', () => {
const xml = generatePain001(company, employees, options)
// The payer is the company's IBAN, inside DbtrAcct.
expect(xml).toContain('<IBAN>SE1234567890123456789012</IBAN>')
// Employees are still domestic BBAN (clearing+account), never IBAN.
expect(xml).toContain('<Othr><Id>56781234567890</Id></Othr>')
})
it('includes SALA category purpose for salary', () => {
const xml = generatePain001(company, employees, options)
expect(xml).toContain('<Cd>SALA</Cd>')
@@ -5,6 +5,8 @@ import {
isValidAccount,
validateEmployeeBankAccount,
lookupBankByClearing,
lookupBicByClearing,
lookupBicByBankName,
} from '@/lib/salary/payment/bank-account'
describe('normalizeBankNumber', () => {
@@ -100,3 +102,37 @@ describe('lookupBankByClearing', () => {
expect(lookupBankByClearing('')).toBeNull()
})
})
describe('lookupBicByClearing', () => {
it('maps clearing numbers to the bank BIC', () => {
expect(lookupBicByClearing('5000')).toBe('ESSESESS') // SEB
expect(lookupBicByClearing('6789')).toBe('HANDSESS') // Handelsbanken
expect(lookupBicByClearing('7123')).toBe('SWEDSESS') // Swedbank
expect(lookupBicByClearing('3000')).toBe('NDEASESS') // Nordea
expect(lookupBicByClearing('1234')).toBe('DABASESX') // Danske Bank
})
it('maps a 5-digit Swedbank clearing via its 8xxx prefix', () => {
expect(lookupBicByClearing('83279')).toBe('SWEDSESS')
})
it('returns null for unknown ranges rather than guessing a BIC', () => {
expect(lookupBicByClearing('9999')).toBeNull()
expect(lookupBicByClearing('123')).toBeNull()
expect(lookupBicByClearing('')).toBeNull()
})
})
describe('lookupBicByBankName', () => {
it('resolves banks outside the clearing table by name', () => {
expect(lookupBicByBankName('Länsförsäkringar')).toBe('ELLFSESS')
expect(lookupBicByBankName('Skandiabanken')).toBe('SKIASESS')
})
it('matches on a normalized substring', () => {
expect(lookupBicByBankName('Danske Bank Sverige')).toBe('DABASESX')
expect(lookupBicByBankName(' SEB ')).toBe('ESSESESS')
})
it('returns null for unknown or empty names', () => {
expect(lookupBicByBankName('Min Lokala Bank')).toBeNull()
expect(lookupBicByBankName('')).toBeNull()
expect(lookupBicByBankName(null)).toBeNull()
})
})
+56 -12
View File
@@ -130,18 +130,18 @@ export function validateEmployeeBankAccount(
* Ranges are matched on the leading 4 digits, so a 5-digit Swedbank clearing
* (8xxxx) maps via its 8xxx prefix.
*/
const BANK_CLEARING_RANGES: ReadonlyArray<{ min: number; max: number; bank: string }> = [
{ min: 1100, max: 1199, bank: 'Nordea' },
{ min: 1200, max: 1399, bank: 'Danske Bank' },
{ min: 1400, max: 2099, bank: 'Nordea' },
{ min: 2400, max: 2499, bank: 'Danske Bank' },
{ min: 3000, max: 3399, bank: 'Nordea' },
{ min: 5000, max: 5999, bank: 'SEB' },
{ min: 6000, max: 6999, bank: 'Handelsbanken' },
{ min: 7000, max: 7999, bank: 'Swedbank' },
{ min: 8000, max: 8999, bank: 'Swedbank/Sparbanken' },
{ min: 9500, max: 9549, bank: 'Nordea (Plusgirot)' },
{ min: 9960, max: 9969, bank: 'Nordea (Plusgirot)' },
const BANK_CLEARING_RANGES: ReadonlyArray<{ min: number; max: number; bank: string; bic: string }> = [
{ min: 1100, max: 1199, bank: 'Nordea', bic: 'NDEASESS' },
{ min: 1200, max: 1399, bank: 'Danske Bank', bic: 'DABASESX' },
{ min: 1400, max: 2099, bank: 'Nordea', bic: 'NDEASESS' },
{ min: 2400, max: 2499, bank: 'Danske Bank', bic: 'DABASESX' },
{ min: 3000, max: 3399, bank: 'Nordea', bic: 'NDEASESS' },
{ min: 5000, max: 5999, bank: 'SEB', bic: 'ESSESESS' },
{ min: 6000, max: 6999, bank: 'Handelsbanken', bic: 'HANDSESS' },
{ min: 7000, max: 7999, bank: 'Swedbank', bic: 'SWEDSESS' },
{ min: 8000, max: 8999, bank: 'Swedbank/Sparbanken', bic: 'SWEDSESS' },
{ min: 9500, max: 9549, bank: 'Nordea (Plusgirot)', bic: 'NDEASESS' },
{ min: 9960, max: 9969, bank: 'Nordea (Plusgirot)', bic: 'NDEASESS' },
]
/** Bank name for a (partial) clearing number, or null when not confidently known. */
@@ -153,3 +153,47 @@ export function lookupBankByClearing(clearingRaw: string | null | undefined): st
const hit = BANK_CLEARING_RANGES.find((r) => first4 >= r.min && first4 <= r.max)
return hit ? hit.bank : null
}
/**
* Bank BIC (SWIFT) for a clearing number, or null when the clearing is not in
* the table above. Used to fill the debtor agent (DbtrAgt) in the pain.001
* salary payment file without asking the company to type its BIC by hand: the
* clearing number it already entered for the debtor account deterministically
* identifies the bank. Only confidently-known, long-stable ranges are covered;
* an unknown clearing returns null so the caller can fall back or fail loudly
* rather than emit a guessed BIC into a real payment instruction.
*/
export function lookupBicByClearing(clearingRaw: string | null | undefined): string | null {
const clearing = normalizeBankNumber(clearingRaw)
if (clearing.length < 4) return null
const first4 = Number.parseInt(clearing.slice(0, 4), 10)
if (Number.isNaN(first4)) return null
const hit = BANK_CLEARING_RANGES.find((r) => first4 >= r.min && first4 <= r.max)
return hit ? hit.bic : null
}
/**
* Fallback BIC lookup by the free-text bank name saved in company settings, for
* the (rare) banks not covered by the clearing ranges above (e.g.
* Länsförsäkringar, Skandiabanken). Matched on a normalized substring so
* "Danske Bank Sverige" still resolves. Only BICs we are confident about are
* listed; anything else returns null. Never guess a BIC for a real payment.
*/
const BANK_NAME_BIC: ReadonlyArray<{ match: string; bic: string }> = [
{ match: 'handelsbanken', bic: 'HANDSESS' },
{ match: 'länsförsäkringar', bic: 'ELLFSESS' },
{ match: 'lansforsakringar', bic: 'ELLFSESS' },
{ match: 'skandia', bic: 'SKIASESS' },
{ match: 'swedbank', bic: 'SWEDSESS' },
{ match: 'sparbank', bic: 'SWEDSESS' },
{ match: 'danske', bic: 'DABASESX' },
{ match: 'nordea', bic: 'NDEASESS' },
{ match: 'seb', bic: 'ESSESESS' },
]
export function lookupBicByBankName(nameRaw: string | null | undefined): string | null {
const name = (nameRaw ?? '').trim().toLowerCase()
if (!name) return null
const hit = BANK_NAME_BIC.find((b) => name.includes(b.match))
return hit ? hit.bic : null
}
+5 -2
View File
@@ -10,8 +10,8 @@
export interface Pain001CompanyData {
name: string
orgNumber: string // NNNNNN-NNNN
iban: string // SE + 22 digits
bic: string // SWIFT/BIC code
iban: string // SE + 22 digits (the company's own account)
bic: string // debtor bank SWIFT/BIC
}
export interface Pain001Employee {
@@ -87,6 +87,9 @@ export function generatePain001(
lines.push(' </Dbtr>')
lines.push(' <DbtrAcct>')
lines.push(' <Id>')
// The company (debtor) is identified by its own IBAN: the canonical form every
// Swedish bank accepts for the payer. Employees (creditors) stay on domestic
// clearing+account below, which is what Swedish payroll actually collects.
lines.push(` <IBAN>${escapeXml(company.iban)}</IBAN>`)
lines.push(' </Id>')
lines.push(' <Ccy>SEK</Ccy>')
+7 -1
View File
@@ -1330,7 +1330,13 @@
"plusgiro_error": "Invalid plusgiro number",
"swish_label": "Swish",
"swish_placeholder": "123 XXX XX XX or 07X XXX XX XX",
"swish_error": "Invalid Swish number (business number 123XXXXXXX or mobile number 07XXXXXXXX)"
"swish_error": "Invalid Swish number (business number 123XXXXXXX or mobile number 07XXXXXXXX)",
"iban_label": "IBAN",
"iban_error": "Invalid IBAN (SE followed by 22 digits)",
"iban_hint": "Required for the payment file (ISO 20022) when paying salaries",
"bic_label": "BIC/SWIFT",
"bic_placeholder": "Filled in automatically",
"bic_error": "Invalid BIC/SWIFT (8 or 11 characters)"
},
"settings_invoice_form": {
"default_our_reference_label": "Default \"Our reference\"",
+7 -1
View File
@@ -1330,7 +1330,13 @@
"plusgiro_error": "Ogiltigt plusgironummer",
"swish_label": "Swish",
"swish_placeholder": "123 XXX XX XX eller 07X XXX XX XX",
"swish_error": "Ogiltigt Swish-nummer (företagsnummer 123XXXXXXX eller mobilnummer 07XXXXXXXX)"
"swish_error": "Ogiltigt Swish-nummer (företagsnummer 123XXXXXXX eller mobilnummer 07XXXXXXXX)",
"iban_label": "IBAN",
"iban_error": "Ogiltigt IBAN (SE följt av 22 siffror)",
"iban_hint": "Krävs för betalfil (ISO 20022) vid löneutbetalning",
"bic_label": "BIC/SWIFT",
"bic_placeholder": "Fylls i automatiskt",
"bic_error": "Ogiltig BIC/SWIFT (8 eller 11 tecken)"
},
"settings_invoice_form": {
"default_our_reference_label": "Standard för Vår referens",