Files
accounted/lib/reports/periodisk-sammanstallning-csv.ts
T
MattssonandClaude Opus 4.7 c8461397c8 Bug/accounting ps eu (#474)
* feat(api): implement commit functionality for journal entries

* fix(extensions): make ExtensionSettings.clear() a real delete so disconnect flows work

The 2026-03-30 multi-tenant refactor dropped all RLS policies on extension_data
and recreated only SELECT/INSERT/UPDATE. Combined with `value jsonb NOT NULL`,
every extension that called `settings.set(key, null)` to clear stored state
(cloud-backup disconnect, skatteverket OAuth/AGI cleanup, arcim-migration
consent reset) silently failed — the upsert hit the NOT NULL constraint and
the error was swallowed, leaving users stuck with stale connection rows.

Adds an `extension_data_delete` RLS policy, a `clear(key)` method backed by a
real DELETE, switches the four affected handlers, and makes `set()` throw on
Supabase error so this class of silent failure can't recur.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(journal-entries): add draft saving functionality to journal entry form

* feat: add periodisk sammanställning report generation and CSV export

- Implemented period date helpers in `period-dates.ts` for calculating start and end dates based on period type (monthly, quarterly, yearly).
- Created `periodisk-sammanstallning.ts` to generate the periodisk sammanställning report, including data fetching, validation, and warning handling.
- Developed CSV serializer in `periodisk-sammanstallning-csv.ts` for exporting the report in SKV574008 format.
- Added new columns to `company_settings` for storing periodisk sammanställning settings and tax contact information via migration.
- Introduced a new migration to add a `paid_with_private_funds` flag to `supplier_invoices` for tracking out-of-pocket expenses.
- Updated journal entries to include the new source type for privately paid supplier invoices.

* feat(migrations): add paid_with_private_funds flag to supplier_invoices and expand journal_entries.source_type CHECK

* fix(ai_requests): drop existing policies and trigger before creating new ones

* fix(migrations): ensure extension_data has a proper DELETE policy for ExtensionSettings.clear()

* fix(supplier-invoices): update error handling for invalid input in POST request

* fix: correct capitalization in project title

* fix(migrations): resolve duplicate version 20260513120000

Two migrations shared the same timestamp prefix, causing
schema_migrations_pkey collision on Supabase preview branches.
Bump extension_data_delete_policy to 20260513120001.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:10:44 +02:00

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',
}
}