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
+2
-2
@@ -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"')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>')
|
||||
|
||||
Reference in New Issue
Block a user