Add/skv salary agi (#423)
* 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
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateBankgiroPaymentBgLb } from '../bg-lb-generator'
|
||||
|
||||
const company = {
|
||||
name: 'Acme AB',
|
||||
senderBankgiro: '123-4567',
|
||||
}
|
||||
|
||||
describe('generateBankgiroPaymentBgLb', () => {
|
||||
it('produces opening + TK14 + closing records', () => {
|
||||
const result = generateBankgiroPaymentBgLb(
|
||||
company,
|
||||
{
|
||||
receiverBankgiro: '5050-1055',
|
||||
ocr: '55601234566',
|
||||
amount: 12345.67,
|
||||
receiverName: 'Skatteverket',
|
||||
},
|
||||
{ paymentDate: '2026-05-12', periodLabel: '2026-04' }
|
||||
)
|
||||
const lines = result.content.split('\r\n').filter((l) => l)
|
||||
expect(lines).toHaveLength(3)
|
||||
expect(lines[0].slice(0, 2)).toBe('11')
|
||||
expect(lines[1].slice(0, 2)).toBe('14')
|
||||
expect(lines[2].slice(0, 2)).toBe('29')
|
||||
expect(result.recordCount).toBe(1)
|
||||
expect(result.totalAmount).toBe(12345.67)
|
||||
})
|
||||
|
||||
it('all records are exactly 80 characters', () => {
|
||||
const result = generateBankgiroPaymentBgLb(
|
||||
company,
|
||||
{ receiverBankgiro: '5050-1055', ocr: '55601234566', amount: 1000 },
|
||||
{ paymentDate: '2026-05-12', periodLabel: '2026-04' }
|
||||
)
|
||||
for (const line of result.content.split('\r\n').filter((l) => l)) {
|
||||
expect(line.length).toBe(80)
|
||||
}
|
||||
})
|
||||
|
||||
it('encodes receiver bankgiro right-justified zero-padded', () => {
|
||||
const result = generateBankgiroPaymentBgLb(
|
||||
company,
|
||||
{ receiverBankgiro: '5050-1055', ocr: '55601234566', amount: 1000 },
|
||||
{ paymentDate: '2026-05-12', periodLabel: '2026-04' }
|
||||
)
|
||||
const paymentLine = result.content.split('\r\n')[1]
|
||||
// Pos 3-12 = receiver BG (10 digits)
|
||||
expect(paymentLine.slice(2, 12)).toBe('0050501055')
|
||||
})
|
||||
|
||||
it('encodes OCR right-justified zero-padded in pos 13-37', () => {
|
||||
const result = generateBankgiroPaymentBgLb(
|
||||
company,
|
||||
{ receiverBankgiro: '5050-1055', ocr: '55601234566', amount: 1000 },
|
||||
{ paymentDate: '2026-05-12', periodLabel: '2026-04' }
|
||||
)
|
||||
const paymentLine = result.content.split('\r\n')[1]
|
||||
// OCR "55601234566" (11 digits) padded to 25 chars right-justified = 14 zeros + OCR
|
||||
expect(paymentLine.slice(12, 37)).toBe('00000000000000055601234566'.slice(-25))
|
||||
expect(paymentLine.slice(12, 37).length).toBe(25)
|
||||
expect(paymentLine.slice(12, 37).endsWith('55601234566')).toBe(true)
|
||||
})
|
||||
|
||||
it('encodes amount in öre (no decimal)', () => {
|
||||
const result = generateBankgiroPaymentBgLb(
|
||||
company,
|
||||
{ receiverBankgiro: '5050-1055', ocr: '55601234566', amount: 12345.67 },
|
||||
{ paymentDate: '2026-05-12', periodLabel: '2026-04' }
|
||||
)
|
||||
const paymentLine = result.content.split('\r\n')[1]
|
||||
// Pos 38-49 = amount in öre (12 digits)
|
||||
expect(paymentLine.slice(37, 49)).toBe('000001234567')
|
||||
})
|
||||
|
||||
it('rejects invalid receiver bankgiro', () => {
|
||||
expect(() =>
|
||||
generateBankgiroPaymentBgLb(
|
||||
company,
|
||||
{ receiverBankgiro: 'invalid', ocr: '55601234566', amount: 100 },
|
||||
{ paymentDate: '2026-05-12', periodLabel: '2026-04' }
|
||||
)
|
||||
).toThrow(/mottagar-bankgiro/)
|
||||
})
|
||||
|
||||
it('rejects invalid OCR (non-numeric)', () => {
|
||||
expect(() =>
|
||||
generateBankgiroPaymentBgLb(
|
||||
company,
|
||||
{ receiverBankgiro: '5050-1055', ocr: 'abcdef', amount: 100 },
|
||||
{ paymentDate: '2026-05-12', periodLabel: '2026-04' }
|
||||
)
|
||||
).toThrow(/OCR/)
|
||||
})
|
||||
|
||||
it('closing record sums total amount in öre', () => {
|
||||
const result = generateBankgiroPaymentBgLb(
|
||||
company,
|
||||
{ receiverBankgiro: '5050-1055', ocr: '55601234566', amount: 100.5 },
|
||||
{ paymentDate: '2026-05-12', periodLabel: '2026-04' }
|
||||
)
|
||||
const closing = result.content.split('\r\n').filter((l) => l)[2]
|
||||
expect(closing.slice(20, 32)).toBe('000000010050') // 100.50 SEK = 10050 öre
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user