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
co-authored by Claude Opus 4.8
parent bacc5914af
commit c1ea0d9bf2
12 changed files with 236 additions and 24 deletions
+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>')