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
@@ -3,6 +3,7 @@ import { ensureInitialized } from '@/lib/init'
|
|||||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||||
import { generatePain001 } from '@/lib/salary/payment/pain001-generator'
|
import { generatePain001 } from '@/lib/salary/payment/pain001-generator'
|
||||||
import { effectiveNetPayout } from '@/lib/salary/payment/effective-net'
|
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 { getBranding } from '@/lib/branding/service'
|
||||||
import type { Pain001CompanyData, Pain001Employee } from '@/lib/salary/payment/pain001-generator'
|
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
|
const { data: settings } = await supabase
|
||||||
.from('company_settings')
|
.from('company_settings')
|
||||||
.select('company_name, iban, bic')
|
.select('company_name, iban, bic, clearing_number, bank_name')
|
||||||
.eq('company_id', companyId)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
@@ -55,8 +56,31 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
|||||||
return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 })
|
return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!settings?.iban || !settings?.bic) {
|
if (!settings) {
|
||||||
return NextResponse.json({ error: 'IBAN och BIC krävs i företagsinställningar för betalfil' }, { status: 400 })
|
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
|
// Load employees
|
||||||
@@ -89,8 +113,8 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
|||||||
// not the frozen onboarding companies.name.
|
// not the frozen onboarding companies.name.
|
||||||
name: settings.company_name || company.name,
|
name: settings.company_name || company.name,
|
||||||
orgNumber: company.org_number || '',
|
orgNumber: company.org_number || '',
|
||||||
iban: settings.iban,
|
iban: senderIban,
|
||||||
bic: settings.bic,
|
bic: senderBic,
|
||||||
}
|
}
|
||||||
|
|
||||||
const employees: Pain001Employee[] = runEmployees
|
const employees: Pain001Employee[] = runEmployees
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
|
|||||||
const [clearingError, setClearingError] = useState<string | null>(null)
|
const [clearingError, setClearingError] = useState<string | null>(null)
|
||||||
const [accountNumberError, setAccountNumberError] = useState<string | null>(null)
|
const [accountNumberError, setAccountNumberError] = useState<string | null>(null)
|
||||||
const [swishError, setSwishError] = 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')
|
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -146,6 +148,45 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
|
|||||||
{swishError && <p className="text-xs text-destructive">{swishError}</p>}
|
{swishError && <p className="text-xs text-destructive">{swishError}</p>}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -158,6 +199,8 @@ export function validateBankFields(formData: FormData): { field: string; message
|
|||||||
const bankgiro = (formData.get('bankgiro') as string || '').trim()
|
const bankgiro = (formData.get('bankgiro') as string || '').trim()
|
||||||
const plusgiro = (formData.get('plusgiro') as string || '').trim()
|
const plusgiro = (formData.get('plusgiro') as string || '').trim()
|
||||||
const swish = normaliseSwish(formData.get('swish') as string)
|
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)) {
|
if (clearing && !/^\d{4,5}$/.test(clearing)) {
|
||||||
errors.push({ field: 'clearing_number', message: 'Clearingnummer måste vara 4-5 siffror' })
|
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)) {
|
if (swish && !isValidSwish(swish)) {
|
||||||
errors.push({ field: 'swish', message: 'Ogiltigt Swish-nummer (företagsnummer 123XXXXXXX eller mobilnummer 07XXXXXXXX)' })
|
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
|
return errors
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export function InvoicingSettingsContent() {
|
|||||||
? formatPlusgiroNumber((formData.get('plusgiro') as string).trim())
|
? formatPlusgiroNumber((formData.get('plusgiro') as string).trim())
|
||||||
: null,
|
: null,
|
||||||
swish: normaliseSwish(formData.get('swish') as string) || 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,
|
invoice_prefix: (formData.get('invoice_prefix') as string) || null,
|
||||||
next_invoice_number: parseInt(formData.get('next_invoice_number') as string) || 1,
|
next_invoice_number: parseInt(formData.get('next_invoice_number') as string) || 1,
|
||||||
next_arrival_number: parseInt(formData.get('next_arrival_number') as string) || 1,
|
next_arrival_number: parseInt(formData.get('next_arrival_number') as string) || 1,
|
||||||
|
|||||||
+2
-2
@@ -1363,8 +1363,8 @@ export const UpdateSettingsSchema = z.object({
|
|||||||
)
|
)
|
||||||
.nullable()
|
.nullable()
|
||||||
.optional(),
|
.optional(),
|
||||||
iban: 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().optional(),
|
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(),
|
accounting_method: AccountingMethodSchema.optional(),
|
||||||
invoice_prefix: z.string().nullable().optional(),
|
invoice_prefix: z.string().nullable().optional(),
|
||||||
next_invoice_number: z.number().int().positive().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', () => {
|
describe('getErrorMessage: existing patterns still work', () => {
|
||||||
it('regex match for "Entry date ... outside fiscal period" on plain string', () => {
|
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"')
|
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,
|
/försök igen/i,
|
||||||
/ogiltigt?/i,
|
/ogiltigt?/i,
|
||||||
/saknas/i,
|
/saknas/i,
|
||||||
|
/saknar/i,
|
||||||
|
/krävs/i,
|
||||||
/måste/i,
|
/måste/i,
|
||||||
/redan finns/i,
|
/redan finns/i,
|
||||||
/gick fel/i,
|
/gick fel/i,
|
||||||
|
|||||||
@@ -40,10 +40,18 @@ describe('generatePain001', () => {
|
|||||||
const xml = generatePain001(company, employees, options)
|
const xml = generatePain001(company, employees, options)
|
||||||
|
|
||||||
expect(xml).toContain('<Nm>Test AB</Nm>')
|
expect(xml).toContain('<Nm>Test AB</Nm>')
|
||||||
expect(xml).toContain('SE1234567890123456789012')
|
|
||||||
expect(xml).toContain('ESSESESS')
|
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', () => {
|
it('includes SALA category purpose for salary', () => {
|
||||||
const xml = generatePain001(company, employees, options)
|
const xml = generatePain001(company, employees, options)
|
||||||
expect(xml).toContain('<Cd>SALA</Cd>')
|
expect(xml).toContain('<Cd>SALA</Cd>')
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
isValidAccount,
|
isValidAccount,
|
||||||
validateEmployeeBankAccount,
|
validateEmployeeBankAccount,
|
||||||
lookupBankByClearing,
|
lookupBankByClearing,
|
||||||
|
lookupBicByClearing,
|
||||||
|
lookupBicByBankName,
|
||||||
} from '@/lib/salary/payment/bank-account'
|
} from '@/lib/salary/payment/bank-account'
|
||||||
|
|
||||||
describe('normalizeBankNumber', () => {
|
describe('normalizeBankNumber', () => {
|
||||||
@@ -100,3 +102,37 @@ describe('lookupBankByClearing', () => {
|
|||||||
expect(lookupBankByClearing('')).toBeNull()
|
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
|
* Ranges are matched on the leading 4 digits, so a 5-digit Swedbank clearing
|
||||||
* (8xxxx) maps via its 8xxx prefix.
|
* (8xxxx) maps via its 8xxx prefix.
|
||||||
*/
|
*/
|
||||||
const BANK_CLEARING_RANGES: ReadonlyArray<{ min: number; max: number; bank: string }> = [
|
const BANK_CLEARING_RANGES: ReadonlyArray<{ min: number; max: number; bank: string; bic: string }> = [
|
||||||
{ min: 1100, max: 1199, bank: 'Nordea' },
|
{ min: 1100, max: 1199, bank: 'Nordea', bic: 'NDEASESS' },
|
||||||
{ min: 1200, max: 1399, bank: 'Danske Bank' },
|
{ min: 1200, max: 1399, bank: 'Danske Bank', bic: 'DABASESX' },
|
||||||
{ min: 1400, max: 2099, bank: 'Nordea' },
|
{ min: 1400, max: 2099, bank: 'Nordea', bic: 'NDEASESS' },
|
||||||
{ min: 2400, max: 2499, bank: 'Danske Bank' },
|
{ min: 2400, max: 2499, bank: 'Danske Bank', bic: 'DABASESX' },
|
||||||
{ min: 3000, max: 3399, bank: 'Nordea' },
|
{ min: 3000, max: 3399, bank: 'Nordea', bic: 'NDEASESS' },
|
||||||
{ min: 5000, max: 5999, bank: 'SEB' },
|
{ min: 5000, max: 5999, bank: 'SEB', bic: 'ESSESESS' },
|
||||||
{ min: 6000, max: 6999, bank: 'Handelsbanken' },
|
{ min: 6000, max: 6999, bank: 'Handelsbanken', bic: 'HANDSESS' },
|
||||||
{ min: 7000, max: 7999, bank: 'Swedbank' },
|
{ min: 7000, max: 7999, bank: 'Swedbank', bic: 'SWEDSESS' },
|
||||||
{ min: 8000, max: 8999, bank: 'Swedbank/Sparbanken' },
|
{ min: 8000, max: 8999, bank: 'Swedbank/Sparbanken', bic: 'SWEDSESS' },
|
||||||
{ min: 9500, max: 9549, bank: 'Nordea (Plusgirot)' },
|
{ min: 9500, max: 9549, bank: 'Nordea (Plusgirot)', bic: 'NDEASESS' },
|
||||||
{ min: 9960, max: 9969, bank: 'Nordea (Plusgirot)' },
|
{ min: 9960, max: 9969, bank: 'Nordea (Plusgirot)', bic: 'NDEASESS' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Bank name for a (partial) clearing number, or null when not confidently known. */
|
/** 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)
|
const hit = BANK_CLEARING_RANGES.find((r) => first4 >= r.min && first4 <= r.max)
|
||||||
return hit ? hit.bank : null
|
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 {
|
export interface Pain001CompanyData {
|
||||||
name: string
|
name: string
|
||||||
orgNumber: string // NNNNNN-NNNN
|
orgNumber: string // NNNNNN-NNNN
|
||||||
iban: string // SE + 22 digits
|
iban: string // SE + 22 digits (the company's own account)
|
||||||
bic: string // SWIFT/BIC code
|
bic: string // debtor bank SWIFT/BIC
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Pain001Employee {
|
export interface Pain001Employee {
|
||||||
@@ -87,6 +87,9 @@ export function generatePain001(
|
|||||||
lines.push(' </Dbtr>')
|
lines.push(' </Dbtr>')
|
||||||
lines.push(' <DbtrAcct>')
|
lines.push(' <DbtrAcct>')
|
||||||
lines.push(' <Id>')
|
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(` <IBAN>${escapeXml(company.iban)}</IBAN>`)
|
||||||
lines.push(' </Id>')
|
lines.push(' </Id>')
|
||||||
lines.push(' <Ccy>SEK</Ccy>')
|
lines.push(' <Ccy>SEK</Ccy>')
|
||||||
|
|||||||
+7
-1
@@ -1330,7 +1330,13 @@
|
|||||||
"plusgiro_error": "Invalid plusgiro number",
|
"plusgiro_error": "Invalid plusgiro number",
|
||||||
"swish_label": "Swish",
|
"swish_label": "Swish",
|
||||||
"swish_placeholder": "123 XXX XX XX or 07X XXX XX XX",
|
"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": {
|
"settings_invoice_form": {
|
||||||
"default_our_reference_label": "Default \"Our reference\"",
|
"default_our_reference_label": "Default \"Our reference\"",
|
||||||
|
|||||||
+7
-1
@@ -1330,7 +1330,13 @@
|
|||||||
"plusgiro_error": "Ogiltigt plusgironummer",
|
"plusgiro_error": "Ogiltigt plusgironummer",
|
||||||
"swish_label": "Swish",
|
"swish_label": "Swish",
|
||||||
"swish_placeholder": "123 XXX XX XX eller 07X XXX XX XX",
|
"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": {
|
"settings_invoice_form": {
|
||||||
"default_our_reference_label": "Standard för Vår referens",
|
"default_our_reference_label": "Standard för Vår referens",
|
||||||
|
|||||||
Reference in New Issue
Block a user