Files
accounted/lib/reports/vat-declaration-pdf-template.tsx
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

207 lines
6.6 KiB
TypeScript

import { formatOrgNumber } from '@/lib/utils'
import {
Document,
Page,
Text,
View,
StyleSheet,
} from '@react-pdf/renderer'
import { pdfNumberText, formatDateSv } from '@/lib/pdf/number-text'
import type { CompanySettings } from '@/types'
import type { ManualFilingRow } from '@/lib/reports/vat-manual-filing'
const styles = StyleSheet.create({
page: {
paddingTop: 40,
paddingHorizontal: 40,
paddingBottom: 90,
fontSize: 10,
fontFamily: 'Helvetica',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 16,
paddingBottom: 14,
borderBottomWidth: 1,
borderBottomColor: '#d4d4d4',
},
titleBlock: { flex: 1 },
title: {
fontSize: 20,
fontWeight: 'bold',
color: '#1a1a1a',
marginBottom: 4,
},
subtitle: { fontSize: 11, color: '#333', marginBottom: 2 },
period: { fontSize: 10, color: '#666' },
companyInfo: { textAlign: 'right' },
companyName: { fontSize: 11, fontWeight: 'bold', marginBottom: 2 },
companyMeta: { fontSize: 9, color: '#666' },
note: {
marginBottom: 18,
paddingVertical: 6,
paddingHorizontal: 10,
borderWidth: 0.8,
borderColor: '#b45309',
backgroundColor: '#fef3c7',
borderRadius: 3,
},
noteText: { fontSize: 8, color: '#78350f', lineHeight: 1.3 },
tableHeadRow: {
flexDirection: 'row',
paddingBottom: 4,
marginBottom: 2,
borderBottomWidth: 1,
borderBottomColor: '#1a1a1a',
},
headRuta: { width: 44, fontSize: 8, fontWeight: 'bold', color: '#444' },
headLabel: { flex: 1, fontSize: 8, fontWeight: 'bold', color: '#444', paddingRight: 12 },
headAmount: { width: 110, fontSize: 8, fontWeight: 'bold', color: '#444', textAlign: 'right' },
row: { flexDirection: 'row', paddingVertical: 3 },
colRuta: { width: 44, fontFamily: 'Courier', color: '#666' },
colLabel: { flex: 1, color: '#1a1a1a', paddingRight: 12 },
colAmount: { width: 110, textAlign: 'right', fontFamily: 'Courier', color: '#1a1a1a' },
netRow: {
flexDirection: 'row',
paddingVertical: 6,
marginTop: 6,
borderTopWidth: 1,
borderTopColor: '#1a1a1a',
},
netLabel: { flex: 1, fontWeight: 'bold', fontSize: 11, paddingLeft: 44 },
netAmount: {
width: 110,
textAlign: 'right',
fontFamily: 'Courier',
fontWeight: 'bold',
fontSize: 11,
},
footer: {
position: 'absolute',
bottom: 24,
left: 40,
right: 40,
borderTopWidth: 0.5,
borderTopColor: '#d4d4d4',
paddingTop: 6,
flexDirection: 'row',
justifyContent: 'space-between',
},
footerText: { fontSize: 8, color: '#888' },
})
// Hela kronor: the momsdeklaration is filed in whole kronor, and the rows are
// already truncated to whole kronor (öretal faller bort, see
// buildManualFilingRows), so format with no decimals.
function formatKr(amount: number): string {
// pdfNumberText: Intl's U+2212 has no glyph in the bundled Helvetica/Courier,
// so moms att få tillbaka would print as moms att betala (issue #1982).
return pdfNumberText(new Intl.NumberFormat('sv-SE', { maximumFractionDigits: 0 }).format(amount))
}
interface VatDeclarationPDFProps {
rows: ManualFilingRow[]
period: { start: string; end: string }
periodLabel: string
company: CompanySettings
generatedAt: string
}
/**
* A momsdeklaration document (SKV 4700 layout) for manual filing at
* skatteverket.se. Amounts are in hela kronor, matching what the user types
* into the form. This is a reading/record copy, NOT a file that is uploaded to
* Skatteverket, moms has no file-submission format; the machine channel is the
* Skatteverket API. The disclaimer says so explicitly.
*
* Swedish-only: momsdeklaration ruta labels are Skatteverket form labels.
*/
export function VatDeclarationPDF({
rows,
period,
periodLabel,
company,
generatedAt,
}: VatDeclarationPDFProps) {
const companyDisplayName = company.company_name || ''
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.header} fixed>
<View style={styles.titleBlock}>
<Text style={styles.title}>Momsdeklaration</Text>
{periodLabel && <Text style={styles.subtitle}>{periodLabel}</Text>}
{period.start && period.end && (
<Text style={styles.period}>
Period: {formatDateSv(period.start)}: {formatDateSv(period.end)}
</Text>
)}
</View>
<View style={styles.companyInfo}>
{companyDisplayName && (
<Text style={styles.companyName}>{companyDisplayName}</Text>
)}
{company.org_number && (
<Text style={styles.companyMeta}>Org.nr: {formatOrgNumber(company.org_number)}</Text>
)}
{company.vat_number && (
<Text style={styles.companyMeta}>Moms-nr: {company.vat_number}</Text>
)}
</View>
</View>
<View style={styles.note}>
<Text style={styles.noteText}>
Underlag för manuell inlämning. Detta är inte en inlämnad deklaration.
Logga in på skatteverket.se med BankID, öppna Moms- och
arbetsgivardeklarationer och skriv in beloppen nedan. Belopp i hela kronor.
</Text>
</View>
<View style={styles.tableHeadRow} fixed>
<Text style={styles.headRuta}>Ruta</Text>
<Text style={styles.headLabel}>Beskrivning</Text>
<Text style={styles.headAmount}>Belopp (kr)</Text>
</View>
{rows
.filter((r) => !r.isNet)
.map((r) => (
<View key={r.ruta} style={styles.row} wrap={false}>
<Text style={styles.colRuta}>{r.ruta}</Text>
<Text style={styles.colLabel}>{r.label}</Text>
<Text style={styles.colAmount}>{formatKr(r.amount)}</Text>
</View>
))}
{rows
.filter((r) => r.isNet)
.map((r) => (
<View key={r.ruta} style={styles.netRow} wrap={false}>
<Text style={styles.netLabel}>
{r.ruta} {r.label}
</Text>
<Text style={styles.netAmount}>{formatKr(r.amount)}</Text>
</View>
))}
<View style={styles.footer} fixed>
<Text style={styles.footerText}>
{companyDisplayName}
{company.org_number ? ` · ${formatOrgNumber(company.org_number)}` : ''}
</Text>
<Text
style={styles.footerText}
render={({ pageNumber, totalPages }) =>
`Genererad ${formatDateSv(generatedAt)} · Sida ${pageNumber} av ${totalPages}`
}
/>
</View>
</Page>
</Document>
)
}