Files
accounted/components/salary/TaxPaymentPanel.tsx
T
cef8206f83 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>
2026-08-24 15:29:21 +02:00

260 lines
9.5 KiB
TypeScript

'use client'
import { useCallback, useEffect, useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
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'
import { postAction } from '@/lib/browser/post-action'
import { failureDescription } from '@/lib/browser/action-failure'
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 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>('')
useEffect(() => {
const m = /^(\d{4})-(\d{2})$/.exec(period)
if (!m) return
const year = parseInt(m[1], 10)
const month = parseInt(m[2], 10)
const dlMonth = month === 12 ? 1 : month + 1
const dlYear = month === 12 ? year + 1 : year
setPaymentDeadline(`${dlYear}-${String(dlMonth).padStart(2, '0')}-12`)
}, [period])
// The page passes the AGI declaration's stored totals when the AGI exists
// (whole kronor for declarations generated since the whole-krona change:
// exactly what the payment file pays and Skatteverket draws), falling back
// to run totals. Display what will actually be paid: no reformatting here,
// so legacy öre declarations still show the öre-exact amount their
// payment file pays.
const totalAmount = roundOre(totalTax + totalAvgifter)
const handleDownload = useCallback(async () => {
// Both buttons are disabled while either is in flight; this guard closes the
// double-click race before React has re-rendered them. A second file for the
// same period is a second payable instruction to Skattekontot.
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?format=${format}`,
filename,
locale,
})
// Exactly one toast per outcome: TOAST_LIMIT is 1.
if (!result.ok) {
toast({
title: t('tax_download_failed_title'),
description: failureDescription(result, {
timeout: t('download_timeout'),
network: t('download_network'),
}),
variant: 'destructive',
})
return
}
toast({ title: t('tax_downloaded') })
onChange?.()
} finally {
setDownloading(false)
}
}, [period, format, toast, onChange, t, locale, downloading, marking])
const handleMarkPaid = useCallback(async () => {
if (downloading || marking) return
setMarking(true)
try {
const result = await postAction({
url: `/api/skatteverket/tax-payments/${period}/mark-paid`,
locale,
})
if (!result.ok) {
toast({
title: t('tax_mark_paid_failed_title'),
description: failureDescription(result, {
// A timeout on a write is genuinely ambiguous: the update may have
// landed. Say that instead of claiming it failed.
timeout: t('tax_mark_paid_timeout'),
network: t('tax_mark_paid_network'),
}),
variant: 'destructive',
})
return
}
toast({ title: t('tax_marked_paid') })
onChange?.()
} finally {
setMarking(false)
}
}, [period, toast, onChange, t, locale, downloading, marking])
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')}
help={<HelpPopover>{t('tax_ocr_note')}</HelpPopover>}
aside={
!readOnly ? (
// Quiet action on the kicker line: the skattekonto at Skatteverket
// is where the payment lands, not something this page does.
<a
href="https://www.skatteverket.se/foretag/skatterochavdrag/skattekonto.4.18e1b10334ebe8bc80004481.html"
target="_blank"
rel="noopener noreferrer"
className={cn(QUIET_LINK_CLASS, 'inline-flex items-center gap-1')}
>
{t('tax_skattekonto_button')}
<ExternalLink className="h-3 w-3" />
</a>
) : undefined
}
>
<div>
<DefRow label={t('tax_label_tax')}>
<span className="tabular-nums">{formatCurrency(totalTax)}</span>
</DefRow>
<DefRow label={t('tax_label_avgifter')}>
<span className="tabular-nums">{formatCurrency(totalAvgifter)}</span>
</DefRow>
<DefRow label={t('tax_label_total')}>
<span className="font-medium tabular-nums">{formatCurrency(totalAmount)}</span>
</DefRow>
<DefRow label={t('tax_recipient')}>{t('tax_recipient_value')}</DefRow>
<DefRow label={t('tax_due_date')}>
<span className="tabular-nums">{paymentDeadline}</span>
</DefRow>
{paymentFileGeneratedAt && (
<DefRow label={t('tax_file_generated')}>
{paymentFileFormat && (
<>{FORMAT_LABEL[paymentFileFormat as PaymentFormat] ?? paymentFileFormat} </>
)}
<span className="tabular-nums">{formatDateTime(paymentFileGeneratedAt)}</span>
</DefRow>
)}
{taxPaidAt && (
<DefRow label={t('tax_marked_paid')}>
<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}>
{downloading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
{t('tax_download_button')}
</Button>
{!taxPaidAt && (
<Button
variant="outline"
onClick={handleMarkPaid}
disabled={downloading || marking}
>
{marking ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
{t('tax_mark_paid_button')}
</Button>
)}
</div>
)}
</DetailSection>
)
}