* 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>
207 lines
6.6 KiB
TypeScript
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>
|
|
)
|
|
}
|