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:
co-authored by
Claude Fable 5
parent
6404591b89
commit
4362bffc0c
@@ -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]
|
||||
}
|
||||
Reference in New Issue
Block a user