* fix(dashboard): enable salary features with "Beta" badge for testing * fix(errors): enhance Swedish error message patterns for better user feedback fix(salary): update Nordea Personkonto handling in account encoding logic * fix(agi): implement feature flag for AGI transmission and update button states * fix(swedish-payroll): update youth rate eligibility criteria and enhance documentation * fix(agi-panel): remove feature flag for AGI transmission and simplify button states
348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
/**
|
||
* Bankgirot LB-fil (Leverantörsbetalningar) generator for salary batch payments.
|
||
*
|
||
* Used by Swedish banks (Swedbank, SEB, Handelsbanken, Nordea) for B2B and
|
||
* salary payments via the corporate portal. Each company has a sender BG
|
||
* registered with Bankgirot; the file is uploaded and Bankgirot routes the
|
||
* funds to the receiver bank accounts.
|
||
*
|
||
* Format reference: Bankgirot — "Leverantörsbetalningar Användarmanual",
|
||
* Posttyp specification (TK 11, 14, 54, 29).
|
||
* https://www.bankgirot.se/tjanster/leverantorsbetalningar
|
||
*
|
||
* Encoding: ISO 8859-1 (Latin-1).
|
||
* Line endings: CRLF.
|
||
* Record length: exactly 80 characters per line.
|
||
*
|
||
* Per BFL: The generated file is räkenskapsinformation (underlag) linked to
|
||
* the salary journal entry. Subject to 7-year retention.
|
||
*/
|
||
|
||
export interface BgLbCompanyData {
|
||
name: string
|
||
/** Sender bankgiro number, with or without dash. e.g. "123-4567" or "1234567" */
|
||
senderBankgiro: string
|
||
}
|
||
|
||
export interface BgLbEmployee {
|
||
name: string
|
||
/** 4–5 digit clearing number. */
|
||
clearingNumber: string
|
||
/** Up to 10-digit bank account number. */
|
||
bankAccountNumber: string
|
||
/** Net salary in SEK (öre handled internally). */
|
||
netSalary: number
|
||
}
|
||
|
||
export interface BgLbOptions {
|
||
/** YYYY-MM-DD execution date. Bankgirot encodes as YYMMDD. */
|
||
paymentDate: string
|
||
/** Period label shown on payslip-side info, e.g. "2026-04". */
|
||
periodLabel: string
|
||
}
|
||
|
||
export interface BgLbResult {
|
||
/** ISO 8859-1 ready text content with CRLF line endings. */
|
||
content: string
|
||
/** Suggested filename. */
|
||
filename: string
|
||
/** Total amount in SEK. */
|
||
totalAmount: number
|
||
/** Number of payment records (TK 54). */
|
||
recordCount: number
|
||
}
|
||
|
||
/**
|
||
* Generate a Bankgirot LB-fil for a salary batch.
|
||
*
|
||
* Layout:
|
||
* 1× Öppningspost (TK 11)
|
||
* N× Betalning till bankkonto (TK 54), one per employee
|
||
* 1× Slutpost (TK 29) with totals
|
||
*/
|
||
export function generateBgLb(
|
||
company: BgLbCompanyData,
|
||
employees: BgLbEmployee[],
|
||
options: BgLbOptions
|
||
): BgLbResult {
|
||
const senderBg = stripBgFormat(company.senderBankgiro)
|
||
if (!/^\d{7,8}$/.test(senderBg)) {
|
||
throw new Error(`Ogiltigt bankgironummer: ${company.senderBankgiro}`)
|
||
}
|
||
|
||
const paymentDateYyMmDd = toYyMmDd(options.paymentDate)
|
||
const todayYyMmDd = toYyMmDd(new Date().toISOString().slice(0, 10))
|
||
|
||
const positivePayments = employees.filter((e) => e.netSalary > 0)
|
||
const totalAmountOre = positivePayments.reduce(
|
||
(sum, e) => sum + Math.round(e.netSalary * 100),
|
||
0
|
||
)
|
||
|
||
const records: string[] = []
|
||
|
||
// ─── Posttyp 11 — Öppningspost ───
|
||
// Pos 1-2: "11"
|
||
// Pos 3-12: Sender bankgiro (10 digits, right-justified, zero-padded)
|
||
// Pos 13-18: Created date YYMMDD
|
||
// Pos 19-40: "LEVERANTÖRSBETALNINGAR" (22 chars)
|
||
// Pos 41-44: "LEVE"
|
||
// Pos 45-50: Payment date YYMMDD
|
||
// Pos 51-52: Currency "SE" (Bankgirot uses "SE" for SEK in file headers)
|
||
// Pos 53-80: Spaces (filler)
|
||
records.push(
|
||
pad('11', 2) +
|
||
padNumber(senderBg, 10) +
|
||
todayYyMmDd +
|
||
padText('LEVERANTÖRSBETALNINGAR', 22) +
|
||
'LEVE' +
|
||
paymentDateYyMmDd +
|
||
'SE' +
|
||
pad('', 28)
|
||
)
|
||
|
||
// ─── Posttyp 54 — Betalning till bankkonto (one per employee) ───
|
||
// Pos 1-2: "54"
|
||
// Pos 3-6: Clearing number (4 digits, right-justified, zero-padded)
|
||
// 5-digit Swedbank clearings: digit 5 goes in pos 7 (we shift
|
||
// into the account field below per Bankgirot spec).
|
||
// Pos 7-16: Bank account number (10 digits, right-justified, zero-padded)
|
||
// Pos 17-41: Receiver name (25 chars, left-justified, space-padded)
|
||
// Pos 42-53: Amount in öre (12 digits, right-justified, zero-padded)
|
||
// Pos 54-59: Payment date YYMMDD
|
||
// Pos 60-80: Free reference / period label (21 chars)
|
||
for (const emp of positivePayments) {
|
||
const { clearing4, accountWithSwedbankPrefix } = encodeReceiverAccount(
|
||
emp.clearingNumber,
|
||
emp.bankAccountNumber
|
||
)
|
||
const amountOre = Math.round(emp.netSalary * 100)
|
||
const reference = `Lon ${options.periodLabel}`
|
||
|
||
records.push(
|
||
pad('54', 2) +
|
||
padNumber(clearing4, 4) +
|
||
padNumber(accountWithSwedbankPrefix, 10) +
|
||
padText(emp.name, 25) +
|
||
padNumber(String(amountOre), 12) +
|
||
paymentDateYyMmDd +
|
||
padText(reference, 21)
|
||
)
|
||
}
|
||
|
||
// ─── Posttyp 29 — Slutpost ───
|
||
// Pos 1-2: "29"
|
||
// Pos 3-12: Sender bankgiro
|
||
// Pos 13-20: Total record count incl. opening + closing (8 digits)
|
||
// Pos 21-32: Total amount in öre (12 digits)
|
||
// Pos 33-80: Spaces
|
||
const totalRecords = records.length + 1 // include the closing record itself
|
||
records.push(
|
||
pad('29', 2) +
|
||
padNumber(senderBg, 10) +
|
||
padNumber(String(totalRecords), 8) +
|
||
padNumber(String(totalAmountOre), 12) +
|
||
pad('', 48)
|
||
)
|
||
|
||
// Validate every record is exactly 80 characters.
|
||
for (let i = 0; i < records.length; i++) {
|
||
if (records[i].length !== 80) {
|
||
throw new Error(
|
||
`Bankgirot LB-fil: post ${i + 1} har fel längd ${records[i].length} (förväntat 80)`
|
||
)
|
||
}
|
||
}
|
||
|
||
const content = records.join('\r\n') + '\r\n'
|
||
|
||
return {
|
||
content,
|
||
filename: `bg_lb_lon_${options.periodLabel}.txt`,
|
||
totalAmount: totalAmountOre / 100,
|
||
recordCount: positivePayments.length,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate a Bankgirot LB-fil with a single TK 14 payment to a Bankgiro
|
||
* receiver. Used for paying skatt + arbetsgivaravgifter to Skatteverket
|
||
* (BG 5050-1055) with the company's Skattekontot OCR.
|
||
*
|
||
* Layout:
|
||
* 1× Öppningspost (TK 11)
|
||
* 1× Betalning till BG (TK 14) — receiver BG, OCR, amount
|
||
* 1× Slutpost (TK 29)
|
||
*/
|
||
export function generateBankgiroPaymentBgLb(
|
||
company: BgLbCompanyData,
|
||
payment: {
|
||
/** Receiver bankgiro (e.g. "5050-1055" for Skattekontot). */
|
||
receiverBankgiro: string
|
||
/** OCR reference (numeric, ≤ 25 digits, including Luhn check digit). */
|
||
ocr: string
|
||
/** Amount in SEK. */
|
||
amount: number
|
||
/** Optional receiver name shown in additional info (max 25 chars). */
|
||
receiverName?: string
|
||
},
|
||
options: BgLbOptions
|
||
): BgLbResult {
|
||
const senderBg = stripBgFormat(company.senderBankgiro)
|
||
const receiverBg = stripBgFormat(payment.receiverBankgiro)
|
||
if (!/^\d{7,8}$/.test(senderBg)) {
|
||
throw new Error(`Ogiltigt avsändar-bankgiro: ${company.senderBankgiro}`)
|
||
}
|
||
if (!/^\d{7,8}$/.test(receiverBg)) {
|
||
throw new Error(`Ogiltigt mottagar-bankgiro: ${payment.receiverBankgiro}`)
|
||
}
|
||
const ocrDigits = payment.ocr.replace(/\D/g, '')
|
||
if (ocrDigits.length === 0 || ocrDigits.length > 25) {
|
||
throw new Error(`Ogiltigt OCR-nummer: ${payment.ocr}`)
|
||
}
|
||
|
||
const paymentDateYyMmDd = toYyMmDd(options.paymentDate)
|
||
const todayYyMmDd = toYyMmDd(new Date().toISOString().slice(0, 10))
|
||
const amountOre = Math.round(payment.amount * 100)
|
||
|
||
const records: string[] = []
|
||
|
||
// ─── Posttyp 11 — Öppningspost ───
|
||
records.push(
|
||
pad('11', 2) +
|
||
padNumber(senderBg, 10) +
|
||
todayYyMmDd +
|
||
padText('LEVERANTÖRSBETALNINGAR', 22) +
|
||
'LEVE' +
|
||
paymentDateYyMmDd +
|
||
'SE' +
|
||
pad('', 28)
|
||
)
|
||
|
||
// ─── Posttyp 14 — Betalning till BG ───
|
||
// Pos 1-2: "14"
|
||
// Pos 3-12: Receiver bankgiro (10 digits)
|
||
// Pos 13-37: OCR / reference (25 chars, right-justified zero-padded for OCR)
|
||
// Pos 38-49: Amount in öre (12 digits)
|
||
// Pos 50-55: Payment date YYMMDD
|
||
// Pos 56-80: Receiver name / free info (25 chars)
|
||
records.push(
|
||
pad('14', 2) +
|
||
padNumber(receiverBg, 10) +
|
||
padNumber(ocrDigits, 25) +
|
||
padNumber(String(amountOre), 12) +
|
||
paymentDateYyMmDd +
|
||
padText(payment.receiverName ?? options.periodLabel, 25)
|
||
)
|
||
|
||
// ─── Posttyp 29 — Slutpost ───
|
||
const totalRecords = records.length + 1
|
||
records.push(
|
||
pad('29', 2) +
|
||
padNumber(senderBg, 10) +
|
||
padNumber(String(totalRecords), 8) +
|
||
padNumber(String(amountOre), 12) +
|
||
pad('', 48)
|
||
)
|
||
|
||
for (let i = 0; i < records.length; i++) {
|
||
if (records[i].length !== 80) {
|
||
throw new Error(
|
||
`Bankgirot LB-fil: post ${i + 1} har fel längd ${records[i].length} (förväntat 80)`
|
||
)
|
||
}
|
||
}
|
||
|
||
return {
|
||
content: records.join('\r\n') + '\r\n',
|
||
filename: `bg_lb_skatt_${options.periodLabel}.txt`,
|
||
totalAmount: payment.amount,
|
||
recordCount: 1,
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Helpers
|
||
// ============================================================
|
||
|
||
/** Strip dashes/spaces from a Bankgiro number. */
|
||
function stripBgFormat(bg: string): string {
|
||
return bg.replace(/[-\s]/g, '')
|
||
}
|
||
|
||
/** Convert YYYY-MM-DD to YYMMDD. */
|
||
function toYyMmDd(isoDate: string): string {
|
||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate)
|
||
if (!m) throw new Error(`Ogiltigt datum: ${isoDate}`)
|
||
return m[1].slice(2) + m[2] + m[3]
|
||
}
|
||
|
||
/** Right-justify with zero-padding (for numeric fields). */
|
||
function padNumber(value: string, length: number): string {
|
||
const digits = value.replace(/\D/g, '')
|
||
if (digits.length > length) {
|
||
throw new Error(`Numeriskt fält för långt (${digits.length} > ${length}): ${value}`)
|
||
}
|
||
return digits.padStart(length, '0')
|
||
}
|
||
|
||
/** Left-justify with space-padding, then truncate to length (for text fields).
|
||
* Bankgirot uses ISO 8859-1; keep å/ä/ö but strip anything outside that range. */
|
||
function padText(value: string, length: number): string {
|
||
const sanitized = value
|
||
.replace(/[\r\n\t]/g, ' ')
|
||
// Strip characters outside ISO 8859-1 printable range to avoid encoding errors.
|
||
.replace(/[^\x20-\x7E\xA0-\xFF]/g, '?')
|
||
.slice(0, length)
|
||
return sanitized.padEnd(length, ' ')
|
||
}
|
||
|
||
/** Plain padding to length (for fixed literals like "11"). */
|
||
function pad(value: string, length: number): string {
|
||
if (value.length > length) return value.slice(0, length)
|
||
return value.padEnd(length, ' ')
|
||
}
|
||
|
||
/**
|
||
* Bankgirot encodes 4-digit clearings directly. Swedbank uses 5-digit clearings
|
||
* (8xxx-x); the 5th digit is moved to the leading position of the account field.
|
||
*
|
||
* For 5-digit clearings starting with "8": digits 1-4 go to the clearing field,
|
||
* the 5th digit becomes the first digit of the 10-position account field.
|
||
*
|
||
* Nordea Personkonto with 11-digit account number: the displayed account
|
||
* already includes the 4-digit clearing as its leading digits (e.g. clearing
|
||
* 1708 + account 17082042825). The 10-digit BG-LB account field cannot fit
|
||
* 11 digits, so we strip the redundant clearing prefix and zero-pad the
|
||
* remaining 7 digits to 10. The receiving bank reconstructs the full
|
||
* personkonto from clearing + account.
|
||
*/
|
||
function encodeReceiverAccount(
|
||
clearingInput: string,
|
||
accountInput: string
|
||
): { clearing4: string; accountWithSwedbankPrefix: string } {
|
||
const clearing = clearingInput.replace(/\D/g, '')
|
||
const account = accountInput.replace(/\D/g, '')
|
||
|
||
if (clearing.length === 4) {
|
||
// Nordea Personkonto: account is 11 digits and starts with the clearing.
|
||
// Strip the redundant clearing prefix so it fits the 10-digit account field.
|
||
if (account.length === 11 && account.startsWith(clearing)) {
|
||
return { clearing4: clearing, accountWithSwedbankPrefix: account.slice(4) }
|
||
}
|
||
return { clearing4: clearing, accountWithSwedbankPrefix: account }
|
||
}
|
||
|
||
if (clearing.length === 5 && clearing.startsWith('8')) {
|
||
// Swedbank: keep 4 leading digits in clearing field, prepend 5th digit to account.
|
||
return {
|
||
clearing4: clearing.slice(0, 4),
|
||
accountWithSwedbankPrefix: clearing.slice(4) + account,
|
||
}
|
||
}
|
||
|
||
throw new Error(
|
||
`Ogiltigt clearingnummer: ${clearingInput} (förväntat 4 siffror, eller 5 siffror som börjar med 8 för Swedbank)`
|
||
)
|
||
}
|