From 4362bffc0cdfbae512f969405672c90c8f9e3d79 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:10:06 +0200 Subject: [PATCH] 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 4 + app/(dashboard)/bookkeeping/page.tsx | 90 +++++++++- .../bookkeeping/NewJournalEntryDialog.tsx | 39 +++- .../skattekonto/SkattekontoBookDialog.tsx | 12 +- .../skatteverket/lib/skattekonto-booking.ts | 3 +- .../skatteverket/lib/skattekonto-match.ts | 2 +- .../manual-verifikat-prefill.test.ts | 164 +++++++++++++++++ lib/skatteverket/manual-verifikat-prefill.ts | 166 ++++++++++++++++++ messages/en.json | 6 + messages/sv.json | 6 + 10 files changed, 480 insertions(+), 12 deletions(-) create mode 100644 lib/skatteverket/__tests__/manual-verifikat-prefill.test.ts create mode 100644 lib/skatteverket/manual-verifikat-prefill.ts diff --git a/DECISIONS.md b/DECISIONS.md index cc1ab61d..c17fd0f7 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -984,8 +984,12 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-13] Radius ladder locked (convention 16): 4 tiers by role (pill toolbar controls / rounded-xl overlays / rounded-lg surfaces / rounded-sm leaves); rounded-md, bare rounded, rounded-2xl and rounded-[Npx] retired app-wide, hard-failed by check:guards off-ladder-radius. Before: 7 radii in circulation with no rule; one toolbar row on /transactions mixed 4 shape languages. [2026-08-13] Toolbar shape language: pills won over "inputs stay rectangles". Search in a page toolbar is a pill (ToolbarSearch, h-8) matching chips/pickers/buttons beside it; the same search inside a dialog or form keeps rounded-lg Input. Rationale: convention 8 already made pickers pill chips and convention 3 made buttons pills, so the rectangle search/segmented were the odd ones out; one row = one shape reads as trust. [2026-08-13] Dialogs promoted 8px -> 12px (rounded-xl): SettingsModal, slide-over and CommandPalette were already 12px, so dialog.tsx was the overlay-tier outlier, not the rule. +[2026-08-14] SKV manual-verifikat deep link passes row data in URL params instead of a new single-row GET: the list endpoint is enrichment-heavy, core cannot import the extension, and the match endpoint re-validates everything server-side (tampered params can only prefill an editable form). +[2026-08-14] Manually created verifikat from the SKV deep link keeps source_type 'manual' (not 'system' like the extension's own drafts): the user typed it; the coupling is the journal_entry_id backlink set via the existing match route. [2026-08-14] SEB Transaktioner layout gets native seb-profile support (#1616), not a new format or generic_csv mapping: detect keys on the Insättningar/Uttag column pair (unique among supported formats, cannot steal files), and an unsigned Uttag magnitude is normalized to negative since the column is withdrawals by definition. User-provided sample rows are the verbatim fixture; #1565's explicit-choice fallback had nothing to fall back to because no profile parsed this layout at all. [2026-08-14] QuickReviewDialog inbox picker links picked docs with inbox_item_id but deliberately NOT transaction_id: the dialog's existing upload path never pinned transactions.document_id either, and adding the pin would be a drive-by behaviour change to uploads. Known asymmetry with TransactionBookingDialog, which pins both; revisit as its own change if the underlag indicator matters on rows booked from att-göra. Verified safe on the compliance side: since migration 20260703160000 every "saknar underlag" predicate (verifikat_without_documents, transactions_without_documents, the MCP tools, full-archive-export, the worklist badge) keys on document_attachments.journal_entry_id, never on transactions.document_id, so an unpinned row is never reported as missing underlag. [2026-08-14] The onboarding branch question ("Var fanns bokföringen innan?") became its own journey step at the existing KLART station (done -> source, same station grammar as momsyn/moms under MOMSEN) instead of a sixth rail station: a 6-point rail crowds the 680px band's 150px label slots, "BOKFÖRINGEN INNAN" would sit next to the existing "BOKFÖRINGEN" station, and mode='add' (which never sees the branch question) would need an asymmetric rail. The done screen ends in a revealed Fortsätt that dispatches DONE_CONTINUE (mode='first' only, reducer-guarded). [2026-08-14] Migration /preview fetches ALL allowed fiscal years (dropped latestOnly): the connect step's "Hittade X konton och Y verifikationer" renders from /preview's sieStats, not /sie-data's generateImportPreview, so fixing only /sie-data would have left the founder-reported "0 verifikationer" (actual: 4153) on screen. Costs one SIE export per extra year at connect time, the same work /sie-data repeats right after; honest counts won over latency. [2026-08-14] /sie-data validation stays newest-file-only (not per-file, not on the merged parse): preserves exactly which datasets are accepted today, and validateSIEFile assumes single-file invariants (balance yearIndexes relative to ONE current year) that mergeParsedSIEFiles deliberately does not preserve. Older files' problems still surface per-file at import time. +[2026-08-14] SKV manual-verifikat deep link payload moved from URL params to single-use sessionStorage (supersedes same-day URL-params decision): compliance swarm flagged financial data in query strings landing in history/access logs/Referer (GDPR Art.5(1)(f), ISO A.8.12); URL now carries only the opaque row id. +[2026-08-14] SKV prefill sessionStorage XSS window accepted as residual risk (ISO A.8.12 low, swarm PR #1621): script execution already implies full ledger read via authenticated APIs; a server-issued staging token adds a roundtrip, not protection. Documented in manual-verifikat-prefill.ts header. diff --git a/app/(dashboard)/bookkeeping/page.tsx b/app/(dashboard)/bookkeeping/page.tsx index 554eecf3..61a723e5 100644 --- a/app/(dashboard)/bookkeeping/page.tsx +++ b/app/(dashboard)/bookkeeping/page.tsx @@ -7,7 +7,14 @@ import { useTranslations } from 'next-intl' import JournalEntryList from '@/components/bookkeeping/JournalEntryList' import { StartCard } from '@/components/dashboard/StartCard' import { type FormLine } from '@/components/bookkeeping/JournalEntryForm' -import type { CopyPrefill } from '@/components/bookkeeping/NewJournalEntryDialog' +import type { CopyPrefill, SkvLinkPrefill } from '@/components/bookkeeping/NewJournalEntryDialog' +import { + buildSkvPrefillLines, + takeSkvManualPrefill, + type SkvManualPrefill, +} from '@/lib/skatteverket/manual-verifikat-prefill' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { formatCurrency, formatDate } from '@/lib/utils' import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton' import { SplitButton } from '@/components/ui/split-button' import { useAgentSheet } from '@/components/agent/AgentSheetProvider' @@ -53,6 +60,9 @@ export default function BookkeepingPage() { const [showTemplateDialog, setShowTemplateDialog] = useState(false) const [copyPrefill, setCopyPrefill] = useState(null) const [isLoadingCopy, setIsLoadingCopy] = useState(false) + // Set from the skattekonto deep link (skv_tx & co): prefills the Nytt + // verifikat dialog and makes the created entry auto-link back to the row. + const [skvLink, setSkvLink] = useState(null) const [nextVoucher, setNextVoucher] = useState(null) const t = useTranslations('bookkeeping') const tStart = useTranslations('start_cards') @@ -117,8 +127,74 @@ export default function BookkeepingPage() { router.replace('/bookkeeping') }) }, [copyFromId, toast, router]) + + // React to the skattekonto deep link the same way: consume the staged + // payload (single-use, sessionStorage; the URL carries only the opaque id), + // open the dialog prefilled, and clean the URL so a refresh doesn't + // re-trigger. copy_from wins if both are somehow present. A missing or + // invalid payload (shared/stale link) degrades to the plain list. + useEffect(() => { + if (copyFromId) return + const rawId = searchParams.get('skv_tx') + if (!rawId) return + const prefill = takeSkvManualPrefill(rawId) + router.replace('/bookkeeping') + if (!prefill) return + setSkvLink(prefill) + setCopyPrefill(null) + setShowNewEntry(true) + }, [searchParams, copyFromId, router]) /* eslint-enable react-hooks/set-state-in-effect */ + const skvPrefill = useMemo(() => { + if (!skvLink) return null + return { + transactionId: skvLink.transactionId, + bannerLabel: [formatDate(skvLink.date), skvLink.text, formatCurrency(skvLink.amount)] + .filter(Boolean) + .join(' • '), + lines: buildSkvPrefillLines(skvLink) as FormLine[], + description: skvLink.text, + date: skvLink.date, + } + }, [skvLink]) + + // Link the created verifikat back to the skattekonto row. The entry exists + // either way, so a failed link degrades to a loud toast pointing at the + // manual "Matcha mot verifikat" path instead of silently orphaning the row. + async function handleSkvEntryCreated(entryId: string) { + if (!skvLink) return + const target = skvLink + setSkvLink(null) + let reason = '' + try { + const res = await fetch( + `/api/extensions/ext/skatteverket/skattekonto/transaktioner/${target.transactionId}/match`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ journal_entry_id: entryId }), + }, + ) + if (res.ok) { + toast({ + title: t('skv_link_success_title'), + description: t('skv_link_success_description', { text: target.text }), + }) + return + } + const json: unknown = await res.json().catch(() => null) + reason = getErrorMessage(json ?? {}, { statusCode: res.status }) + } catch (err) { + reason = err instanceof Error ? getErrorMessage(err) : '' + } + toast({ + title: t('skv_link_failed_title'), + description: [reason, t('skv_link_failed_hint')].filter(Boolean).join(' '), + variant: 'destructive', + }) + } + // Fetch the next voucher number for today's fiscal period + default series. // Re-runs after each commit (refreshKey++) so the tab label stays current. useEffect(() => { @@ -162,6 +238,7 @@ export default function BookkeepingPage() { description: t('create_tomt_desc'), onSelect: () => { setCopyPrefill(null) + setSkvLink(null) setShowNewEntry(true) }, }, @@ -214,6 +291,7 @@ export default function BookkeepingPage() { 'tomt', () => { setCopyPrefill(null) + setSkvLink(null) setShowNewEntry(true) }, ], @@ -249,14 +327,22 @@ export default function BookkeepingPage() { open onOpenChange={(o) => { setShowNewEntry(o) - if (!o) setCopyPrefill(null) + if (!o) { + setCopyPrefill(null) + setSkvLink(null) + } }} onCreated={() => { setRefreshKey((k) => k + 1) setShowNewEntry(false) setCopyPrefill(null) + // handleSkvEntryCreated runs from the same render's closure, so + // clearing here doesn't rob it of the link target. + setSkvLink(null) }} + onEntryCreated={skvLink ? handleSkvEntryCreated : undefined} copyPrefill={copyPrefill} + skvPrefill={skvPrefill} isLoading={isLoadingCopy} /> )} diff --git a/components/bookkeeping/NewJournalEntryDialog.tsx b/components/bookkeeping/NewJournalEntryDialog.tsx index 1af3d529..b0c20c3f 100644 --- a/components/bookkeeping/NewJournalEntryDialog.tsx +++ b/components/bookkeeping/NewJournalEntryDialog.tsx @@ -1,7 +1,7 @@ 'use client' import { useTranslations } from 'next-intl' -import { Copy, Loader2 } from 'lucide-react' +import { Copy, Link2, Loader2 } from 'lucide-react' import { Dialog, DialogContent, @@ -18,13 +18,28 @@ export interface CopyPrefill { notes: string } +/** Prefill for the skattekonto "Skapa verifikat manuellt" deep link. */ +export interface SkvLinkPrefill { + transactionId: string + /** Row summary for the banner: date, text and amount, built by the caller. */ + bannerLabel: string + lines: FormLine[] + description: string + date: string +} + interface Props { open: boolean onOpenChange: (open: boolean) => void /** Fired after a verifikat is created/saved as draft. */ onCreated: () => void + /** Fired with the created entry's id (both posted and draft saves). */ + onEntryCreated?: (entryId: string) => void /** When set, the form is pre-filled from a copied verifikat. */ copyPrefill?: CopyPrefill | null + /** When set, the form is pre-filled from a skattekonto row and the caller + * links the created entry back to it. copyPrefill wins if both are set. */ + skvPrefill?: SkvLinkPrefill | null /** True while the copy source is being fetched. */ isLoading?: boolean } @@ -38,10 +53,13 @@ export default function NewJournalEntryDialog({ open, onOpenChange, onCreated, + onEntryCreated, copyPrefill, + skvPrefill, isLoading, }: Props) { const t = useTranslations('bookkeeping') + const activeSkvPrefill = copyPrefill ? null : (skvPrefill ?? null) return ( @@ -81,12 +99,25 @@ export default function NewJournalEntryDialog({ )} + {activeSkvPrefill && ( +
+ +
+

+ {t('skv_link_banner_title', { label: activeSkvPrefill.bannerLabel })} +

+

{t('skv_link_banner_body')}

+
+
+ )} diff --git a/components/skattekonto/SkattekontoBookDialog.tsx b/components/skattekonto/SkattekontoBookDialog.tsx index 276e3617..9f4016e5 100644 --- a/components/skattekonto/SkattekontoBookDialog.tsx +++ b/components/skattekonto/SkattekontoBookDialog.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' +import { stageSkvManualPrefill } from '@/lib/skatteverket/manual-verifikat-prefill' import { cn, formatCurrency, formatDate } from '@/lib/utils' import { Loader2 } from 'lucide-react' import type { @@ -159,10 +160,15 @@ export default function SkattekontoBookDialog({ } function handleManualCreate() { - // /bookkeeping owns manual verifikat creation ("Nytt verifikat" in the - // page header); there is no dedicated create route to deep-link. + if (!row) return + // Deep-link into /bookkeeping's Nytt verifikat dialog: the row payload is + // staged in sessionStorage (only the opaque id rides in the URL) so the + // form opens prefilled (1630 + counter line) and the created verifikat is + // auto-linked back to this skattekonto row via the match endpoint. Plain + // '/bookkeeping' here was a dead end: the user landed on the list with no + // form, no prefill and no link. onOpenChange(false) - router.push('/bookkeeping') + router.push(stageSkvManualPrefill(row)) } return ( diff --git a/extensions/general/skatteverket/lib/skattekonto-booking.ts b/extensions/general/skatteverket/lib/skattekonto-booking.ts index aa8b08b5..23a602a5 100644 --- a/extensions/general/skatteverket/lib/skattekonto-booking.ts +++ b/extensions/general/skatteverket/lib/skattekonto-booking.ts @@ -1,6 +1,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { commitEntry, createDraftEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' import { getBASReference } from '@/lib/bookkeeping/bas-reference' +import { SKATTEKONTO_ACCOUNT } from '@/lib/skatteverket/manual-verifikat-prefill' import { getPrimary as getPrimaryCashAccount } from '@/lib/cash-accounts/service' import type { CreateJournalEntryInput, @@ -32,8 +33,6 @@ import type { * case of anstånd granted across a closed period manually. */ -const SKATTEKONTO_ACCOUNT = '1630' - /** * Sentinel emitted by system rules for inbetalning / utbetalning: resolves to the * company's primary SEK cash account at runtime so the resolver doesn't assume 1930. diff --git a/extensions/general/skatteverket/lib/skattekonto-match.ts b/extensions/general/skatteverket/lib/skattekonto-match.ts index 54fe2d5d..ccfeb58b 100644 --- a/extensions/general/skatteverket/lib/skattekonto-match.ts +++ b/extensions/general/skatteverket/lib/skattekonto-match.ts @@ -1,6 +1,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import type { StoredSkattekontoTransaction } from '../types' import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' +import { SKATTEKONTO_ACCOUNT } from '@/lib/skatteverket/manual-verifikat-prefill' /** * "Matcha mot befintligt verifikat"-flöde för skattekonto-rader. @@ -25,7 +26,6 @@ import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-l * have identical totals. */ -const SKATTEKONTO_ACCOUNT = '1630' const DATE_WINDOW_DAYS = 14 export class SkattekontoMatchError extends Error { diff --git a/lib/skatteverket/__tests__/manual-verifikat-prefill.test.ts b/lib/skatteverket/__tests__/manual-verifikat-prefill.test.ts new file mode 100644 index 00000000..cbffa5ef --- /dev/null +++ b/lib/skatteverket/__tests__/manual-verifikat-prefill.test.ts @@ -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() + 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') + }) +}) diff --git a/lib/skatteverket/manual-verifikat-prefill.ts b/lib/skatteverket/manual-verifikat-prefill.ts new file mode 100644 index 00000000..a1181eb7 --- /dev/null +++ b/lib/skatteverket/manual-verifikat-prefill.ts @@ -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] +} diff --git a/messages/en.json b/messages/en.json index 4c4fd098..e0f3c2c9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5530,6 +5530,12 @@ "copy_banner_title": "Copy of voucher {label}", "copy_banner_unknown_label": "(unknown number)", "copy_banner_body": "A new, standalone voucher will be created with its own voucher series and number. This is NOT a correction or reversal of the original: use \"Skapa ändringsverifikation\" if you want to correct the source voucher.", + "skv_link_banner_title": "Will be linked to tax account event: {label}", + "skv_link_banner_body": "When the voucher is saved it is linked automatically to the tax account event.", + "skv_link_success_title": "Voucher linked to the tax account", + "skv_link_success_description": "The voucher was linked to the event {text}.", + "skv_link_failed_title": "The voucher was created but linking failed", + "skv_link_failed_hint": "Link it manually via Matcha mot verifikat on the Transactions page.", "create_tomt": "Blank voucher", "create_tomt_desc": "Open the editor directly", "create_mall": "Book from template", diff --git a/messages/sv.json b/messages/sv.json index d5d6647c..cfc635a5 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -5530,6 +5530,12 @@ "copy_banner_title": "Kopia av verifikat {label}", "copy_banner_unknown_label": "(okänt nummer)", "copy_banner_body": "Ett nytt, fristående verifikat skapas med egen verifikationsserie och nummer. Detta är inte en rättelse eller storno av originalet: använd \"Skapa ändringsverifikation\" om du vill korrigera källverifikatet.", + "skv_link_banner_title": "Kopplas till skattekontohändelse: {label}", + "skv_link_banner_body": "När verifikatet sparas kopplas det automatiskt till händelsen på skattekontot.", + "skv_link_success_title": "Verifikat kopplat till skattekontot", + "skv_link_success_description": "Verifikatet kopplades till händelsen {text}.", + "skv_link_failed_title": "Verifikatet skapades men kopplingen misslyckades", + "skv_link_failed_hint": "Koppla det manuellt via Matcha mot verifikat på sidan Transaktioner.", "create_tomt": "Tomt verifikat", "create_tomt_desc": "Öppna editorn direkt", "create_mall": "Bokför från mall",