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