ec27228a8e
Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
160 lines
5.1 KiB
TypeScript
160 lines
5.1 KiB
TypeScript
/**
|
|
* SKV574008 CSV serializer for periodisk sammanställning.
|
|
*
|
|
* Format (Skatteverket):
|
|
*
|
|
* SKV574008;
|
|
* {orgnr};{period};{kontaktnamn};{telefon};{email}
|
|
* {landskod+vatnr};{tjänster};{varor};{trepartshandel};
|
|
* ...
|
|
*
|
|
* - Semicolon-separated, ISO-8859-1 (Latin-1) encoded, CRLF terminated.
|
|
* - Period codes: YYMM for monthly (e.g. "2505"), YY-Q for quarterly
|
|
* (e.g. "25-2"). Verified against Skatteverket's current spec.
|
|
* - Amounts are whole kronor, signed integers (negative allowed).
|
|
* - Empty buckets are blank fields (`;;`), never `0;0`.
|
|
* - Avropslager codes X/Y/Z are NOT emitted in v1: only numeric amounts.
|
|
*
|
|
* The serializer refuses to build if any row has hasBlockingIssue=true or any
|
|
* warning is error-level. Caller (API route) returns 400 to the client.
|
|
*/
|
|
|
|
import type { PeriodiskSammanstallningReport } from './periodisk-sammanstallning'
|
|
|
|
export interface CsvFilerInfo {
|
|
organizationNumber: string // 10 or 12 digits, dashes/spaces stripped
|
|
contactName: string
|
|
contactPhone: string
|
|
contactEmail: string
|
|
}
|
|
|
|
export class PsCsvBuildError extends Error {
|
|
constructor(
|
|
public readonly reason:
|
|
| 'BLOCKING_WARNINGS'
|
|
| 'MISSING_FILER_INFO'
|
|
| 'INVALID_PERIOD_CODE',
|
|
message: string,
|
|
) {
|
|
super(message)
|
|
this.name = 'PsCsvBuildError'
|
|
}
|
|
}
|
|
|
|
const PERIOD_MONTHLY_RE = /^\d{2}(0[1-9]|1[0-2])$/
|
|
const PERIOD_QUARTERLY_RE = /^\d{2}-[1-4]$/
|
|
|
|
/**
|
|
* Encode the period for the file header.
|
|
* monthly: YYMM (e.g. 2505 for May 2025)
|
|
* quarterly: YY-Q (e.g. 25-2 for Q2 2025)
|
|
*/
|
|
export function formatPeriodCode(
|
|
type: 'monthly' | 'quarterly',
|
|
year: number,
|
|
period: number,
|
|
): string {
|
|
const yy = String(year % 100).padStart(2, '0')
|
|
if (type === 'monthly') {
|
|
if (period < 1 || period > 12) {
|
|
throw new PsCsvBuildError('INVALID_PERIOD_CODE', `Ogiltig månad: ${period}`)
|
|
}
|
|
return `${yy}${String(period).padStart(2, '0')}`
|
|
}
|
|
if (period < 1 || period > 4) {
|
|
throw new PsCsvBuildError('INVALID_PERIOD_CODE', `Ogiltigt kvartal: ${period}`)
|
|
}
|
|
return `${yy}-${period}`
|
|
}
|
|
|
|
function normalizeOrgNumber(raw: string): string {
|
|
return (raw ?? '').replace(/[^\d]/g, '')
|
|
}
|
|
|
|
function csvField(value: string): string {
|
|
// Skatteverket's CSV uses semicolon as separator. Any embedded semicolon or
|
|
// newline would corrupt the line; strip them rather than quote (Skatteverket
|
|
// does not document a quoting mechanism for SKV574008).
|
|
return value.replace(/[;\r\n]/g, ' ').trim()
|
|
}
|
|
|
|
function emptyOrAmount(n: number): string {
|
|
if (!Number.isFinite(n) || n === 0) return ''
|
|
return String(Math.trunc(n))
|
|
}
|
|
|
|
export function buildPeriodiskSammanstallningCsv(
|
|
report: PeriodiskSammanstallningReport,
|
|
filer: CsvFilerInfo,
|
|
): { filename: string; content: Buffer; mimeType: string } {
|
|
// 1. Filer info present?
|
|
const orgnr = normalizeOrgNumber(filer.organizationNumber)
|
|
if (!orgnr || !filer.contactName?.trim() || !filer.contactPhone?.trim() || !filer.contactEmail?.trim()) {
|
|
throw new PsCsvBuildError(
|
|
'MISSING_FILER_INFO',
|
|
'Kontaktuppgifter för skatterapportering saknas. Fyll i namn, telefon och e-post under Inställningar → Företag.',
|
|
)
|
|
}
|
|
|
|
// 2. No blocking warnings?
|
|
const blockingErrors = report.warnings.filter(w => w.level === 'error')
|
|
if (blockingErrors.length > 0) {
|
|
throw new PsCsvBuildError(
|
|
'BLOCKING_WARNINGS',
|
|
`${blockingErrors.length} blockerande fel måste åtgärdas innan CSV kan laddas ner.`,
|
|
)
|
|
}
|
|
|
|
// 3. Period code.
|
|
const periodCode = formatPeriodCode(report.period.type, report.period.year, report.period.period)
|
|
// Defensive: formatPeriodCode already validates, but double-check via regex.
|
|
const re = report.period.type === 'monthly' ? PERIOD_MONTHLY_RE : PERIOD_QUARTERLY_RE
|
|
if (!re.test(periodCode)) {
|
|
throw new PsCsvBuildError('INVALID_PERIOD_CODE', `Periodkod ${periodCode} matchar inte SKV-format.`)
|
|
}
|
|
|
|
// 4. Assemble lines.
|
|
const lines: string[] = []
|
|
lines.push('SKV574008;')
|
|
lines.push(
|
|
[
|
|
csvField(orgnr),
|
|
csvField(periodCode),
|
|
csvField(filer.contactName),
|
|
csvField(filer.contactPhone),
|
|
csvField(filer.contactEmail),
|
|
].join(';'),
|
|
)
|
|
|
|
for (const row of report.rows) {
|
|
// Skip rows where all three buckets round to zero (defensive: the
|
|
// generator already drops them, but a row could be passed in manually).
|
|
if (row.services === 0 && row.goods === 0 && row.triangulation === 0) continue
|
|
|
|
const vatField = `${row.country}${row.vatNumber}`
|
|
lines.push(
|
|
[
|
|
csvField(vatField),
|
|
emptyOrAmount(row.services),
|
|
emptyOrAmount(row.goods),
|
|
emptyOrAmount(row.triangulation),
|
|
'',
|
|
].join(';'),
|
|
)
|
|
}
|
|
|
|
// CRLF line endings, ISO-8859-1 encoded.
|
|
const text = lines.join('\r\n') + '\r\n'
|
|
const content = Buffer.from(text, 'latin1')
|
|
|
|
const periodLabel = report.period.type === 'monthly'
|
|
? periodCode // 2505
|
|
: periodCode.replace('-', 'Q') // 25Q2 for filename clarity
|
|
|
|
return {
|
|
filename: `Periodisk_sammanstallning_${orgnr}_${periodLabel}.csv`,
|
|
content,
|
|
mimeType: 'text/csv; charset=iso-8859-1',
|
|
}
|
|
}
|