feat(skattekonto): bulk Bokför + inline single-row booking (fewer clicks) (#1535)

* feat(skattekonto): bulk Bokför and inline single-row booking

Booking a year of skattekonto events took 6 clicks per row (list, Bokför,
review page, Bokför, confirm, navigate back), even for a +1 kr
intäktsränta row.

- GET /skattekonto/transaktioner now attaches a deterministic
  booking_suggestion per unbooked row (one hoisted skattekonto_rules +
  entity_type fetch via the new attachBookingSuggestions), shown as muted
  text on the inbox row ('Bokförs mot 8314 ...').
- New POST /skattekonto/transaktioner/bokfor-batch (Zod, max 200 ids):
  sequential draft+commit per row server-side (no orphan drafts), per-row
  results, never aborts on a row failure. Commit attribution: bulk_accept
  for real batches, user_accept for the one-row inline flow. A failed
  commit keeps the linked draft (degrades to the old review flow).
- Inbox: hover-reveal checkboxes on eligible SKV rows (suggestion
  present, no duplicate hint, unbooked, genomförd), separate skvSelectedIds
  set, bulkbar 'Bokför valda (N)' with ONE summary ConfirmationDialog
  grouped by suggestion with per-group sums, chunked batchProgress, ONE
  aggregate toast, local state patch with exit animation.
- Single-row: new SkattekontoBookDialog (dynamic import) replaces the
  draft-then-window.location detour on both /transactions and /skattekonto;
  'Öppna som utkast' keeps the old draft path via router.push.

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

* fix(skattekonto): batch booking guards for unsettled rows, double-post race and locked periods

- reject status != booked rows in the batch flow with NOT_SETTLED before
  any draft exists (server no longer trusts client eligibility); the
  single-row draft endpoint keeps its behaviour
- make the journal_entry_id backlink a conditional claim (update where
  journal_entry_id is null, select affected rows): zero affected rows maps
  to ALREADY_BOOKED and commitEntry only runs after a won claim, so two
  concurrent submissions can no longer double-post the same row
- detect the period-lock trigger signature in the batch catch and map it
  to PERIOD_LOCKED with Swedish text instead of UNKNOWN with raw DB output
- SkattekontoBookDialog: rows with no matched rule no longer get the
  guaranteed-422 draft CTA; they route to the existing match flow and to
  manual verifikat creation in /bookkeeping
- pass an explicit skv_book_dialog.commit_warning key for the
  direct-commit warning instead of ConfirmationDialog's hardcoded default

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-12 21:00:57 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 7cf0e34434
commit dea5e31756
13 changed files with 1884 additions and 156 deletions
+1
View File
@@ -876,6 +876,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
+65 -40
View File
@@ -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<TransaktionerEnvelope['data'] | null>(null)
const [loading, setLoading] = useState(true)
const [syncing, setSyncing] = useState(false)
const [bookingId, setBookingId] = useState<string | null>(null)
// Row whose inline booking dialog is open (null = closed).
const [bookTarget, setBookTarget] = useState<SkattekontoTransactionWithSuggestion | null>(
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 ? (
<ToastAction altText={t('booked_toast_show')} asChild>
<Link href={`/bookkeeping/${result.journal_entry_id}`}>
{t('booked_toast_show')}
</Link>
</ToastAction>
) : undefined,
})
void reload()
}
async function openMatch(row: StoredSkattekontoTransaction) {
@@ -538,13 +554,28 @@ export default function SkattekontoPage() {
tx={tx}
onBokfor={bokfor}
onMatch={openMatch}
bookingId={bookingId}
/>
<p className="px-1 text-xs leading-5 text-muted-foreground">
{t('pgnote', { amount: formatCurrency(data?.saldoKronofogden ?? 0) })}
</p>
{bookTarget && (
<SkattekontoBookDialog
row={bookTarget}
open
onOpenChange={(o) => {
if (!o) setBookTarget(null)
}}
onBooked={handleBooked}
onMatch={() => {
const target = bookTarget
setBookTarget(null)
void openMatch(target)
}}
/>
)}
<MatchDialog
row={matchOpenFor}
candidates={matchCandidates}
@@ -629,12 +660,10 @@ function SkattekontoTable({
tx,
onBokfor,
onMatch,
bookingId,
}: {
tx: TransaktionerEnvelope['data'] | null
onBokfor: (id: string) => 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({
<button
type="button"
onClick={() => onBokfor(row.id)}
disabled={bookingId === row.id}
className={QUIET_LINK_CLASS}
>
{bookingId === row.id ? t('action_booking') : t('action_book')}
{t('action_book')}
</button>
</span>
)}
+368 -61
View File
@@ -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<string, InvoiceWithCustomer> {
if (!rows) return {}
return rows.reduce<Record<string, InvoiceWithCustomer>>((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<SkattekontoTransactionWithSuggestion[]>([])
const [skvProcessingId, setSkvProcessingId] = useState<string | null>(null)
const [skvMatchTarget, setSkvMatchTarget] = useState<StoredSkattekontoTransaction | null>(
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<SkattekontoTransactionWithSuggestion | null>(
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<Set<string>>(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 ? (
<ToastAction altText={t('skv_booked_show')} asChild>
<Link href={`/bookkeeping/${result.journal_entry_id}`}>
{t('skv_booked_show')}
</Link>
</ToastAction>
) : 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<string, string>()
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<string, number>()
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) && (
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-b border-border px-1 py-2.5 text-[12.5px] animate-fade-in">
{batchProgress ? (
<span className="flex items-center gap-2 text-muted-foreground">
@@ -2615,41 +2841,60 @@ export default function TransactionsPage() {
) : (
<>
<span className="whitespace-nowrap">
<strong className="font-semibold tabular-nums">{selectedIds.size}</strong>{' '}
{t('bulkbar_selected', { count: selectedIds.size })}
<strong className="font-semibold tabular-nums">
{selectedIds.size + skvSelectedIds.size}
</strong>{' '}
{t('bulkbar_selected', { count: selectedIds.size + skvSelectedIds.size })}
</span>
<Button size="sm" onClick={() => setShowBatchSelector(true)}>
{t('batch_book')}
</Button>
{/* Bulk-book (samlingsverifikation): only when ≥2 selected
on the same date + same direction. Disabled state
explains why via title. */}
<Button
size="sm"
variant="outline"
onClick={() => setBulkBookOpen(true)}
disabled={!bulkBookEligible}
title={!bulkBookEligible ? t('batch_bulk_book_hint') : undefined}
>
{t('batch_bulk_book')}
</Button>
<button type="button" className={QUIET_LINK_CLASS} onClick={handleBatchIgnore}>
{t('batch_ignore')}
</button>
<button
type="button"
className={cn(QUIET_LINK_CLASS, 'hover:text-destructive')}
onClick={handleBatchDelete}
>
{t('batch_delete')}
</button>
{selectedIds.size < selectableInboxIds.length && (
{selectedIds.size > 0 && (
<>
<Button size="sm" onClick={() => setShowBatchSelector(true)}>
{t('batch_book')}
</Button>
{/* Bulk-book (samlingsverifikation): only when ≥2 selected
on the same date + same direction. Disabled state
explains why via title. */}
<Button
size="sm"
variant="outline"
onClick={() => setBulkBookOpen(true)}
disabled={!bulkBookEligible}
title={!bulkBookEligible ? t('batch_bulk_book_hint') : undefined}
>
{t('batch_bulk_book')}
</Button>
<button type="button" className={QUIET_LINK_CLASS} onClick={handleBatchIgnore}>
{t('batch_ignore')}
</button>
<button
type="button"
className={cn(QUIET_LINK_CLASS, 'hover:text-destructive')}
onClick={handleBatchDelete}
>
{t('batch_delete')}
</button>
</>
)}
{/* SKV rows book through the skatteverket extension: one
summary confirmation, then draft+commit per row. */}
{skvSelectedIds.size > 0 && (
<Button size="sm" onClick={() => setSkvBulkConfirmOpen(true)}>
{t('batch_skv_book', { count: skvSelectedIds.size })}
</Button>
)}
{(selectedIds.size < selectableInboxIds.length ||
skvSelectedIds.size < selectableSkvIds.length) && (
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => setSelectedIds(new Set(selectableInboxIds))}
onClick={() => {
setSelectedIds(new Set(selectableInboxIds))
setSkvSelectedIds(new Set(selectableSkvIds))
}}
>
{t('batch_select_all', { count: selectableInboxIds.length })}
{t('batch_select_all', {
count: selectableInboxIds.length + selectableSkvIds.length,
})}
</button>
)}
<button type="button" className={QUIET_LINK_CLASS} onClick={exitBatchMode}>
@@ -2706,7 +2951,11 @@ export default function TransactionsPage() {
key={`skv-${item.data.id}`}
row={item.data}
matchSuggestion={item.data.match_suggestion}
processing={skvProcessingId === item.data.id}
bookingSuggestion={item.data.booking_suggestion}
processing={skvBulkSubmitting}
selectable={isSkvBulkEligible(item.data)}
isSelected={skvSelectedIds.has(item.data.id)}
onToggleSelect={toggleSkvSelect}
onBokfor={handleSkvBokfor}
onMatch={r => setSkvMatchTarget(r)}
/>
@@ -3001,6 +3250,64 @@ export default function TransactionsPage() {
/>
)}
{skvBookTarget && (
<SkattekontoBookDialog
row={skvBookTarget}
open
onOpenChange={(o) => {
if (!o) setSkvBookTarget(null)
}}
onBooked={handleSkvBooked}
onMatch={() => {
setSkvMatchTarget(skvBookTarget)
setSkvBookTarget(null)
}}
/>
)}
{/* SKV bulk booking: one summary confirmation for the whole selection,
grouped by deterministic suggestion. Count-based: voucher numbers
are assigned atomically at commit. */}
{skvBulkConfirmOpen && (
<ConfirmationDialog
open
onOpenChange={setSkvBulkConfirmOpen}
title={t('skv_bulk_title', { count: skvSelectedIds.size })}
isSubmitting={skvBulkSubmitting}
confirmLabel={t('skv_bulk_confirm')}
warningText={t('skv_bulk_warning', { count: skvSelectedIds.size })}
onConfirm={handleSkvBulkConfirm}
>
<div className="space-y-2 py-2 text-sm">
{skvBulkGroups.map((group) => (
<div
key={`${group.account}|${group.label}`}
className="flex items-baseline justify-between gap-4"
>
<span className="min-w-0 truncate">
{t('skv_bulk_group', {
count: group.count,
label: group.label,
account: group.account,
})}
</span>
<span className={cn('shrink-0 tabular-nums', group.sum > 0 && 'text-success')}>
{group.sum > 0 ? '+' : ''}
{formatCurrency(group.sum)}
</span>
</div>
))}
<div className="flex items-baseline justify-between gap-4 border-t border-border pt-2 font-medium">
<span>{t('skv_bulk_total', { count: skvSelectedIds.size })}</span>
<span className="tabular-nums">
{skvBulkTotal > 0 ? '+' : ''}
{formatCurrency(skvBulkTotal)}
</span>
</div>
</div>
</ConfirmationDialog>
)}
{/* Prong B: match-against-supplier-invoice suggestion */}
{siMatchSuggestion && <Dialog
open
@@ -0,0 +1,253 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
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 { cn, formatCurrency, formatDate } from '@/lib/utils'
import { Loader2 } from 'lucide-react'
import type {
SkattekontoBatchResult,
SkattekontoBatchRowResult,
SkattekontoTransactionWithSuggestion,
} from '@/types/skatteverket'
/**
* Inline booking for one skattekonto row: confirm-and-post without leaving
* the list (convention 10: confirm up front). The primary "Bokför" runs the
* server-side draft+commit (bokfor-batch with one id) so the row lands as a
* posted verifikat in one step; "Öppna som utkast" preserves the old
* draft-then-review path, but with a client-side navigation instead of a
* full reload.
*
* Suggestion states drive the layout:
* - object: rule matched → direct booking is the primary action
* - null: computed, no rule matched → the draft endpoint re-derives the same
* null rule and rejects with NO_COUNTER_ACCOUNT, so no draft CTA here;
* route to the match flow (onMatch) or manual creation in /bookkeeping
* - undefined: not computed (kommande rows) → plain draft confirm
*/
export default function SkattekontoBookDialog({
row,
open,
onOpenChange,
onBooked,
onMatch,
}: {
row: SkattekontoTransactionWithSuggestion | null
open: boolean
onOpenChange: (open: boolean) => void
onBooked: (rowId: string, result: SkattekontoBatchRowResult) => void
// Opens the existing "Matcha mot verifikat" flow for this row; the dialog
// closes itself first. Optional: callers without a match flow fall back to
// manual verifikat creation only.
onMatch?: () => void
}) {
const t = useTranslations('skv_book_dialog')
const { toast } = useToast()
const router = useRouter()
const [isBooking, setIsBooking] = useState(false)
const [isOpeningDraft, setIsOpeningDraft] = useState(false)
if (!row) return null
const amount = Number(row.belopp_skatteverket)
const suggestion = row.booking_suggestion
const canBookDirectly = suggestion != null
// null (not undefined) means the rules were evaluated and none matched:
// the draft endpoint would be a guaranteed 422 (NO_COUNTER_ACCOUNT).
const noRuleMatched = suggestion === null
async function handleBook() {
if (!row) return
setIsBooking(true)
try {
const res = await fetch(
'/api/extensions/ext/skatteverket/skattekonto/transaktioner/bokfor-batch',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: [row.id] }),
},
)
const json = await res.json()
if (!res.ok) {
toast({
title: t('book_failed'),
description: getErrorMessage(json, { statusCode: res.status }),
variant: 'destructive',
})
return
}
const result = (json.data as SkattekontoBatchResult).results[0]
if (!result) {
toast({ title: t('book_failed'), variant: 'destructive' })
return
}
if (result.ok) {
onOpenChange(false)
onBooked(row.id, result)
return
}
if (result.error_code === 'COMMIT_FAILED' && result.journal_entry_id) {
// The draft exists and is linked: hand the user over to it instead
// of leaving a half-done state behind a destructive toast.
toast({
title: t('commit_failed_title'),
description: result.error_message,
variant: 'destructive',
})
onOpenChange(false)
router.push(`/bookkeeping/${result.journal_entry_id}`)
return
}
toast({
title: t('book_failed'),
description: result.error_message,
variant: 'destructive',
})
} catch (err) {
toast({
title: t('book_failed'),
description: err instanceof Error ? getErrorMessage(err) : undefined,
variant: 'destructive',
})
} finally {
setIsBooking(false)
}
}
async function handleOpenDraft() {
if (!row) return
setIsOpeningDraft(true)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/skattekonto/transaktioner/${row.id}/bokfor`,
{ method: 'POST' },
)
const json = await res.json()
if (!res.ok) {
// Map the parsed body plus the status, never `new Error(json.error)`:
// the mapper would discard the route's own Swedish reason.
toast({
title: t('book_failed'),
description: getErrorMessage(json, { statusCode: res.status }),
variant: 'destructive',
})
return
}
toast({ title: t('draft_created_title'), description: t('draft_created_description') })
onOpenChange(false)
router.push(`/bookkeeping/${json.data.entry.id}`)
} catch (err) {
toast({
title: t('book_failed'),
description: err instanceof Error ? getErrorMessage(err) : undefined,
variant: 'destructive',
})
} finally {
setIsOpeningDraft(false)
}
}
function handleMatchInstead() {
onOpenChange(false)
onMatch?.()
}
function handleManualCreate() {
// /bookkeeping owns manual verifikat creation ("Nytt verifikat" in the
// page header); there is no dedicated create route to deep-link.
onOpenChange(false)
router.push('/bookkeeping')
}
return (
<ConfirmationDialog
open={open}
onOpenChange={onOpenChange}
title={t('title')}
isSubmitting={isBooking || isOpeningDraft}
confirmLabel={
canBookDirectly
? t('confirm_book')
: noRuleMatched
? onMatch
? t('match_cta')
: t('manual_create_cta')
: t('open_draft')
}
// A draft is still editable: the immutable-verifikat warning only
// applies when the confirm commits directly.
warningText={canBookDirectly ? t('commit_warning') : ''}
onConfirm={
canBookDirectly
? handleBook
: noRuleMatched
? onMatch
? handleMatchInstead
: handleManualCreate
: handleOpenDraft
}
extraActions={
canBookDirectly ? (
<Button
variant="ghost"
onClick={handleOpenDraft}
disabled={isBooking || isOpeningDraft}
className="min-h-11 w-full sm:w-auto text-muted-foreground"
>
{isOpeningDraft && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('open_draft')}
</Button>
) : noRuleMatched && onMatch ? (
<Button
variant="ghost"
onClick={handleManualCreate}
disabled={isBooking || isOpeningDraft}
className="min-h-11 w-full sm:w-auto text-muted-foreground"
>
{t('manual_create_cta')}
</Button>
) : undefined
}
>
<dl className="space-y-3 py-2 text-sm">
<div className="flex items-baseline justify-between gap-4">
<dt className="shrink-0 text-muted-foreground">{t('event_label')}</dt>
<dd className="text-right">{row.transaktionstext}</dd>
</div>
<div className="flex items-baseline justify-between gap-4">
<dt className="text-muted-foreground">{t('date_label')}</dt>
<dd className="tabular-nums">{formatDate(row.transaktionsdatum)}</dd>
</div>
<div className="flex items-baseline justify-between gap-4">
<dt className="text-muted-foreground">{t('amount_label')}</dt>
<dd className={cn('tabular-nums', amount > 0 && 'text-success')}>
{amount > 0 ? '+' : ''}
{formatCurrency(amount)}
</dd>
</div>
{suggestion ? (
<div className="flex items-baseline justify-between gap-4">
<dt className="text-muted-foreground">{t('posting_label')}</dt>
<dd className="text-right tabular-nums">
{t('posting_value', {
account: suggestion.account_name
? `${suggestion.account} ${suggestion.account_name}`
: suggestion.account,
})}
</dd>
</div>
) : suggestion === null ? (
<p className="pt-1 text-xs leading-5 text-muted-foreground">
{t('no_rule_matched')}
</p>
) : null}
</dl>
</ConfirmationDialog>
)
}
@@ -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 (
<tr className="group transition-colors duration-150 hover:bg-secondary/35">
<td className={cn(TD_CLASS, 'w-0 !p-0')} aria-hidden="true"></td>
<tr
className={cn(
'group transition-colors duration-150 hover:bg-secondary/35',
isSelected && 'bg-secondary/40',
)}
>
{/* 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. */}
<td className={cn(TD_CLASS, 'relative w-0 !p-0')}>
{selectable && (
<Checkbox
checked={isSelected}
onCheckedChange={() => 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',
)}
/>
)}
</td>
<td className={cn(TD_CLASS, '!pl-0 whitespace-nowrap tabular-nums text-muted-foreground')}>
{formatDate(row.transaktionsdatum)}
</td>
@@ -64,6 +96,18 @@ export default function SkattekontoInboxCard({
{duplicateLabel}
</Badge>
)}
{/* 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 && (
<span className="hidden shrink-0 whitespace-nowrap text-xs text-muted-foreground md:inline">
{t('suggestion_line', {
account: bookingSuggestion.account_name
? `${bookingSuggestion.account} ${bookingSuggestion.account_name}`
: bookingSuggestion.account,
})}
</span>
)}
</span>
</td>
<td
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// The route delegates to the batch function: stub it so these tests cover
// the route's validation/envelope behaviour only (the batch logic itself is
// covered in skattekonto-booking-batch.test.ts).
vi.mock('../lib/skattekonto-booking', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/skattekonto-booking')>()
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],
)
})
})
@@ -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<typeof import('@/lib/bookkeeping/engine')>()
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<string, unknown> = {}) {
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<typeof makeSupabase>['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()
})
})
+70 -2
View File
@@ -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
@@ -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<CounterAccountMatch | null> {
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<SkattekontoRuleRow[]> {
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<CounterAccountMatch | null> {
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<string>
}
export async function loadRuleContext(
supabase: SupabaseClient,
companyId: string,
): Promise<SkattekontoRuleContext> {
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<JournalEntry> {
// 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<SkattekontoBatchResult> {
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,
},
}
}
+1
View File
@@ -324,6 +324,7 @@ export interface SkatteverketFel {
export type {
StoredSkattekontoTransaction,
SkattekontoMatchSuggestion,
SkattekontoBookingSuggestion,
SkattekontoTransactionWithSuggestion,
} from '@/types/skatteverket'
+43 -1
View File
@@ -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)",
+43 -1
View File
@@ -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)",
+45
View File
@@ -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 }
}