fix(skattekonto): bound the sync to the first räkenskapsår, add an ignore path, EF-aware avdragen skatt (#1729)

* fix(skattekonto): scope the sync and the avdragen-skatt rule for enskild firma

Two EF problems on the skattekonto surface:

1. Stuck pre-company rows. The sync never passed datumFrom, so SKV's
   ~555-day default lookback imported the owner's PERSONAL skattekonto
   history from before the company existed. Those rows can never be
   booked (no fiscal period covers them), never deleted (external
   mirror), and had no ignore path: visible forever.
   - syncSkattekonto now bounds the fetch at the company's earliest
     fiscal_periods.period_start (new getEarliestFiscalPeriodStart in
     period-service; no bound when no period exists yet). Applied
     uniformly to EF and AB.
   - New skattekonto_transactions.is_ignored column (migration
     20260819080000, copies the transactions.is_ignored precedent:
     CHECK that an ignored row has no journal_entry_id, partial index;
     the existing company-scoped UPDATE policy already covers it) plus
     PATCH /skattekonto/transaktioner/:id/ignore (409 on booked rows,
     race-guarded on journal_entry_id IS NULL). Ignored rows leave the
     default GET buckets; ignored_count is always reported and
     include_ignored=1 returns the rows, surfaced as a count line +
     "Ignorerade" band on /skattekonto and an Ignorera affordance with
     confirm + Ångra on both /skattekonto and the /transactions inbox.
   - PERIOD_LOCKED for a date before the first fiscal period now says
     the row predates the company's bookkeeping and can be ignored,
     instead of "lås upp perioden" (a dead end for those rows).

2. "Avdragen skatt" auto-mapped to 2710 for every entity type. For an
   EF without employees that line is almost always A-skatt an outside
   employer withheld from the owner's private salary, not the firm's
   payroll liability. New data-driven skattekonto_rules.requires_employer
   column (migration 20260819080100, set on the avdragen-skatt seed and
   its per-company clones); the matcher gates such rules for an
   enskild_firma unless company_settings.employer_registered is true
   (the existing AGI gate signal, fetched in the same settings query).
   Gated rows take the NO_COUNTER_ACCOUNT path with a distinct hint;
   AB and employer-registered EF keep 2710 unconditionally. Regression
   guard pins EF preliminärskatt to 2013.

The nightly sync upsert excludes is_ignored so it can never silently
un-ignore a row. New pg tests for the CHECK + RLS need a test:pg run.

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

* fix(skattekonto): clamp datumFrom to the SKV window, gate ignored rows, widen the employer signal

Review fixes on the EF-scoping PR:

- sync: clamp datumFrom to max(earliestPeriodStart, today - 555 days); a
  bookkeeping start older than SKV's 555-day default is omitted entirely,
  since sending it would widen the window past the default and anything
  older than ~915 days fails the whole sync with felkod 2. The misleading
  "no-op for AB" comment is corrected and boundary tests added.
- booking/match: an ignored row now throws a typed ROW_IGNORED error
  (409) before any draft is created or link is written, in both
  bokforSkattekontoTransaction and matchSkattekontoToEntry.
- page: the Nasta dragning / shortfall math re-includes ignored upcoming
  charges (SKV draws them regardless of our ignore flag) while the
  work-list buckets keep excluding them.
- employer gate: treat employer_registered ?? pays_salaries as the
  signal (same fallback as lib/tax/deadline-config.ts), so an EF that
  attested pays_salaries keeps 2710 for avdragen skatt.

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

* test(skattekonto): assert the is_ignored RLS toggle inside the rolled-back transaction

withUserContext always rolls back (tests/pg/setup.ts), so the previous test
wrote inside it and read the pre-write value back on the pool connection:
it failed against a correct policy and would have passed against a missing
one only by accident. The assertions now live inside the same transaction,
pin rowCount=1 (an RLS-filtered UPDATE silently matches zero rows), and a
new test pins the negative: a non-member's UPDATE matches zero rows.

Falsification-verified against a real Postgres: dropping the UPDATE policy
makes both tests fail; with the policy they pass.

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-20 10:22:13 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent b77af371c4
commit 4cf227001d
23 changed files with 1311 additions and 81 deletions
+162 -7
View File
@@ -30,6 +30,10 @@ import {
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton'
import { cn } from '@/lib/utils'
import {
@@ -71,6 +75,8 @@ interface TransaktionerEnvelope {
booked: SkattekontoTransactionWithSuggestion[]
overdue: StoredSkattekontoTransaction[]
upcoming: StoredSkattekontoTransaction[]
ignored_count: number
ignored?: StoredSkattekontoTransaction[]
}
}
@@ -111,6 +117,11 @@ export default function SkattekontoPage() {
const [matchCandidates, setMatchCandidates] = useState<MatchCandidate[] | null>(null)
const [matchLoading, setMatchLoading] = useState(false)
const [matchSubmitting, setMatchSubmitting] = useState<string | null>(null)
// Ignored rows are always fetched (include_ignored=1) but rendered only on
// demand: the count line below the table toggles the "Ignorerade" band.
const [showIgnored, setShowIgnored] = useState(false)
const { dialogProps: ignoreConfirmProps, confirm: confirmIgnore } =
useDestructiveConfirm()
const reload = useCallback(async () => {
setLoading(true)
@@ -118,7 +129,7 @@ export default function SkattekontoPage() {
try {
const [saldoRes, txRes] = await Promise.all([
fetch('/api/extensions/ext/skatteverket/skattekonto/saldo'),
fetch('/api/extensions/ext/skatteverket/skattekonto/transaktioner'),
fetch('/api/extensions/ext/skatteverket/skattekonto/transaktioner?include_ignored=1'),
])
if (saldoRes.status === 401) {
@@ -352,12 +363,97 @@ export default function SkattekontoPage() {
.catch(() => {})
}
async function unignoreRow(id: string) {
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/skattekonto/transaktioner/${id}/ignore`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_ignored: false }),
},
)
if (!res.ok) {
const json = await res.json().catch(() => ({}))
toast({
title: t('unignore_failed'),
description: getUserErrorMessage(json, { statusCode: res.status }),
variant: 'destructive',
})
return
}
await reload()
} catch (err) {
toast({
title: t('unignore_failed'),
description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
}
}
async function ignoreRow(row: StoredSkattekontoTransaction) {
// Semi-destructive (the row leaves the work list), so confirm up front;
// the toast's Ångra plus the standing "Ignorerade" band are the second
// and third recovery affordances. Never a delete: the row stays in the
// table with is_ignored = true.
const ok = await confirmIgnore(
{
title: t('ignore_confirm_title'),
description: t('ignore_confirm_body', {
text: row.transaktionstext,
amount: formatCurrency(Number(row.belopp_skatteverket)),
date: formatDate(row.transaktionsdatum),
}),
confirmLabel: t('ignore_confirm_cta'),
cancelLabel: t('ignore_confirm_cancel'),
variant: 'warning',
},
async () => {
const res = await fetch(
`/api/extensions/ext/skatteverket/skattekonto/transaktioner/${row.id}/ignore`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_ignored: true }),
},
)
if (!res.ok) {
const json = await res.json().catch(() => ({}))
toast({
title: t('ignore_failed'),
description: getUserErrorMessage(json, { statusCode: res.status }),
variant: 'destructive',
})
throw new Error('ignore failed')
}
},
)
if (!ok) return
toast({
title: t('ignored_toast_title'),
action: (
<ToastAction altText={t('ignored_undo')} onClick={() => void unignoreRow(row.id)}>
{t('ignored_undo')}
</ToastAction>
),
})
await reload()
}
// Next charge (concept attn line): the earliest upcoming due date and the
// sum of everything Skatteverket draws that day.
// sum of everything Skatteverket draws that day. Ignored rows stay out of
// the work-list buckets, but SKV draws an upcoming charge regardless of our
// ignore flag, so the saldo-coverage math re-includes them here (same
// future-due predicate the server bucket applies to unignored rows).
const nextCharge = useMemo(() => {
const upcoming = tx?.upcoming ?? []
const dueOf = (r: StoredSkattekontoTransaction) =>
r.forfallodatum ?? r.transaktionsdatum
const today = new Date().toISOString().slice(0, 10)
const ignoredUpcoming = (tx?.ignored ?? []).filter(
(r) => r.status !== 'booked' && dueOf(r) >= today,
)
const upcoming = [...(tx?.upcoming ?? []), ...ignoredUpcoming]
const due = upcoming.map(dueOf).sort()[0]
if (!due) return null
const rows = upcoming.filter((r) => dueOf(r) === due)
@@ -378,7 +474,8 @@ export default function SkattekontoPage() {
)
const hasLocalRows =
tx !== null && tx.booked.length + tx.overdue.length + tx.upcoming.length > 0
tx !== null &&
tx.booked.length + tx.overdue.length + tx.upcoming.length + tx.ignored_count > 0
if (notConnected && !hasLocalRows) {
return (
@@ -586,14 +683,34 @@ export default function SkattekontoPage() {
{/* One dry table with band rows (concept): Kommande, Förfallna, Genomförda */}
<SkattekontoTable
tx={tx}
showIgnored={showIgnored}
onBokfor={bokfor}
onMatch={openMatch}
onIgnore={ignoreRow}
onUnignore={(row) => void unignoreRow(row.id)}
/>
{/* Ignored rows never disappear silently: a standing count line with a
toggle keeps them one click away (BFL 5 kap anti-vanish ethos). */}
{(tx?.ignored_count ?? 0) > 0 && (
<p className="px-1 text-xs leading-5 text-muted-foreground">
{t('ignored_count_line', { count: tx?.ignored_count ?? 0 })}{' '}
<button
type="button"
onClick={() => setShowIgnored((v) => !v)}
className={QUIET_LINK_CLASS}
>
{showIgnored ? t('hide_ignored') : t('show_ignored')}
</button>
</p>
)}
<p className="px-1 text-xs leading-5 text-muted-foreground">
{t('pgnote', { amount: formatCurrency(data?.saldoKronofogden ?? 0) })}
</p>
<DestructiveConfirmDialog {...ignoreConfirmProps} />
{bookTarget && (
<SkattekontoBookDialog
row={bookTarget}
@@ -672,7 +789,7 @@ export default function SkattekontoPage() {
}
type TableSection = {
key: 'upcoming' | 'overdue' | 'booked'
key: 'upcoming' | 'overdue' | 'booked' | 'ignored'
label: string
rows: SkattekontoTransactionWithSuggestion[]
}
@@ -692,12 +809,18 @@ function rowDisplayDate(
function SkattekontoTable({
tx,
showIgnored,
onBokfor,
onMatch,
onIgnore,
onUnignore,
}: {
tx: TransaktionerEnvelope['data'] | null
showIgnored: boolean
onBokfor: (id: string) => void
onMatch: (row: StoredSkattekontoTransaction) => void
onIgnore: (row: StoredSkattekontoTransaction) => void
onUnignore: (row: StoredSkattekontoTransaction) => void
}) {
const t = useTranslations('skattekonto')
@@ -705,6 +828,9 @@ function SkattekontoTable({
{ key: 'upcoming', label: t('band_upcoming'), rows: tx?.upcoming ?? [] },
{ key: 'overdue', label: t('band_overdue'), rows: tx?.overdue ?? [] },
{ key: 'booked', label: t('band_booked'), rows: tx?.booked ?? [] },
...(showIgnored
? [{ key: 'ignored' as const, label: t('band_ignored'), rows: tx?.ignored ?? [] }]
: []),
]
// Rows from a retroactive omprövningsbeslut share date, text and amount, so
// they render identically unless we surface ränteberäkningsdatum. Resolved
@@ -761,6 +887,8 @@ function SkattekontoTable({
section={section.key}
onBokfor={onBokfor}
onMatch={onMatch}
onIgnore={onIgnore}
onUnignore={onUnignore}
showInterestDate={section.interestDateRowIds.has(row.id)}
/>
))}
@@ -777,21 +905,31 @@ function SkattekontoRow({
section,
onBokfor,
onMatch,
onIgnore,
onUnignore,
showInterestDate,
}: {
row: SkattekontoTransactionWithSuggestion
section: TableSection['key']
onBokfor: (id: string) => void
onMatch: (row: StoredSkattekontoTransaction) => void
onIgnore: (row: StoredSkattekontoTransaction) => void
onUnignore: (row: StoredSkattekontoTransaction) => void
showInterestDate: boolean
}) {
const t = useTranslations('skattekonto')
const amount = Number(row.belopp_skatteverket)
const isBooked = !!row.journal_entry_id
const isIgnoredSection = section === 'ignored'
const displayDate = rowDisplayDate(row, section)
return (
<tr className="group transition-colors duration-150 hover:bg-secondary/35">
<tr
className={cn(
'group transition-colors duration-150 hover:bg-secondary/35',
isIgnoredSection && 'opacity-60',
)}
>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
{formatDate(displayDate)}
</td>
@@ -840,7 +978,17 @@ function SkattekontoRow({
{amount > 0 ? `+${formatCurrency(amount)}` : formatCurrency(amount)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right')}>
{isBooked ? (
{isIgnoredSection ? (
<span className={cn('inline-flex items-center gap-3', HOVER_REVEAL_CLASS)}>
<button
type="button"
onClick={() => onUnignore(row)}
className={QUIET_LINK_CLASS}
>
{t('action_unignore')}
</button>
</span>
) : isBooked ? (
<span className="inline-flex items-center justify-end gap-1">
<Link
href={`/bookkeeping/${row.journal_entry_id}`}
@@ -852,6 +1000,13 @@ function SkattekontoRow({
</span>
) : (
<span className={cn('inline-flex items-center gap-3', HOVER_REVEAL_CLASS)}>
<button
type="button"
onClick={() => onIgnore(row)}
className={QUIET_LINK_CLASS}
>
{t('ignore_action')}
</button>
<button
type="button"
onClick={() => onMatch(row)}
+102 -4
View File
@@ -617,7 +617,7 @@ export default function TransactionsPage() {
| { source: 'skatteverket'; date: string; data: SkattekontoTransactionWithSuggestion }
const skvUnmatched = useMemo(
() => skvRows.filter((row) => !row.journal_entry_id),
() => skvRows.filter((row) => !row.journal_entry_id && !row.is_ignored),
[skvRows],
)
@@ -674,7 +674,10 @@ export default function TransactionsPage() {
(tx) => !isWithinBounds(tx.date, periodBounds),
).length
const skvOutside = skvRows.filter(
(r) => !r.journal_entry_id && !isWithinBounds(r.transaktionsdatum, periodBounds),
(r) =>
!r.journal_entry_id &&
!r.is_ignored &&
!isWithinBounds(r.transaktionsdatum, periodBounds),
).length
return bankOutside + skvOutside
}, [periodBounds, skvRows, uncategorizedTransactions])
@@ -779,8 +782,9 @@ export default function TransactionsPage() {
// Inbox only shows SKV rows that need action (no verifikat yet).
for (const r of skvRows) {
// Exiting rows stay rendered for the exit animation, even if a
// refetch already gave them a journal_entry_id mid-window (see above).
if (r.journal_entry_id && !exitingIds.has(r.id)) continue
// refetch already gave them a journal_entry_id (or an ignore) mid-
// window (see above).
if ((r.journal_entry_id || r.is_ignored) && !exitingIds.has(r.id)) continue
// SKV rows live client-side only, so the period filter applies here.
if (!isWithinBounds(r.transaktionsdatum, periodBounds)) continue
if (
@@ -2733,6 +2737,99 @@ export default function TransactionsPage() {
})
}
async function handleSkvUnignore(id: string) {
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/skattekonto/transaktioner/${id}/ignore`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_ignored: false }),
},
)
if (!res.ok) {
toast({ title: t('skv_ignore_undo_failed'), variant: 'destructive' })
return
}
setSkvRows((prev) =>
prev.map((r) => (r.id === id ? { ...r, is_ignored: false } : r)),
)
} catch {
toast({ title: t('skv_ignore_undo_failed'), variant: 'destructive' })
}
}
/**
* Ignorera a skattekonto row: hides it from the work list without booking
* (mirrors handleIgnoreTransaction for bank rows; skattekonto rows are
* never deleted). Confirm up front + Ångra toast; the standing recovery
* surface is the "Ignorerade" band on the Skattekonto page.
*/
async function handleSkvIgnore(row: StoredSkattekontoTransaction) {
const ok = await confirm(
{
title: t('skv_ignore_confirm_title'),
description: t('skv_ignore_confirm_body', {
text: row.transaktionstext,
amount: formatCurrency(Number(row.belopp_skatteverket)),
date: formatDate(row.transaktionsdatum),
}),
confirmLabel: t('skv_ignore_confirm_cta'),
cancelLabel: t('skv_ignore_confirm_cancel'),
variant: 'warning',
},
async () => {
const res = await fetch(
`/api/extensions/ext/skatteverket/skattekonto/transaktioner/${row.id}/ignore`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_ignored: true }),
},
)
if (!res.ok) {
const json = await res.json().catch(() => ({}))
toast({
title: t('skv_ignore_failed'),
description: getErrorMessage(json, { statusCode: res.status }),
variant: 'destructive',
})
throw new Error('skv ignore failed')
}
},
)
if (!ok) return
setSkvSelectedIds((prev) => {
if (!prev.has(row.id)) return prev
const next = new Set(prev)
next.delete(row.id)
return next
})
setExitingIds((prev) => new Set(prev).add(row.id))
setTimeout(() => {
setSkvRows((prev) =>
prev.map((r) => (r.id === row.id ? { ...r, is_ignored: true } : r)),
)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(row.id)
return next
})
}, 350)
toast({
title: t('skv_ignored_title'),
action: (
<ToastAction
altText={t('skv_ignore_undo')}
onClick={() => void handleSkvUnignore(row.id)}
>
{t('skv_ignore_undo')}
</ToastAction>
),
})
}
/**
* Bulk "Bokför valda": one confirmed summary → server-side draft+commit
* per row via bokfor-batch (chunked so batchProgress moves), then ONE
@@ -3742,6 +3839,7 @@ export default function TransactionsPage() {
onToggleSelect={toggleSkvSelect}
onBokfor={handleSkvBokfor}
onMatch={r => setSkvMatchTarget(r)}
onIgnore={handleSkvIgnore}
/>
),
)}