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:
Jakob Wennberg
2026-08-24 15:29:21 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 1b54bcd4bd
commit cef8206f83
6 changed files with 287 additions and 49 deletions
+1
View File
@@ -1185,3 +1185,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-24] Inline matcher (PR 6a) applies through the existing /api/reconciliation/bank/run intersection guard rather than new endpoints: the page renders the dry-run pairs and applies per row or strong-only (floor 0.85, re-enforced server-side), so a stale preview can never link a pair the fresh run would not. The old bank view keeps living until 6b (manual N:M + residual booking) reaches parity; only its matcher trips became unnecessary today.
[2026-08-24] vat_amount (categorize/bulk_book) is transaction-currency, converted to SEK at booking: the validation bound already read it in transaction currency (the underlag's denomination), and the gross line already converts through resolveSekAmount, so converting the VAT the same way was the only coherent option. Documenting it as SEK instead (the reporter's first suggestion, feedback seq 254607) would force agents to pre-convert with a settlement rate they cannot see.
[2026-08-24] Declared currency/voucher_series nullable in three MCP listing schemas on column-nullability alone (no traced null producer): loosening an output schema can only stop false validation failures, never cause one, and legacy rows predate the columns' defaults. Declined (for now) a full Ajv execute-vs-schema round-trip harness in output-schema.test.ts: right long-term answer to this bug class, but a session-sized project of its own; the audit's seven confirmed sites are pinned by a targeted declaration test instead.
[2026-08-24] Skattekonto payment file gets pain.001 through the supplier-payment generator (generateSupplierPain001), not the salary pain001 generator: the payment is a plain BG+OCR giro transfer (no SALA CtgyPurp), and the supplier dialect is the Validex-validated shape for exactly that; the LB path stays the default so nothing changes for banks still on LB.
@@ -73,6 +73,7 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
const [taxPayment, setTaxPayment] = useState<{
total_tax: number
total_avgifter: number
tax_payment_file_format: string | null
tax_payment_file_generated_at: string | null
tax_paid_at: string | null
} | null>(null)
@@ -868,8 +869,12 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
// the pre-AGI fallback and get truncated inside the panel.
totalTax={taxPayment?.total_tax ?? run.total_tax}
totalAvgifter={taxPayment?.total_avgifter ?? run.total_avgifter}
paymentFileFormat={taxPayment?.tax_payment_file_format ?? null}
paymentFileGeneratedAt={taxPayment?.tax_payment_file_generated_at ?? null}
taxPaidAt={taxPayment?.tax_paid_at ?? null}
defaultFormat={preferredPaymentFormat}
senderBankgiro={senderBankgiro}
senderIban={senderIban}
readOnly={!canWrite}
onChange={loadRun}
/>
@@ -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}"`,
},
})
},
+63 -6
View File
@@ -6,6 +6,8 @@ import { Button } from '@/components/ui/button'
import { DetailSection, DefRow } from '@/components/ui/detail-section'
import { HelpPopover } from '@/components/ui/help-popover'
import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { AttnLine } from '@/components/ui/attn-line'
import { SettingsSelect } from '@/components/settings/SettingsRows'
import { Download, Loader2, CheckCircle2, ExternalLink } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { downloadFile } from '@/lib/browser/download-file'
@@ -15,34 +17,51 @@ import type { ErrorLocale } from '@/lib/errors/get-error-message'
import { cn, formatCurrency, formatDateTime } from '@/lib/utils'
import { roundOre } from '@/lib/money'
type PaymentFormat = 'bg_lb' | 'pain001'
interface TaxPaymentPanelProps {
/** YYYY-MM */
period: string
totalTax: number
totalAvgifter: number
paymentFileFormat: string | null
paymentFileGeneratedAt: string | null
taxPaidAt: string | null
/** company_settings.preferred_payment_format: seeds the format selector. */
defaultFormat: PaymentFormat
/**
* company_settings.bankgiro / iban: the sender account each format requires.
* null means missing as of the latest settings fetch (warn up front, the
* download would 400); undefined means unknown (settings not loaded).
*/
senderBankgiro?: string | null
senderIban?: string | null
readOnly?: boolean
onChange?: () => void
}
/**
* Generates a Bankgirot LB-fil for paying skatt + arbetsgivaravgifter for an
* AGI period to Skatteverket Bankgiro 5050-1055 with the company's
* Skattekontot OCR.
* Generates the payment file (Bankgirot LB or ISO 20022 pain.001) for paying
* skatt + arbetsgivaravgifter for an AGI period to Skatteverket Bankgiro
* 5050-1055 with the company's Skattekontot OCR.
*/
export function TaxPaymentPanel({
period,
totalTax,
totalAvgifter,
paymentFileFormat,
paymentFileGeneratedAt,
taxPaidAt,
defaultFormat,
senderBankgiro,
senderIban,
readOnly,
onChange,
}: TaxPaymentPanelProps) {
const t = useTranslations('salary_payments')
const locale = useLocale() as ErrorLocale
const { toast } = useToast()
const [format, setFormat] = useState<PaymentFormat>(defaultFormat)
const [downloading, setDownloading] = useState(false)
const [marking, setMarking] = useState(false)
const [paymentDeadline, setPaymentDeadline] = useState<string>('')
@@ -72,12 +91,14 @@ export function TaxPaymentPanel({
if (downloading || marking) return
setDownloading(true)
try {
const filename =
format === 'pain001' ? `pain001_skatt_${period}.xml` : `bg_lb_skatt_${period}.txt`
// Bounded, and no file is written unless the server answered 2xx with a
// complete body: an error envelope saved as bg_lb_skatt_2026-04.txt is a
// file the user would upload to the bank before discovering it pays no tax.
const result = await downloadFile({
url: `/api/skatteverket/tax-payments/${period}/payment-file`,
filename: `bg_lb_skatt_${period}.txt`,
url: `/api/skatteverket/tax-payments/${period}/payment-file?format=${format}`,
filename,
locale,
})
// Exactly one toast per outcome: TOAST_LIMIT is 1.
@@ -97,7 +118,7 @@ export function TaxPaymentPanel({
} finally {
setDownloading(false)
}
}, [period, toast, onChange, t, locale, downloading, marking])
}, [period, format, toast, onChange, t, locale, downloading, marking])
const handleMarkPaid = useCallback(async () => {
if (downloading || marking) return
@@ -129,6 +150,18 @@ export function TaxPaymentPanel({
if (totalAmount <= 0) return null
const FORMAT_LABEL: Record<PaymentFormat, string> = {
bg_lb: t('format_bg_lb'),
pain001: t('format_pain001'),
}
// The sender account lives in company_settings, not in the Bolagsverket
// snapshot shown on the settings overview: say the precondition here,
// before the download 400s on it (same logic as PaymentFilePanel).
const senderMissing =
(format === 'bg_lb' && senderBankgiro === null) ||
(format === 'pain001' && senderIban === null)
return (
<DetailSection
kicker={t('tax_title')}
@@ -165,6 +198,9 @@ export function TaxPaymentPanel({
</DefRow>
{paymentFileGeneratedAt && (
<DefRow label={t('tax_file_generated')}>
{paymentFileFormat && (
<>{FORMAT_LABEL[paymentFileFormat as PaymentFormat] ?? paymentFileFormat} </>
)}
<span className="tabular-nums">{formatDateTime(paymentFileGeneratedAt)}</span>
</DefRow>
)}
@@ -173,8 +209,29 @@ export function TaxPaymentPanel({
<span className="tabular-nums">{formatDateTime(taxPaidAt)}</span>
</DefRow>
)}
{!readOnly && (
<DefRow label={t('format_label')}>
<SettingsSelect
aria-label={t('format_label')}
value={format}
onChange={(e) => setFormat(e.target.value as PaymentFormat)}
wrapperClassName="-my-1"
>
<option value="pain001">{FORMAT_LABEL.pain001}</option>
<option value="bg_lb">{FORMAT_LABEL.bg_lb}</option>
</SettingsSelect>
</DefRow>
)}
</div>
{!readOnly && senderMissing && (
<div className="mt-3">
<AttnLine action={{ label: t('missing_sender_link'), href: '/settings/invoicing' }}>
{format === 'bg_lb' ? t('missing_bankgiro_warning') : t('missing_iban_warning')}
</AttnLine>
</div>
)}
{!readOnly && (
<div className="mt-3 flex flex-wrap justify-end gap-2">
<Button onClick={handleDownload} disabled={downloading || marking}>
@@ -0,0 +1,16 @@
-- Allow ISO 20022 pain.001 as a tax payment file format.
--
-- The skattekonto payment file (skatt + arbetsgivaravgifter to Skatteverket
-- BG 5050-1055) was Bankgirot LB only; banks are sunsetting LB file uploads
-- during 2026 and companies on pain.001 for salary need the same format for
-- the tax payment. The route now generates either format, so the CHECK on
-- agi_declarations.tax_payment_file_format must admit 'pain001'.
ALTER TABLE public.agi_declarations
DROP CONSTRAINT IF EXISTS agi_declarations_tax_payment_format_check;
ALTER TABLE public.agi_declarations
ADD CONSTRAINT agi_declarations_tax_payment_format_check
CHECK (tax_payment_file_format IS NULL OR tax_payment_file_format IN ('bg_lb', 'pain001'));
NOTIFY pgrst, 'reload schema';