diff --git a/app/api/salary/runs/[id]/payment/pain001/route.ts b/app/api/salary/runs/[id]/payment/pain001/route.ts index 96eb20bc..f0ef2019 100644 --- a/app/api/salary/runs/[id]/payment/pain001/route.ts +++ b/app/api/salary/runs/[id]/payment/pain001/route.ts @@ -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 diff --git a/components/settings/BankDetailsForm.tsx b/components/settings/BankDetailsForm.tsx index 5c8f6a89..840537c9 100644 --- a/components/settings/BankDetailsForm.tsx +++ b/components/settings/BankDetailsForm.tsx @@ -21,6 +21,8 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) { const [clearingError, setClearingError] = useState(null) const [accountNumberError, setAccountNumberError] = useState(null) const [swishError, setSwishError] = useState(null) + const [ibanError, setIbanError] = useState(null) + const [bicError, setBicError] = useState(null) const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking') return ( @@ -146,6 +148,45 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) { {swishError &&

{swishError}

} + +
+
+ + { + 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 + ?

{ibanError}

+ :

{t('iban_hint')}

} +
+ +
+ + { + 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 &&

{bicError}

} +
+
) } @@ -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 } diff --git a/components/settings/sections/InvoicingSettingsContent.tsx b/components/settings/sections/InvoicingSettingsContent.tsx index 77d0d3be..71bc777a 100644 --- a/components/settings/sections/InvoicingSettingsContent.tsx +++ b/components/settings/sections/InvoicingSettingsContent.tsx @@ -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, diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index c5ad6748..b6e587ca 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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(), diff --git a/lib/errors/__tests__/get-error-message.test.ts b/lib/errors/__tests__/get-error-message.test.ts index e23d05c1..639111d0 100644 --- a/lib/errors/__tests__/get-error-message.test.ts +++ b/lib/errors/__tests__/get-error-message.test.ts @@ -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"') diff --git a/lib/errors/get-error-message.ts b/lib/errors/get-error-message.ts index 85a25b3a..62491e6f 100644 --- a/lib/errors/get-error-message.ts +++ b/lib/errors/get-error-message.ts @@ -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, diff --git a/lib/salary/__tests__/pain001.test.ts b/lib/salary/__tests__/pain001.test.ts index 9c04f897..ebe01172 100644 --- a/lib/salary/__tests__/pain001.test.ts +++ b/lib/salary/__tests__/pain001.test.ts @@ -40,10 +40,18 @@ describe('generatePain001', () => { const xml = generatePain001(company, employees, options) expect(xml).toContain('Test AB') - 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('SE1234567890123456789012') + // Employees are still domestic BBAN (clearing+account), never IBAN. + expect(xml).toContain('56781234567890') + }) + it('includes SALA category purpose for salary', () => { const xml = generatePain001(company, employees, options) expect(xml).toContain('SALA') diff --git a/lib/salary/payment/__tests__/bank-account.test.ts b/lib/salary/payment/__tests__/bank-account.test.ts index b6b3604e..5a0bb308 100644 --- a/lib/salary/payment/__tests__/bank-account.test.ts +++ b/lib/salary/payment/__tests__/bank-account.test.ts @@ -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() + }) +}) diff --git a/lib/salary/payment/bank-account.ts b/lib/salary/payment/bank-account.ts index e50fd899..bf1f34ed 100644 --- a/lib/salary/payment/bank-account.ts +++ b/lib/salary/payment/bank-account.ts @@ -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 +} diff --git a/lib/salary/payment/pain001-generator.ts b/lib/salary/payment/pain001-generator.ts index f3efc8fe..d2c2b415 100644 --- a/lib/salary/payment/pain001-generator.ts +++ b/lib/salary/payment/pain001-generator.ts @@ -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(' ') lines.push(' ') lines.push(' ') + // 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(` ${escapeXml(company.iban)}`) lines.push(' ') lines.push(' SEK') diff --git a/messages/en.json b/messages/en.json index 6f230cd0..3d9b9505 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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\"", diff --git a/messages/sv.json b/messages/sv.json index 3bf843fd..cac13a5e 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",