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",