diff --git a/DECISIONS.md b/DECISIONS.md index dd2044b2..aad7d183 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -876,6 +876,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-11] Anthropic, Vercel and Supabase removed from the portal directory: all three email their invoices to European customers, so listing them told the user to go and log in for a document already in their inbox. The directory's bar is "does not send the invoice", not "also has a portal", and the poll it was seeded from asked which portals people log into, which people answered with where an invoice can ALSO be found. The same objection may reach further down the list; an entry is a claim that the invoice cannot be had any other way and is worth checking per vendor. [2026-08-11] Portal URLs are swept by scripts/check-portal-urls.mts rather than trusted: the directory shipped with 18 hand-written paths, none opened, the file said so and shipped anyway, and a founder then hit a 404 on Google Workspace (/ac/billing/history). A sweep found GitHub's /settings/billing 404 too. Rule now is the shallowest URL that certainly resolves: landing one click short of the invoice costs little, landing on an error page spends the trust the feature runs on. Google, OpenAI and Hetzner refuse automated requests, so they cannot be swept and are kept shallow deliberately; only a genuine 404 fails the script, since failing on an unreachable host would train people to ignore it. Trygg Hansa removed: neither candidate URL could be reached at all. [2026-08-11] Credit-note deduction fields (deduction_total, per-item deduction_amount) stay POSITIVE magnitudes, unlike every other amount on a credit note: both columns carry CHECK (>= 0) in the DB, and negating them made every ROT/RUT credit fail at insert (prod support case 2026-08-11). Verified inert: the reversing verifikat recomputes the ROT/RUT split from quantity/unit_price (generateRotRutLines), the PDF hides the deduction section for credit notes, getAmountToPay skips deductions when credited_invoice_id is set, and ROT payout candidates require status='paid', which invoices_credit_note_not_paid makes impossible for credit notes. Any future reader summing these fields across invoice + credit note must special-case credit notes. +[2026-08-12] Skattekonto booking claim race: the loser leaves its just-created draft unlinked instead of deleting it. lib/bookkeeping/engine.ts exposes no draft-discard function and journal tables are never raw-deleted; the delete_last_voucher RPC exists but is owner/admin-gated and lives outside the engine, so best-effort cleanup could fail on role and add phantom-delete audit noise. An orphan draft is legally deletable by the user in /bookkeeping; the request that lost the claim returns ALREADY_BOOKED and never commits. [2026-08-12] SLP on supplier invoices mirrors the reverse-charge zero-net pair mechanism (apply_slp item flag) instead of free-form credit rows: keeps the 2440 balance guarantee and the item model intact; year-end calculator nets off posted 7533 to avoid double provision. [2026-08-12] Enable Banking auth-method pin narrowed to hidden_method=true + psu_types match: PR #854's blanket decoupled pin broke Lunar-class banks; hidden-only restores its stated intent. [2026-08-12] Skattekonto bulk booking commits server-side per row (draft+commit in one request): avoids orphan drafts and 2N round trips; upcoming/duplicate-suspect/no-rule rows excluded. diff --git a/app/(dashboard)/skattekonto/page.tsx b/app/(dashboard)/skattekonto/page.tsx index a0777df6..7133ea55 100644 --- a/app/(dashboard)/skattekonto/page.tsx +++ b/app/(dashboard)/skattekonto/page.tsx @@ -1,6 +1,7 @@ 'use client' import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import dynamic from 'next/dynamic' import Link from 'next/link' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' @@ -27,6 +28,8 @@ import { DialogTitle, } from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' +import { ToastAction } from '@/components/ui/toast' +import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton' import { cn } from '@/lib/utils' import { formatCurrency, @@ -50,8 +53,14 @@ import type { SkattekontoTransactionWithSuggestion, StoredSkattekontoTransaction, } from '@/extensions/general/skatteverket/types' +import type { SkattekontoBatchRowResult } from '@/types/skatteverket' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +const SkattekontoBookDialog = dynamic( + () => import('@/components/skattekonto/SkattekontoBookDialog'), + { loading: DialogLoadingSkeleton }, +) + interface SaldoEnvelope { data: SkatteverketSaldoResponse | null fetchedAt: string | null @@ -86,7 +95,10 @@ export default function SkattekontoPage() { const [tx, setTx] = useState(null) const [loading, setLoading] = useState(true) const [syncing, setSyncing] = useState(false) - const [bookingId, setBookingId] = useState(null) + // Row whose inline booking dialog is open (null = closed). + const [bookTarget, setBookTarget] = useState( + null, + ) const [notConnected, setNotConnected] = useState(false) const [loadError, setLoadError] = useState(false) // Set when a sync fails with an auth error while a connection exists @@ -222,37 +234,41 @@ export default function SkattekontoPage() { } } - async function bokfor(id: string) { - setBookingId(id) - try { - const res = await fetch( - `/api/extensions/ext/skatteverket/skattekonto/transaktioner/${id}/bokfor`, - { method: 'POST' }, - ) - const json = await res.json() - if (!res.ok) { - toast({ - title: 'Kunde inte bokföra', - description: getUserErrorMessage(json, { statusCode: res.status }), - variant: 'destructive', - }) - return - } - toast({ - title: 'Utkast skapat', - description: 'Granska och bokför verifikatet i Bokföring.', - }) - // Take the user to the draft so they can review. - window.location.href = `/bookkeeping/${json.data.entry.id}` - } catch (err) { - toast({ - title: 'Kunde inte bokföra', - description: err instanceof Error ? getUserErrorMessage(err) : undefined, - variant: 'destructive', - }) - } finally { - setBookingId(null) - } + function bokfor(id: string) { + // Open the inline booking dialog instead of the old draft-then-navigate + // detour. The row may live in any bucket: genomförda rows carry the + // booking suggestion, kommande/förfallna open in plain draft mode. + const row = + tx?.booked.find((r) => r.id === id) ?? + tx?.overdue.find((r) => r.id === id) ?? + tx?.upcoming.find((r) => r.id === id) ?? + null + if (row) setBookTarget(row) + } + + function handleBooked(_rowId: string, result: SkattekontoBatchRowResult) { + setBookTarget(null) + const voucherLabel = + result.voucher_series && result.voucher_number != null + ? formatVoucher({ + voucher_series: result.voucher_series, + voucher_number: result.voucher_number, + }) + : null + toast({ + title: t('booked_toast_title'), + description: voucherLabel + ? t('booked_toast_description', { voucher: voucherLabel }) + : undefined, + action: result.journal_entry_id ? ( + + + {t('booked_toast_show')} + + + ) : undefined, + }) + void reload() } async function openMatch(row: StoredSkattekontoTransaction) { @@ -538,13 +554,28 @@ export default function SkattekontoPage() { tx={tx} onBokfor={bokfor} onMatch={openMatch} - bookingId={bookingId} />

{t('pgnote', { amount: formatCurrency(data?.saldoKronofogden ?? 0) })}

+ {bookTarget && ( + { + if (!o) setBookTarget(null) + }} + onBooked={handleBooked} + onMatch={() => { + const target = bookTarget + setBookTarget(null) + void openMatch(target) + }} + /> + )} + void onMatch: (row: StoredSkattekontoTransaction) => void - bookingId: string | null }) { const t = useTranslations('skattekonto') @@ -698,7 +727,6 @@ function SkattekontoTable({ section={section.key} onBokfor={onBokfor} onMatch={onMatch} - bookingId={bookingId} showInterestDate={section.interestDateRowIds.has(row.id)} /> ))} @@ -715,14 +743,12 @@ function SkattekontoRow({ section, onBokfor, onMatch, - bookingId, showInterestDate, }: { row: SkattekontoTransactionWithSuggestion section: TableSection['key'] onBokfor: (id: string) => void onMatch: (row: StoredSkattekontoTransaction) => void - bookingId: string | null showInterestDate: boolean }) { const t = useTranslations('skattekonto') @@ -802,10 +828,9 @@ function SkattekontoRow({ )} diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 06afcf4b..1280b1f2 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -11,6 +11,7 @@ import { Badge } from '@/components/ui/badge' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' import { ToastAction } from '@/components/ui/toast' +import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' import { DataList, DataListEmpty } from '@/components/ui/data-list' import { Input } from '@/components/ui/input' @@ -41,9 +42,12 @@ import type { CategorizeHandler, } from '@/components/transactions/transaction-types' import type { + SkattekontoBatchResult, + SkattekontoBatchRowResult, SkattekontoTransactionWithSuggestion, StoredSkattekontoTransaction, } from '@/types/skatteverket' +import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { findBankSkvCounterparts } from '@/lib/skatteverket/bank-counterpart' import { MATCHABLE_INVOICE_STATUSES, @@ -54,6 +58,7 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all' import { useRealtimeSupabase } from '@/lib/hooks/use-realtime-supabase' import { getErrorMessage } from '@/lib/errors/get-error-message' import { cn, formatCurrency, formatDate } from '@/lib/utils' +import { roundOre } from '@/lib/money' import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, SupplierInvoice, Supplier, VatTreatment, EntityType, LinePatternEntry, BookingTemplateLibrary, CashAccount } from '@/types' import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions' import { isImportedTransaction } from '@/lib/transactions/origin' @@ -94,6 +99,10 @@ const SkattekontoMatchDialog = dynamic( () => import('@/components/skattekonto/SkattekontoMatchDialog').then((module) => module.SkattekontoMatchDialog), { loading: DialogLoadingSkeleton }, ) +const SkattekontoBookDialog = dynamic( + () => import('@/components/skattekonto/SkattekontoBookDialog'), + { loading: DialogLoadingSkeleton }, +) const DuplicateBookingDialog = dynamic( () => import('@/components/transactions/DuplicateBookingDialog'), { loading: DialogLoadingSkeleton }, @@ -123,6 +132,19 @@ function isSourceFilter(value: string | null): value is SourceFilter { ) } +// A skattekonto row qualifies for bulk booking when the outcome is fully +// deterministic: a rule matched (booking_suggestion), it is not a likely +// duplicate (match_suggestion), it is unbooked, and it has actually happened +// (kommande rows have nothing to book yet). +function isSkvBulkEligible(row: SkattekontoTransactionWithSuggestion): boolean { + return ( + row.booking_suggestion != null && + row.match_suggestion == null && + !row.journal_entry_id && + row.status !== 'upcoming' + ) +} + function buildInvoiceMap(rows: InvoiceWithCustomer[] | null): Record { if (!rows) return {} return rows.reduce>((acc, inv) => { @@ -365,10 +387,20 @@ export default function TransactionsPage() { // Skatteverket extension is enabled and connected. 503/401 → silently // hidden (extension disabled or user not connected). const [skvRows, setSkvRows] = useState([]) - const [skvProcessingId, setSkvProcessingId] = useState(null) const [skvMatchTarget, setSkvMatchTarget] = useState( null, ) + // Inline single-row booking dialog (replaces the old draft-then-navigate + // flow that dumped the user in /bookkeeping and lost all list state). + const [skvBookTarget, setSkvBookTarget] = useState( + null, + ) + // SKV bulk selection: deliberately a SEPARATE set from the bank + // `selectedIds`: every bank batch handler POSTs /api/transactions/* and + // would 404 on skattekonto ids. + const [skvSelectedIds, setSkvSelectedIds] = useState>(new Set()) + const [skvBulkConfirmOpen, setSkvBulkConfirmOpen] = useState(false) + const [skvBulkSubmitting, setSkvBulkSubmitting] = useState(false) // True when an SKV connection exists but is dead (needs_reconsent, or // expired with no refresh left). Drives the reconnect banner: without it // a user whose token died sees an empty skattekonto and has no reason to @@ -568,12 +600,60 @@ export default function TransactionsPage() { }, [sourceFilter, sourceItems]) // Rows the bulkbar's "Markera alla" can select: the visible bank rows - // (skattekonto rows aren't batch-bookable). + // (they feed the /api/transactions/* batch handlers) ... const selectableInboxIds = useMemo( () => inboxItems.filter((item) => item.source === 'bank').map((item) => item.data.id), [inboxItems], ) + // ... plus the visible skattekonto rows whose booking is deterministic + // (rule matched, no duplicate hint, unbooked, genomförd). These go through + // the skatteverket extension's bokfor-batch endpoint instead. + const selectableSkvIds = useMemo( + () => + inboxItems + .filter((item) => item.source === 'skatteverket' && isSkvBulkEligible(item.data)) + .map((item) => item.data.id), + [inboxItems], + ) + + const skvSelectedRows = useMemo( + () => skvRows.filter((r) => skvSelectedIds.has(r.id)), + [skvRows, skvSelectedIds], + ) + + // Bulk confirmation summary: selected rows grouped by their deterministic + // suggestion ("3 × Intäktsränta skattekonto → 8314") with per-group sums. + // Count-based on purpose: voucher numbers are assigned atomically at + // commit, so predicting them here would lie under concurrency. + const skvBulkGroups = useMemo(() => { + const groups = new Map< + string, + { label: string; account: string; count: number; sum: number } + >() + for (const row of skvSelectedRows) { + const suggestion = row.booking_suggestion + if (!suggestion) continue + const label = suggestion.label ?? suggestion.account_name ?? row.transaktionstext + const key = `${suggestion.account}|${label}` + const group = groups.get(key) ?? { + label, + account: suggestion.account, + count: 0, + sum: 0, + } + group.count += 1 + group.sum = roundOre(group.sum + Number(row.belopp_skatteverket)) + groups.set(key, group) + } + return Array.from(groups.values()) + }, [skvSelectedRows]) + + const skvBulkTotal = useMemo( + () => roundOre(skvBulkGroups.reduce((sum, g) => sum + g.sum, 0)), + [skvBulkGroups], + ) + const PAGE_SIZE = 200 @@ -1971,38 +2051,183 @@ export default function TransactionsPage() { } } - async function handleSkvBokfor(row: StoredSkattekontoTransaction) { - setSkvProcessingId(row.id) - try { - const res = await fetch( - `/api/extensions/ext/skatteverket/skattekonto/transaktioner/${row.id}/bokfor`, - { method: 'POST' }, + function handleSkvBokfor(row: StoredSkattekontoTransaction) { + // Prefer the enriched row (booking_suggestion) when it's in state: + // history-view callers only hold the stored shape. + setSkvBookTarget(skvRows.find((r) => r.id === row.id) ?? row) + } + + /** + * Single-row booking succeeded (inline dialog): exit animation, local + * journal_entry_id patch (no refetch), and one toast linking the verifikat. + */ + function handleSkvBooked(rowId: string, result: SkattekontoBatchRowResult) { + setSkvBookTarget(null) + setSkvSelectedIds((prev) => { + if (!prev.has(rowId)) return prev + const next = new Set(prev) + next.delete(rowId) + return next + }) + setExitingIds((prev) => new Set(prev).add(rowId)) + setTimeout(() => { + setSkvRows((prev) => + prev.map((r) => + r.id === rowId && result.journal_entry_id + ? { ...r, journal_entry_id: result.journal_entry_id } + : r, + ), ) - const json = await res.json() - if (!res.ok) { - // Map the parsed body plus the status, never `new Error(json.error)`: - // the Error constructor stringifies a non-string body field, and the - // mapper would discard the route's own Swedish reason. - toast({ - title: 'Kunde inte bokföra', - description: getErrorMessage(json, { statusCode: res.status }), - variant: 'destructive', - }) - return - } - toast({ - title: 'Utkast skapat', - description: t('review_in_bookkeeping_description'), + setExitingIds((prev) => { + const next = new Set(prev) + next.delete(rowId) + return next }) - window.location.href = `/bookkeeping/${json.data.entry.id}` - } catch (err) { + }, 350) + + const voucherLabel = + result.voucher_series && result.voucher_number != null + ? formatVoucher({ + voucher_series: result.voucher_series, + voucher_number: result.voucher_number, + }) + : null + toast({ + title: t('skv_booked_title'), + description: voucherLabel + ? t('skv_booked_description', { voucher: voucherLabel }) + : undefined, + action: result.journal_entry_id ? ( + + + {t('skv_booked_show')} + + + ) : undefined, + }) + } + + function toggleSkvSelect(id: string) { + setSkvSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + /** + * Bulk "Bokför valda": one confirmed summary → server-side draft+commit + * per row via bokfor-batch (chunked so batchProgress moves), then ONE + * aggregate toast and a local state patch with the exit animation. + */ + async function handleSkvBulkConfirm() { + // Re-check eligibility at submit time: a refetch may have attached a + // duplicate hint or booked a row while the selection sat idle. + const ids = skvSelectedRows.filter(isSkvBulkEligible).map((r) => r.id) + if (ids.length === 0) { + setSkvBulkConfirmOpen(false) + setSkvSelectedIds(new Set()) + return + } + setSkvBulkConfirmOpen(false) + setSkvBulkSubmitting(true) + setBatchProgress({ done: 0, total: ids.length }) + + const results: SkattekontoBatchRowResult[] = [] + const CHUNK = 25 + try { + for (let i = 0; i < ids.length; i += CHUNK) { + const chunk = ids.slice(i, i + CHUNK) + try { + const res = await fetch( + '/api/extensions/ext/skatteverket/skattekonto/transaktioner/bokfor-batch', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: chunk }), + }, + ) + const json = await res.json() + if (res.ok) { + results.push(...(json.data as SkattekontoBatchResult).results) + } else { + const message = getErrorMessage(json, { statusCode: res.status }) + for (const id of chunk) { + results.push({ id, ok: false, error_code: 'UNKNOWN', error_message: message }) + } + } + } catch { + for (const id of chunk) { + results.push({ id, ok: false, error_code: 'UNKNOWN' }) + } + } + setBatchProgress({ done: Math.min(i + CHUNK, ids.length), total: ids.length }) + } + } finally { + setBatchProgress(null) + setSkvBulkSubmitting(false) + } + + // Rows that got a journal entry (posted, or a kept draft on + // COMMIT_FAILED) leave the inbox: patch locally instead of refetching. + const patched = new Map() + for (const r of results) { + if (r.journal_entry_id) patched.set(r.id, r.journal_entry_id) + } + if (patched.size > 0) { + setExitingIds((prev) => { + const next = new Set(prev) + for (const id of patched.keys()) next.add(id) + return next + }) + setTimeout(() => { + setSkvRows((prev) => + prev.map((r) => + patched.has(r.id) ? { ...r, journal_entry_id: patched.get(r.id)! } : r, + ), + ) + setExitingIds((prev) => { + const next = new Set(prev) + for (const id of patched.keys()) next.delete(id) + return next + }) + }, 350) + } + setSkvSelectedIds(new Set()) + + const succeeded = results.filter((r) => r.ok).length + const failures = results.filter((r) => !r.ok) + if (failures.length === 0) { toast({ - title: 'Kunde inte bokföra', - description: err instanceof Error ? getErrorMessage(err) : undefined, + title: t('skv_bulk_done_title'), + description: t('skv_bulk_done_description', { count: succeeded }), + }) + } else { + const codeCounts = new Map() + for (const f of failures) { + const code = f.error_code ?? 'UNKNOWN' + codeCounts.set(code, (codeCounts.get(code) ?? 0) + 1) + } + const codeLabel = (code: string) => + code === 'PERIOD_LOCKED' + ? t('skv_err_period_locked') + : code === 'NO_COUNTER_ACCOUNT' + ? t('skv_err_no_counter_account') + : code === 'ALREADY_BOOKED' + ? t('skv_err_already_booked') + : code === 'NOT_SETTLED' + ? t('skv_err_not_settled') + : code === 'COMMIT_FAILED' + ? t('skv_err_commit_failed') + : t('skv_err_other') + const parts = [t('skv_bulk_partial_ok', { count: succeeded })] + for (const [code, n] of codeCounts) parts.push(`${n} ${codeLabel(code)}`) + toast({ + title: t('skv_bulk_partial_title'), + description: parts.join(', '), variant: 'destructive', }) - } finally { - setSkvProcessingId(null) } } @@ -2099,6 +2324,7 @@ export default function TransactionsPage() { function exitBatchMode() { setSelectedIds(new Set()) + setSkvSelectedIds(new Set()) } async function handleBatchDelete() { @@ -2605,7 +2831,7 @@ export default function TransactionsPage() { {/* Bulkbar (concept): hidden until at least one transaction is selected via the hover checkboxes, then it pops in with the count and the batch actions. */} - {selectedIds.size > 0 && ( + {(selectedIds.size > 0 || skvSelectedIds.size > 0) && (
{batchProgress ? ( @@ -2615,41 +2841,60 @@ export default function TransactionsPage() { ) : ( <> - {selectedIds.size}{' '} - {t('bulkbar_selected', { count: selectedIds.size })} + + {selectedIds.size + skvSelectedIds.size} + {' '} + {t('bulkbar_selected', { count: selectedIds.size + skvSelectedIds.size })} - - {/* Bulk-book (samlingsverifikation): only when ≥2 selected - on the same date + same direction. Disabled state - explains why via title. */} - - - - {selectedIds.size < selectableInboxIds.length && ( + {selectedIds.size > 0 && ( + <> + + {/* Bulk-book (samlingsverifikation): only when ≥2 selected + on the same date + same direction. Disabled state + explains why via title. */} + + + + + )} + {/* SKV rows book through the skatteverket extension: one + summary confirmation, then draft+commit per row. */} + {skvSelectedIds.size > 0 && ( + + )} + {(selectedIds.size < selectableInboxIds.length || + skvSelectedIds.size < selectableSkvIds.length) && ( )} + ) : noRuleMatched && onMatch ? ( + + ) : undefined + } + > +
+
+
{t('event_label')}
+
{row.transaktionstext}
+
+
+
{t('date_label')}
+
{formatDate(row.transaktionsdatum)}
+
+
+
{t('amount_label')}
+
0 && 'text-success')}> + {amount > 0 ? '+' : ''} + {formatCurrency(amount)} +
+
+ {suggestion ? ( +
+
{t('posting_label')}
+
+ {t('posting_value', { + account: suggestion.account_name + ? `${suggestion.account} ${suggestion.account_name}` + : suggestion.account, + })} +
+
+ ) : suggestion === null ? ( +

+ {t('no_rule_matched')} +

+ ) : null} +
+ + ) +} diff --git a/components/transactions/SkattekontoInboxCard.tsx b/components/transactions/SkattekontoInboxCard.tsx index fc770293..d60dd3f8 100644 --- a/components/transactions/SkattekontoInboxCard.tsx +++ b/components/transactions/SkattekontoInboxCard.tsx @@ -3,11 +3,13 @@ import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' +import { Checkbox } from '@/components/ui/checkbox' import { TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table' import { cn, formatCurrency, formatDate } from '@/lib/utils' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { AlertCircle, Landmark, Link2, Loader2 } from 'lucide-react' import type { + SkattekontoBookingSuggestion, SkattekontoMatchSuggestion, StoredSkattekontoTransaction, } from '@/types/skatteverket' @@ -21,13 +23,21 @@ import type { export default function SkattekontoInboxCard({ row, matchSuggestion, + bookingSuggestion, processing, + selectable, + isSelected, + onToggleSelect, onBokfor, onMatch, }: { row: StoredSkattekontoTransaction matchSuggestion?: SkattekontoMatchSuggestion | null + bookingSuggestion?: SkattekontoBookingSuggestion | null processing: boolean + selectable?: boolean + isSelected?: boolean + onToggleSelect?: (id: string) => void onBokfor: (row: StoredSkattekontoTransaction) => void onMatch: (row: StoredSkattekontoTransaction) => void }) { @@ -46,8 +56,30 @@ export default function SkattekontoInboxCard({ : t('duplicate_title_draft') return ( - - + + {/* Hover-revealed selection checkbox (concept .cb) */} + {/* Zero-width cell: the checkbox hangs in the left page margin so + the date column can sit flush with the page edge. */} + + {selectable && ( + onToggleSelect?.(row.id)} + aria-label={t('select_row')} + className={cn( + 'absolute -left-5 top-1/2 -translate-y-1/2 transition-opacity duration-150 md:-left-6', + isSelected + ? 'opacity-100' + : 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100', + )} + /> + )} + {formatDate(row.transaktionsdatum)} @@ -64,6 +96,18 @@ export default function SkattekontoInboxCard({ {duplicateLabel} )} + {/* What "Bokför" will do: the deterministic rule match, muted so it + reads as information, not state. Suppressed on likely duplicates + where linking (not booking) is the recommended action. */} + {bookingSuggestion && !matchSuggestion && ( + + {t('suggestion_line', { + account: bookingSuggestion.account_name + ? `${bookingSuggestion.account} ${bookingSuggestion.account_name}` + : bookingSuggestion.account, + })} + + )}
{ + const actual = await importOriginal() + return { + ...actual, + bokforSkattekontoTransactionsBatch: vi.fn(), + } +}) + +import { skatteverketExtension } from '../index' +import { bokforSkattekontoTransactionsBatch } from '../lib/skattekonto-booking' +import type { ExtensionContext } from '@/lib/extensions/types' +import type { SkattekontoBatchResult } from '@/types/skatteverket' + +const ROUTE_PATH = '/skattekonto/transaktioner/bokfor-batch' +const UUID_A = '11111111-1111-4111-8111-111111111111' +const UUID_B = '22222222-2222-4222-8222-222222222222' + +function findRoute() { + const route = skatteverketExtension.apiRoutes?.find( + (r) => r.method === 'POST' && r.path === ROUTE_PATH, + ) + if (!route) throw new Error('bokfor-batch route not registered') + return route +} + +function makeContext(): ExtensionContext { + return { + userId: 'user-1', + companyId: 'company-1', + extensionId: 'skatteverket', + requestId: 'req_test', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + supabase: { from: vi.fn() } as any, + emit: vi.fn().mockResolvedValue(undefined), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), child: vi.fn() }, + settings: { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + clear: vi.fn().mockResolvedValue(undefined), + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any +} + +function makeRequest(body: unknown): Request { + return new Request('http://localhost/api/extensions/ext/skatteverket' + ROUTE_PATH, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }) +} + +describe('POST /skattekonto/transaktioner/bokfor-batch', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns 500 without an extension context', async () => { + const res = await findRoute().handler(makeRequest({ ids: [UUID_A] })) + expect(res.status).toBe(500) + }) + + it('rejects invalid JSON with 400', async () => { + const res = await findRoute().handler(makeRequest('not json{'), makeContext()) + expect(res.status).toBe(400) + expect(vi.mocked(bokforSkattekontoTransactionsBatch)).not.toHaveBeenCalled() + }) + + it('rejects an empty ids list with 400', async () => { + const res = await findRoute().handler(makeRequest({ ids: [] }), makeContext()) + expect(res.status).toBe(400) + }) + + it('rejects non-uuid ids with 400', async () => { + const res = await findRoute().handler( + makeRequest({ ids: ['abc; drop table'] }), + makeContext(), + ) + expect(res.status).toBe(400) + }) + + it('rejects more than 200 ids with 400', async () => { + const ids = Array.from({ length: 201 }, (_, i) => + `${String(i).padStart(8, '0')}-1111-4111-8111-111111111111`, + ) + const res = await findRoute().handler(makeRequest({ ids }), makeContext()) + expect(res.status).toBe(400) + expect(vi.mocked(bokforSkattekontoTransactionsBatch)).not.toHaveBeenCalled() + }) + + it('runs the batch and returns its results in the data envelope', async () => { + const payload: SkattekontoBatchResult = { + results: [ + { id: UUID_A, ok: true, journal_entry_id: 'je-1', voucher_number: 7, voucher_series: 'A' }, + { id: UUID_B, ok: false, error_code: 'PERIOD_LOCKED', error_message: 'Låst period' }, + ], + summary: { total: 2, succeeded: 1, failed: 1 }, + } + vi.mocked(bokforSkattekontoTransactionsBatch).mockResolvedValue(payload) + + const ctx = makeContext() + const res = await findRoute().handler(makeRequest({ ids: [UUID_A, UUID_B] }), ctx) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toEqual(payload) + expect(vi.mocked(bokforSkattekontoTransactionsBatch)).toHaveBeenCalledWith( + ctx.supabase, + 'company-1', + 'user-1', + [UUID_A, UUID_B], + ) + }) + + it('dedupes repeated ids before running the batch', async () => { + vi.mocked(bokforSkattekontoTransactionsBatch).mockResolvedValue({ + results: [{ id: UUID_A, ok: true }], + summary: { total: 1, succeeded: 1, failed: 0 }, + }) + + await findRoute().handler(makeRequest({ ids: [UUID_A, UUID_A] }), makeContext()) + expect(vi.mocked(bokforSkattekontoTransactionsBatch)).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + [UUID_A], + ) + }) +}) diff --git a/extensions/general/skatteverket/__tests__/skattekonto-booking-batch.test.ts b/extensions/general/skatteverket/__tests__/skattekonto-booking-batch.test.ts new file mode 100644 index 00000000..e75d0e56 --- /dev/null +++ b/extensions/general/skatteverket/__tests__/skattekonto-booking-batch.test.ts @@ -0,0 +1,441 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' +import { createQueuedMockSupabase } from '@/tests/helpers' + +// The batch loop delegates all journal writes to the engine: stub the three +// engine entry points so the tests exercise the batch/enrichment logic, not +// the engine's own chains (those are covered by the engine's tests). +vi.mock('@/lib/bookkeeping/engine', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + findFiscalPeriod: vi.fn(), + createDraftEntry: vi.fn(), + commitEntry: vi.fn(), + } +}) + +import { findFiscalPeriod, createDraftEntry, commitEntry } from '@/lib/bookkeeping/engine' +import { getBASReference } from '@/lib/bookkeeping/bas-reference' +import { + attachBookingSuggestions, + bokforSkattekontoTransactionsBatch, +} from '../lib/skattekonto-booking' + +/** + * System seeds mirror supabase/migrations/20260519100000_skattekonto_rules.sql + * (same fixture as skattekonto-booking.test.ts). + */ +const SEED_RULES = [ + { + id: 'sys-1', priority: 10, pattern: 'inbetalning bokförd,inbetalning,överföring från bank', + amount_min: null, amount_max: null, company_type: 'all', + counter_account: '__PRIMARY_SEK__', counter_account_ef: null, + label: 'Inbetalning till skattekonto', active: true, + }, + { + id: 'sys-3', priority: 20, pattern: 'debiterad preliminärskatt,preliminärskatt,f-skatt,fskatt', + amount_min: null, amount_max: null, company_type: 'all', + counter_account: '2510', counter_account_ef: '2012', + label: 'Preliminär skatt', active: true, + }, + { + id: 'sys-8', priority: 30, pattern: 'kostnadsränta', + amount_min: null, amount_max: null, company_type: 'all', + counter_account: '8423', counter_account_ef: null, + label: 'Kostnadsränta skattekonto', active: true, + }, + { + id: 'sys-9', priority: 30, pattern: 'intäktsränta', + amount_min: null, amount_max: null, company_type: 'all', + counter_account: '8314', counter_account_ef: null, + label: 'Intäktsränta skattekonto', active: true, + }, +] + +let rowSeq = 0 +function makeSkvRow(overrides: Record = {}) { + rowSeq += 1 + return { + id: `row-${rowSeq}`, + company_id: 'company-1', + transaktionstext: 'Intäktsränta', + transaktionsdatum: '2026-01-15', + belopp_skatteverket: 1, + status: 'booked', + journal_entry_id: null, + ...overrides, + } +} + +function makeSupabase() { + return createQueuedMockSupabase() +} + +/** Number of times a table was targeted by supabase.from(). */ +function fromCount( + supabase: ReturnType['supabase'], + table: string, +): number { + return supabase.from.mock.calls.filter((c: unknown[]) => c[0] === table).length +} + +beforeEach(() => { + vi.clearAllMocks() + rowSeq = 0 + vi.mocked(findFiscalPeriod).mockResolvedValue('fp-1') + vi.mocked(createDraftEntry).mockImplementation(async () => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ({ id: 'je-draft-1' }) as any, + ) + vi.mocked(commitEntry).mockImplementation(async () => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ({ id: 'je-draft-1', voucher_number: 42, voucher_series: 'A' }) as any, + ) +}) + +describe('attachBookingSuggestions', () => { + it('attaches account, BAS name and rule label on a rule match', async () => { + const { supabase, enqueue } = makeSupabase() + enqueue({ data: SEED_RULES }) // skattekonto_rules + enqueue({ data: { entity_type: 'aktiebolag' } }) // company_settings + + const rows = [makeSkvRow({ transaktionstext: 'Intäktsränta' })] + const enriched = await attachBookingSuggestions( + supabase as unknown as SupabaseClient, + 'company-1', + rows, + ) + + expect(enriched[0].booking_suggestion).toEqual({ + account: '8314', + account_name: getBASReference('8314')?.account_name ?? null, + label: 'Intäktsränta skattekonto', + }) + }) + + it('resolves EF-specific counter accounts from the hoisted entity_type', async () => { + const { supabase, enqueue } = makeSupabase() + enqueue({ data: SEED_RULES }) + enqueue({ data: { entity_type: 'enskild_firma' } }) + + const enriched = await attachBookingSuggestions( + supabase as unknown as SupabaseClient, + 'company-1', + [makeSkvRow({ transaktionstext: 'Debiterad preliminärskatt' })], + ) + expect(enriched[0].booking_suggestion?.account).toBe('2012') + + const ab = makeSupabase() + ab.enqueue({ data: SEED_RULES }) + ab.enqueue({ data: { entity_type: 'aktiebolag' } }) + const enrichedAb = await attachBookingSuggestions( + ab.supabase as unknown as SupabaseClient, + 'company-1', + [makeSkvRow({ transaktionstext: 'Debiterad preliminärskatt' })], + ) + expect(enrichedAb[0].booking_suggestion?.account).toBe('2510') + }) + + it('returns null when no rule matches', async () => { + const { supabase, enqueue } = makeSupabase() + enqueue({ data: SEED_RULES }) + enqueue({ data: { entity_type: 'aktiebolag' } }) + + const enriched = await attachBookingSuggestions( + supabase as unknown as SupabaseClient, + 'company-1', + [makeSkvRow({ transaktionstext: 'Något helt okänt' })], + ) + expect(enriched[0].booking_suggestion).toBeNull() + }) + + it('hoists the rules fetch: one skattekonto_rules query for many rows', async () => { + const { supabase, enqueue } = makeSupabase() + enqueue({ data: SEED_RULES }) + enqueue({ data: { entity_type: 'aktiebolag' } }) + + const rows = [ + makeSkvRow({ transaktionstext: 'Intäktsränta' }), + makeSkvRow({ transaktionstext: 'Kostnadsränta' }), + makeSkvRow({ transaktionstext: 'Debiterad preliminärskatt' }), + ] + const enriched = await attachBookingSuggestions( + supabase as unknown as SupabaseClient, + 'company-1', + rows, + ) + + expect(enriched.map((r) => r.booking_suggestion?.account)).toEqual([ + '8314', + '8423', + '2510', + ]) + expect(fromCount(supabase, 'skattekonto_rules')).toBe(1) + expect(fromCount(supabase, 'company_settings')).toBe(1) + }) + + it('resolves the __PRIMARY_SEK__ sentinel once via cash_accounts', async () => { + const { supabase, enqueue } = makeSupabase() + enqueue({ data: SEED_RULES }) + enqueue({ data: { entity_type: 'aktiebolag' } }) + enqueue({ data: { ledger_account: '1932' } }) // cash_accounts primary + + const rows = [ + makeSkvRow({ transaktionstext: 'Inbetalning bokförd 240412' }), + makeSkvRow({ transaktionstext: 'Inbetalning bokförd 240513' }), + ] + const enriched = await attachBookingSuggestions( + supabase as unknown as SupabaseClient, + 'company-1', + rows, + ) + + expect(enriched[0].booking_suggestion?.account).toBe('1932') + expect(enriched[1].booking_suggestion?.account).toBe('1932') + // Memoized: the second sentinel row must not refetch cash_accounts. + expect(fromCount(supabase, 'cash_accounts')).toBe(1) + }) + + it('gives booked rows null without any fetches when nothing needs a suggestion', async () => { + const { supabase } = makeSupabase() + const enriched = await attachBookingSuggestions( + supabase as unknown as SupabaseClient, + 'company-1', + [makeSkvRow({ journal_entry_id: 'je-9' }), makeSkvRow({ status: 'upcoming' })], + ) + expect(enriched.every((r) => r.booking_suggestion === null)).toBe(true) + expect(supabase.from).not.toHaveBeenCalled() + }) +}) + +describe('bokforSkattekontoTransactionsBatch', () => { + it('books each row, commits per row, and never aborts on a row failure', async () => { + const { supabase, enqueue } = makeSupabase() + const row1 = makeSkvRow({ transaktionstext: 'Intäktsränta' }) + const row2 = makeSkvRow({ transaktionstext: 'Ingen regel matchar detta' }) + const row3 = makeSkvRow({ transaktionstext: 'Kostnadsränta', belopp_skatteverket: -100 }) + + enqueue({ data: SEED_RULES }) // hoisted rules + enqueue({ data: { entity_type: 'aktiebolag' } }) // hoisted entity_type + enqueue({ data: row1 }) // tx fetch row1 + enqueue({ data: [{ id: row1.id }] }) // journal_entry_id claim row1 + enqueue({ data: row2 }) // tx fetch row2 (fails matching, no claim) + enqueue({ data: row3 }) // tx fetch row3 + enqueue({ data: [{ id: row3.id }] }) // claim row3 + + const result = await bokforSkattekontoTransactionsBatch( + supabase as unknown as SupabaseClient, + 'company-1', + 'user-1', + [row1.id, row2.id, row3.id], + ) + + expect(result.results).toHaveLength(3) + expect(result.results[0]).toMatchObject({ + id: row1.id, + ok: true, + journal_entry_id: 'je-draft-1', + voucher_number: 42, + voucher_series: 'A', + }) + expect(result.results[1]).toMatchObject({ + id: row2.id, + ok: false, + error_code: 'NO_COUNTER_ACCOUNT', + }) + expect(result.results[2]).toMatchObject({ id: row3.id, ok: true }) + expect(result.summary).toEqual({ total: 3, succeeded: 2, failed: 1 }) + + // Commit runs once per successful row, attributed as a bulk acceptance. + expect(vi.mocked(commitEntry)).toHaveBeenCalledTimes(2) + expect(vi.mocked(commitEntry)).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + 'je-draft-1', + 'bulk_accept', + ) + expect(vi.mocked(createDraftEntry)).toHaveBeenCalledTimes(2) + }) + + it('hoists rules/entity_type: one fetch each for the whole batch', async () => { + const { supabase, enqueue } = makeSupabase() + const row1 = makeSkvRow({ transaktionstext: 'Intäktsränta' }) + const row2 = makeSkvRow({ transaktionstext: 'Kostnadsränta' }) + + enqueue({ data: SEED_RULES }) + enqueue({ data: { entity_type: 'aktiebolag' } }) + enqueue({ data: row1 }) + enqueue({ data: [{ id: row1.id }] }) + enqueue({ data: row2 }) + enqueue({ data: [{ id: row2.id }] }) + + await bokforSkattekontoTransactionsBatch( + supabase as unknown as SupabaseClient, + 'company-1', + 'user-1', + [row1.id, row2.id], + ) + + expect(fromCount(supabase, 'skattekonto_rules')).toBe(1) + expect(fromCount(supabase, 'company_settings')).toBe(1) + }) + + it('attributes a one-row batch as user_accept', async () => { + const { supabase, enqueue } = makeSupabase() + const row = makeSkvRow({ transaktionstext: 'Intäktsränta' }) + enqueue({ data: SEED_RULES }) + enqueue({ data: { entity_type: 'aktiebolag' } }) + enqueue({ data: row }) + enqueue({ data: [{ id: row.id }] }) + + await bokforSkattekontoTransactionsBatch( + supabase as unknown as SupabaseClient, + 'company-1', + 'user-1', + [row.id], + ) + + expect(vi.mocked(commitEntry)).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + 'je-draft-1', + 'user_accept', + ) + }) + + it('reports COMMIT_FAILED with the kept draft id when the commit throws', async () => { + const { supabase, enqueue } = makeSupabase() + const row = makeSkvRow({ transaktionstext: 'Intäktsränta' }) + enqueue({ data: SEED_RULES }) + enqueue({ data: { entity_type: 'aktiebolag' } }) + enqueue({ data: row }) + enqueue({ data: [{ id: row.id }] }) + + vi.mocked(commitEntry).mockRejectedValueOnce(new Error('Bokföringen är låst')) + + const result = await bokforSkattekontoTransactionsBatch( + supabase as unknown as SupabaseClient, + 'company-1', + 'user-1', + [row.id], + ) + + expect(result.results[0]).toMatchObject({ + id: row.id, + ok: false, + error_code: 'COMMIT_FAILED', + journal_entry_id: 'je-draft-1', + error_message: 'Bokföringen är låst', + }) + expect(result.summary).toEqual({ total: 1, succeeded: 0, failed: 1 }) + }) + + it('reports ALREADY_BOOKED for rows that already carry a journal entry', async () => { + const { supabase, enqueue } = makeSupabase() + const row = makeSkvRow({ journal_entry_id: 'je-existing' }) + enqueue({ data: SEED_RULES }) + enqueue({ data: { entity_type: 'aktiebolag' } }) + enqueue({ data: row }) + + const result = await bokforSkattekontoTransactionsBatch( + supabase as unknown as SupabaseClient, + 'company-1', + 'user-1', + [row.id], + ) + + expect(result.results[0]).toMatchObject({ + id: row.id, + ok: false, + error_code: 'ALREADY_BOOKED', + }) + expect(vi.mocked(createDraftEntry)).not.toHaveBeenCalled() + expect(vi.mocked(commitEntry)).not.toHaveBeenCalled() + }) + + it('rejects unsettled (kommande) rows with NOT_SETTLED before any draft exists', async () => { + const { supabase, enqueue } = makeSupabase() + // Rule WOULD match: only status must stop the row from being posted. + const row = makeSkvRow({ transaktionstext: 'Intäktsränta', status: 'upcoming' }) + enqueue({ data: SEED_RULES }) + enqueue({ data: { entity_type: 'aktiebolag' } }) + enqueue({ data: row }) + + const result = await bokforSkattekontoTransactionsBatch( + supabase as unknown as SupabaseClient, + 'company-1', + 'user-1', + [row.id], + ) + + expect(result.results[0]).toMatchObject({ + id: row.id, + ok: false, + error_code: 'NOT_SETTLED', + }) + expect(result.summary).toEqual({ total: 1, succeeded: 0, failed: 1 }) + expect(vi.mocked(createDraftEntry)).not.toHaveBeenCalled() + expect(vi.mocked(commitEntry)).not.toHaveBeenCalled() + }) + + it('reports ALREADY_BOOKED and never commits when the backlink claim affects 0 rows', async () => { + const { supabase, enqueue } = makeSupabase() + const row = makeSkvRow({ transaktionstext: 'Intäktsränta' }) + enqueue({ data: SEED_RULES }) + enqueue({ data: { entity_type: 'aktiebolag' } }) + enqueue({ data: row }) // tx fetch: still unbooked at precheck time + enqueue({ data: [] }) // claim: a concurrent submission won the race + + const result = await bokforSkattekontoTransactionsBatch( + supabase as unknown as SupabaseClient, + 'company-1', + 'user-1', + [row.id], + ) + + expect(result.results[0]).toMatchObject({ + id: row.id, + ok: false, + error_code: 'ALREADY_BOOKED', + }) + // The draft was created before the claim, but the losing request must + // never post it: no voucher may be committed for a row someone else owns. + expect(vi.mocked(createDraftEntry)).toHaveBeenCalledTimes(1) + expect(vi.mocked(commitEntry)).not.toHaveBeenCalled() + }) + + it('maps the period-lock trigger error to PERIOD_LOCKED with Swedish text', async () => { + const { supabase, enqueue } = makeSupabase() + const row = makeSkvRow({ transaktionstext: 'Intäktsränta' }) + enqueue({ data: SEED_RULES }) + enqueue({ data: { entity_type: 'aktiebolag' } }) + enqueue({ data: row }) + + // A locked-but-not-closed period passes findFiscalPeriod's is_closed + // precheck; the enforcement trigger then rejects the draft INSERT with + // this signature (wrapped by the engine's BookkeepingDatabaseError). + vi.mocked(createDraftEntry).mockRejectedValueOnce( + new Error( + 'Database operation "create_draft_entry" failed: Cannot write to locked/closed fiscal period "2026" (is_closed=f, locked_at=2026-02-01 00:00:00+00)', + ), + ) + + const result = await bokforSkattekontoTransactionsBatch( + supabase as unknown as SupabaseClient, + 'company-1', + 'user-1', + [row.id], + ) + + expect(result.results[0]).toMatchObject({ + id: row.id, + ok: false, + error_code: 'PERIOD_LOCKED', + }) + expect(result.results[0].error_message).not.toContain('locked/closed fiscal period') + expect(vi.mocked(commitEntry)).not.toHaveBeenCalled() + }) +}) diff --git a/extensions/general/skatteverket/index.ts b/extensions/general/skatteverket/index.ts index 18415a87..8f0df99a 100644 --- a/extensions/general/skatteverket/index.ts +++ b/extensions/general/skatteverket/index.ts @@ -1,4 +1,5 @@ import crypto from 'crypto' +import { z } from 'zod' import type { SupabaseClient } from '@supabase/supabase-js' import type { Extension, ExtensionContext } from '@/lib/extensions/types' import { NextResponse, after } from 'next/server' @@ -45,7 +46,12 @@ import { } from './lib/agi-client' import { syncSkattekonto, SKATTEKONTO_BALANCE_SNAPSHOT_KEY, SKATTEKONTO_LAST_SYNCED_AT_KEY } from './lib/skattekonto-sync' import { runPostConnectRefresh } from './lib/post-connect-refresh' -import { bokforSkattekontoTransaction, SkattekontoBookingError } from './lib/skattekonto-booking' +import { + attachBookingSuggestions, + bokforSkattekontoTransaction, + bokforSkattekontoTransactionsBatch, + SkattekontoBookingError, +} from './lib/skattekonto-booking' import { handleSkattekontoDriftDetected } from './lib/skattekonto-drift-email' import { handleSkattekontoConnectionExpired } from './lib/connection-expired-notification' import { @@ -61,6 +67,13 @@ import { createLogger } from '@/lib/logger' const log = createLogger('skatteverket') +// Body for POST /skattekonto/transaktioner/bokfor-batch. Capped at 200 ids: +// a full year of skattekonto events fits comfortably, and the sequential +// draft+commit loop stays well inside the dispatcher's time budget. +const SkattekontoBokforBatchSchema = z.object({ + ids: z.array(z.string().uuid()).min(1).max(200), +}) + /** * Skatteverket integration extension. * @@ -2097,7 +2110,16 @@ export const skatteverketExtension: Extension = { })), ) - const bookedEnriched = booked.map(r => ({ + // Deterministic booking suggestion per unbooked row (one hoisted + // rules fetch): the UI shows what "Bokför" will do and uses it to + // decide bulk-booking eligibility. + const withBookingSuggestions = await attachBookingSuggestions( + ctx.supabase, + ctx.companyId, + booked, + ) + + const bookedEnriched = withBookingSuggestions.map(r => ({ ...r, match_suggestion: suggestions.get(r.id) ?? null, })) @@ -2132,6 +2154,52 @@ export const skatteverketExtension: Extension = { }, }, + // ── Bokför several rows → committed verifikat per row ───────── + // Draft + commit per id, server-side, so a successful row lands as a + // posted verifikat with no orphan drafts. Row failures never abort the + // loop: the response carries per-row results plus a summary for one + // aggregate toast. Also serves the inline single-row flow (ids: [id]). + { + method: 'POST', + path: '/skattekonto/transaktioner/bokfor-batch', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + + let parsedBody: unknown + try { + parsedBody = await request.json() + } catch { + return NextResponse.json({ error: 'Ogiltig JSON i förfrågan.' }, { status: 400 }) + } + + const parsed = SkattekontoBokforBatchSchema.safeParse(parsedBody) + if (!parsed.success) { + return NextResponse.json( + { error: 'Ogiltiga parametrar: ids måste vara en lista med 1-200 transaktions-id.' }, + { status: 400 }, + ) + } + + // Dedupe: a repeated id would just burn an ALREADY_BOOKED failure + // on its second pass. + const ids = [...new Set(parsed.data.ids)] + + try { + const result = await bokforSkattekontoTransactionsBatch( + ctx.supabase, + ctx.companyId, + ctx.userId, + ids, + ) + return NextResponse.json({ data: result }) + } catch (err) { + return handleSkvError(err) + } + }, + }, + // ── Bokför one row → draft journal entry ────────────────────── // Creates a DRAFT verifikat in /bookkeeping for the user to review // and commit. The skattekonto_transactions row is linked via diff --git a/extensions/general/skatteverket/lib/skattekonto-booking.ts b/extensions/general/skatteverket/lib/skattekonto-booking.ts index fec225ab..aa8b08b5 100644 --- a/extensions/general/skatteverket/lib/skattekonto-booking.ts +++ b/extensions/general/skatteverket/lib/skattekonto-booking.ts @@ -1,11 +1,17 @@ import type { SupabaseClient } from '@supabase/supabase-js' -import { createDraftEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' +import { commitEntry, createDraftEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' +import { getBASReference } from '@/lib/bookkeeping/bas-reference' import { getPrimary as getPrimaryCashAccount } from '@/lib/cash-accounts/service' import type { CreateJournalEntryInput, CreateJournalEntryLineInput, JournalEntry, } from '@/types' +import type { + SkattekontoBatchResult, + SkattekontoBatchRowResult, + SkattekontoBookingSuggestion, +} from '@/types/skatteverket' /** * Per-row "Bokför" helper. @@ -60,6 +66,7 @@ export class SkattekontoBookingError extends Error { | 'NO_FISCAL_PERIOD' | 'PERIOD_LOCKED' | 'ALREADY_BOOKED' + | 'NOT_SETTLED' | 'TRANSACTION_NOT_FOUND', ) { super(message) @@ -107,37 +114,22 @@ const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/ const SKATTEKONTO_RULE_COLUMNS = 'id, priority, pattern, amount_min, amount_max, company_type, counter_account, counter_account_ef, label, active' -export async function guessCounterAccount( - supabase: SupabaseClient, - companyId: string, +/** + * Pure core matcher shared by every suggestion/booking path: walk the + * priority-ordered rules and return the first match. The returned account may + * still be the __PRIMARY_SEK__ sentinel; callers resolve it against + * cash_accounts (so the DB round trip stays out of the pure matcher). + */ +function matchSkattekontoRule( + rules: SkattekontoRuleRow[], transaktionstext: string, entityType: EntityType, belopp?: number, -): Promise { - if (!SAFE_ID_PATTERN.test(companyId)) { - // The caller is supposed to pass a validated company id (from - // requireCompanyId). Refuse rather than interpolate an unknown string - // into the PostgREST filter: the .or() string parser is forgiving and - // we don't want to depend on it for safety. - return null - } - +): CounterAccountMatch | null { const normalized = transaktionstext.toLowerCase() const absBelopp = belopp === undefined ? null : Math.abs(belopp) - const { data: rules, error } = await supabase - .from('skattekonto_rules') - .select(SKATTEKONTO_RULE_COLUMNS) - .eq('active', true) - .or(`company_id.eq.${companyId},company_id.is.null`) - .order('priority', { ascending: true }) - .order('id', { ascending: true }) - - if (error || !rules || rules.length === 0) { - return null - } - - for (const rule of rules as SkattekontoRuleRow[]) { + for (const rule of rules) { if (rule.company_type !== 'all' && rule.company_type !== entityType) { continue } @@ -154,15 +146,11 @@ export async function guessCounterAccount( if (!patterns.some(p => normalized.includes(p))) continue - let account = + const account = entityType === 'enskild_firma' && rule.counter_account_ef ? rule.counter_account_ef : rule.counter_account - if (account === PRIMARY_SEK_SENTINEL) { - account = await resolvePrimarySekAccount(supabase, companyId) - } - return { account, label: rule.label ?? transaktionstext, @@ -172,6 +160,169 @@ export async function guessCounterAccount( return null } +/** The active rules for a company (system seeds + overrides), priority order. */ +async function fetchSkattekontoRules( + supabase: SupabaseClient, + companyId: string, +): Promise { + const { data: rules, error } = await supabase + .from('skattekonto_rules') + .select(SKATTEKONTO_RULE_COLUMNS) + .eq('active', true) + .or(`company_id.eq.${companyId},company_id.is.null`) + .order('priority', { ascending: true }) + .order('id', { ascending: true }) + + if (error || !rules) return [] + return rules as SkattekontoRuleRow[] +} + +export async function guessCounterAccount( + supabase: SupabaseClient, + companyId: string, + transaktionstext: string, + entityType: EntityType, + belopp?: number, +): Promise { + if (!SAFE_ID_PATTERN.test(companyId)) { + // The caller is supposed to pass a validated company id (from + // requireCompanyId). Refuse rather than interpolate an unknown string + // into the PostgREST filter: the .or() string parser is forgiving and + // we don't want to depend on it for safety. + return null + } + + const rules = await fetchSkattekontoRules(supabase, companyId) + if (rules.length === 0) return null + + const match = matchSkattekontoRule(rules, transaktionstext, entityType, belopp) + if (!match) return null + + return { + ...match, + account: + match.account === PRIMARY_SEK_SENTINEL + ? await resolvePrimarySekAccount(supabase, companyId) + : match.account, + } +} + +/** + * One-shot context for enrichment/batch paths: hoists the rules and + * entity_type fetches out of per-row work. The primary SEK account is + * resolved lazily and memoized: most batches never contain an + * in-/utbetalning row, so the cash_accounts query only runs when a + * __PRIMARY_SEK__ rule actually matches. + */ +export interface SkattekontoRuleContext { + rules: SkattekontoRuleRow[] + entityType: EntityType + resolvePrimarySek: () => Promise +} + +export async function loadRuleContext( + supabase: SupabaseClient, + companyId: string, +): Promise { + if (!SAFE_ID_PATTERN.test(companyId)) { + // Same defence-in-depth refusal as guessCounterAccount: never interpolate + // an unvalidated id into the PostgREST .or() filter string. + return { + rules: [], + entityType: 'aktiebolag', + resolvePrimarySek: async () => PRIMARY_SEK_FALLBACK, + } + } + + const [rules, settingsResult] = await Promise.all([ + fetchSkattekontoRules(supabase, companyId), + supabase + .from('company_settings') + .select('entity_type') + .eq('company_id', companyId) + .single(), + ]) + + let primary: string | null = null + return { + rules, + entityType: (settingsResult.data?.entity_type as EntityType) ?? 'aktiebolag', + resolvePrimarySek: async () => { + if (primary === null) { + primary = await resolvePrimarySekAccount(supabase, companyId) + } + return primary + }, + } +} + +/** + * Enrich skattekonto rows with the deterministic booking suggestion the + * per-row "Bokför" would use, so the list can show what a booking will do + * before the user commits to it. One rules/entity_type fetch for the whole + * page of rows. + * + * Only unbooked, genomförda rows get a computed suggestion; already-booked + * rows and kommande rows get `booking_suggestion: null` without any matching + * work (and a page of only such rows skips the fetches entirely). + */ +export async function attachBookingSuggestions< + T extends { + transaktionstext: string + belopp_skatteverket: number | string + journal_entry_id: string | null + status: string + }, +>( + supabase: SupabaseClient, + companyId: string, + rows: T[], +): Promise<(T & { booking_suggestion: SkattekontoBookingSuggestion | null })[]> { + const needsSuggestion = (row: T) => + !row.journal_entry_id && row.status !== 'upcoming' + + if (!rows.some(needsSuggestion)) { + return rows.map(row => ({ ...row, booking_suggestion: null })) + } + + const ctx = await loadRuleContext(supabase, companyId) + const enriched: (T & { booking_suggestion: SkattekontoBookingSuggestion | null })[] = [] + + for (const row of rows) { + if (!needsSuggestion(row)) { + enriched.push({ ...row, booking_suggestion: null }) + continue + } + + const match = matchSkattekontoRule( + ctx.rules, + row.transaktionstext, + ctx.entityType, + Number(row.belopp_skatteverket), + ) + if (!match) { + enriched.push({ ...row, booking_suggestion: null }) + continue + } + + const account = + match.account === PRIMARY_SEK_SENTINEL + ? await ctx.resolvePrimarySek() + : match.account + + enriched.push({ + ...row, + booking_suggestion: { + account, + account_name: getBASReference(account)?.account_name ?? null, + label: match.label, + }, + }) + } + + return enriched +} + /** * Create a draft journal entry for one skattekonto_transactions row. * @@ -188,6 +339,15 @@ export async function bokforSkattekontoTransaction( companyId: string, userId: string, transactionId: string, + // Batch callers pass a preloaded context so rules/entity_type are fetched + // once per batch instead of once per row. Omitted → per-call fetches, + // identical to the original single-row behaviour. + ruleContext?: SkattekontoRuleContext, + // requireSettled: reject rows that Skatteverket has not settled yet + // (status !== 'booked'). The batch commit path sets this: a kommande row + // must never land in an immutable posted verifikat. The single-row draft + // endpoint keeps its historical behaviour (draft for user review). + options?: { requireSettled?: boolean }, ): Promise { // 1. Load the transaction const { data: tx, error: txError } = await supabase @@ -211,24 +371,50 @@ export async function bokforSkattekontoTransaction( ) } - // 2. Get entity_type for AB/EF-specific accounts - const { data: settings } = await supabase - .from('company_settings') - .select('entity_type') - .eq('company_id', companyId) - .single() + if (options?.requireSettled && tx.status !== 'booked') { + throw new SkattekontoBookingError( + 'Händelsen är inte genomförd hos Skatteverket ännu och kan inte bokföras.', + 'NOT_SETTLED', + ) + } - const entityType: EntityType = - (settings?.entity_type as EntityType) ?? 'aktiebolag' + // 2+3. Resolve counter-account via skattekonto_rules (entity_type decides + // AB/EF-specific accounts). + let guess: CounterAccountMatch | null + if (ruleContext) { + const match = matchSkattekontoRule( + ruleContext.rules, + tx.transaktionstext, + ruleContext.entityType, + Number(tx.belopp_skatteverket), + ) + guess = match + ? { + ...match, + account: + match.account === PRIMARY_SEK_SENTINEL + ? await ruleContext.resolvePrimarySek() + : match.account, + } + : null + } else { + const { data: settings } = await supabase + .from('company_settings') + .select('entity_type') + .eq('company_id', companyId) + .single() - // 3. Resolve counter-account via skattekonto_rules - const guess = await guessCounterAccount( - supabase, - companyId, - tx.transaktionstext, - entityType, - Number(tx.belopp_skatteverket), - ) + const entityType: EntityType = + (settings?.entity_type as EntityType) ?? 'aktiebolag' + + guess = await guessCounterAccount( + supabase, + companyId, + tx.transaktionstext, + entityType, + Number(tx.belopp_skatteverket), + ) + } if (!guess) { throw new SkattekontoBookingError( `Vi kunde inte gissa motkontot för "${tx.transaktionstext}". Skapa verifikatet manuellt.`, @@ -296,12 +482,152 @@ export async function bokforSkattekontoTransaction( const entry = await createDraftEntry(supabase, companyId, userId, input) - // Link the row back so the dashboard can show "Bokförd" status. - await supabase + // Link the row back so the dashboard can show "Bokförd" status. The + // backlink is a conditional CLAIM, not a blind write: `.is('journal_entry_id', + // null)` makes concurrent submissions race on the same row and lets exactly + // one win. Zero affected rows means another request already booked the row + // between our precheck and now: surface ALREADY_BOOKED instead of + // double-posting. The just-created draft is left behind unlinked: the + // engine has no sanctioned draft-discard function and journal tables must + // never be raw-deleted, so an orphan draft (legally deletable by the user + // in /bookkeeping) is the safe leftover. + const { data: claimed, error: claimError } = await supabase .from('skattekonto_transactions') .update({ journal_entry_id: entry.id }) .eq('id', tx.id) .eq('company_id', companyId) + .is('journal_entry_id', null) + .select('id') + + if (claimError || !claimed || claimed.length === 0) { + throw new SkattekontoBookingError( + 'Transaktionen är redan bokförd.', + 'ALREADY_BOOKED', + ) + } return entry } + +export type { SkattekontoBatchResult, SkattekontoBatchRowResult } + +/** + * The period-lock enforcement trigger (migration 20240101000017) raises + * 'Cannot write to locked/closed fiscal period "..." (is_closed=..., locked_at=...)'. + * findFiscalPeriod's precheck only filters is_closed=false, so a period that + * is locked (locked_at set) but not closed passes the precheck and the draft + * INSERT throws this instead. Detect the signature (also when wrapped by + * BookkeepingDatabaseError, which appends the DB message) so the batch can + * report PERIOD_LOCKED rather than UNKNOWN with raw English trigger text. + */ +function isPeriodLockTriggerError(err: unknown): boolean { + return ( + err instanceof Error && + err.message.includes('locked/closed fiscal period') + ) +} + +/** + * Book several skattekonto rows in one server-side pass: draft + commit per + * row so a successful row lands as a posted verifikat immediately (no orphan + * drafts for the user to chase). Rules/entity_type are fetched once for the + * whole batch. + * + * A row failure never aborts the loop: the caller gets a per-row result list + * plus a summary and reports the aggregate. If the draft was created but the + * commit failed (e.g. a mandatory-dimension policy), the draft is kept and + * stays linked to the row: that degrades to the pre-existing + * draft-then-review flow instead of deleting bookkeeping material. + */ +export async function bokforSkattekontoTransactionsBatch( + supabase: SupabaseClient, + companyId: string, + userId: string, + ids: string[], +): Promise { + const ruleContext = await loadRuleContext(supabase, companyId) + // A one-row batch is the inline single-row flow: attribute it as a normal + // user acceptance; real bulk runs are attributed as bulk_accept. + const commitMethod = ids.length === 1 ? 'user_accept' : 'bulk_accept' + const results: SkattekontoBatchRowResult[] = [] + + for (const id of ids) { + let entry: JournalEntry + try { + entry = await bokforSkattekontoTransaction( + supabase, + companyId, + userId, + id, + ruleContext, + // Batch rows commit immediately: never post an unsettled (kommande) + // Skatteverket row into an immutable verifikat. + { requireSettled: true }, + ) + } catch (err) { + if (err instanceof SkattekontoBookingError) { + results.push({ + id, + ok: false, + error_code: err.code, + error_message: err.message, + }) + } else if (isPeriodLockTriggerError(err)) { + results.push({ + id, + ok: false, + error_code: 'PERIOD_LOCKED', + error_message: + 'Raden ligger i en låst räkenskapsperiod. Lås upp perioden eller hoppa över raden.', + }) + } else { + results.push({ + id, + ok: false, + error_code: 'UNKNOWN', + error_message: + err instanceof Error ? err.message : 'Transaktionen kunde inte bokföras.', + }) + } + continue + } + + try { + const committed = await commitEntry( + supabase, + companyId, + userId, + entry.id, + commitMethod, + ) + results.push({ + id, + ok: true, + journal_entry_id: committed.id, + voucher_number: committed.voucher_number ?? null, + voucher_series: committed.voucher_series ?? null, + }) + } catch (err) { + results.push({ + id, + ok: false, + journal_entry_id: entry.id, + error_code: 'COMMIT_FAILED', + error_message: + err instanceof Error + ? err.message + : 'Utkastet skapades men kunde inte bokföras.', + }) + } + } + + const succeeded = results.filter(r => r.ok).length + return { + results, + summary: { + total: results.length, + succeeded, + failed: results.length - succeeded, + }, + } +} diff --git a/extensions/general/skatteverket/types.ts b/extensions/general/skatteverket/types.ts index ce1d86f2..82a73787 100644 --- a/extensions/general/skatteverket/types.ts +++ b/extensions/general/skatteverket/types.ts @@ -324,6 +324,7 @@ export interface SkatteverketFel { export type { StoredSkattekontoTransaction, SkattekontoMatchSuggestion, + SkattekontoBookingSuggestion, SkattekontoTransactionWithSuggestion, } from '@/types/skatteverket' diff --git a/messages/en.json b/messages/en.json index 72380b0d..78c2604f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3227,7 +3227,27 @@ "link_to_voucher": "Link to voucher", "book_anyway": "Post anyway", "book": "Post", - "match_to_voucher": "Match to voucher" + "match_to_voucher": "Match to voucher", + "suggestion_line": "Posts to {account}", + "select_row": "Select tax account event" + }, + "skv_book_dialog": { + "title": "Post tax account event", + "event_label": "Event", + "date_label": "Date", + "amount_label": "Amount", + "posting_label": "Posting", + "posting_value": "1630 against {account}", + "no_rule_matched": "No booking rule matched this event. Match it to an existing voucher, or create the voucher manually in Bookkeeping.", + "confirm_book": "Post", + "open_draft": "Open as draft", + "match_cta": "Match to voucher", + "manual_create_cta": "Create voucher manually", + "commit_warning": "A voucher will be created and cannot be changed afterwards.", + "draft_created_title": "Draft created", + "draft_created_description": "Review and post the voucher in Bookkeeping.", + "book_failed": "Could not post", + "commit_failed_title": "Draft created but not posted" }, "tx_form": { "date_required": "Date is required", @@ -5325,6 +5345,25 @@ "batch_select_all": "Select all ({count})", "batch_clear": "Clear selection", "batch_progress": "Booking {done} of {total}…", + "batch_skv_book": "Post selected ({count})", + "skv_booked_title": "Posted", + "skv_booked_description": "Voucher {voucher} was created.", + "skv_booked_show": "Show voucher", + "skv_bulk_title": "Post {count, plural, one {1 tax account event} other {# tax account events}}", + "skv_bulk_confirm": "Post", + "skv_bulk_warning": "{count, plural, one {One voucher will be created and cannot be changed afterwards.} other {# vouchers will be created and cannot be changed afterwards.}}", + "skv_bulk_group": "{count} × {label} → {account}", + "skv_bulk_total": "Total {count, plural, one {1 event} other {# events}}", + "skv_bulk_done_title": "Done", + "skv_bulk_done_description": "{count, plural, one {1 tax account event posted} other {# tax account events posted}}", + "skv_bulk_partial_title": "Partially done", + "skv_bulk_partial_ok": "{count} posted", + "skv_err_period_locked": "locked period", + "skv_err_no_counter_account": "no counter account", + "skv_err_already_booked": "already posted", + "skv_err_not_settled": "not settled yet", + "skv_err_commit_failed": "draft created, not posted", + "skv_err_other": "failed", "mode_all": "All", "footer_to_handle": "{count, plural, =0 {Nothing to handle} =1 {1 to handle} other {# to handle}}" }, @@ -6902,6 +6941,9 @@ "action_match": "Match", "action_book": "Book", "action_booking": "Booking…", + "booked_toast_title": "Posted", + "booked_toast_description": "Voucher {voucher} was created.", + "booked_toast_show": "Show voucher", "payment_title": "Payment details", "payment_description": "The charge on {date} is {charge}.", "payment_bankgiro_label": "Bankgiro (Skatteverket)", diff --git a/messages/sv.json b/messages/sv.json index cf2484ab..39ca6dfc 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -3227,7 +3227,27 @@ "link_to_voucher": "Koppla till verifikat", "book_anyway": "Bokför ändå", "book": "Bokför", - "match_to_voucher": "Matcha mot verifikat" + "match_to_voucher": "Matcha mot verifikat", + "suggestion_line": "Bokförs mot {account}", + "select_row": "Välj skattekontohändelse" + }, + "skv_book_dialog": { + "title": "Bokför skattekontohändelse", + "event_label": "Händelse", + "date_label": "Datum", + "amount_label": "Belopp", + "posting_label": "Kontering", + "posting_value": "1630 mot {account}", + "no_rule_matched": "Ingen bokföringsregel matchade händelsen. Matcha den mot ett befintligt verifikat, eller skapa verifikatet manuellt i Bokföring.", + "confirm_book": "Bokför", + "open_draft": "Öppna som utkast", + "match_cta": "Matcha mot verifikat", + "manual_create_cta": "Skapa verifikat manuellt", + "commit_warning": "En verifikation skapas och kan inte ändras efteråt.", + "draft_created_title": "Utkast skapat", + "draft_created_description": "Granska och bokför verifikatet i Bokföring.", + "book_failed": "Kunde inte bokföra", + "commit_failed_title": "Utkast skapat men inte bokfört" }, "tx_form": { "date_required": "Datum krävs", @@ -5325,6 +5345,25 @@ "batch_select_all": "Markera alla ({count})", "batch_clear": "Avmarkera", "batch_progress": "Bokför {done} av {total}…", + "batch_skv_book": "Bokför valda ({count})", + "skv_booked_title": "Bokförd", + "skv_booked_description": "Verifikat {voucher} skapades.", + "skv_booked_show": "Visa verifikat", + "skv_bulk_title": "Bokför {count, plural, one {1 skattekontohändelse} other {# skattekontohändelser}}", + "skv_bulk_confirm": "Bokför", + "skv_bulk_warning": "{count, plural, one {En verifikation skapas och kan inte ändras efteråt.} other {# verifikationer skapas och kan inte ändras efteråt.}}", + "skv_bulk_group": "{count} × {label} → {account}", + "skv_bulk_total": "Totalt {count, plural, one {1 händelse} other {# händelser}}", + "skv_bulk_done_title": "Klart", + "skv_bulk_done_description": "{count, plural, one {1 skattekontohändelse bokförd} other {# skattekontohändelser bokförda}}", + "skv_bulk_partial_title": "Delvis klart", + "skv_bulk_partial_ok": "{count} bokförda", + "skv_err_period_locked": "låst period", + "skv_err_no_counter_account": "saknar motkonto", + "skv_err_already_booked": "redan bokförd", + "skv_err_not_settled": "ännu inte genomförd", + "skv_err_commit_failed": "utkast skapat, ej bokfört", + "skv_err_other": "misslyckades", "mode_all": "Alla", "footer_to_handle": "{count, plural, =0 {Inget att hantera} =1 {1 att hantera} other {# att hantera}}" }, @@ -6902,6 +6941,9 @@ "action_match": "Matcha", "action_book": "Bokför", "action_booking": "Bokför…", + "booked_toast_title": "Bokförd", + "booked_toast_description": "Verifikat {voucher} skapades.", + "booked_toast_show": "Visa verifikat", "payment_title": "Betalningsuppgifter", "payment_description": "Dragningen {date} är {charge}.", "payment_bankgiro_label": "Bankgiro (Skatteverket)", diff --git a/types/skatteverket.ts b/types/skatteverket.ts index 163820b4..7f48a3e8 100644 --- a/types/skatteverket.ts +++ b/types/skatteverket.ts @@ -43,11 +43,56 @@ export interface SkattekontoMatchSuggestion { status: 'draft' | 'posted' | 'reversed' } +/** + * The deterministic counter-account a "Bokför" on this row would use, + * resolved from `skattekonto_rules` server-side. Lets the list show what a + * booking will do ("Bokförs mot 8314 Skattefria ränteintäkter") and drives + * bulk-booking eligibility. `account_name` comes from the BAS reference and + * may be null for custom accounts; `label` is the matched rule's label. + */ +export interface SkattekontoBookingSuggestion { + account: string + account_name?: string | null + label?: string | null +} + /** * API response variant: stored row plus optional auto-match suggestion. * `match_suggestion` is optional because kommande/upcoming rows skip the * enrichment step entirely (no journal entry can match a future event). + * `booking_suggestion` is likewise only computed for unbooked genomförda + * rows: undefined means "not computed", null means "no rule matched". */ export interface SkattekontoTransactionWithSuggestion extends StoredSkattekontoTransaction { match_suggestion?: SkattekontoMatchSuggestion | null + booking_suggestion?: SkattekontoBookingSuggestion | null +} + +/** + * Per-row outcome from POST /skattekonto/transaktioner/bokfor-batch. + * `journal_entry_id` is present on success AND on COMMIT_FAILED (the draft + * was created and stays linked; only the commit step failed). + */ +export interface SkattekontoBatchRowResult { + id: string + ok: boolean + journal_entry_id?: string + voucher_number?: number | null + voucher_series?: string | null + error_code?: + | 'NO_COUNTER_ACCOUNT' + | 'NO_FISCAL_PERIOD' + | 'PERIOD_LOCKED' + | 'ALREADY_BOOKED' + | 'NOT_SETTLED' + | 'TRANSACTION_NOT_FOUND' + | 'COMMIT_FAILED' + | 'UNKNOWN' + error_message?: string +} + +/** Response envelope body for the bokfor-batch endpoint. */ +export interface SkattekontoBatchResult { + results: SkattekontoBatchRowResult[] + summary: { total: number; succeeded: number; failed: number } }