fix(skattekonto): deep-link Skapa verifikat manuellt to a prefilled, auto-linked verifikat (#1621)

* fix(skattekonto): deep-link Skapa verifikat manuellt to a prefilled, auto-linked verifikat

"Skapa verifikat manuellt" in the SkattekontoBookDialog routed to plain
/bookkeeping: the user landed on the list with no form, no prefill and no
link to the row (reported by a user for a Slutlig skatt event, which has
no booking rule by design).

The CTA now deep-links to /bookkeeping?skv_tx=... carrying the row's id,
date, text and amount. The bookkeeping page opens the Nytt verifikat
dialog prefilled (1630 on the correct side per the booking sign
convention, balanced counter line with the motkonto left to pick, date
and description set) and, once the verifikat is saved (posted or draft),
links it back to the skattekonto row via the existing match endpoint. A
failed link degrades to a destructive toast pointing at the manual
"Matcha mot verifikat" path.

The URL params are prefill convenience only: the match route re-validates
ownership, ALREADY_BOOKED and ENTRY_ALREADY_LINKED server-side. The
parse/build/line-shaping contract lives in core lib
(lib/skatteverket/manual-verifikat-prefill.ts, unit-tested) because the
bookkeeping page cannot import from the extension.

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

* fix(skattekonto): keep deep-link payload out of the URL + share the 1630 constant

Resolves the PR #1621 review findings in one pass:

- Compliance swarm (GDPR Art.5(1)(f), ISO A.8.12): the deep link no longer
  carries date, text and amount as query params, where they would persist
  in browser history, access logs and Referer headers. The row payload is
  staged in sessionStorage, consumed single-use and validated against the
  opaque skv_tx id, which is all the URL exposes. A missing or mismatched
  payload degrades to the plain /bookkeeping list; the auto-link itself is
  still validated server-side by the match route.
- Swedish accounting review note: SKATTEKONTO_ACCOUNT ('1630') is now
  imported by the extension's booking and match libs from the core prefill
  lib instead of being duplicated, so prefill and server-side booking
  cannot drift.
- CodeRabbit docstring warning: the new lib exports carry docstrings.

Storage is injectable (PrefillStorage) so the node-env tests cover the
round-trip, single-use semantics, id mismatch, malformed payloads and a
throwing privacy-mode storage.

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

* docs(skattekonto): record the sessionStorage staging window as accepted residual risk

The compliance swarm's remaining LOW finding (ISO A.8.12) offers
documentation as its remediation path: an XSS attacker already reads the
full ledger via the session's authenticated APIs, so the sub-second
sessionStorage staging window adds no capability worth a server-issued
token roundtrip. Recorded in the lib header and DECISIONS.md.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-15 15:10:06 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 6404591b89
commit 4362bffc0c
10 changed files with 480 additions and 12 deletions
@@ -0,0 +1,164 @@
import { describe, expect, it } from 'vitest'
import {
buildSkvPrefillLines,
stageSkvManualPrefill,
takeSkvManualPrefill,
type PrefillStorage,
} from '@/lib/skatteverket/manual-verifikat-prefill'
const ROW = {
id: '4f9c2b1a-0d3e-4c5b-8a7f-1e2d3c4b5a69',
transaktionsdatum: '2026-07-13',
transaktionstext: 'Slutlig skatt',
belopp_skatteverket: '-2546',
}
function fakeStorage(): PrefillStorage {
const map = new Map<string, string>()
return {
getItem: (k) => map.get(k) ?? null,
setItem: (k, v) => void map.set(k, v),
removeItem: (k) => void map.delete(k),
}
}
function idOf(url: string): string | null {
return new URL(url, 'http://localhost').searchParams.get('skv_tx')
}
describe('stageSkvManualPrefill + takeSkvManualPrefill', () => {
it('round-trips a row through storage and exposes only the id in the URL', () => {
const storage = fakeStorage()
const url = stageSkvManualPrefill(ROW, storage)
expect(url).toBe(`/bookkeeping?skv_tx=${ROW.id}`)
for (const leak of ['2546', 'Slutlig', '2026-07-13']) {
expect(url).not.toContain(leak)
}
expect(takeSkvManualPrefill(idOf(url), storage)).toEqual({
transactionId: ROW.id,
date: '2026-07-13',
text: 'Slutlig skatt',
amount: -2546,
})
})
it('is single-use: a second take returns null', () => {
const storage = fakeStorage()
const url = stageSkvManualPrefill(ROW, storage)
expect(takeSkvManualPrefill(idOf(url), storage)).not.toBeNull()
expect(takeSkvManualPrefill(idOf(url), storage)).toBeNull()
})
it('rejects a missing or malformed transaction id', () => {
const storage = fakeStorage()
stageSkvManualPrefill(ROW, storage)
expect(takeSkvManualPrefill(null, storage)).toBeNull()
expect(takeSkvManualPrefill('not-a-uuid', storage)).toBeNull()
})
it('rejects a payload staged for a different row id', () => {
const storage = fakeStorage()
stageSkvManualPrefill(ROW, storage)
const otherId = '9f9c2b1a-0d3e-4c5b-8a7f-1e2d3c4b5a00'
expect(takeSkvManualPrefill(otherId, storage)).toBeNull()
// The mismatched attempt consumed the payload: single-use holds.
expect(takeSkvManualPrefill(ROW.id, storage)).toBeNull()
})
it('rejects missing storage, missing payload, and malformed JSON', () => {
expect(takeSkvManualPrefill(ROW.id, null)).toBeNull()
expect(takeSkvManualPrefill(ROW.id, fakeStorage())).toBeNull()
const storage = fakeStorage()
storage.setItem('accounted.skv-manual-prefill', '{not json')
expect(takeSkvManualPrefill(ROW.id, storage)).toBeNull()
})
it('rejects a malformed date and a zero or non-numeric amount', () => {
for (const row of [
{ ...ROW, transaktionsdatum: '13/07/2026' },
{ ...ROW, belopp_skatteverket: '0' },
{ ...ROW, belopp_skatteverket: 'abc' },
{ ...ROW, belopp_skatteverket: '' },
]) {
const storage = fakeStorage()
const url = stageSkvManualPrefill(row, storage)
expect(takeSkvManualPrefill(idOf(url), storage)).toBeNull()
}
})
it('preserves text needing no URL encoding since it never enters the URL', () => {
const storage = fakeStorage()
const url = stageSkvManualPrefill(
{ ...ROW, transaktionstext: 'Omprövningsbeslut & ränta 50%' },
storage,
)
expect(takeSkvManualPrefill(idOf(url), storage)?.text).toBe(
'Omprövningsbeslut & ränta 50%',
)
})
it('survives a storage that throws (privacy mode): URL still built, take returns null', () => {
const throwing: PrefillStorage = {
getItem: () => {
throw new Error('denied')
},
setItem: () => {
throw new Error('denied')
},
removeItem: () => {
throw new Error('denied')
},
}
const url = stageSkvManualPrefill(ROW, throwing)
expect(idOf(url)).toBe(ROW.id)
expect(takeSkvManualPrefill(ROW.id, throwing)).toBeNull()
})
})
describe('buildSkvPrefillLines', () => {
it('credits 1630 and pre-fills a debit counter line for a negative belopp', () => {
const lines = buildSkvPrefillLines({
transactionId: ROW.id,
date: '2026-07-13',
text: 'Slutlig skatt',
amount: -2546,
})
expect(lines).toEqual([
{
account_number: '1630',
debit_amount: '',
credit_amount: '2546.00',
line_description: 'Slutlig skatt',
},
{
account_number: '',
debit_amount: '2546.00',
credit_amount: '',
line_description: '',
},
])
})
it('debits 1630 for a positive belopp', () => {
const lines = buildSkvPrefillLines({
transactionId: ROW.id,
date: '2026-07-13',
text: 'Intäktsränta',
amount: 1.5,
})
expect(lines[0].debit_amount).toBe('1.50')
expect(lines[0].credit_amount).toBe('')
expect(lines[1].credit_amount).toBe('1.50')
})
it('rounds öre drift before formatting', () => {
const lines = buildSkvPrefillLines({
transactionId: ROW.id,
date: '2026-07-13',
text: 'Ränta',
// 1409.1 * 100 = 140910.00000000003 territory: the classic float trap
amount: -1409.1,
})
expect(lines[0].credit_amount).toBe('1409.10')
})
})
@@ -0,0 +1,166 @@
/**
* Deep-link contract for "Skapa verifikat manuellt" on a skattekonto row:
* the SkattekontoBookDialog stages the row payload and navigates to
* /bookkeeping, whose page consumes the payload to prefill the Nytt
* verifikat dialog and auto-link the created entry via the extension's
* match endpoint. Kept in core lib (not the extension) because the
* bookkeeping page cannot import from @/extensions/.
*
* The URL carries ONLY the opaque transaction id; date, text and amount
* ride in sessionStorage (compliance swarm PR #1621: query params leak
* financial data into browser history, access logs and Referer headers).
* The payload is prefill convenience only and is consumed single-use:
* linking is validated server-side by the match route (ownership,
* ALREADY_BOOKED, ENTRY_ALREADY_LINKED), so a missing or tampered payload
* can at worst degrade to the plain /bookkeeping list.
*
* Accepted residual risk (ISO A.8.12, decided 2026-08-14): the staged
* payload sits unencrypted in sessionStorage for the sub-second navigation
* window, readable by XSS. An attacker with script execution already reads
* the full ledger through the session's authenticated APIs, so a
* server-issued staging token would add a roundtrip without adding
* protection.
*/
import { isIsoDateShaped } from '@/lib/invariants'
import { roundOre } from '@/lib/money'
/**
* The BAS account for skattekontot. The skatteverket extension's booking lib
* imports this constant so prefill and server-side booking cannot drift.
*/
export const SKATTEKONTO_ACCOUNT = '1630'
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const STORAGE_KEY = 'accounted.skv-manual-prefill'
export interface SkvManualPrefill {
transactionId: string
/** transaktionsdatum, YYYY-MM-DD */
date: string
/** transaktionstext */
text: string
/** belopp_skatteverket: positive = money into skattekontot */
amount: number
}
/** Structurally compatible with the journal form's FormLine. */
export interface SkvPrefillLine {
account_number: string
debit_amount: string
credit_amount: string
line_description: string
}
/** Minimal Storage surface, injectable so node-env tests can fake it. */
export interface PrefillStorage {
getItem(key: string): string | null
setItem(key: string, value: string): void
removeItem(key: string): void
}
function defaultStorage(): PrefillStorage | null {
// sessionStorage can be absent (SSR) or throw (some privacy modes).
try {
return typeof window === 'undefined' ? null : window.sessionStorage
} catch {
return null
}
}
/**
* Stage a skattekonto row for manual verifikat creation and return the
* /bookkeeping URL to navigate to. The row payload goes into sessionStorage;
* the URL exposes only the opaque row id.
*/
export function stageSkvManualPrefill(
row: {
id: string
transaktionsdatum: string
transaktionstext: string
belopp_skatteverket: string | number
},
storage: PrefillStorage | null = defaultStorage(),
): string {
try {
storage?.setItem(
STORAGE_KEY,
JSON.stringify({
transactionId: row.id,
date: row.transaktionsdatum,
text: row.transaktionstext,
amount: Number(row.belopp_skatteverket),
}),
)
} catch {
// Quota/privacy-mode failure: the deep link still arms the auto-link
// fallback path; only the prefill convenience is lost.
}
const params = new URLSearchParams({ skv_tx: row.id })
return `/bookkeeping?${params.toString()}`
}
/**
* Consume (single-use) the staged payload for the given skv_tx URL param.
* Returns null when the id is malformed or the stored payload is missing,
* mismatched or invalid; the caller then degrades to the plain list.
*/
export function takeSkvManualPrefill(
rawId: string | null,
storage: PrefillStorage | null = defaultStorage(),
): SkvManualPrefill | null {
if (!rawId || !UUID_RE.test(rawId) || !storage) return null
let raw: string | null = null
try {
raw = storage.getItem(STORAGE_KEY)
storage.removeItem(STORAGE_KEY)
} catch {
return null
}
if (!raw) return null
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
return null
}
const p = parsed as {
transactionId?: unknown
date?: unknown
text?: unknown
amount?: unknown
}
if (p.transactionId !== rawId) return null
if (typeof p.date !== 'string' || !isIsoDateShaped(p.date)) return null
const amount = typeof p.amount === 'number' ? p.amount : Number.NaN
if (!Number.isFinite(amount) || amount === 0) return null
const text = typeof p.text === 'string' ? p.text.trim() : ''
return { transactionId: rawId, date: p.date, text, amount }
}
/**
* Prefill lines following the extension's booking sign convention:
* positive belopp = money into skattekontot = debit 1630, counter credit;
* negative = credit 1630, counter debit. The counter line carries the amount
* but no account: picking the motkonto is exactly the manual decision this
* flow exists for.
*/
export function buildSkvPrefillLines(prefill: SkvManualPrefill): SkvPrefillLine[] {
const rounded = roundOre(Math.abs(prefill.amount))
// toFixed here only pads an already-rounded value for the form's text
// inputs; the rounding itself is done above.
const value = rounded.toFixed(2)
const skattekontoLine: SkvPrefillLine = {
account_number: SKATTEKONTO_ACCOUNT,
debit_amount: prefill.amount > 0 ? value : '',
credit_amount: prefill.amount > 0 ? '' : value,
line_description: prefill.text,
}
const counterLine: SkvPrefillLine = {
account_number: '',
debit_amount: prefill.amount > 0 ? '' : value,
credit_amount: prefill.amount > 0 ? value : '',
line_description: '',
}
return [skattekontoLine, counterLine]
}