Files
accounted/lib/reports/ink2/sru-generator.ts
T
MattssonandClaude Fable 5 39f4ecdad4 fix(providers): surface migration step errors; INK2 SRU 7104; non-modal invoice dialog (#1465)
* feat(mileage): körjournal with milersättning booking, MCP tools and CSV export

New mileage_trips table (RLS, booked-delete trigger per BFL retention),
lib/mileage service reusing the payroll schablon rates, /api/mileage routes
(trips CRUD, period booking to 7331, salary-run push, körjournal CSV),
Körjournal dashboard page + nav, and three staged MCP tools (search-only
catalog). Trips book as one verifikat per period via the engine; salary
path inserts mileage_taxfree line items. mileage_trips classified in the
full-archive export.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(mileage): use shared roundOre helper per tightened ratchet baseline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): pending_operations op-type migration + Swedish review findings

- New migration pair adds log_mileage_trip/book_mileage_period to the
  pending_operations operation_type CHECK (pg-real audit).
- bookMileagePeriod refuses a period spanning several employees and names
  the employee in the verifikationstext when scoped (BFL motpart).
- vehicle_registration required for förmånsbil trips (schema, service,
  MCP staging, UI surfaces the field).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): claim-first booking, CSV injection guard and driver column

- bookMileagePeriod claims trips (draft to booked CAS) before creating the
  verifikat, so a concurrent second booking loses the race instead of
  double-booking; claim reverts if verifikat creation fails.
- Körjournal CSV neutralizes formula-injection triggers (OWASP) and adds a
  Förare column naming the employee per trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): resolve CodeRabbit + Swedish review round: race, drift and hardening

- Copying a round trip no longer re-doubles the stored distance.
- pushMileageToSalaryRun claims trips before inserting line items (retry can
  no longer double-pay); CLAIM_LOST replaces misleading NO_TRIPS on lost races.
- Booked trips are DB-immutable via a BEFORE UPDATE trigger (new migration
  20260807113215): only claim/link/revert transitions and notes edits pass.
- Cross-year periods rejected (schablon rates are per calendar year); payroll
  config year read from the date string, not TZ-dependent getFullYear().
- MCP staged bookings freeze the previewed trip set (trip_ids in params) and
  the commit fails on drift; validation errors return 400, not 500.
- PATCH enforces the förmånsbil regnr rule on the effective row; export
  validates dates before they reach the Content-Disposition header; employee_id
  is verified company-scoped on trip creation; stale orphaned claims released.
- UI: fetch flags reset in finally; ICU plural for draft summary; distance
  stored at the column's 1-decimal precision.
- Tests: [id] route suite, pushMileageToSalaryRun suite, claim-race, drift,
  cross-year and update-trigger pg cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): revert-to-draft must clear salary_run_id at the trigger level

New migration 20260807114924 replaces the booked-immutability function: a
booked -> draft revert now rejects rows keeping salary_run_id, closing the
DB-level double-pay path CodeRabbit flagged. pg test pins both directions;
the CLAIM_LOST unit test now asserts the revert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): company-scope employee_id on PATCH (Superagent P2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mileage): valid v4 uuid in cross-company employee PATCH test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(providers): surface migration step errors instead of silent empty syncs

A Visma company without the API module activated (403 ErrorCode 4002,
"No access to module: api_standard") failed every provider call during
migration, yet the wizard reported success with zero rows and mapped the
403 to "reconnect", which loops forever since OAuth succeeds against
Visma's shared identity server. A real user burned time re-syncing and
reconnecting, then filed the config issue as a bug.

- New PROVIDER_API_MODULE_INACTIVE code; classifyProviderError reads the
  error body and recognizes the module error before the 403 to
  AUTH_EXPIRED mapping. Registry entry carries the remediation in
  Swedish and English (activate the API under Appar och tillagg, paid
  add-on on smaller plans, clear standardforetag, SIE fallback).
- Orchestrator: connection-level failures (auth expired, license
  missing, module inactive) rethrow and abort the doomed run so /migrate
  answers with the typed code; other step failures stay non-fatal but
  land on results.stepErrors instead of only in server logs.
- /preview fails fast on the two subscription codes so the user reads
  the remediation at connect time, before any sync.
- Wizard: preview treats the new code like the Fortnox license case
  (CTA + SIE fallback); the result step renders error cards per cause
  and says "Migrering delvis genomford" instead of "Allt ar uppdaterat";
  the completion toast is honest on partial failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ink2): SRU field 1.1 is 7104, not 7113 (Skatteverket rejects 7113)

The INK2 huvudblankett code for 1.1 Overskott av naringsverksamhet is
7104 per Skatteverket's official 2025P4 faltkoder (INK2_SKV2002-33-01-24-04).
We emitted 7113, which does not exist on INK2, so filoverforing rejected
every profitable company's BLANKETTER.SRU with 'UPPGIFT 7113 ar inte ett
giltigt postnamn' (reported by a user for FY 2024-10-07..2025-12-31).
Underskott (7114) was already correct.

The wrong code originated in the swedish-sru-filing skill reference;
fixed there too and regenerated the atom seed. All other emitted
INK2/INK2R/INK2S codes verified against the official 2025P4 lists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): keep the AI chat usable over the new-invoice dialog

The new-invoice dialog was a modal Radix dialog: modal mode sets body
pointer-events: none, aria-hidden on body siblings, and a focus trap, so
the agent sheet (z-60, painted above the dialog) was visible but dead:
clicks swallowed, input unfocusable, and all three dismiss paths
preventDefaulted, leaving no way out except the header X.

Now non-modal: page modality is restored by hand instead. A new
DialogVeil primitive supplies the backdrop (Radix renders no overlay in
non-modal mode) at z-40, under dialog content (z-50) and the agent sheet
(z-60), and inert on #dash-shell blocks pointer, keyboard, and AT access
to the page behind while the sheet (a body-level sibling) stays live.
The lazy-load fallback dialog on /invoices gets the same treatment so a
hung or 404'd chunk cannot dead-lock the route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 16:04:58 +02:00

300 lines
9.2 KiB
TypeScript

import { getBranding } from '@/lib/branding/service'
import type {
INK2Declaration,
INK2RSRUCode,
INK2SRutor,
SRUSubmission,
} from './types'
import {
INK2R_ASSET_CODES,
INK2R_EQUITY_LIABILITY_CODES,
INK2R_INCOME_CODES,
} from './types'
/**
* SRU File Generator for INK2 (Aktiebolag)
*
* Generates a Skatteverket-compliant SRU submission consisting of:
* - INFO.SRU: submitter metadata
* - BLANKETTER.SRU: three blankett blocks (INK2, INK2R, INK2S)
*
* Encoding: ISO 8859-1 (handled by the API route when writing the response)
* Line endings: CRLF
* Amounts: integers in hela kronor, no decimals, no thousands separators
* Org number: 12 digits with century prefix 16 for juridisk person
*/
const CRLF = '\r\n'
const PROGRAM_VERSION = '1.0'
/**
* Compute the period suffix for blankett type strings.
* Based on which month the fiscal year ENDS in:
* P1 = Jan-Apr, P2 = May-Aug, P3 = special, P4 = Sep-Dec
*/
function computePeriodSuffix(fiscalYearEnd: string): string {
const endMonth = parseInt(fiscalYearEnd.substring(5, 7), 10)
if (endMonth >= 1 && endMonth <= 4) return 'P1'
if (endMonth >= 5 && endMonth <= 8) return 'P2'
// P4 covers Sep-Dec (most common: calendar year companies)
// NOTE: P3 (first/short fiscal year) cannot be derived from end month alone.
// Callers must handle P3 manually for brutet räkenskapsår.
return 'P4'
}
/**
* Get the income year from the fiscal year end date.
* The year in the blankett type string is the income year.
*/
function getIncomeYear(fiscalYearEnd: string): string {
return fiscalYearEnd.substring(0, 4)
}
/**
* Format org number as 12-digit with century prefix.
* Swedish juridiska personer use century prefix "16".
* Input: "556677-8899" or "5566778899"
* Output: "165566778899"
*/
function formatOrgNumber12(orgNumber: string): string {
const clean = orgNumber.replace(/-/g, '')
if (clean.length === 12) return clean
if (clean.length === 10) return `16${clean}`
return `16${clean}`
}
/**
* Format a Date as YYYYMMDD
*/
function formatDate(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}${m}${d}`
}
/**
* Format a Date as HHMMSS
*/
function formatTime(date: Date): string {
const h = String(date.getHours()).padStart(2, '0')
const m = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `${h}${m}${s}`
}
/**
* Format integer amount for SRU. No decimals, no thousands separator.
* Truncated to hela kronor by the engine.
*/
function formatAmount(amount: number): string {
return Math.trunc(amount).toString()
}
/**
* Generate the INFO.SRU file content
*/
function generateInfoSru(declaration: INK2Declaration, now: Date): string {
const lines: string[] = []
const orgNumber12 = declaration.companyInfo.orgNumber
? formatOrgNumber12(declaration.companyInfo.orgNumber)
: '000000000000'
// DATABESKRIVNING block (required order)
lines.push('#DATABESKRIVNING_START')
lines.push('#PRODUKT SRU')
lines.push(`#SKAPAD ${formatDate(now)} ${formatTime(now)}`)
lines.push(`#PROGRAM ${sanitizeString(getBranding().appName.toLowerCase())} ${PROGRAM_VERSION}`)
lines.push('#FILNAMN BLANKETTER.SRU')
lines.push('#DATABESKRIVNING_SLUT')
// MEDIELEV block
lines.push('#MEDIELEV_START')
lines.push(`#ORGNR ${orgNumber12}`)
lines.push(`#NAMN ${sanitizeString(declaration.companyInfo.companyName)}`)
if (declaration.companyInfo.addressLine1) {
lines.push(`#ADRESS ${sanitizeString(declaration.companyInfo.addressLine1)}`)
}
lines.push(`#POSTNR ${declaration.companyInfo.postalCode || '00000'}`)
lines.push(`#POSTORT ${sanitizeString(declaration.companyInfo.city || 'Okänd')}`)
if (declaration.companyInfo.email) {
lines.push(`#EMAIL ${declaration.companyInfo.email}`)
}
lines.push('#MEDIELEV_SLUT')
return lines.join(CRLF) + CRLF
}
/**
* Generate the BLANKETTER.SRU file content with three blankett blocks
*/
function generateBlanketterSru(declaration: INK2Declaration, now: Date): string {
const lines: string[] = []
const orgNumber12 = declaration.companyInfo.orgNumber
? formatOrgNumber12(declaration.companyInfo.orgNumber)
: '000000000000'
const incomeYear = getIncomeYear(declaration.fiscalYear.end)
const periodSuffix = computePeriodSuffix(declaration.fiscalYear.end)
const companyName = sanitizeString(declaration.companyInfo.companyName)
const dateStr = formatDate(now)
// Each blankett gets a unique timestamp (increment seconds)
const time0 = formatTime(now)
const time1 = formatTime(new Date(now.getTime() + 1000))
const time2 = formatTime(new Date(now.getTime() + 2000))
// ---- Block 1: INK2 (huvudblankett) ----
lines.push(`#BLANKETT INK2-${incomeYear}${periodSuffix}`)
lines.push(`#IDENTITET ${orgNumber12} ${dateStr} ${time0}`)
lines.push(`#NAMN ${companyName}`)
// Fiscal year dates
lines.push(`#UPPGIFT 7011 ${declaration.ink2['7011']}`)
lines.push(`#UPPGIFT 7012 ${declaration.ink2['7012']}`)
// Överskott/underskott
if (declaration.ink2['7104'] > 0) {
lines.push(`#UPPGIFT 7104 ${formatAmount(declaration.ink2['7104'])}`)
}
if (declaration.ink2['7114'] > 0) {
lines.push(`#UPPGIFT 7114 ${formatAmount(declaration.ink2['7114'])}`)
}
lines.push('#BLANKETTSLUT')
// ---- Block 2: INK2R (räkenskapsschema) ----
lines.push(`#BLANKETT INK2R-${incomeYear}${periodSuffix}`)
lines.push(`#IDENTITET ${orgNumber12} ${dateStr} ${time1}`)
lines.push(`#NAMN ${companyName}`)
// Fiscal year dates
lines.push(`#UPPGIFT 7011 ${declaration.ink2['7011']}`)
lines.push(`#UPPGIFT 7012 ${declaration.ink2['7012']}`)
// All INK2R fields in canonical Skatteverket order: emit non-zero values only
const ink2rCodes: INK2RSRUCode[] = [
...INK2R_ASSET_CODES,
...INK2R_EQUITY_LIABILITY_CODES,
...INK2R_INCOME_CODES,
]
for (const code of ink2rCodes) {
const value = declaration.ink2r[code]
if (value !== 0) {
lines.push(`#UPPGIFT ${code} ${formatAmount(value)}`)
}
}
lines.push('#BLANKETTSLUT')
// ---- Block 3: INK2S (skattemässiga justeringar) ----
lines.push(`#BLANKETT INK2S-${incomeYear}${periodSuffix}`)
lines.push(`#IDENTITET ${orgNumber12} ${dateStr} ${time2}`)
lines.push(`#NAMN ${companyName}`)
// Fiscal year dates
lines.push(`#UPPGIFT 7011 ${declaration.ink2s['7011']}`)
lines.push(`#UPPGIFT 7012 ${declaration.ink2s['7012']}`)
// INK2S numeric fields: emit non-zero values only
const ink2sNumericFields: (keyof INK2SRutor)[] = [
'7650',
'7750',
'7651',
'7653',
'7754',
'8020',
'8021',
]
for (const code of ink2sNumericFields) {
const value = declaration.ink2s[code]
if (typeof value === 'number' && value !== 0) {
lines.push(`#UPPGIFT ${code} ${formatAmount(value)}`)
}
}
lines.push('#BLANKETTSLUT')
// Required terminator
lines.push('#FIL_SLUT')
return lines.join(CRLF) + CRLF
}
/**
* Sanitize string for SRU: remove # characters (reserved), limit to 250 chars
*/
function sanitizeString(str: string): string {
return str.replace(/#/g, '').replace(/[\r\n]/g, ' ').substring(0, 250)
}
/**
* Generate complete SRU submission (INFO.SRU + BLANKETTER.SRU)
*/
export function generateSRUSubmission(declaration: INK2Declaration): SRUSubmission {
const now = new Date()
return {
infoSru: generateInfoSru(declaration, now),
blanketterSru: generateBlanketterSru(declaration, now),
generatedAt: now.toISOString(),
}
}
/**
* Validate the generated BLANKETTER.SRU content
*/
export function validateBlanketterSru(content: string): {
isValid: boolean
errors: string[]
} {
const errors: string[] = []
// Check for required blankett blocks
const hasINK2 = /^#BLANKETT INK2-/m.test(content)
const hasINK2R = /^#BLANKETT INK2R-/m.test(content)
const hasINK2S = /^#BLANKETT INK2S-/m.test(content)
const hasFilSlut = /^#FIL_SLUT/m.test(content)
if (!hasINK2) errors.push('Missing INK2 blankett block')
if (!hasINK2R) errors.push('Missing INK2R blankett block')
if (!hasINK2S) errors.push('Missing INK2S blankett block')
if (!hasFilSlut) errors.push('Missing #FIL_SLUT terminator')
// Count BLANKETTSLUT: should be exactly 3
const blankettslutCount = (content.match(/^#BLANKETTSLUT/gm) || []).length
if (blankettslutCount !== 3) {
errors.push(`Expected 3 BLANKETTSLUT, found ${blankettslutCount}`)
}
// Check that each blankett has #IDENTITET
const blankettBlocks = content.split(/^#BLANKETT /m).slice(1)
for (const block of blankettBlocks) {
if (!block.includes('#IDENTITET')) {
const type = block.split('\n')[0]?.split('\r')[0] || 'unknown'
errors.push(`Blankett ${type} missing #IDENTITET`)
}
if (!block.includes('#NAMN')) {
const type = block.split('\n')[0]?.split('\r')[0] || 'unknown'
errors.push(`Blankett ${type} missing #NAMN`)
}
}
return {
isValid: errors.length === 0,
errors,
}
}
/**
* Get ZIP filename for download
*/
export function getZipFilename(declaration: INK2Declaration): string {
const year = declaration.fiscalYear.start.substring(0, 4)
const orgNumber = declaration.companyInfo.orgNumber?.replace(/-/g, '') || 'unknown'
return `INK2_SRU_${orgNumber}_${year}.zip`
}