diff --git a/DECISIONS.md b/DECISIONS.md index 6db35dcb..7d4ee824 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1185,3 +1185,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/salary/runs/[id]/page.tsx b/app/(dashboard)/salary/runs/[id]/page.tsx index bcbf1c9a..6afafcc7 100644 --- a/app/(dashboard)/salary/runs/[id]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/page.tsx @@ -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} /> diff --git a/app/api/skatteverket/tax-payments/[period]/payment-file/__tests__/route.test.ts b/app/api/skatteverket/tax-payments/[period]/payment-file/__tests__/route.test.ts index 3ed3f65a..f31702d2 100644 --- a/app/api/skatteverket/tax-payments/[period]/payment-file/__tests__/route.test.ts +++ b/app/api/skatteverket/tax-payments/[period]/payment-file/__tests__/route.test.ts @@ -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('') + 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() + }) }) diff --git a/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts b/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts index 3ffca45b..6efce6a7 100644 --- a/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts +++ b/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts @@ -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}"`, }, }) }, diff --git a/components/salary/TaxPaymentPanel.tsx b/components/salary/TaxPaymentPanel.tsx index 06ad6adc..75e6c851 100644 --- a/components/salary/TaxPaymentPanel.tsx +++ b/components/salary/TaxPaymentPanel.tsx @@ -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(defaultFormat) const [downloading, setDownloading] = useState(false) const [marking, setMarking] = useState(false) const [paymentDeadline, setPaymentDeadline] = useState('') @@ -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 = { + 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 ( {paymentFileGeneratedAt && ( + {paymentFileFormat && ( + <>{FORMAT_LABEL[paymentFileFormat as PaymentFormat] ?? paymentFileFormat} + )} {formatDateTime(paymentFileGeneratedAt)} )} @@ -173,8 +209,29 @@ export function TaxPaymentPanel({ {formatDateTime(taxPaidAt)} )} + {!readOnly && ( + + setFormat(e.target.value as PaymentFormat)} + wrapperClassName="-my-1" + > + + + + + )} + {!readOnly && senderMissing && ( +
+ + {format === 'bg_lb' ? t('missing_bankgiro_warning') : t('missing_iban_warning')} + +
+ )} + {!readOnly && (