* feat: add Bankgirot LB-fil support for salary payments and tax payments - Implemented `generateBgLb` for salary batch payments, producing opening, payment, and closing records. - Added tests for `generateBgLb` to ensure correct record generation and validation. - Created `generateBankgiroPaymentBgLb` for single tax payments to Skatteverket, including validation and formatting. - Added tests for `generateBankgiroPaymentBgLb` to verify record structure and data integrity. - Introduced `generateSkattekontoOcr` for generating valid OCR references for Skattekonto payments, with tests for various input formats. - Updated database schema to track payment file formats and timestamps for salary runs and AGI declarations. - Created a new table for logging salary payslip deliveries to ensure compliance with audit requirements. * feat: add write permission check and company ID validation for payment file generation * feat: add write permission check for salary payment file generation
153 lines
6.1 KiB
TypeScript
153 lines
6.1 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { generateBgLb } from '../bg-lb-generator'
|
|
import type { BgLbCompanyData, BgLbEmployee, BgLbOptions } from '../bg-lb-generator'
|
|
|
|
const company: BgLbCompanyData = {
|
|
name: 'Acme AB',
|
|
senderBankgiro: '123-4567',
|
|
}
|
|
|
|
const baseOptions: BgLbOptions = {
|
|
paymentDate: '2026-04-25',
|
|
periodLabel: '2026-04',
|
|
}
|
|
|
|
describe('generateBgLb', () => {
|
|
it('produces a file with opening, payment, and closing records', () => {
|
|
const employees: BgLbEmployee[] = [
|
|
{
|
|
name: 'Anna Andersson',
|
|
clearingNumber: '6000',
|
|
bankAccountNumber: '1234567',
|
|
netSalary: 25000,
|
|
},
|
|
{
|
|
name: 'Bo Bergström',
|
|
clearingNumber: '6000',
|
|
bankAccountNumber: '7654321',
|
|
netSalary: 30500.5,
|
|
},
|
|
]
|
|
|
|
const result = generateBgLb(company, employees, baseOptions)
|
|
const lines = result.content.split('\r\n').filter((l) => l.length > 0)
|
|
|
|
expect(lines).toHaveLength(4) // 1 opening + 2 payments + 1 closing
|
|
expect(lines[0]).toMatch(/^11/)
|
|
expect(lines[1]).toMatch(/^54/)
|
|
expect(lines[2]).toMatch(/^54/)
|
|
expect(lines[3]).toMatch(/^29/)
|
|
expect(result.recordCount).toBe(2)
|
|
expect(result.totalAmount).toBe(55500.5)
|
|
})
|
|
|
|
it('writes records exactly 80 characters wide', () => {
|
|
const employees: BgLbEmployee[] = [
|
|
{ name: 'Cecilia Carlsson', clearingNumber: '6000', bankAccountNumber: '1234567', netSalary: 28000 },
|
|
]
|
|
const result = generateBgLb(company, employees, baseOptions)
|
|
const lines = result.content.split('\r\n').filter((l) => l.length > 0)
|
|
for (const line of lines) {
|
|
expect(line.length).toBe(80)
|
|
}
|
|
})
|
|
|
|
it('encodes amounts in öre (no decimal)', () => {
|
|
const employees: BgLbEmployee[] = [
|
|
{ name: 'Test', clearingNumber: '6000', bankAccountNumber: '1234567', netSalary: 12345.67 },
|
|
]
|
|
const result = generateBgLb(company, employees, baseOptions)
|
|
const paymentLine = result.content.split('\r\n')[1]
|
|
// Amount field for TK 54 is positions 42-53 (12 chars, zero-padded)
|
|
const amountField = paymentLine.slice(41, 53)
|
|
expect(amountField).toBe('000001234567') // 12345.67 SEK = 1234567 öre
|
|
})
|
|
|
|
it('handles 5-digit Swedbank clearings by shifting the 5th digit into the account', () => {
|
|
const employees: BgLbEmployee[] = [
|
|
{ name: 'Swedbank', clearingNumber: '83271', bankAccountNumber: '123456789', netSalary: 1000 },
|
|
]
|
|
const result = generateBgLb(company, employees, baseOptions)
|
|
const paymentLine = result.content.split('\r\n')[1]
|
|
// Pos 3-6 = clearing (4 digits)
|
|
expect(paymentLine.slice(2, 6)).toBe('8327')
|
|
// Pos 7-16 = account (10 digits): 5th clearing digit "1" + account "123456789"
|
|
expect(paymentLine.slice(6, 16)).toBe('1123456789')
|
|
})
|
|
|
|
it('rejects invalid bankgiro number', () => {
|
|
expect(() =>
|
|
generateBgLb({ ...company, senderBankgiro: 'invalid' }, [], baseOptions)
|
|
).toThrow(/Ogiltigt bankgironummer/)
|
|
})
|
|
|
|
it('rejects 5-digit clearing not starting with 8', () => {
|
|
const employees: BgLbEmployee[] = [
|
|
{ name: 'X', clearingNumber: '90001', bankAccountNumber: '1234567', netSalary: 100 },
|
|
]
|
|
expect(() => generateBgLb(company, employees, baseOptions)).toThrow(/clearing/)
|
|
})
|
|
|
|
it('skips employees with zero or negative net salary', () => {
|
|
const employees: BgLbEmployee[] = [
|
|
{ name: 'A', clearingNumber: '6000', bankAccountNumber: '1234567', netSalary: 25000 },
|
|
{ name: 'B', clearingNumber: '6000', bankAccountNumber: '7654321', netSalary: 0 },
|
|
{ name: 'C', clearingNumber: '6000', bankAccountNumber: '1111111', netSalary: -500 },
|
|
]
|
|
const result = generateBgLb(company, employees, baseOptions)
|
|
expect(result.recordCount).toBe(1)
|
|
expect(result.totalAmount).toBe(25000)
|
|
})
|
|
|
|
it('opening record contains LEVERANTÖRSBETALNINGAR and LEVE markers', () => {
|
|
const result = generateBgLb(company, [
|
|
{ name: 'X', clearingNumber: '6000', bankAccountNumber: '1234567', netSalary: 100 },
|
|
], baseOptions)
|
|
const opening = result.content.split('\r\n')[0]
|
|
expect(opening.slice(18, 40)).toBe('LEVERANTÖRSBETALNINGAR')
|
|
expect(opening.slice(40, 44)).toBe('LEVE')
|
|
})
|
|
|
|
it('closing record sums total amount in öre across all payments', () => {
|
|
const employees: BgLbEmployee[] = [
|
|
{ name: 'A', clearingNumber: '6000', bankAccountNumber: '1234567', netSalary: 100 },
|
|
{ name: 'B', clearingNumber: '6000', bankAccountNumber: '7654321', netSalary: 250.5 },
|
|
]
|
|
const result = generateBgLb(company, employees, baseOptions)
|
|
const lines = result.content.split('\r\n').filter((l) => l)
|
|
const closing = lines[lines.length - 1]
|
|
expect(closing.startsWith('29')).toBe(true)
|
|
// Pos 21-32 = total amount in öre (12 digits)
|
|
expect(closing.slice(20, 32)).toBe('000000035050') // 350.50 SEK = 35050 öre
|
|
})
|
|
|
|
it('encodes payment date as YYMMDD on opening and payment records', () => {
|
|
const result = generateBgLb(company, [
|
|
{ name: 'X', clearingNumber: '6000', bankAccountNumber: '1234567', netSalary: 100 },
|
|
], { ...baseOptions, paymentDate: '2026-12-31' })
|
|
const lines = result.content.split('\r\n')
|
|
// Opening: pos 45-50 = payment date YYMMDD
|
|
expect(lines[0].slice(44, 50)).toBe('261231')
|
|
// Payment: pos 54-59 = payment date YYMMDD
|
|
expect(lines[1].slice(53, 59)).toBe('261231')
|
|
})
|
|
|
|
it('truncates over-long employee names safely', () => {
|
|
const longName = 'A'.repeat(50)
|
|
const result = generateBgLb(company, [
|
|
{ name: longName, clearingNumber: '6000', bankAccountNumber: '1234567', netSalary: 100 },
|
|
], baseOptions)
|
|
const paymentLine = result.content.split('\r\n')[1]
|
|
// Pos 17-41 = receiver name (25 chars)
|
|
expect(paymentLine.slice(16, 41)).toBe('A'.repeat(25))
|
|
})
|
|
|
|
it('preserves Swedish characters å ä ö in receiver name', () => {
|
|
const result = generateBgLb(company, [
|
|
{ name: 'Åke Östberg', clearingNumber: '6000', bankAccountNumber: '1234567', netSalary: 100 },
|
|
], baseOptions)
|
|
const paymentLine = result.content.split('\r\n')[1]
|
|
expect(paymentLine.slice(16, 41).trimEnd()).toBe('Åke Östberg')
|
|
})
|
|
})
|