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:
Mattsson
2026-05-09 12:41:16 +02:00
committed by GitHub
parent c238542596
commit 7e81f661b2
24 changed files with 1870 additions and 20 deletions
+2
View File
@@ -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
})
})
+335
View File
@@ -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')
})
})
+53
View File
@@ -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()
}