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:
@@ -396,6 +396,8 @@ export const UpdateSettingsSchema = z.object({
|
||||
invoice_credit_terms_text: z.string().nullable().optional(),
|
||||
// AI agent flow
|
||||
ai_flow_enabled: z.boolean().optional(),
|
||||
// Salary payment file
|
||||
preferred_payment_format: z.enum(['bg_lb', 'pain001']).optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
// BFL 3 kap.: Enskild firma must have fiscal year starting January
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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) {
|
||||
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)`
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateSkattekontoOcr, SKATTEKONTO_BANKGIRO } from '../skattekonto-ocr'
|
||||
import { luhnValidate } from '@/lib/bankgiro/luhn'
|
||||
|
||||
describe('generateSkattekontoOcr', () => {
|
||||
it('produces 11-digit OCR with valid Luhn check digit for AB org-number', () => {
|
||||
const ocr = generateSkattekontoOcr('556012-3456')
|
||||
expect(ocr).toHaveLength(11)
|
||||
expect(ocr.startsWith('5560123456')).toBe(true)
|
||||
expect(luhnValidate(ocr)).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts org-number without dash', () => {
|
||||
expect(generateSkattekontoOcr('5560123456')).toBe(generateSkattekontoOcr('556012-3456'))
|
||||
})
|
||||
|
||||
it('accepts 12-digit personnummer by stripping century prefix', () => {
|
||||
const ocr12 = generateSkattekontoOcr('198802251234')
|
||||
const ocr10 = generateSkattekontoOcr('880225-1234')
|
||||
expect(ocr12).toBe(ocr10)
|
||||
})
|
||||
|
||||
it('rejects malformed numbers', () => {
|
||||
expect(() => generateSkattekontoOcr('123')).toThrow(/Ogiltigt/)
|
||||
expect(() => generateSkattekontoOcr('')).toThrow(/Ogiltigt/)
|
||||
expect(() => generateSkattekontoOcr('abcdefghij')).toThrow(/Ogiltigt/)
|
||||
})
|
||||
|
||||
it('exports correct Bankgiro for Skattekontot', () => {
|
||||
expect(SKATTEKONTO_BANKGIRO).toBe('5050-1055')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* OCR-nummer for Skatteverket Skattekonto payments.
|
||||
*
|
||||
* Companies pay tax (skatt + arbetsgivaravgifter + F-skatt + slutlig skatt etc.)
|
||||
* to Bankgiro 5050-1055 with an OCR reference. The reference identifies which
|
||||
* Skattekonto receives the credit; Skatteverket applies it to the most recent
|
||||
* declared liability.
|
||||
*
|
||||
* Format (per Skatteverket "OCR-nummer för inbetalning till skattekontot"):
|
||||
* - 10-digit organisationsnummer (AB) or 10-digit personnummer (EF)
|
||||
* stripped of dashes/spaces
|
||||
* - Followed by a single Luhn check digit
|
||||
* - Total: 11 digits
|
||||
*
|
||||
* Examples:
|
||||
* 556012-3456 → "5560123456" + check digit "6" = "55601234566"
|
||||
* 880225-1234 → "8802251234" + check digit → 11 digits
|
||||
*
|
||||
* Reference: https://www.skatteverket.se/foretag/skatterochavdrag/skattekonto/betalainochavskattekonto/sabetalardupaskattekontot.4.18e1b10334ebe8bc80004499.html
|
||||
*/
|
||||
|
||||
import { luhnCheckDigit } from '@/lib/bankgiro/luhn'
|
||||
|
||||
/** Bankgiro number for all payments to Skattekontot. */
|
||||
export const SKATTEKONTO_BANKGIRO = '5050-1055'
|
||||
|
||||
/**
|
||||
* Generate the standard Skattekontot OCR reference for a company.
|
||||
*
|
||||
* Accepts org_number/personnummer in any common Swedish format
|
||||
* ("556012-3456", "5560123456", "19880225-1234", "198802251234").
|
||||
*
|
||||
* For 12-digit personnummer (with century prefix), the leading century digits
|
||||
* are stripped — Skatteverket's Skattekonto-OCR uses the 10-digit form.
|
||||
*/
|
||||
export function generateSkattekontoOcr(orgOrPersonnummer: string): string {
|
||||
const digits = orgOrPersonnummer.replace(/\D/g, '')
|
||||
|
||||
let base: string
|
||||
if (digits.length === 10) {
|
||||
base = digits
|
||||
} else if (digits.length === 12) {
|
||||
// Strip century prefix (1900s = "19", 2000s = "20")
|
||||
base = digits.slice(2)
|
||||
} else {
|
||||
throw new Error(
|
||||
`Ogiltigt org/personnummer för Skattekonto-OCR: "${orgOrPersonnummer}" (förväntat 10 eller 12 siffror)`
|
||||
)
|
||||
}
|
||||
|
||||
const checkDigit = luhnCheckDigit(base)
|
||||
return base + checkDigit.toString()
|
||||
}
|
||||
Reference in New Issue
Block a user