* fix(salary): show birthdate in masked personnummer, hide the 4-digit suffix Flip the personnummer display format from XXXXXXXX-NNNN to YYYYMMDD-XXXX so the sensitive 4-digit suffix is hidden while the (public) birthdate stays visible. Affects the employees list/detail, salary run, payslip PDF, payslip email, and the MCP server tools (list_employees, get_salary_run). Each call site now decrypts the stored personnummer before masking. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(transactions): allow deleting unbooked transactions from "Alla transaktioner" The history list only let users delete via the inbox card; once a category or mall was picked but the verifikation hadn't been created, the row showed "Ej bokförd" with no way to remove it. The API already permits delete while journal_entry_id is null, so the gap was purely a missing UI affordance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(vat): populate ruta 20-24 for reverse charge + dishonest "Validera OK" Three connected issues caused Skatteverket to reject momsdeklarationer with FK004 even after our local "Validera"-knapp returned OK. 1. supplier-invoice-entries booked fiktiv moms (2614/2624/2634 + 2645/2647) on reverse-charge invoices but never the underlying basbelopp on 44xx/45xx. Ruta 30-32 filled up at SKV while ruta 20-24 stayed at 0 — SKV's FK004 ("silent netting prohibited", ML 13 kap kräver båda sidor). Fix: generateReverseChargeBasisLines in vat-entries.ts emits parallel 45xx/44xx debit + 4598 motkonto credit per rate group. Engine calls it from registration, cash, and credit-note paths. Skipped when the user booked the expense directly on a basis account to avoid double-counting. 4598 added to BAS reference (no migration needed; account_number is plain text on journal_entry_lines). 2. rutorToMomsuppgift rounded each ruta independently but computed summaMoms from the unrounded ruta49. SKV recomputes the sum from integer rutor on their side, so fractional öres caused ±1 SEK drift and SKV rejected with FK009. Fix: derive summaMoms from the already-rounded VAT-amount rutor. 3. "Validera"-knappen only confirmed SKV's internal arithmetic — a declaration with ruta 30-32 populated and ruta 20-24 empty validated fine until /utkast hit FK004. Users got a false green light. Fix: vat-declaration-checks.ts runs locally before the SKV call, blocks Validera/Spara when ERROR-level findings exist, and surfaces them in a separate "Lokala kontroller"-section. Success message reworded so SKV's OK is no longer presented as filing-ready. Tests: 4535/4536/4531/4425 lines + 4598 motkonto on EU/non-EU/byggtjänster RC, credit-note reversal, fractional-öres summaMoms, all four pre-flight codes (RC_BASIS_MISSING, RC_OUTPUT_MISSING, RC_INPUT_VAT_MISMATCH, SUMMA_MOMS_DRIFT). Backfill for already-posted entries follows in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: add skattekonto matching functionality - Enhance TransactionInboxCard to display a warning for potential 1930↔1630 transfers. - Implement match suggestions for skattekonto transactions in the backend. - Create SkattekontoMatchDialog component for linking skattekonto rows to existing journal entries. - Develop SkattekontoInboxCard component to handle skattekonto transactions in the inbox. - Introduce skattekonto-match utility functions for candidate matching and linking. - Update types to include match suggestions and enriched transaction responses. * refactor: reorganize skattekonto types and implement bank counterpart matching logic * docs: update CLAUDE.md to streamline integrations and clarify architecture details * refactor: enhance reverse charge logic to handle non-basis accounts and prevent double-counting --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer'
|
||
import { getBranding } from '@/lib/branding/service'
|
||
|
||
/**
|
||
* Pay slip PDF template (Lönespecifikation).
|
||
*
|
||
* Legally required per BFL as räkenskapsinformation/underlag.
|
||
* Subject to 7-year retention per BFL 7 kap.
|
||
*
|
||
* Contents:
|
||
* - Company + employee identification
|
||
* - Line items (salary, absence, benefits, deductions)
|
||
* - Gross → Tax → Net summary with tax table reference
|
||
* - Employer cost breakdown (avgifter, vacation accrual) — transparency feature
|
||
* - YTD totals (cumulative year-to-date)
|
||
* - Calculation breakdown (optional detail showing every formula step)
|
||
*/
|
||
|
||
const styles = StyleSheet.create({
|
||
page: {
|
||
fontFamily: 'Helvetica',
|
||
fontSize: 9,
|
||
padding: 40,
|
||
color: '#1a1a1a',
|
||
},
|
||
header: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
marginBottom: 20,
|
||
},
|
||
title: {
|
||
fontSize: 16,
|
||
fontFamily: 'Helvetica-Bold',
|
||
marginBottom: 4,
|
||
},
|
||
subtitle: {
|
||
fontSize: 10,
|
||
color: '#666',
|
||
},
|
||
companyName: {
|
||
fontSize: 12,
|
||
fontFamily: 'Helvetica-Bold',
|
||
},
|
||
companyInfo: {
|
||
fontSize: 8,
|
||
color: '#666',
|
||
marginTop: 2,
|
||
},
|
||
section: {
|
||
marginBottom: 14,
|
||
},
|
||
sectionTitle: {
|
||
fontSize: 10,
|
||
fontFamily: 'Helvetica-Bold',
|
||
marginBottom: 6,
|
||
paddingBottom: 3,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: '#e0e0e0',
|
||
},
|
||
row: {
|
||
flexDirection: 'row',
|
||
paddingVertical: 3,
|
||
},
|
||
rowAlt: {
|
||
flexDirection: 'row',
|
||
paddingVertical: 3,
|
||
backgroundColor: '#f8f8f8',
|
||
},
|
||
headerRow: {
|
||
flexDirection: 'row',
|
||
paddingVertical: 4,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: '#ccc',
|
||
marginBottom: 2,
|
||
},
|
||
colDesc: { flex: 3.5 },
|
||
colQty: { flex: 1, textAlign: 'right' as const },
|
||
colRate: { flex: 1.5, textAlign: 'right' as const },
|
||
colAmount: { flex: 1.5, textAlign: 'right' as const },
|
||
headerText: {
|
||
fontSize: 8,
|
||
fontFamily: 'Helvetica-Bold',
|
||
color: '#666',
|
||
textTransform: 'uppercase' as const,
|
||
},
|
||
summaryRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
paddingVertical: 3,
|
||
},
|
||
summaryLabel: {
|
||
fontSize: 9,
|
||
color: '#444',
|
||
},
|
||
summaryValue: {
|
||
fontSize: 9,
|
||
fontFamily: 'Helvetica-Bold',
|
||
textAlign: 'right' as const,
|
||
},
|
||
totalRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
paddingVertical: 6,
|
||
borderTopWidth: 2,
|
||
borderTopColor: '#1a1a1a',
|
||
marginTop: 4,
|
||
},
|
||
totalLabel: {
|
||
fontSize: 11,
|
||
fontFamily: 'Helvetica-Bold',
|
||
},
|
||
totalValue: {
|
||
fontSize: 11,
|
||
fontFamily: 'Helvetica-Bold',
|
||
textAlign: 'right' as const,
|
||
},
|
||
infoGrid: {
|
||
flexDirection: 'row',
|
||
gap: 20,
|
||
marginBottom: 14,
|
||
},
|
||
infoColumn: {
|
||
flex: 1,
|
||
},
|
||
infoLabel: {
|
||
fontSize: 7,
|
||
color: '#999',
|
||
textTransform: 'uppercase' as const,
|
||
marginBottom: 1,
|
||
},
|
||
infoValue: {
|
||
fontSize: 9,
|
||
marginBottom: 6,
|
||
},
|
||
breakdownSection: {
|
||
marginTop: 10,
|
||
padding: 10,
|
||
backgroundColor: '#f5f5f5',
|
||
borderRadius: 3,
|
||
},
|
||
breakdownTitle: {
|
||
fontSize: 9,
|
||
fontFamily: 'Helvetica-Bold',
|
||
marginBottom: 6,
|
||
},
|
||
breakdownRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
paddingVertical: 1.5,
|
||
},
|
||
breakdownLabel: {
|
||
fontSize: 7.5,
|
||
color: '#555',
|
||
flex: 3,
|
||
},
|
||
breakdownFormula: {
|
||
fontSize: 7,
|
||
color: '#888',
|
||
flex: 3,
|
||
fontFamily: 'Courier',
|
||
},
|
||
breakdownValue: {
|
||
fontSize: 7.5,
|
||
textAlign: 'right' as const,
|
||
flex: 1.5,
|
||
},
|
||
footer: {
|
||
position: 'absolute' as const,
|
||
bottom: 30,
|
||
left: 40,
|
||
right: 40,
|
||
borderTopWidth: 1,
|
||
borderTopColor: '#e0e0e0',
|
||
paddingTop: 6,
|
||
fontSize: 7,
|
||
color: '#999',
|
||
textAlign: 'center' as const,
|
||
},
|
||
ytdSection: {
|
||
marginTop: 10,
|
||
},
|
||
ytdRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
paddingVertical: 2,
|
||
},
|
||
ytdLabel: {
|
||
fontSize: 8,
|
||
color: '#666',
|
||
},
|
||
ytdValue: {
|
||
fontSize: 8,
|
||
color: '#666',
|
||
textAlign: 'right' as const,
|
||
},
|
||
})
|
||
|
||
export interface PayslipData {
|
||
// Company
|
||
companyName: string
|
||
companyOrgNumber: string
|
||
companyAddress?: string
|
||
|
||
// Employee
|
||
employeeName: string
|
||
personnummerMasked: string // YYYYMMDD-XXXX
|
||
employmentType: string
|
||
|
||
// Period
|
||
periodYear: number
|
||
periodMonth: number
|
||
paymentDate: string
|
||
|
||
// Line items
|
||
lineItems: PayslipLineItem[]
|
||
|
||
// Summary
|
||
grossSalary: number
|
||
taxWithheld: number
|
||
netSalary: number
|
||
taxReference: string // e.g. "Tabell 33, kolumn 1"
|
||
|
||
// Employer cost (transparency feature)
|
||
avgifterRate: number
|
||
avgifterAmount: number
|
||
vacationAccrual: number
|
||
vacationAccrualAvgifter: number
|
||
totalEmployerCost: number
|
||
|
||
// YTD
|
||
ytdGross: number
|
||
ytdTax: number
|
||
ytdNet: number
|
||
|
||
// Bank
|
||
bankAccount?: string // masked
|
||
|
||
// Calculation breakdown (optional)
|
||
breakdownSteps?: { label: string; formula: string; output: number }[]
|
||
}
|
||
|
||
export interface PayslipLineItem {
|
||
description: string
|
||
quantity?: number
|
||
unitPrice?: number
|
||
amount: number
|
||
}
|
||
|
||
function fmt(amount: number): string {
|
||
return new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(amount)
|
||
}
|
||
|
||
const MONTH_NAMES = [
|
||
'januari', 'februari', 'mars', 'april', 'maj', 'juni',
|
||
'juli', 'augusti', 'september', 'oktober', 'november', 'december',
|
||
]
|
||
|
||
export function PayslipPDF({ data }: { data: PayslipData }) {
|
||
const periodLabel = `${MONTH_NAMES[data.periodMonth - 1]} ${data.periodYear}`
|
||
|
||
return (
|
||
<Document>
|
||
<Page size="A4" style={styles.page}>
|
||
{/* Header */}
|
||
<View style={styles.header}>
|
||
<View>
|
||
<Text style={styles.title}>Lönespecifikation</Text>
|
||
<Text style={styles.subtitle}>{periodLabel}</Text>
|
||
</View>
|
||
<View style={{ alignItems: 'flex-end' as const }}>
|
||
<Text style={styles.companyName}>{data.companyName}</Text>
|
||
<Text style={styles.companyInfo}>Org.nr {data.companyOrgNumber}</Text>
|
||
{data.companyAddress && <Text style={styles.companyInfo}>{data.companyAddress}</Text>}
|
||
</View>
|
||
</View>
|
||
|
||
{/* Employee + Period info */}
|
||
<View style={styles.infoGrid}>
|
||
<View style={styles.infoColumn}>
|
||
<Text style={styles.infoLabel}>Anställd</Text>
|
||
<Text style={styles.infoValue}>{data.employeeName}</Text>
|
||
<Text style={styles.infoLabel}>Personnummer</Text>
|
||
<Text style={styles.infoValue}>{data.personnummerMasked}</Text>
|
||
</View>
|
||
<View style={styles.infoColumn}>
|
||
<Text style={styles.infoLabel}>Period</Text>
|
||
<Text style={styles.infoValue}>{periodLabel}</Text>
|
||
<Text style={styles.infoLabel}>Utbetalningsdag</Text>
|
||
<Text style={styles.infoValue}>{data.paymentDate}</Text>
|
||
</View>
|
||
<View style={styles.infoColumn}>
|
||
<Text style={styles.infoLabel}>Skattetabell</Text>
|
||
<Text style={styles.infoValue}>{data.taxReference}</Text>
|
||
{data.bankAccount && (
|
||
<>
|
||
<Text style={styles.infoLabel}>Bankkonto</Text>
|
||
<Text style={styles.infoValue}>{data.bankAccount}</Text>
|
||
</>
|
||
)}
|
||
</View>
|
||
</View>
|
||
|
||
{/* Line items table */}
|
||
<View style={styles.section}>
|
||
<Text style={styles.sectionTitle}>Lönespecifikation</Text>
|
||
<View style={styles.headerRow}>
|
||
<Text style={[styles.headerText, styles.colDesc]}>Beskrivning</Text>
|
||
<Text style={[styles.headerText, styles.colQty]}>Antal</Text>
|
||
<Text style={[styles.headerText, styles.colRate]}>á-pris</Text>
|
||
<Text style={[styles.headerText, styles.colAmount]}>Belopp</Text>
|
||
</View>
|
||
{data.lineItems.map((item, i) => (
|
||
<View key={i} style={i % 2 === 1 ? styles.rowAlt : styles.row}>
|
||
<Text style={styles.colDesc}>{item.description}</Text>
|
||
<Text style={styles.colQty}>{item.quantity != null ? item.quantity : ''}</Text>
|
||
<Text style={styles.colRate}>{item.unitPrice != null ? fmt(item.unitPrice) : ''}</Text>
|
||
<Text style={styles.colAmount}>{fmt(item.amount)}</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
|
||
{/* Summary: Gross → Tax → Net */}
|
||
<View style={styles.section}>
|
||
<View style={styles.summaryRow}>
|
||
<Text style={styles.summaryLabel}>Bruttolön</Text>
|
||
<Text style={styles.summaryValue}>{fmt(data.grossSalary)}</Text>
|
||
</View>
|
||
<View style={styles.summaryRow}>
|
||
<Text style={styles.summaryLabel}>Preliminär skatt ({data.taxReference})</Text>
|
||
<Text style={styles.summaryValue}>−{fmt(data.taxWithheld)}</Text>
|
||
</View>
|
||
<View style={styles.totalRow}>
|
||
<Text style={styles.totalLabel}>Nettolön (utbetalas)</Text>
|
||
<Text style={styles.totalValue}>{fmt(data.netSalary)}</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* Employer cost (transparency feature — our differentiator) */}
|
||
<View style={styles.section}>
|
||
<Text style={styles.sectionTitle}>Arbetsgivarkostnad</Text>
|
||
<View style={styles.summaryRow}>
|
||
<Text style={styles.summaryLabel}>Arbetsgivaravgifter ({(data.avgifterRate * 100).toFixed(2)}%)</Text>
|
||
<Text style={styles.summaryValue}>{fmt(data.avgifterAmount)}</Text>
|
||
</View>
|
||
<View style={styles.summaryRow}>
|
||
<Text style={styles.summaryLabel}>Semesteravsättning</Text>
|
||
<Text style={styles.summaryValue}>{fmt(data.vacationAccrual)}</Text>
|
||
</View>
|
||
<View style={styles.summaryRow}>
|
||
<Text style={styles.summaryLabel}>Sociala avgifter på semester</Text>
|
||
<Text style={styles.summaryValue}>{fmt(data.vacationAccrualAvgifter)}</Text>
|
||
</View>
|
||
<View style={styles.totalRow}>
|
||
<Text style={styles.totalLabel}>Total arbetsgivarkostnad</Text>
|
||
<Text style={styles.totalValue}>{fmt(data.totalEmployerCost)}</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* YTD */}
|
||
<View style={styles.ytdSection}>
|
||
<Text style={[styles.sectionTitle, { fontSize: 9 }]}>Ackumulerat {data.periodYear}</Text>
|
||
<View style={styles.ytdRow}>
|
||
<Text style={styles.ytdLabel}>Brutto</Text>
|
||
<Text style={styles.ytdValue}>{fmt(data.ytdGross)}</Text>
|
||
</View>
|
||
<View style={styles.ytdRow}>
|
||
<Text style={styles.ytdLabel}>Skatt</Text>
|
||
<Text style={styles.ytdValue}>{fmt(data.ytdTax)}</Text>
|
||
</View>
|
||
<View style={styles.ytdRow}>
|
||
<Text style={styles.ytdLabel}>Netto</Text>
|
||
<Text style={styles.ytdValue}>{fmt(data.ytdNet)}</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* Calculation breakdown (optional detail page) */}
|
||
{data.breakdownSteps && data.breakdownSteps.length > 0 && (
|
||
<View style={styles.breakdownSection}>
|
||
<Text style={styles.breakdownTitle}>Beräkningsunderlag</Text>
|
||
{data.breakdownSteps.map((step, i) => (
|
||
<View key={i} style={styles.breakdownRow}>
|
||
<Text style={styles.breakdownLabel}>{step.label}</Text>
|
||
<Text style={styles.breakdownFormula}>{step.formula}</Text>
|
||
<Text style={styles.breakdownValue}>{fmt(step.output)}</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
)}
|
||
|
||
{/* Footer */}
|
||
<Text style={styles.footer}>
|
||
{data.companyName} · Org.nr {data.companyOrgNumber} · Lönespecifikation {periodLabel} · Genererad av {getBranding().appName.toLowerCase()}
|
||
</Text>
|
||
</Page>
|
||
</Document>
|
||
)
|
||
}
|