Files
accounted/lib/reports/vat-manual-filing.ts
T
MattssonandClaude Opus 4.8 abe9ac9d8c Fix/attributes config (#926)
* fix(git): pin LF on generated extension registry and vitest snapshots

setup:extensions and vitest write these files with LF; with
core.autocrlf=true git expects CRLF and flags them as phantom
modifications on every dev/build run.

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

* fix(security): enforce MFA on mcp-oauth consent and gate viewer storno route

mcp-oauth/authorize renders an HTML consent page and issues 303 redirects that withRouteContext cannot express, so it kept raw getUser() and thereby skipped the AAL2 gate: a password-only (AAL1) session could approve consent that mints a long-lived, MFA-bypassing API key. Add a route-local requireAal2() step-up on GET and POST; AAL1 sessions redirect to /mfa/verify, BankID users are exempt.

Separately, POST /api/reports/vat-declaration/rc-basis-gaps/fix calls correctEntry() (storno of a posted entry) but lacked requireWrite, so viewer-role members could trigger it. Add { requireWrite: true }.

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

* fix(api): route transactions endpoints through withRouteContext

Migrate the transactions routes off hand-rolled supabase.auth.getUser() onto the MFA-enforcing withRouteContext wrapper; add requireWrite on mutating handlers (book, uncategorize, attach-document, ignore, batch-match, create-from-document). Behavior and response shapes preserved; tests updated to the wrapper mock pattern.

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

* fix(api): route SIE import and bank reconciliation through withRouteContext

Migrate import/sie and reconciliation/bank routes onto the MFA-enforcing wrapper; requireWrite on mutations (import execute, create-accounts, mappings write verbs, link/unlink/run/mark-opening-balance). Reads (status, unmatched-entries) stay ungated. Response shapes preserved; tests added/updated to the wrapper mock pattern.

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

* fix(api): route salary endpoints through withRouteContext

Migrate salary employees and runs routes (plus ku, payroll-config, tax-tables) onto the MFA-enforcing wrapper; requireWrite on mutations. Personnummer masking/encryption untouched; file downloads (AGI XML, payslip PDF, payment files) keep their headers. Two payment-file GETs retain requireWrite because they stamp *_file_generated_at and previously gated viewers. Tests added/updated to the wrapper mock pattern.

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

* fix(api): route report endpoints through withRouteContext

Migrate the read-only report routes (trial balance, balansrapport, resultatrapport, income statement, ledgers, KPI, VAT declaration, salary journal, monthly breakdown, journal register, continuity check, full archive, etc.) onto the MFA-enforcing wrapper. All read-only, no requireWrite. JSON/XLSX/PDF/ZIP response bodies and headers preserved byte-for-byte; tests updated to the wrapper mock pattern.

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

* fix(api): route invoices, skatteverket, agent and extension endpoints through withRouteContext

Migrate invoices, supplier-invoices, skatteverket tax-payments, and dynamic extension routes onto the MFA-enforcing wrapper with requireWrite on mutations. The two NDJSON streaming agent routes (invoke, onboarding/stream) use requireAuth() directly (the wrapper can't wrap a streaming response) so MFA is still enforced. skatteverket payment-file GET keeps requireWrite (stamps a generated-at field). Response shapes and file headers preserved; tests added/updated.

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

* fix(api): route documents, events, team and account endpoints through withRouteContext

Migrate documents, events, kpi/preferences, vat/validate, support/contact onto the MFA-enforcing wrapper with requireWrite on mutations. account/password, team/accept and team/members use requireAuth() directly (user-level or pre-membership flows with no active company context) so MFA is still enforced. events keeps its dual API-key-or-session auth. Document retention guard untouched; tests added/updated.

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

* fix(api): route settings and pending-operations endpoints through withRouteContext

Migrate settings (api-keys, oauth-clients, booking-templates, counterparty-templates, logo, company settings) and pending-operations (commit, bulk-commit, reject, edit-before-approve) onto the MFA-enforcing wrapper with requireWrite on mutations. Credential-guarding routes keep their per-user ownership filters. Response shapes preserved; tests added/updated to the wrapper mock pattern.

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

* chore(guards): ratchet raw-route-auth baseline 119->1 after A1 migration

Lock in the withRouteContext migration so the count cannot regress. The single remaining entry, mcp-oauth/authorize, is a documented exception (HTML consent + redirects, MFA enforced via route-local step-up). Record the campaign and requireWrite decisions in DECISIONS.md.

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

* feat(vat): add eSKD momsdeklaration file export for "Deklarera via fil"

Generate the Skatteverket eSKDUpload v6.0 XML file so users can file VAT by
upload instead of typing every ruta into the form. Extract buildFiledAmounts()
as the shared whole-krona source of truth (öre truncated per SFL 22 kap 1 §) so
the XML file and the manual-filing PDF can never disagree. Adds the /eskd API
route, an XML option in the report export menu, and the upload button on the
manual-filing card. Strings in sv + en.

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

* feat(vat): add 'vat_settlement' source type and update related components

* fix(booking): adjust search input layout and enable autofocus

* fix(vat): support 12-digit org numbers and adjust emission order for eSKD file

* fix(migration): add 'vat_settlement' to journal_entries.source_type CHECK

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:54:46 +02:00

107 lines
4.4 KiB
TypeScript

import { VAT_RUTA_LABELS, type VatDeclarationRutor } from '@/types'
export interface ManualFilingRow {
/** Two-digit ruta number, e.g. '05', '10', '49'. */
ruta: string
label: string
/**
* Amount in whole kronor (hela kronor), as filed. For ruta 49 (the net) this
* is the absolute value; the direction is carried by the label.
*/
amount: number
/** True only for the ruta 49 net row (Moms att betala / återfå). */
isNet?: boolean
}
// The output-VAT rutor that sum into ruta 49 per the SKV 4700 formula
// (swedish-vat reference, Section G): (10+11+12+30+31+32+60+61+62) - 48 = 49.
const OUTPUT_VAT_RUTOR: (keyof VatDeclarationRutor)[] = [
'ruta10', 'ruta11', 'ruta12',
'ruta30', 'ruta31', 'ruta32',
'ruta60', 'ruta61', 'ruta62',
]
/**
* The rutor exactly as they are filed at Skatteverket: every ruta truncated to
* a whole krona (öretal faller bort per SFL 22 kap 1 §: dropped, not rounded to
* nearest) and the ruta 49 net recomputed from those truncated output/input
* rutor rather than from the pre-computed öre value, so the arithmetic ties out
* exactly to what is filed. This is NOT bookkeeping math (nothing here is
* posted), so the öre-precision money rule (Math.round(x*100)/100) does not
* apply; it mirrors the SRU income-tax path, which drops öre under the same
* statute.
*
* This is the single source of truth for the whole-krona amounts shared by the
* manual-filing PDF (buildManualFilingRows) and the eSKD XML file
* (lib/reports/vat-eskd-file.ts), so the two can never disagree.
*/
export function buildFiledAmounts(rutor: VatDeclarationRutor): {
/** Every ruta truncated to whole kronor. ruta49 carries its sign (negative = återfå). */
amounts: Record<keyof VatDeclarationRutor, number>
/** The recomputed ruta 49 net, signed: positive = att betala, negative = att återfå. */
net: number
} {
// Truncate toward zero: öretal faller bort (SFL 22 kap 1 §), never round up.
const kr = (key: keyof VatDeclarationRutor): number => Math.trunc(rutor[key] ?? 0)
const outputVat = OUTPUT_VAT_RUTOR.reduce((sum, key) => sum + kr(key), 0)
const net = outputVat - kr('ruta48')
const amounts = {} as Record<keyof VatDeclarationRutor, number>
for (const key of Object.keys(VAT_RUTA_LABELS) as (keyof VatDeclarationRutor)[]) {
amounts[key] = kr(key)
}
// ruta49 is the recomputed net, not the source öre value.
amounts.ruta49 = net
return { amounts, net }
}
/**
* Builds the momsdeklaration rows for manual filing at skatteverket.se, in
* hela kronor.
*
* Skatteverket files whole kronor with no öre, so each ruta is truncated to a
* whole krona (öretal faller bort per SFL 22 kap 1 §: the öre are dropped, not
* rounded to nearest) and ruta 49 (the net) is recomputed from the truncated
* output/input rutor, not from the pre-computed öre value, so the document's
* arithmetic ties out exactly to what the user types into the form. This
* whole-krona truncation is intentional and specific to the filing document; it
* is NOT bookkeeping math (nothing here is posted), so the usual öre-precision
* money rule (Math.round(x*100)/100) does not apply. It also matches the SRU
* income-tax filing path, which drops öre under the same statute.
*
* Only populated rutor are included, plus ruta 48 and the ruta 49 net (always),
* so a manual filer sees every box that needs a value and nothing that doesn't.
* Ruta 49 is always rendered last, mirroring its position on the SKV 4700 form.
*
* Swedish-only by design: these are Skatteverket form labels (VAT_RUTA_LABELS),
* which stay Swedish in both locales.
*/
export function buildManualFilingRows(rutor: VatDeclarationRutor): ManualFilingRow[] {
const { amounts, net } = buildFiledAmounts(rutor)
// Every ruta except 49, in ascending form order; 49 is appended last below.
const keys = (Object.keys(VAT_RUTA_LABELS) as (keyof VatDeclarationRutor)[])
.filter((key) => key !== 'ruta49')
.sort((a, b) => Number(a.slice(4)) - Number(b.slice(4)))
const rows: ManualFilingRow[] = []
for (const key of keys) {
const amount = amounts[key]
// Always surface ruta 48 (deductible input VAT); otherwise only populated
// rutor so the reference stays short.
if (key !== 'ruta48' && amount === 0) continue
rows.push({ ruta: key.slice(4), label: VAT_RUTA_LABELS[key], amount })
}
rows.push({
ruta: '49',
label: net >= 0 ? 'Moms att betala' : 'Moms att återfå',
amount: Math.abs(net),
isNet: true,
})
return rows
}