feat(salary): skattekonto payment file as ISO 20022 pain.001 (#1845)
The tax payment file (skatt + arbetsgivaravgifter to Skatteverket BG 5050-1055) was Bankgirot LB only; banks that take pain.001 for salary, like SEB via file communication agreements, refuse the LB .txt. The route now accepts ?format=pain001 and generates the payment through the supplier-payment pain.001 generator, whose Swedish giro dialect (BG payee + SCOR OCR, Validex-validated) is exactly this payment shape. The TaxPaymentPanel gets the same format selector as the salary PaymentFilePanel, seeded from company_settings.preferred_payment_format, with the missing-sender warning per format (bankgiro for LB, IBAN for pain.001). A migration widens the tax_payment_file_format CHECK to admit 'pain001'; bg_lb stays the default for old clients. Claude-Session: https://claude.ai/code/session_01DUB7L8DbVP2icn8ZpoPkoE Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
1b54bcd4bd
commit
cef8206f83
@@ -30,6 +30,20 @@ vi.mock('@/lib/salary/payment/bg-lb-generator', () => ({
|
||||
generateBankgiroPaymentBgLb: (...args: unknown[]) => mockGenerateBgLb(...args),
|
||||
}))
|
||||
|
||||
const mockGeneratePain001 = vi.fn()
|
||||
vi.mock('@/lib/payments/pain001-supplier', () => ({
|
||||
generateSupplierPain001: (...args: unknown[]) => mockGeneratePain001(...args),
|
||||
}))
|
||||
|
||||
const mockResolveBatchDebtor = vi.fn()
|
||||
vi.mock('@/lib/payments/batch-service', () => ({
|
||||
resolveBatchDebtor: (...args: unknown[]) => mockResolveBatchDebtor(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/branding/service', () => ({
|
||||
getBranding: () => ({ appName: 'Accounted' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/skatteverket/skattekonto-ocr', () => ({
|
||||
generateSkattekontoOcr: vi.fn().mockReturnValue('1234567890'),
|
||||
SKATTEKONTO_BANKGIRO: '5050-1055',
|
||||
@@ -50,6 +64,18 @@ describe('GET /api/skatteverket/tax-payments/[period]/payment-file', () => {
|
||||
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
mockGenerateBgLb.mockReturnValue({ content: 'LB-FILE', filename: 'skatt-2026-04.txt' })
|
||||
mockGeneratePain001.mockReturnValue('<Document/>')
|
||||
mockResolveBatchDebtor.mockResolvedValue({
|
||||
ok: true,
|
||||
debtor: {
|
||||
name: 'Test AB',
|
||||
org_number: '5566778899',
|
||||
iban: 'SE3550000000054910000003',
|
||||
bic: 'ESSESESS',
|
||||
bankgiro: '1234567',
|
||||
city: 'Stockholm',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
@@ -134,4 +160,62 @@ describe('GET /api/skatteverket/tax-payments/[period]/payment-file', () => {
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockGenerateBgLb.mock.calls[0][1]).toMatchObject({ amount: 28341.84 })
|
||||
})
|
||||
|
||||
it('generates a pain.001 file when format=pain001', async () => {
|
||||
enqueue({ data: { id: 'agi-1', total_tax: 1000, total_avgifter: 500 } }) // agi
|
||||
enqueue({ data: { name: 'Test AB', org_number: '5566778899' } }) // companies
|
||||
enqueue({ data: null, error: null }) // update tax_payment_file_generated_at
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/skatteverket/tax-payments/2026-04/payment-file', {
|
||||
searchParams: { format: 'pain001' },
|
||||
}),
|
||||
createMockRouteParams({ period: '2026-04' }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('application/xml; charset=utf-8')
|
||||
expect(response.headers.get('Content-Disposition')).toContain('pain001_skatt_2026-04.xml')
|
||||
expect(mockGenerateBgLb).not.toHaveBeenCalled()
|
||||
expect(mockGeneratePain001).toHaveBeenCalledTimes(1)
|
||||
const [debtor, payments] = mockGeneratePain001.mock.calls[0]
|
||||
expect(debtor).toMatchObject({ iban: 'SE3550000000054910000003', bic: 'ESSESESS' })
|
||||
expect(payments).toHaveLength(1)
|
||||
expect(payments[0]).toMatchObject({
|
||||
payee: { type: 'bankgiro', bankgiro: '50501055' },
|
||||
payeeName: 'Skatteverket',
|
||||
amount: 1500,
|
||||
reference: { type: 'ocr', value: '1234567890' },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 400 when the pain.001 debtor is missing an IBAN', async () => {
|
||||
mockResolveBatchDebtor.mockResolvedValue({ ok: false, missing: 'iban' })
|
||||
enqueue({ data: { id: 'agi-1', total_tax: 1000, total_avgifter: 500 } }) // agi
|
||||
enqueue({ data: { name: 'Test AB', org_number: '5566778899' } }) // companies
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/skatteverket/tax-payments/2026-04/payment-file', {
|
||||
searchParams: { format: 'pain001' },
|
||||
}),
|
||||
createMockRouteParams({ period: '2026-04' }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const body = await response.json()
|
||||
expect(JSON.stringify(body)).toContain('IBAN')
|
||||
expect(mockGeneratePain001).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 for an unknown format', async () => {
|
||||
const response = await GET(
|
||||
createMockRequest('/api/skatteverket/tax-payments/2026-04/payment-file', {
|
||||
searchParams: { format: 'csv' },
|
||||
}),
|
||||
createMockRouteParams({ period: '2026-04' }),
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockGenerateBgLb).not.toHaveBeenCalled()
|
||||
expect(mockGeneratePain001).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,18 +3,24 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { generateBankgiroPaymentBgLb } from '@/lib/salary/payment/bg-lb-generator'
|
||||
import { generateSupplierPain001 } from '@/lib/payments/pain001-supplier'
|
||||
import { resolveBatchDebtor } from '@/lib/payments/batch-service'
|
||||
import { generateSkattekontoOcr, SKATTEKONTO_BANKGIRO } from '@/lib/skatteverket/skattekonto-ocr'
|
||||
import { validateBankgiroNumber } from '@/lib/bankgiro/luhn'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { roundOre } from '@/lib/money'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* Generate Bankgirot LB-fil for paying skatt + arbetsgivaravgifter for a
|
||||
* Generate the payment file for paying skatt + arbetsgivaravgifter for a
|
||||
* given AGI period to Skatteverket's Bankgiro 5050-1055 with the company's
|
||||
* Skattekontot OCR.
|
||||
*
|
||||
* Period format: "YYYY-MM" (e.g. "2026-04").
|
||||
* `?format=bg_lb` (default) yields a Bankgirot LB-fil; `?format=pain001`
|
||||
* yields ISO 20022 pain.001 XML through the supplier-payment generator,
|
||||
* whose Swedish giro dialect (BG payee + SCOR OCR) is exactly this payment.
|
||||
*
|
||||
* Per BFL: Generated payment file is räkenskapsinformation linked to the
|
||||
* salary journal entry. Subject to 7-year retention.
|
||||
@@ -37,6 +43,14 @@ export const GET = withRouteContext<{ params: Promise<{ period: string }> }>(
|
||||
const periodYear = parseInt(periodMatch[1], 10)
|
||||
const periodMonth = parseInt(periodMatch[2], 10)
|
||||
|
||||
const format = new URL(request.url).searchParams.get('format') ?? 'bg_lb'
|
||||
if (format !== 'bg_lb' && format !== 'pain001') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ogiltigt filformat. Använd bg_lb eller pain001.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: agi } = await supabase
|
||||
.from('agi_declarations')
|
||||
.select('id, total_tax, total_avgifter')
|
||||
@@ -83,28 +97,6 @@ export const GET = withRouteContext<{ params: Promise<{ period: string }> }>(
|
||||
)
|
||||
}
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('bankgiro')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!settings?.bankgiro) {
|
||||
return NextResponse.json(
|
||||
// Same wording as the salary LB route: the settings overview shows a
|
||||
// registry bankgiro that this route does not read.
|
||||
{ error: 'Företagets bankgironummer är inte ifyllt. Fyll i det under Inställningar → Fakturering för att skapa betalfilen.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!validateBankgiroNumber(settings.bankgiro)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Bankgironumret är ogiltigt (felaktig kontrollsiffra).' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let ocr: string
|
||||
try {
|
||||
ocr = generateSkattekontoOcr(company.org_number)
|
||||
@@ -116,37 +108,120 @@ export const GET = withRouteContext<{ params: Promise<{ period: string }> }>(
|
||||
// (17th in Jan/Aug for ≤40 MSEK turnover, but we play safe with 12th here).
|
||||
const paymentDate = computeTaxPaymentDate(periodYear, periodMonth)
|
||||
|
||||
let result
|
||||
try {
|
||||
result = generateBankgiroPaymentBgLb(
|
||||
{ name: company.name, senderBankgiro: settings.bankgiro },
|
||||
{
|
||||
receiverBankgiro: SKATTEKONTO_BANKGIRO,
|
||||
ocr,
|
||||
amount: totalAmount,
|
||||
receiverName: 'Skatteverket',
|
||||
},
|
||||
{ paymentDate, periodLabel: period }
|
||||
)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: getErrorMessage(err) }, { status: 400 })
|
||||
let fileContent: Buffer
|
||||
let filename: string
|
||||
let contentType: string
|
||||
|
||||
if (format === 'pain001') {
|
||||
// The paying company resolves exactly like a supplier payment batch:
|
||||
// IBAN + BIC (derived when possible) + org number, with the company
|
||||
// bankgiro riding along so the BG payee is debited BGNR-to-BGNR where
|
||||
// the bank's MIG demands it (Swedbank Validex rule 219).
|
||||
const debtorResolution = await resolveBatchDebtor(supabase, companyId)
|
||||
if (!debtorResolution.ok) {
|
||||
const message = {
|
||||
iban: 'Företagets IBAN saknas i företagsinställningar. Fyll i det under Inställningar → Fakturering för att skapa betalfil (ISO 20022).',
|
||||
bic: 'Företagsbankens BIC saknas och kunde inte härledas. Fyll i BIC under Inställningar → Fakturering för att skapa betalfilen.',
|
||||
org_number: 'Organisationsnummer saknas för företaget.',
|
||||
}[debtorResolution.missing]
|
||||
return NextResponse.json({ error: message }, { status: 400 })
|
||||
}
|
||||
const { debtor } = debtorResolution
|
||||
|
||||
// Deterministic per period, like the salary pain.001 MsgId: re-downloads
|
||||
// reuse the id, so bank-side duplicate detection (keyed on MsgId) still
|
||||
// catches the same period being uploaded twice.
|
||||
const orgDigits = company.org_number.replace(/\D/g, '')
|
||||
const messageId = `${getBranding().appName.toUpperCase()}-SKATT-${orgDigits}-${period}`
|
||||
|
||||
let xml: string
|
||||
try {
|
||||
xml = generateSupplierPain001(
|
||||
{
|
||||
name: debtor.name,
|
||||
orgNumber: debtor.org_number,
|
||||
iban: debtor.iban,
|
||||
bic: debtor.bic,
|
||||
bankgiro: debtor.bankgiro,
|
||||
city: debtor.city,
|
||||
},
|
||||
[
|
||||
{
|
||||
payee: { type: 'bankgiro', bankgiro: SKATTEKONTO_BANKGIRO.replace(/\D/g, '') },
|
||||
payeeName: 'Skatteverket',
|
||||
// Skatteverket's seat; the MIG demands a creditor town (rule 222).
|
||||
payeeCity: 'Solna',
|
||||
amount: totalAmount,
|
||||
paymentDate,
|
||||
reference: { type: 'ocr', value: ocr },
|
||||
},
|
||||
],
|
||||
{ messageId, createdAt: new Date().toISOString() }
|
||||
)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: getErrorMessage(err) }, { status: 400 })
|
||||
}
|
||||
|
||||
fileContent = Buffer.from(xml, 'utf-8')
|
||||
filename = `pain001_skatt_${period}.xml`
|
||||
contentType = 'application/xml; charset=utf-8'
|
||||
} else {
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('bankgiro')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!settings?.bankgiro) {
|
||||
return NextResponse.json(
|
||||
// Same wording as the salary LB route: the settings overview shows a
|
||||
// registry bankgiro that this route does not read.
|
||||
{ error: 'Företagets bankgironummer är inte ifyllt. Fyll i det under Inställningar → Fakturering för att skapa betalfilen.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!validateBankgiroNumber(settings.bankgiro)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Bankgironumret är ogiltigt (felaktig kontrollsiffra).' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let result
|
||||
try {
|
||||
result = generateBankgiroPaymentBgLb(
|
||||
{ name: company.name, senderBankgiro: settings.bankgiro },
|
||||
{
|
||||
receiverBankgiro: SKATTEKONTO_BANKGIRO,
|
||||
ocr,
|
||||
amount: totalAmount,
|
||||
receiverName: 'Skatteverket',
|
||||
},
|
||||
{ paymentDate, periodLabel: period }
|
||||
)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: getErrorMessage(err) }, { status: 400 })
|
||||
}
|
||||
|
||||
fileContent = Buffer.from(result.content, 'latin1')
|
||||
filename = result.filename
|
||||
contentType = 'text/plain; charset=iso-8859-1'
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('agi_declarations')
|
||||
.update({
|
||||
tax_payment_file_generated_at: new Date().toISOString(),
|
||||
tax_payment_file_format: 'bg_lb',
|
||||
tax_payment_file_format: format,
|
||||
})
|
||||
.eq('id', agi.id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
const buffer = Buffer.from(result.content, 'latin1')
|
||||
|
||||
return new Response(buffer, {
|
||||
return new Response(fileContent, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=iso-8859-1',
|
||||
'Content-Disposition': `attachment; filename="${result.filename}"`,
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user