Files
accounted/lib/salary/payment/bg-lb-generator.ts
T
Mattsson 993b3f5962 Fix/remove disabled fields (#426)
* 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
2026-05-09 23:55:50 +02:00

348 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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)`
)
}