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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
b77af371c4
commit
4cf227001d
@@ -1093,3 +1093,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-19] Banking settings UI state derives from a pure helper (extensions/general/enable-banking/lib/connection-state.ts), not inline JSX conditions: sort precedence, the single page-level .attn sentence, and each row's one primary action must agree on which state a connection is in, and only a pure module can unit-test that. The same-bank connect intercept excludes 'pending' rows (an in-flight authorization is not a renewable connection) and the fresh-connect body sends force_new: true after the intercept so the parallel 409 server guard can distinguish deliberate second connections; 'pending' rows now render as a spinner row ("Väntar på banken") for their whole lifetime instead of only locking the connect button for 30 s, since an invisible in-flight row was the confusion.
|
||||
[2026-08-19] Bank reconnect supersede reuses status 'revoked' plus a new superseded_by column instead of a new status value, and re-points transactions.bank_connection_id to the superseding row: every existing filter, ledger-claim release, and cron skip already handles 'revoked' correctly (no CHECK-constraint migration on a live table), superseded_by disambiguates a supersede from a user disconnect, and re-pointing the feed rows (plain FK metadata, never journal tables) is what makes the picker's gap-fill probe and per-connection scoping survive a renewal.
|
||||
[2026-08-19] /transactions source filter persists in per-company localStorage (v2 key) rather than user_preferences.ui_state, matching the FyPicker/JournalEntryList page-scoped filter idiom (design rule 9 reserves ui_state for split-button modes/nav), and a derived effectiveSourceFilter memo replaces the stale-filter reset-guard effect: the guard raced the async cashAccounts/skvRows/transactions loads on every mount and wiped the restored choice back to 'Alla källor'.
|
||||
[2026-08-19] Skattekonto sync datumFrom bound applied uniformly to EF and AB (earliest fiscal_periods.period_start, no bound when none exists): an AB's skattekonto is the company's own so the bound is a no-op there, and one code path beats an entity_type branch; the EF case (owner's personal account, ~555 days of private pre-company history) is the one that needed it.
|
||||
[2026-08-19] company_settings.employer_registered chosen as the "does this EF employ anyone" signal for the avdragen-skatt gate (not an employees-table query): it is the existing AGI gate signal (20260717151000, attested pays_salaries OR any salary_run), already maintained for exactly this question, and one boolean on the settings row the rule context already fetches costs no extra round trip.
|
||||
[2026-08-19] requires_employer added as a data-driven skattekonto_rules column (set on the 'avdragen skatt,personalskatt,a-skatt' seed) instead of a hardcoded pattern match in TypeScript: the table exists precisely so rule semantics ship without redeploys, and a string match in code would silently miss per-company clones of the seed pattern.
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -250,7 +250,14 @@ export default function SkattekontoBookDialog({
|
||||
</div>
|
||||
) : suggestion === null ? (
|
||||
<p className="pt-1 text-xs leading-5 text-muted-foreground">
|
||||
{t('no_rule_matched')}
|
||||
{/* The employer gate is not "no rule matched": the rule matched,
|
||||
but this enskild firma has no registered employees, so the row
|
||||
is most likely the owner's private A-skatt. Distinct hint so
|
||||
the user knows why booking is not offered and that ignoring
|
||||
the row is fine. */}
|
||||
{row.booking_gate === 'requires_employer'
|
||||
? t('ef_private_tax_hint')
|
||||
: t('no_rule_matched')}
|
||||
</p>
|
||||
) : null}
|
||||
</dl>
|
||||
|
||||
@@ -31,6 +31,7 @@ export default function SkattekontoInboxCard({
|
||||
onToggleSelect,
|
||||
onBokfor,
|
||||
onMatch,
|
||||
onIgnore,
|
||||
}: {
|
||||
row: StoredSkattekontoTransaction
|
||||
matchSuggestion?: SkattekontoMatchSuggestion | null
|
||||
@@ -44,6 +45,10 @@ export default function SkattekontoInboxCard({
|
||||
onToggleSelect?: (id: string) => void
|
||||
onBokfor: (row: StoredSkattekontoTransaction) => void
|
||||
onMatch: (row: StoredSkattekontoTransaction) => void
|
||||
/** Optional "Ignorera" affordance: hides the row from the work list without
|
||||
* booking it (skattekonto rows are never deleted). The parent owns the
|
||||
* confirm dialog and the PATCH. */
|
||||
onIgnore?: (row: StoredSkattekontoTransaction) => void
|
||||
}) {
|
||||
const t = useTranslations('tx_skattekonto_card')
|
||||
const amount = Number(row.belopp_skatteverket)
|
||||
@@ -175,6 +180,16 @@ export default function SkattekontoInboxCard({
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{onIgnore && (
|
||||
<button
|
||||
type="button"
|
||||
className={QUIET_LINK_CLASS}
|
||||
onClick={() => onIgnore(row)}
|
||||
disabled={processing}
|
||||
>
|
||||
{t('ignore')}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -36,7 +36,7 @@ const SEED_RULES = [
|
||||
{
|
||||
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',
|
||||
counter_account: '2510', counter_account_ef: '2013',
|
||||
label: 'Preliminär skatt', active: true,
|
||||
},
|
||||
{
|
||||
@@ -124,7 +124,7 @@ describe('attachBookingSuggestions', () => {
|
||||
'company-1',
|
||||
[makeSkvRow({ transaktionstext: 'Debiterad preliminärskatt' })],
|
||||
)
|
||||
expect(enriched[0].booking_suggestion?.account).toBe('2012')
|
||||
expect(enriched[0].booking_suggestion?.account).toBe('2013')
|
||||
|
||||
const ab = makeSupabase()
|
||||
ab.enqueue({ data: SEED_RULES })
|
||||
@@ -150,6 +150,94 @@ describe('attachBookingSuggestions', () => {
|
||||
expect(enriched[0].booking_suggestion).toBeNull()
|
||||
})
|
||||
|
||||
it('gates a requires_employer rule for a non-employer EF and marks booking_gate', async () => {
|
||||
const AVDRAGEN_RULE = [
|
||||
{
|
||||
id: 'sys-5', priority: 20, pattern: 'avdragen skatt,personalskatt,a-skatt',
|
||||
amount_min: null, amount_max: null, company_type: 'all',
|
||||
counter_account: '2710', counter_account_ef: null,
|
||||
label: 'Avdragen skatt anställda', active: true, requires_employer: true,
|
||||
},
|
||||
]
|
||||
|
||||
// EF, not employer_registered: no suggestion, distinct gate marker.
|
||||
const ef = makeSupabase()
|
||||
ef.enqueue({ data: AVDRAGEN_RULE })
|
||||
ef.enqueue({ data: { entity_type: 'enskild_firma', employer_registered: null } })
|
||||
const gated = await attachBookingSuggestions(
|
||||
ef.supabase as unknown as SupabaseClient,
|
||||
'company-1',
|
||||
[makeSkvRow({ transaktionstext: 'Avdragen skatt' })],
|
||||
)
|
||||
expect(gated[0].booking_suggestion).toBeNull()
|
||||
expect(gated[0].booking_gate).toBe('requires_employer')
|
||||
|
||||
// EF that runs payroll keeps 2710, with no gate marker.
|
||||
const employer = makeSupabase()
|
||||
employer.enqueue({ data: AVDRAGEN_RULE })
|
||||
employer.enqueue({ data: { entity_type: 'enskild_firma', employer_registered: true } })
|
||||
const kept = await attachBookingSuggestions(
|
||||
employer.supabase as unknown as SupabaseClient,
|
||||
'company-1',
|
||||
[makeSkvRow({ transaktionstext: 'Avdragen skatt' })],
|
||||
)
|
||||
expect(kept[0].booking_suggestion?.account).toBe('2710')
|
||||
expect(kept[0].booking_gate).toBeUndefined()
|
||||
|
||||
// AB is never gated.
|
||||
const ab = makeSupabase()
|
||||
ab.enqueue({ data: AVDRAGEN_RULE })
|
||||
ab.enqueue({ data: { entity_type: 'aktiebolag', employer_registered: null } })
|
||||
const abRows = await attachBookingSuggestions(
|
||||
ab.supabase as unknown as SupabaseClient,
|
||||
'company-1',
|
||||
[makeSkvRow({ transaktionstext: 'Avdragen skatt' })],
|
||||
)
|
||||
expect(abRows[0].booking_suggestion?.account).toBe('2710')
|
||||
})
|
||||
|
||||
it('falls back to pays_salaries when employer_registered was never attested', async () => {
|
||||
const AVDRAGEN_RULE = [
|
||||
{
|
||||
id: 'sys-5', priority: 20, pattern: 'avdragen skatt,personalskatt,a-skatt',
|
||||
amount_min: null, amount_max: null, company_type: 'all',
|
||||
counter_account: '2710', counter_account_ef: null,
|
||||
label: 'Avdragen skatt anställda', active: true, requires_employer: true,
|
||||
},
|
||||
]
|
||||
|
||||
// Same signal as lib/tax/deadline-config.ts: an EF that answered
|
||||
// pays_salaries in onboarding but never attested employer_registered
|
||||
// (null) still runs payroll, so the gate opens and 2710 stays.
|
||||
const ef = makeSupabase()
|
||||
ef.enqueue({ data: AVDRAGEN_RULE })
|
||||
ef.enqueue({
|
||||
data: { entity_type: 'enskild_firma', employer_registered: null, pays_salaries: true },
|
||||
})
|
||||
const kept = await attachBookingSuggestions(
|
||||
ef.supabase as unknown as SupabaseClient,
|
||||
'company-1',
|
||||
[makeSkvRow({ transaktionstext: 'Avdragen skatt' })],
|
||||
)
|
||||
expect(kept[0].booking_suggestion?.account).toBe('2710')
|
||||
expect(kept[0].booking_gate).toBeUndefined()
|
||||
|
||||
// An explicit employer_registered = false wins over pays_salaries: the
|
||||
// attestation is the stronger, later signal, so the gate stays closed.
|
||||
const attested = makeSupabase()
|
||||
attested.enqueue({ data: AVDRAGEN_RULE })
|
||||
attested.enqueue({
|
||||
data: { entity_type: 'enskild_firma', employer_registered: false, pays_salaries: true },
|
||||
})
|
||||
const gated = await attachBookingSuggestions(
|
||||
attested.supabase as unknown as SupabaseClient,
|
||||
'company-1',
|
||||
[makeSkvRow({ transaktionstext: 'Avdragen skatt' })],
|
||||
)
|
||||
expect(gated[0].booking_suggestion).toBeNull()
|
||||
expect(gated[0].booking_gate).toBe('requires_employer')
|
||||
})
|
||||
|
||||
it('hoists the rules fetch: one skattekonto_rules query for many rows', async () => {
|
||||
const { supabase, enqueue } = makeSupabase()
|
||||
enqueue({ data: SEED_RULES })
|
||||
@@ -438,4 +526,64 @@ describe('bokforSkattekontoTransactionsBatch', () => {
|
||||
expect(result.results[0].error_message).not.toContain('locked/closed fiscal period')
|
||||
expect(vi.mocked(commitEntry)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('explains a row that predates the first fiscal year instead of "unlock the period"', async () => {
|
||||
const { supabase, enqueue } = makeSupabase()
|
||||
// Typical EF case: the personal skattekonto carries history from before
|
||||
// the company existed. No period covers the date and none ever will, so
|
||||
// "lås upp perioden" is a dead end; the message must point at ignore.
|
||||
const row = makeSkvRow({
|
||||
transaktionstext: 'Intäktsränta',
|
||||
transaktionsdatum: '2025-04-12',
|
||||
})
|
||||
enqueue({ data: SEED_RULES })
|
||||
enqueue({ data: { entity_type: 'enskild_firma' } })
|
||||
enqueue({ data: row })
|
||||
enqueue({ data: [{ period_start: '2025-11-01' }] }) // earliest fiscal period
|
||||
|
||||
vi.mocked(findFiscalPeriod).mockResolvedValue(null)
|
||||
|
||||
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).toContain('före företagets första räkenskapsår')
|
||||
expect(result.results[0].error_message).toContain('kan ignoreras')
|
||||
expect(vi.mocked(createDraftEntry)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an ignored row with ROW_IGNORED before any draft exists', async () => {
|
||||
const { supabase, enqueue } = makeSupabase()
|
||||
// Rule WOULD match: only the user's explicit ignore stops the booking.
|
||||
const row = makeSkvRow({ transaktionstext: 'Intäktsränta', is_ignored: true })
|
||||
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: 'ROW_IGNORED',
|
||||
error_message: 'Transaktionen är ignorerad. Återställ den innan du bokför.',
|
||||
})
|
||||
expect(result.summary).toEqual({ total: 1, succeeded: 0, failed: 1 })
|
||||
// The gate fires before the engine is touched: no orphan draft.
|
||||
expect(vi.mocked(createDraftEntry)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(commitEntry)).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,9 @@ import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { guessCounterAccount } from '../lib/skattekonto-booking'
|
||||
|
||||
/**
|
||||
* System seeds mirror supabase/migrations/20260519100000_skattekonto_rules.sql.
|
||||
* System seeds mirror supabase/migrations/20260519100000_skattekonto_rules.sql
|
||||
* plus the follow-up corrections 20260817120100 (EF preliminärskatt 2012 ->
|
||||
* 2013) and 20260819200100 (requires_employer on the 'avdragen skatt' rule).
|
||||
* Kept in lockstep so the resolver behaves identically against mock and real DB.
|
||||
*/
|
||||
const SEED_RULES = [
|
||||
@@ -12,55 +14,55 @@ 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,
|
||||
label: 'Inbetalning till skattekonto', active: true, requires_employer: false,
|
||||
},
|
||||
{
|
||||
id: 'sys-2', priority: 10, pattern: 'utbetalning,återbetalning',
|
||||
amount_min: null, amount_max: null, company_type: 'all',
|
||||
counter_account: '__PRIMARY_SEK__', counter_account_ef: null,
|
||||
label: 'Utbetalning från skattekonto', active: true,
|
||||
label: 'Utbetalning från skattekonto', active: true, requires_employer: false,
|
||||
},
|
||||
{
|
||||
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,
|
||||
counter_account: '2510', counter_account_ef: '2013',
|
||||
label: 'Preliminär skatt', active: true, requires_employer: false,
|
||||
},
|
||||
{
|
||||
id: 'sys-4', priority: 20, pattern: 'arbetsgivaravgift,sociala avgifter,agi',
|
||||
amount_min: null, amount_max: null, company_type: 'all',
|
||||
counter_account: '2730', counter_account_ef: null,
|
||||
label: 'Arbetsgivaravgifter', active: true,
|
||||
label: 'Arbetsgivaravgifter', active: true, requires_employer: false,
|
||||
},
|
||||
{
|
||||
id: 'sys-5', priority: 20, pattern: 'avdragen skatt,personalskatt,a-skatt',
|
||||
amount_min: null, amount_max: null, company_type: 'all',
|
||||
counter_account: '2710', counter_account_ef: null,
|
||||
label: 'Avdragen skatt anställda', active: true,
|
||||
label: 'Avdragen skatt anställda', active: true, requires_employer: true,
|
||||
},
|
||||
{
|
||||
id: 'sys-6', priority: 20, pattern: 'mervärdesskatt,moms,momsdeklaration',
|
||||
amount_min: null, amount_max: null, company_type: 'all',
|
||||
counter_account: '2650', counter_account_ef: null,
|
||||
label: 'Redovisningskonto för moms', active: true,
|
||||
label: 'Redovisningskonto för moms', active: true, requires_employer: false,
|
||||
},
|
||||
{
|
||||
id: 'sys-7', priority: 25, pattern: 'skattetillägg,förseningsavgift',
|
||||
amount_min: null, amount_max: null, company_type: 'all',
|
||||
counter_account: '6992', counter_account_ef: null,
|
||||
label: 'Ej avdragsgilla skatteavgifter', active: true,
|
||||
label: 'Ej avdragsgilla skatteavgifter', active: true, requires_employer: false,
|
||||
},
|
||||
{
|
||||
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,
|
||||
label: 'Kostnadsränta skattekonto', active: true, requires_employer: false,
|
||||
},
|
||||
{
|
||||
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,
|
||||
label: 'Intäktsränta skattekonto', active: true, requires_employer: false,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -120,7 +122,7 @@ describe('guessCounterAccount', () => {
|
||||
).toBe('1930')
|
||||
})
|
||||
|
||||
it('uses 2510 for AB preliminär skatt and 2012 for EF', async () => {
|
||||
it('uses 2510 for AB preliminär skatt and 2013 for EF (regression: 2012 is not standard BAS)', async () => {
|
||||
const { supabase, enqueue } = makeSupabase()
|
||||
enqueue({ data: SEED_RULES })
|
||||
expect(
|
||||
@@ -130,7 +132,7 @@ describe('guessCounterAccount', () => {
|
||||
enqueue({ data: SEED_RULES })
|
||||
expect(
|
||||
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Debiterad preliminärskatt', 'enskild_firma'))?.account,
|
||||
).toBe('2012')
|
||||
).toBe('2013')
|
||||
})
|
||||
|
||||
it('routes employer payroll taxes to 2730 (clearing/redovisningskonto, not 2731 accrual)', async () => {
|
||||
@@ -146,14 +148,38 @@ describe('guessCounterAccount', () => {
|
||||
).toBe('2730')
|
||||
})
|
||||
|
||||
it('routes deducted income tax to 2710', async () => {
|
||||
it('routes deducted income tax to 2710 for an aktiebolag', async () => {
|
||||
const { supabase, enqueue } = makeSupabase()
|
||||
enqueue({ data: SEED_RULES })
|
||||
// requires_employer only gates enskild_firma: an AB with a personalskatt
|
||||
// line by definition has payroll, so 2710 stays unconditional.
|
||||
expect(
|
||||
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Avdragen skatt anställda', 'aktiebolag'))?.account,
|
||||
).toBe('2710')
|
||||
})
|
||||
|
||||
it('returns null for "avdragen skatt" on an enskild firma without employer registration', async () => {
|
||||
const { supabase, enqueue } = makeSupabase()
|
||||
enqueue({ data: SEED_RULES })
|
||||
// The EF's skattekonto is the owner's PERSONAL account: "Avdragen skatt"
|
||||
// there is almost always A-skatt an outside employer withheld from the
|
||||
// owner's private salary. Auto-crediting 2710 would fabricate a payroll
|
||||
// liability, so the gate forces the NO_COUNTER_ACCOUNT path instead.
|
||||
expect(
|
||||
await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Avdragen skatt', 'enskild_firma'),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps 2710 for "avdragen skatt" on an employer-registered enskild firma', async () => {
|
||||
const { supabase, enqueue } = makeSupabase()
|
||||
enqueue({ data: SEED_RULES })
|
||||
// An EF that genuinely runs payroll (employer_registered) withholds tax
|
||||
// from real third-party employees: 2710 is then correct, same as an AB.
|
||||
expect(
|
||||
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Avdragen skatt', 'enskild_firma', undefined, true))?.account,
|
||||
).toBe('2710')
|
||||
})
|
||||
|
||||
it('routes VAT settlements to 2650', async () => {
|
||||
const { supabase, enqueue } = makeSupabase()
|
||||
enqueue({ data: SEED_RULES })
|
||||
|
||||
@@ -18,6 +18,7 @@ function makeRow(
|
||||
belopp_kronofogden: 0,
|
||||
status: 'upcoming',
|
||||
journal_entry_id: null,
|
||||
is_ignored: false,
|
||||
source: 'api',
|
||||
file_import_id: null,
|
||||
imported_at: '2026-05-15T10:00:00Z',
|
||||
@@ -101,4 +102,16 @@ describe('splitTransactions', () => {
|
||||
expect(out.overdue.map(r => r.id)).toEqual(['o1', 'o2'])
|
||||
expect(out.upcoming.map(r => r.id)).toEqual(['u1'])
|
||||
})
|
||||
it('routes ignored rows to the ignored bucket regardless of status', () => {
|
||||
const rows = [
|
||||
makeRow({ id: 'a', status: 'booked', is_ignored: true }),
|
||||
makeRow({ id: 'b', status: 'upcoming', forfallodatum: '2026-05-01', is_ignored: true }),
|
||||
makeRow({ id: 'c', status: 'booked' }),
|
||||
]
|
||||
const out = splitTransactions(rows, today)
|
||||
expect(out.ignored.map(r => r.id)).toEqual(['a', 'b'])
|
||||
expect(out.booked.map(r => r.id)).toEqual(['c'])
|
||||
expect(out.overdue).toEqual([])
|
||||
expect(out.upcoming).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { skatteverketExtension } from '../index'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
|
||||
/**
|
||||
* PATCH /skattekonto/transaktioner/:id/ignore
|
||||
*
|
||||
* Skattekonto rows are never deleted (external mirror); is_ignored is the
|
||||
* sanctioned way off the work list. The route must refuse to ignore a booked
|
||||
* row (409, mirroring the DB CHECK) and be fully reversible.
|
||||
*/
|
||||
|
||||
const ROUTE_PATH = '/skattekonto/transaktioner/:id/ignore'
|
||||
const ROW_ID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
const { supabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
|
||||
|
||||
function findRoute() {
|
||||
const route = skatteverketExtension.apiRoutes?.find(
|
||||
(r) => r.method === 'PATCH' && r.path === ROUTE_PATH,
|
||||
)
|
||||
if (!route) throw new Error('ignore route not registered')
|
||||
return route
|
||||
}
|
||||
|
||||
function makeContext(): ExtensionContext {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
extensionId: 'skatteverket',
|
||||
requestId: 'req_test',
|
||||
supabase,
|
||||
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, id: string | null = ROW_ID): Request {
|
||||
const url = new URL('http://localhost/api/extensions/ext/skatteverket/skattekonto/transaktioner/x/ignore')
|
||||
// The catch-all dispatcher passes :id via the `_id` search param.
|
||||
if (id) url.searchParams.set('_id', id)
|
||||
return new Request(url.toString(), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: typeof body === 'string' ? body : JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
describe('PATCH /skattekonto/transaktioner/:id/ignore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
})
|
||||
|
||||
it('returns 500 without an extension context', async () => {
|
||||
const res = await findRoute().handler(makeRequest({ is_ignored: true }))
|
||||
expect(res.status).toBe(500)
|
||||
})
|
||||
|
||||
it('returns 400 without a transaction id', async () => {
|
||||
const res = await findRoute().handler(makeRequest({ is_ignored: true }, null), makeContext())
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects invalid JSON with 400', async () => {
|
||||
const res = await findRoute().handler(makeRequest('not json{'), makeContext())
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects a non-boolean is_ignored with 400', async () => {
|
||||
const res = await findRoute().handler(makeRequest({ is_ignored: 'yes' }), makeContext())
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when the row does not exist for the company', async () => {
|
||||
enqueue({ data: null, error: { message: 'No rows' } }) // select .single()
|
||||
const res = await findRoute().handler(makeRequest({ is_ignored: true }), makeContext())
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('refuses to ignore a booked row with 409', async () => {
|
||||
enqueue({ data: { id: ROW_ID, journal_entry_id: 'je-1', is_ignored: false } })
|
||||
const res = await findRoute().handler(makeRequest({ is_ignored: true }), makeContext())
|
||||
expect(res.status).toBe(409)
|
||||
// No write was attempted.
|
||||
expect(findCalls('skattekonto_transactions', 'update')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ignores an unbooked row and guards the write on journal_entry_id IS NULL', async () => {
|
||||
enqueue({ data: { id: ROW_ID, journal_entry_id: null, is_ignored: false } })
|
||||
enqueue({ data: [{ id: ROW_ID }] }) // conditional update claimed the row
|
||||
const res = await findRoute().handler(makeRequest({ is_ignored: true }), makeContext())
|
||||
expect(res.status).toBe(200)
|
||||
const json = await res.json()
|
||||
expect(json.data).toEqual({ ok: true, is_ignored: true })
|
||||
|
||||
const updates = findCalls('skattekonto_transactions', 'update')
|
||||
expect(updates).toHaveLength(1)
|
||||
expect(updates[0][0]).toEqual({ is_ignored: true })
|
||||
// The `.is('journal_entry_id', null)` race guard must be on the write.
|
||||
expect(findCalls('skattekonto_transactions', 'is')).toContainEqual([
|
||||
'journal_entry_id',
|
||||
null,
|
||||
])
|
||||
})
|
||||
|
||||
it('returns 409 when a concurrent booking wins the race (zero rows updated)', async () => {
|
||||
enqueue({ data: { id: ROW_ID, journal_entry_id: null, is_ignored: false } })
|
||||
enqueue({ data: [] }) // conditional update matched nothing
|
||||
const res = await findRoute().handler(makeRequest({ is_ignored: true }), makeContext())
|
||||
expect(res.status).toBe(409)
|
||||
})
|
||||
|
||||
it('unignores a row without the journal_entry_id guard', async () => {
|
||||
enqueue({ data: { id: ROW_ID, journal_entry_id: null, is_ignored: true } })
|
||||
enqueue({ data: [{ id: ROW_ID }] })
|
||||
const res = await findRoute().handler(makeRequest({ is_ignored: false }), makeContext())
|
||||
expect(res.status).toBe(200)
|
||||
const json = await res.json()
|
||||
expect(json.data).toEqual({ ok: true, is_ignored: false })
|
||||
expect(findCalls('skattekonto_transactions', 'is')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -218,6 +218,22 @@ describe('matchSkattekontoToEntry', () => {
|
||||
).rejects.toMatchObject({ code: 'ALREADY_BOOKED' })
|
||||
})
|
||||
|
||||
it('throws ROW_IGNORED for an ignored row before touching the candidate entry', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: txRow({ is_ignored: true }) })
|
||||
|
||||
await expect(
|
||||
matchSkattekontoToEntry(supabase as never, COMPANY, TX_ID, 'je-1'),
|
||||
).rejects.toMatchObject({
|
||||
code: 'ROW_IGNORED',
|
||||
message: 'Transaktionen är ignorerad. Återställ den innan du bokför.',
|
||||
})
|
||||
// Only the tx fetch ran: the guard fires before the journal_entries read
|
||||
// and before any update.
|
||||
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||
expect(supabase.from).toHaveBeenCalledWith('skattekonto_transactions')
|
||||
})
|
||||
|
||||
it('throws ENTRY_NOT_FOUND when the candidate verifikat does not exist', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: txRow() })
|
||||
|
||||
@@ -83,6 +83,7 @@ describe('syncSkattekonto: takeover of file-imported rows', () => {
|
||||
})
|
||||
|
||||
enqueue({ data: { org_number: '556677-8899', entity_type: 'aktiebolag' } }) // company_settings
|
||||
enqueue({ data: [] }) // fiscal_periods (earliest period start: none)
|
||||
enqueue({ data: [] }) // existing dedup_key lookup: id:9001 not present
|
||||
enqueue({
|
||||
data: [
|
||||
@@ -130,6 +131,7 @@ describe('syncSkattekonto: takeover of file-imported rows', () => {
|
||||
belopp_skatteverket: -15710,
|
||||
}
|
||||
enqueue({ data: { org_number: '556677-8899', entity_type: 'aktiebolag' } }) // company_settings
|
||||
enqueue({ data: [] }) // fiscal_periods (earliest period start: none)
|
||||
enqueue({ data: [] }) // existing dedup_key lookup
|
||||
enqueue({
|
||||
data: [
|
||||
@@ -158,6 +160,7 @@ describe('syncSkattekonto: takeover of file-imported rows', () => {
|
||||
})
|
||||
|
||||
enqueue({ data: { org_number: '556677-8899', entity_type: 'aktiebolag' } }) // company_settings
|
||||
enqueue({ data: [] }) // fiscal_periods (earliest period start: none)
|
||||
enqueue({ data: [{ dedup_key: 'id:9001', status: 'booked' }] }) // key already known
|
||||
enqueue({ data: null }) // upsert
|
||||
|
||||
@@ -184,6 +187,7 @@ describe('syncSkattekonto: takeover of file-imported rows', () => {
|
||||
})
|
||||
|
||||
enqueue({ data: { org_number: '556677-8899', entity_type: 'aktiebolag' } }) // company_settings
|
||||
enqueue({ data: [] }) // fiscal_periods (earliest period start: none)
|
||||
enqueue({ data: [{ dedup_key: FILE_ROW_HASH_KEY, status: 'booked' }] }) // same key, already booked
|
||||
|
||||
await syncSkattekonto(makeCtx())
|
||||
@@ -192,3 +196,101 @@ describe('syncSkattekonto: takeover of file-imported rows', () => {
|
||||
expect(findCalls('skattekonto_transactions', 'upsert')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
/** ISO date `days` days before today (UTC), matching the sync's clamp math. */
|
||||
function isoDaysAgo(days: number): string {
|
||||
const d = new Date()
|
||||
d.setUTCDate(d.getUTCDate() - days)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
describe('syncSkattekonto: fiscal-year lower bound on the fetch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
getSaldoMock.mockResolvedValue(makeSaldo())
|
||||
})
|
||||
|
||||
it('passes the earliest fiscal period start as datumFrom when inside the SKV window', async () => {
|
||||
getTransaktionerMock.mockResolvedValue({
|
||||
tidigareTransaktioner: [],
|
||||
kommandeTransaktioner: [],
|
||||
})
|
||||
|
||||
const periodStart = isoDaysAgo(100)
|
||||
enqueue({ data: { org_number: '556677-8899', entity_type: 'aktiebolag' } }) // company_settings
|
||||
enqueue({ data: [{ period_start: periodStart }] }) // fiscal_periods earliest
|
||||
|
||||
await syncSkattekonto(makeCtx())
|
||||
|
||||
// Without the bound, SKV defaults to ~555 days of history, which for an
|
||||
// enskild firma imports the owner's private pre-company transactions.
|
||||
expect(getTransaktionerMock).toHaveBeenCalledTimes(1)
|
||||
expect(getTransaktionerMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.any(String),
|
||||
periodStart,
|
||||
)
|
||||
})
|
||||
|
||||
it('omits datumFrom when bookkeeping started before the 555-day default window', async () => {
|
||||
getTransaktionerMock.mockResolvedValue({
|
||||
tidigareTransaktioner: [],
|
||||
kommandeTransaktioner: [],
|
||||
})
|
||||
|
||||
// 800 days ago: still ACCEPTED by SKV (limit ~915 days) but OLDER than
|
||||
// the 555-day default. Sending it would silently widen the window past
|
||||
// what an unbounded fetch returns; omitting it is identical to what we
|
||||
// want, so nothing is sent.
|
||||
enqueue({ data: { org_number: '556677-8899', entity_type: 'aktiebolag' } }) // company_settings
|
||||
enqueue({ data: [{ period_start: isoDaysAgo(800) }] }) // fiscal_periods earliest
|
||||
|
||||
await syncSkattekonto(makeCtx())
|
||||
|
||||
expect(getTransaktionerMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.any(String),
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it('omits datumFrom when bookkeeping started past the ~915-day SKV limit (felkod 2)', async () => {
|
||||
getTransaktionerMock.mockResolvedValue({
|
||||
tidigareTransaktioner: [],
|
||||
kommandeTransaktioner: [],
|
||||
})
|
||||
|
||||
// 1690 days ago: a datumFrom this old is rejected by SKV with felkod 2
|
||||
// and would break the whole sync. The clamp must omit it.
|
||||
enqueue({ data: { org_number: '556677-8899', entity_type: 'aktiebolag' } }) // company_settings
|
||||
enqueue({ data: [{ period_start: isoDaysAgo(1690) }] }) // fiscal_periods earliest
|
||||
|
||||
await syncSkattekonto(makeCtx())
|
||||
|
||||
expect(getTransaktionerMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.any(String),
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it('passes no datumFrom when the company has no fiscal period yet', async () => {
|
||||
getTransaktionerMock.mockResolvedValue({
|
||||
tidigareTransaktioner: [],
|
||||
kommandeTransaktioner: [],
|
||||
})
|
||||
|
||||
enqueue({ data: { org_number: '556677-8899', entity_type: 'aktiebolag' } }) // company_settings
|
||||
enqueue({ data: [] }) // fiscal_periods: none yet (brand-new company)
|
||||
|
||||
await syncSkattekonto(makeCtx())
|
||||
|
||||
// Fall back to today's unbounded behavior rather than blocking the sync.
|
||||
expect(getTransaktionerMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.any(String),
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { seedCompany } from '@/tests/pg/fixtures'
|
||||
import { insertDraftJournalEntry, seedCompany } from '@/tests/pg/fixtures'
|
||||
import { getPool, withUserContext } from '@/tests/pg/setup'
|
||||
|
||||
/**
|
||||
* RLS smoke for skattekonto_transactions. Locks in tenant isolation +
|
||||
* the (company_id, dedup_key) unique constraint that the sync UPSERT
|
||||
* relies on for idempotency.
|
||||
* relies on for idempotency, and the is_ignored CHECK (migration
|
||||
* 20260819200000): an ignored row must never carry a journal_entry_id.
|
||||
*/
|
||||
|
||||
async function insertSkattekontoTransaction(params: {
|
||||
@@ -89,3 +90,111 @@ describe('skattekonto_transactions.pg: RLS tenant isolation', () => {
|
||||
).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('skattekonto_transactions.pg: is_ignored', () => {
|
||||
it('defaults to false and can be toggled on an unbooked row by a company member', async () => {
|
||||
const a = await seedCompany()
|
||||
const id = await insertSkattekontoTransaction({ companyId: a.companyId, dedupKey: 'id:666' })
|
||||
|
||||
const before = await getPool().query<{ is_ignored: boolean }>(
|
||||
`SELECT is_ignored FROM public.skattekonto_transactions WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
expect(before.rows[0]!.is_ignored).toBe(false)
|
||||
|
||||
// The pre-existing company-scoped UPDATE policy covers the new column:
|
||||
// a member can ignore and unignore under RLS with no policy change.
|
||||
// withUserContext ALWAYS rolls back (tests/pg/setup.ts), so the write and
|
||||
// its verification must both happen inside the same transaction; reading
|
||||
// back on the pool connection afterwards would always see the pre-write
|
||||
// value and say nothing about the policy. rowCount is the actual proof:
|
||||
// an RLS-filtered UPDATE silently matches zero rows instead of raising.
|
||||
await withUserContext(a.userId, async (client) => {
|
||||
const ignored = await client.query(
|
||||
`UPDATE public.skattekonto_transactions SET is_ignored = true WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
expect(ignored.rowCount).toBe(1)
|
||||
const mid = await client.query<{ is_ignored: boolean }>(
|
||||
`SELECT is_ignored FROM public.skattekonto_transactions WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
expect(mid.rows[0]!.is_ignored).toBe(true)
|
||||
|
||||
const unignored = await client.query(
|
||||
`UPDATE public.skattekonto_transactions SET is_ignored = false WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
expect(unignored.rowCount).toBe(1)
|
||||
const after = await client.query<{ is_ignored: boolean }>(
|
||||
`SELECT is_ignored FROM public.skattekonto_transactions WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
expect(after.rows[0]!.is_ignored).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('RLS keeps a non-member from ignoring another company\'s row', async () => {
|
||||
const a = await seedCompany()
|
||||
const b = await seedCompany()
|
||||
const id = await insertSkattekontoTransaction({ companyId: a.companyId, dedupKey: 'id:999' })
|
||||
|
||||
// Company B's member is scoped out by the USING clause, so the UPDATE is
|
||||
// filtered to zero rows rather than erroring: assert the row is untouched.
|
||||
await withUserContext(b.userId, async (client) => {
|
||||
const res = await client.query(
|
||||
`UPDATE public.skattekonto_transactions SET is_ignored = true WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
expect(res.rowCount).toBe(0)
|
||||
})
|
||||
|
||||
const after = await getPool().query<{ is_ignored: boolean }>(
|
||||
`SELECT is_ignored FROM public.skattekonto_transactions WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
expect(after.rows[0]!.is_ignored).toBe(false)
|
||||
})
|
||||
|
||||
it('CHECK blocks ignoring a row that has a journal_entry_id', async () => {
|
||||
const a = await seedCompany()
|
||||
const txId = await insertSkattekontoTransaction({ companyId: a.companyId, dedupKey: 'id:777' })
|
||||
const entryId = await insertDraftJournalEntry({
|
||||
userId: a.userId,
|
||||
companyId: a.companyId,
|
||||
fiscalPeriodId: a.fiscalPeriodId,
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.skattekonto_transactions SET journal_entry_id = $1 WHERE id = $2`,
|
||||
[entryId, txId],
|
||||
)
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.skattekonto_transactions SET is_ignored = true WHERE id = $1`,
|
||||
[txId],
|
||||
),
|
||||
).rejects.toThrow(/skattekonto_transactions_is_ignored_no_journal_entry|check constraint/i)
|
||||
})
|
||||
|
||||
it('CHECK blocks booking (linking a journal entry to) an ignored row', async () => {
|
||||
const a = await seedCompany()
|
||||
const txId = await insertSkattekontoTransaction({ companyId: a.companyId, dedupKey: 'id:888' })
|
||||
await getPool().query(
|
||||
`UPDATE public.skattekonto_transactions SET is_ignored = true WHERE id = $1`,
|
||||
[txId],
|
||||
)
|
||||
const entryId = await insertDraftJournalEntry({
|
||||
userId: a.userId,
|
||||
companyId: a.companyId,
|
||||
fiscalPeriodId: a.fiscalPeriodId,
|
||||
})
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.skattekonto_transactions SET journal_entry_id = $1 WHERE id = $2`,
|
||||
[entryId, txId],
|
||||
),
|
||||
).rejects.toThrow(/skattekonto_transactions_is_ignored_no_journal_entry|check constraint/i)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2126,6 +2126,9 @@ export const skatteverketExtension: Extension = {
|
||||
// ── Transaktioner (from local table) ───────────────────────────
|
||||
// Returns booked + upcoming transactions for the active company.
|
||||
// Optional `from` query filters tidigare on transaktionsdatum >= from.
|
||||
// Ignored rows (is_ignored) are excluded from the work buckets and only
|
||||
// reported as `ignored_count`; pass `include_ignored=1` to also get the
|
||||
// rows themselves (the "Ignorerade" band). Never silently dropped.
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/skattekonto/transaktioner',
|
||||
@@ -2135,6 +2138,9 @@ export const skatteverketExtension: Extension = {
|
||||
}
|
||||
const url = new URL(request.url)
|
||||
const from = url.searchParams.get('from')
|
||||
const includeIgnoredParam = url.searchParams.get('include_ignored')
|
||||
const includeIgnored =
|
||||
includeIgnoredParam === '1' || includeIgnoredParam === 'true'
|
||||
|
||||
let query = ctx.supabase
|
||||
.from('skattekonto_transactions')
|
||||
@@ -2151,7 +2157,7 @@ export const skatteverketExtension: Extension = {
|
||||
|
||||
const rows = data ?? []
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const { booked, overdue, upcoming } = splitTransactions(rows, today)
|
||||
const { booked, overdue, upcoming, ignored } = splitTransactions(rows, today)
|
||||
|
||||
// Enrich obokförda rader with a single-best-candidate suggestion.
|
||||
// Only attached when there's exactly one match: avoids the UI
|
||||
@@ -2186,6 +2192,8 @@ export const skatteverketExtension: Extension = {
|
||||
booked: bookedEnriched,
|
||||
overdue,
|
||||
upcoming,
|
||||
ignored_count: ignored.length,
|
||||
...(includeIgnored ? { ignored } : {}),
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -2297,6 +2305,7 @@ export const skatteverketExtension: Extension = {
|
||||
const status =
|
||||
err.code === 'TRANSACTION_NOT_FOUND' ? 404
|
||||
: err.code === 'ALREADY_BOOKED' ? 409
|
||||
: err.code === 'ROW_IGNORED' ? 409
|
||||
: err.code === 'PERIOD_LOCKED' ? 423
|
||||
: err.code === 'NO_COUNTER_ACCOUNT' ? 422
|
||||
: 400
|
||||
@@ -2385,6 +2394,7 @@ export const skatteverketExtension: Extension = {
|
||||
err.code === 'TRANSACTION_NOT_FOUND' ? 404
|
||||
: err.code === 'ENTRY_NOT_FOUND' ? 404
|
||||
: err.code === 'ALREADY_BOOKED' ? 409
|
||||
: err.code === 'ROW_IGNORED' ? 409
|
||||
: err.code === 'ENTRY_ALREADY_LINKED' ? 409
|
||||
: 422
|
||||
return NextResponse.json({ error: err.message, code: err.code }, { status })
|
||||
@@ -2393,6 +2403,83 @@ export const skatteverketExtension: Extension = {
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── Ignorera / återställ en rad ───────────────────────────────────
|
||||
// Skattekonto rows mirror Skatteverket's ledger and are never deleted;
|
||||
// is_ignored is the sanctioned way to take an unbookable row off the
|
||||
// work list (e.g. an EF row predating the first räkenskapsår). Fully
|
||||
// reversible: PATCH { is_ignored: false } restores it. A booked row
|
||||
// cannot be ignored (409; the DB CHECK enforces the same invariant).
|
||||
{
|
||||
method: 'PATCH',
|
||||
path: '/skattekonto/transaktioner/:id/ignore',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) {
|
||||
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||
}
|
||||
const url = new URL(request.url)
|
||||
const id = url.searchParams.get('_id')
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Saknar transaktions-id' }, { status: 400 })
|
||||
}
|
||||
let body: { is_ignored?: unknown }
|
||||
try {
|
||||
body = (await request.json()) as { is_ignored?: unknown }
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Ogiltig request body' }, { status: 400 })
|
||||
}
|
||||
if (typeof body.is_ignored !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: 'is_ignored måste vara true eller false' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const isIgnored = body.is_ignored
|
||||
|
||||
const { data: tx, error: txError } = await ctx.supabase
|
||||
.from('skattekonto_transactions')
|
||||
.select('id, journal_entry_id, is_ignored')
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.single()
|
||||
if (txError || !tx) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Skattekonto-transaktionen hittades inte.' },
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
if (isIgnored && tx.journal_entry_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Transaktionen är redan bokförd och kan inte ignoreras.' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
|
||||
// Conditional write: `.is('journal_entry_id', null)` on the ignore
|
||||
// path makes a concurrent booking race lose cleanly (zero rows
|
||||
// updated -> 409) instead of tripping the DB CHECK.
|
||||
let update = ctx.supabase
|
||||
.from('skattekonto_transactions')
|
||||
.update({ is_ignored: isIgnored })
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
if (isIgnored) {
|
||||
update = update.is('journal_entry_id', null)
|
||||
}
|
||||
const { data: updated, error: updateError } = await update.select('id')
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||||
}
|
||||
if (!updated || updated.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Transaktionen är redan bokförd och kan inte ignoreras.' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { ok: true, is_ignored: isIgnored } })
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
eventHandlers: [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { commitEntry, createDraftEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { getEarliestFiscalPeriodStart } from '@/lib/core/bookkeeping/period-service'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
import { SKATTEKONTO_ACCOUNT } from '@/lib/skatteverket/manual-verifikat-prefill'
|
||||
import { getPrimary as getPrimaryCashAccount } from '@/lib/cash-accounts/service'
|
||||
@@ -55,6 +56,9 @@ interface SkattekontoRuleRow {
|
||||
counter_account_ef: string | null
|
||||
label: string | null
|
||||
active: boolean
|
||||
/** Rule only applies to an enskild firma when the company is
|
||||
* employer_registered (migration 20260819200100). An AB is unaffected. */
|
||||
requires_employer: boolean
|
||||
}
|
||||
|
||||
export class SkattekontoBookingError extends Error {
|
||||
@@ -66,6 +70,7 @@ export class SkattekontoBookingError extends Error {
|
||||
| 'PERIOD_LOCKED'
|
||||
| 'ALREADY_BOOKED'
|
||||
| 'NOT_SETTLED'
|
||||
| 'ROW_IGNORED'
|
||||
| 'TRANSACTION_NOT_FOUND',
|
||||
) {
|
||||
super(message)
|
||||
@@ -111,7 +116,24 @@ const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/
|
||||
// ship override metadata we don't need to the application layer (SOC 2
|
||||
// CC6.1, ISO 27001 A.8.5 least-privilege data access).
|
||||
const SKATTEKONTO_RULE_COLUMNS =
|
||||
'id, priority, pattern, amount_min, amount_max, company_type, counter_account, counter_account_ef, label, active'
|
||||
'id, priority, pattern, amount_min, amount_max, company_type, counter_account, counter_account_ef, label, active, requires_employer'
|
||||
|
||||
/**
|
||||
* Result of walking the rules for one transaktionstext.
|
||||
*
|
||||
* 'employer_gated' means a rule DID match the text, but it is flagged
|
||||
* requires_employer and the company is an enskild firma without
|
||||
* employer_registered: "Avdragen skatt" on the owner's personal skattekonto
|
||||
* is then almost always A-skatt an outside employer withheld from the
|
||||
* owner's private salary, not the firm's payroll liability, so auto-booking
|
||||
* 2710 would fabricate a liability. Matching stops (no weaker rule may
|
||||
* catch the text); the caller surfaces the NO_COUNTER_ACCOUNT path with a
|
||||
* distinct hint.
|
||||
*/
|
||||
type RuleMatchOutcome =
|
||||
| { kind: 'match'; match: CounterAccountMatch }
|
||||
| { kind: 'employer_gated' }
|
||||
| { kind: 'none' }
|
||||
|
||||
/**
|
||||
* Pure core matcher shared by every suggestion/booking path: walk the
|
||||
@@ -124,7 +146,8 @@ function matchSkattekontoRule(
|
||||
transaktionstext: string,
|
||||
entityType: EntityType,
|
||||
belopp?: number,
|
||||
): CounterAccountMatch | null {
|
||||
employerRegistered = false,
|
||||
): RuleMatchOutcome {
|
||||
const normalized = transaktionstext.toLowerCase()
|
||||
const absBelopp = belopp === undefined ? null : Math.abs(belopp)
|
||||
|
||||
@@ -145,18 +168,32 @@ function matchSkattekontoRule(
|
||||
|
||||
if (!patterns.some(p => normalized.includes(p))) continue
|
||||
|
||||
// Employer gate: the rule matched, but for a non-employer EF the safe
|
||||
// outcome is NO counter account (manual review or ignore), never 2710.
|
||||
// An AB, and an employer-registered EF, keep the rule's account.
|
||||
if (
|
||||
rule.requires_employer &&
|
||||
entityType === 'enskild_firma' &&
|
||||
!employerRegistered
|
||||
) {
|
||||
return { kind: 'employer_gated' }
|
||||
}
|
||||
|
||||
const account =
|
||||
entityType === 'enskild_firma' && rule.counter_account_ef
|
||||
? rule.counter_account_ef
|
||||
: rule.counter_account
|
||||
|
||||
return {
|
||||
account,
|
||||
label: rule.label ?? transaktionstext,
|
||||
kind: 'match',
|
||||
match: {
|
||||
account,
|
||||
label: rule.label ?? transaktionstext,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
return { kind: 'none' }
|
||||
}
|
||||
|
||||
/** The active rules for a company (system seeds + overrides), priority order. */
|
||||
@@ -182,6 +219,9 @@ export async function guessCounterAccount(
|
||||
transaktionstext: string,
|
||||
entityType: EntityType,
|
||||
belopp?: number,
|
||||
// Whether (company_settings.employer_registered ?? pays_salaries) is true.
|
||||
// Defaults false: the safe side of the requires_employer gate for an EF.
|
||||
employerRegistered = false,
|
||||
): Promise<CounterAccountMatch | null> {
|
||||
if (!SAFE_ID_PATTERN.test(companyId)) {
|
||||
// The caller is supposed to pass a validated company id (from
|
||||
@@ -194,8 +234,15 @@ export async function guessCounterAccount(
|
||||
const rules = await fetchSkattekontoRules(supabase, companyId)
|
||||
if (rules.length === 0) return null
|
||||
|
||||
const match = matchSkattekontoRule(rules, transaktionstext, entityType, belopp)
|
||||
if (!match) return null
|
||||
const outcome = matchSkattekontoRule(
|
||||
rules,
|
||||
transaktionstext,
|
||||
entityType,
|
||||
belopp,
|
||||
employerRegistered,
|
||||
)
|
||||
if (outcome.kind !== 'match') return null
|
||||
const match = outcome.match
|
||||
|
||||
return {
|
||||
...match,
|
||||
@@ -216,6 +263,9 @@ export async function guessCounterAccount(
|
||||
export interface SkattekontoRuleContext {
|
||||
rules: SkattekontoRuleRow[]
|
||||
entityType: EntityType
|
||||
/** (company_settings.employer_registered ?? pays_salaries) === true.
|
||||
* Drives the requires_employer rule gate for enskild firma. */
|
||||
employerRegistered: boolean
|
||||
resolvePrimarySek: () => Promise<string>
|
||||
}
|
||||
|
||||
@@ -229,6 +279,7 @@ export async function loadRuleContext(
|
||||
return {
|
||||
rules: [],
|
||||
entityType: 'aktiebolag',
|
||||
employerRegistered: false,
|
||||
resolvePrimarySek: async () => PRIMARY_SEK_FALLBACK,
|
||||
}
|
||||
}
|
||||
@@ -237,7 +288,7 @@ export async function loadRuleContext(
|
||||
fetchSkattekontoRules(supabase, companyId),
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type')
|
||||
.select('entity_type, employer_registered, pays_salaries')
|
||||
.eq('company_id', companyId)
|
||||
.single(),
|
||||
])
|
||||
@@ -246,6 +297,13 @@ export async function loadRuleContext(
|
||||
return {
|
||||
rules,
|
||||
entityType: (settingsResult.data?.entity_type as EntityType) ?? 'aktiebolag',
|
||||
// Same signal as lib/tax/deadline-config.ts: employer_registered is the
|
||||
// explicit attestation (nullable, 20260717151000; null = never attested)
|
||||
// and falls back to the onboarding pays_salaries answer. An explicit
|
||||
// false wins over pays_salaries; only a resolved true opens the gate.
|
||||
employerRegistered:
|
||||
(settingsResult.data?.employer_registered ??
|
||||
settingsResult.data?.pays_salaries) === true,
|
||||
resolvePrimarySek: async () => {
|
||||
if (primary === null) {
|
||||
primary = await resolvePrimarySekAccount(supabase, companyId)
|
||||
@@ -276,7 +334,12 @@ export async function attachBookingSuggestions<
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
rows: T[],
|
||||
): Promise<(T & { booking_suggestion: SkattekontoBookingSuggestion | null })[]> {
|
||||
): Promise<
|
||||
(T & {
|
||||
booking_suggestion: SkattekontoBookingSuggestion | null
|
||||
booking_gate?: 'requires_employer' | null
|
||||
})[]
|
||||
> {
|
||||
const needsSuggestion = (row: T) =>
|
||||
!row.journal_entry_id && row.status !== 'upcoming'
|
||||
|
||||
@@ -285,7 +348,10 @@ export async function attachBookingSuggestions<
|
||||
}
|
||||
|
||||
const ctx = await loadRuleContext(supabase, companyId)
|
||||
const enriched: (T & { booking_suggestion: SkattekontoBookingSuggestion | null })[] = []
|
||||
const enriched: (T & {
|
||||
booking_suggestion: SkattekontoBookingSuggestion | null
|
||||
booking_gate?: 'requires_employer' | null
|
||||
})[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
if (!needsSuggestion(row)) {
|
||||
@@ -293,16 +359,24 @@ export async function attachBookingSuggestions<
|
||||
continue
|
||||
}
|
||||
|
||||
const match = matchSkattekontoRule(
|
||||
const outcome = matchSkattekontoRule(
|
||||
ctx.rules,
|
||||
row.transaktionstext,
|
||||
ctx.entityType,
|
||||
Number(row.belopp_skatteverket),
|
||||
ctx.employerRegistered,
|
||||
)
|
||||
if (!match) {
|
||||
if (outcome.kind === 'employer_gated') {
|
||||
// No suggestion, but tell the UI WHY so it can show the "likely your
|
||||
// private A-skatt" hint instead of the generic "no rule matched".
|
||||
enriched.push({ ...row, booking_suggestion: null, booking_gate: 'requires_employer' })
|
||||
continue
|
||||
}
|
||||
if (outcome.kind === 'none') {
|
||||
enriched.push({ ...row, booking_suggestion: null })
|
||||
continue
|
||||
}
|
||||
const match = outcome.match
|
||||
|
||||
const account =
|
||||
match.account === PRIMARY_SEK_SENTINEL
|
||||
@@ -370,6 +444,17 @@ export async function bokforSkattekontoTransaction(
|
||||
)
|
||||
}
|
||||
|
||||
// An ignored row is triaged-and-excluded by an explicit user decision:
|
||||
// booking it silently would contradict that decision. The gate sits before
|
||||
// any draft is created so no orphan draft is left behind. Unignore (fully
|
||||
// reversible) is the way back onto the work list.
|
||||
if (tx.is_ignored) {
|
||||
throw new SkattekontoBookingError(
|
||||
'Transaktionen är ignorerad. Återställ den innan du bokför.',
|
||||
'ROW_IGNORED',
|
||||
)
|
||||
}
|
||||
|
||||
if (options?.requireSettled && tx.status !== 'booked') {
|
||||
throw new SkattekontoBookingError(
|
||||
'Händelsen är inte genomförd hos Skatteverket ännu och kan inte bokföras.',
|
||||
@@ -378,42 +463,35 @@ export async function bokforSkattekontoTransaction(
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
const entityType: EntityType =
|
||||
(settings?.entity_type as EntityType) ?? 'aktiebolag'
|
||||
|
||||
guess = await guessCounterAccount(
|
||||
supabase,
|
||||
companyId,
|
||||
tx.transaktionstext,
|
||||
entityType,
|
||||
Number(tx.belopp_skatteverket),
|
||||
// AB/EF-specific accounts; requires_employer gates payroll rules for a
|
||||
// non-employer enskild firma). The per-call path builds the same context
|
||||
// the batch path preloads, so both share one matcher and one gate.
|
||||
const ctx = ruleContext ?? (await loadRuleContext(supabase, companyId))
|
||||
const outcome = matchSkattekontoRule(
|
||||
ctx.rules,
|
||||
tx.transaktionstext,
|
||||
ctx.entityType,
|
||||
Number(tx.belopp_skatteverket),
|
||||
ctx.employerRegistered,
|
||||
)
|
||||
if (outcome.kind === 'employer_gated') {
|
||||
throw new SkattekontoBookingError(
|
||||
`"${tx.transaktionstext}" på en enskild firmas skattekonto är oftast skatt som en ` +
|
||||
'arbetsgivare dragit från din privata lön och ingen affärshändelse i firman: ' +
|
||||
'bokför manuellt om den ändå gäller firmans anställda, annars kan raden ignoreras.',
|
||||
'NO_COUNTER_ACCOUNT',
|
||||
)
|
||||
}
|
||||
const guess: CounterAccountMatch | null =
|
||||
outcome.kind === 'match'
|
||||
? {
|
||||
...outcome.match,
|
||||
account:
|
||||
outcome.match.account === PRIMARY_SEK_SENTINEL
|
||||
? await ctx.resolvePrimarySek()
|
||||
: outcome.match.account,
|
||||
}
|
||||
: null
|
||||
if (!guess) {
|
||||
throw new SkattekontoBookingError(
|
||||
`Vi kunde inte gissa motkontot för "${tx.transaktionstext}". Skapa verifikatet manuellt.`,
|
||||
@@ -428,6 +506,23 @@ export async function bokforSkattekontoTransaction(
|
||||
tx.transaktionsdatum,
|
||||
)
|
||||
if (!fiscalPeriodId) {
|
||||
// Distinguish "predates the company's bookkeeping entirely" from an
|
||||
// ordinary locked/missing period: for an enskild firma the personal
|
||||
// skattekonto history predates the company, and telling the user to
|
||||
// "unlock the period" for a date no period will ever cover is a dead
|
||||
// end. The ignore action is the way out for those rows.
|
||||
const earliestPeriodStart = await getEarliestFiscalPeriodStart(
|
||||
supabase,
|
||||
companyId,
|
||||
)
|
||||
if (earliestPeriodStart && tx.transaktionsdatum < earliestPeriodStart) {
|
||||
throw new SkattekontoBookingError(
|
||||
`Datumet ${tx.transaktionsdatum} ligger före företagets första räkenskapsår ` +
|
||||
`(som börjar ${earliestPeriodStart}). Händelsen gäller sannolikt tiden före ` +
|
||||
'bokföringens start och kan ignoreras.',
|
||||
'PERIOD_LOCKED',
|
||||
)
|
||||
}
|
||||
throw new SkattekontoBookingError(
|
||||
`Datumet ${tx.transaktionsdatum} ligger i en låst eller saknad räkenskapsperiod. ` +
|
||||
'Lås upp perioden eller hoppa över raden.',
|
||||
|
||||
@@ -4,6 +4,10 @@ export interface SkattekontoBuckets<T extends StoredSkattekontoTransaction> {
|
||||
booked: T[]
|
||||
overdue: T[]
|
||||
upcoming: T[]
|
||||
/** Rows the user explicitly ignored (is_ignored = true). Excluded from the
|
||||
* work buckets above; surfaced as a count line so they never disappear
|
||||
* silently (same anti-silent-disappearance ethos as /transactions). */
|
||||
ignored: T[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -14,6 +18,9 @@ export interface SkattekontoBuckets<T extends StoredSkattekontoTransaction> {
|
||||
* those into a separate "Förfallna" bucket here. Stored `status` keeps
|
||||
* mirroring SKV.
|
||||
*
|
||||
* Ignored rows (is_ignored) leave the work buckets entirely and land in
|
||||
* `ignored`, regardless of status: an ignored row is triaged-and-excluded.
|
||||
*
|
||||
* `today` is an ISO date string ('YYYY-MM-DD'). Lexicographic compare on
|
||||
* ISO dates is chronological.
|
||||
*/
|
||||
@@ -24,8 +31,13 @@ export function splitTransactions<T extends StoredSkattekontoTransaction>(
|
||||
const booked: T[] = []
|
||||
const overdue: T[] = []
|
||||
const upcoming: T[] = []
|
||||
const ignored: T[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.is_ignored) {
|
||||
ignored.push(row)
|
||||
continue
|
||||
}
|
||||
if (row.status === 'booked') {
|
||||
booked.push(row)
|
||||
continue
|
||||
@@ -38,5 +50,5 @@ export function splitTransactions<T extends StoredSkattekontoTransaction>(
|
||||
}
|
||||
}
|
||||
|
||||
return { booked, overdue, upcoming }
|
||||
return { booked, overdue, upcoming, ignored }
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export class SkattekontoMatchError extends Error {
|
||||
public readonly code:
|
||||
| 'TRANSACTION_NOT_FOUND'
|
||||
| 'ALREADY_BOOKED'
|
||||
| 'ROW_IGNORED'
|
||||
| 'ENTRY_NOT_FOUND'
|
||||
| 'ENTRY_ALREADY_LINKED'
|
||||
| 'INVALID_CANDIDATE',
|
||||
@@ -608,6 +609,16 @@ export async function matchSkattekontoToEntry(
|
||||
)
|
||||
}
|
||||
|
||||
// Same gate as bokforSkattekontoTransaction: an ignored row was explicitly
|
||||
// triaged off the work list, so linking it to a verifikat must first be
|
||||
// preceded by an explicit unignore.
|
||||
if (tx.is_ignored) {
|
||||
throw new SkattekontoMatchError(
|
||||
'Transaktionen är ignorerad. Återställ den innan du bokför.',
|
||||
'ROW_IGNORED',
|
||||
)
|
||||
}
|
||||
|
||||
const { data: entry, error: entryError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { eventBus } from '@/lib/events/bus'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { formatRedovisare } from '@/lib/skatteverket/format'
|
||||
import { computeDedupKey, contentSignature } from '@/lib/skatteverket/skattekonto-dedup'
|
||||
import { getEarliestFiscalPeriodStart } from '@/lib/core/bookkeeping/period-service'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { settleAgiTaxPayments } from './agi-tax-settlement'
|
||||
import { getSaldo, getTransaktioner } from './skattekonto-client'
|
||||
@@ -21,6 +22,30 @@ const log = createLogger('skattekonto-sync')
|
||||
const BALANCE_SNAPSHOT_KEY = 'skattekonto_balance_snapshot'
|
||||
const LAST_SYNCED_AT_KEY = 'skattekonto_last_synced_at'
|
||||
|
||||
/** SKV's default lookback for tidigare transaktioner when no datumFrom is sent. */
|
||||
const SKV_DEFAULT_WINDOW_DAYS = 555
|
||||
|
||||
function isoDateDaysAgo(days: number): string {
|
||||
const d = new Date()
|
||||
d.setUTCDate(d.getUTCDate() - days)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp the bookkeeping-start lower bound to SKV's own window.
|
||||
*
|
||||
* Sending a datumFrom OLDER than the 555-day default would silently widen
|
||||
* the fetch past what an unbounded call returns, and anything older than
|
||||
* ~915 days is rejected outright (felkod 2). In both cases omitting the
|
||||
* parameter is identical to what we actually want (the default window), so
|
||||
* a bound is only sent when it is strictly inside the default window.
|
||||
*/
|
||||
function clampDatumFrom(earliestPeriodStart: string | null): string | undefined {
|
||||
if (!earliestPeriodStart) return undefined
|
||||
const defaultFrom = isoDateDaysAgo(SKV_DEFAULT_WINDOW_DAYS)
|
||||
return earliestPeriodStart > defaultFrom ? earliestPeriodStart : undefined
|
||||
}
|
||||
|
||||
export interface SkattekontoSyncResult {
|
||||
/** Number of new or status-promoted booked rows */
|
||||
booked: number
|
||||
@@ -66,10 +91,12 @@ async function resolveOmfragad(
|
||||
*/
|
||||
// file_import_id is excluded from the upsert payload on purpose: when the
|
||||
// sync takes over a file-imported row (see below) the provenance link back
|
||||
// to the uploaded file should survive the conflict-update.
|
||||
// to the uploaded file should survive the conflict-update. is_ignored is
|
||||
// likewise excluded: it is a user decision, and a nightly sync must never
|
||||
// silently un-ignore a row via the conflict-update.
|
||||
type SyncRow = Omit<
|
||||
StoredSkattekontoTransaction,
|
||||
'id' | 'imported_at' | 'updated_at' | 'journal_entry_id' | 'file_import_id'
|
||||
'id' | 'imported_at' | 'updated_at' | 'journal_entry_id' | 'file_import_id' | 'is_ignored'
|
||||
>
|
||||
|
||||
function bookedToRow(companyId: string, tx: SkatteverketBookedTransaction): SyncRow {
|
||||
@@ -128,12 +155,27 @@ export async function syncSkattekonto(
|
||||
): Promise<SkattekontoSyncResult> {
|
||||
const omfragad = await resolveOmfragad(ctx.supabase, ctx.companyId)
|
||||
|
||||
// Lower-bound the transaction fetch at the company's bookkeeping start.
|
||||
// Without datumFrom, SKV defaults to ~555 days back, which for an enskild
|
||||
// firma (whose skattekonto is the owner's PERSONAL account) imports private
|
||||
// pre-company rows that can never be booked (no fiscal period covers them).
|
||||
// Applied uniformly to EF and AB, but clamped to SKV's window (see
|
||||
// clampDatumFrom): a company whose bookkeeping started more than 555 days
|
||||
// ago gets no datumFrom at all, because sending one would either widen the
|
||||
// window past the default or (past ~915 days) fail the whole sync with
|
||||
// felkod 2. No fiscal period yet (brand-new company) -> no bound either.
|
||||
const earliestPeriodStart = await getEarliestFiscalPeriodStart(
|
||||
ctx.supabase,
|
||||
ctx.companyId,
|
||||
)
|
||||
const datumFrom = clampDatumFrom(earliestPeriodStart)
|
||||
|
||||
let saldo: SkatteverketSaldoResponse
|
||||
let transaktioner: Awaited<ReturnType<typeof getTransaktioner>>
|
||||
try {
|
||||
;[saldo, transaktioner] = await Promise.all([
|
||||
getSaldo(auth, omfragad),
|
||||
getTransaktioner(auth, omfragad),
|
||||
getTransaktioner(auth, omfragad, datumFrom),
|
||||
])
|
||||
} catch (err) {
|
||||
if (err instanceof SkatteverketAuthError) {
|
||||
|
||||
@@ -136,6 +136,30 @@ export async function countUnbookedInPeriod(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The company's earliest fiscal_periods.period_start (ISO date), or null when
|
||||
* no fiscal period exists yet (brand-new company before onboarding created
|
||||
* one). This is the company's "bookkeeping starts here" boundary: external
|
||||
* mirrors (e.g. the skattekonto sync) use it as a lower fetch bound, and
|
||||
* booking flows use it to tell "row predates the first rakenskapsar" apart
|
||||
* from an ordinary locked period.
|
||||
*/
|
||||
export async function getEarliestFiscalPeriodStart(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<string | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_start')
|
||||
.eq('company_id', companyId)
|
||||
.order('period_start', { ascending: true })
|
||||
.limit(1)
|
||||
|
||||
if (error || !data || data.length === 0) return null
|
||||
const start = (data[0] as { period_start?: unknown }).period_start
|
||||
return typeof start === 'string' ? start : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock a fiscal period: prevents new journal entries from being posted.
|
||||
* Requires: period exists, belongs to company, not already locked/closed.
|
||||
|
||||
@@ -3397,6 +3397,7 @@
|
||||
"book_anyway": "Post anyway",
|
||||
"book": "Post",
|
||||
"match_to_voucher": "Match to voucher",
|
||||
"ignore": "Ignore",
|
||||
"suggestion_line": "Posts to {account}",
|
||||
"select_row": "Select tax account event"
|
||||
},
|
||||
@@ -3408,6 +3409,7 @@
|
||||
"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.",
|
||||
"ef_private_tax_hint": "Deducted tax on a sole trader's tax account is usually tax an employer withheld from your personal salary and not a business event of the company, so the row can be ignored or booked manually.",
|
||||
"confirm_book": "Post",
|
||||
"open_draft": "Open as draft",
|
||||
"match_cta": "Match to voucher",
|
||||
@@ -5770,6 +5772,14 @@
|
||||
"skv_err_not_settled": "not settled yet",
|
||||
"skv_err_commit_failed": "draft created, not posted",
|
||||
"skv_err_other": "failed",
|
||||
"skv_ignore_confirm_title": "Ignore this tax account event?",
|
||||
"skv_ignore_confirm_body": "{text}, {amount} ({date}) is hidden from the list without being booked. Use for events that are not business events of the company, for example rows from before the first fiscal year. You can restore it under Skattekonto at any time.",
|
||||
"skv_ignore_confirm_cta": "Ignore",
|
||||
"skv_ignore_confirm_cancel": "Cancel",
|
||||
"skv_ignore_failed": "Could not ignore the event",
|
||||
"skv_ignore_undo_failed": "Could not restore the event",
|
||||
"skv_ignored_title": "Event ignored",
|
||||
"skv_ignore_undo": "Undo",
|
||||
"mode_all": "All",
|
||||
"mode_review": "Review suggestions",
|
||||
"review_attn_body": "{count, plural, one {# historical bank transaction matches} other {# historical bank transactions match}} your imported bookkeeping.",
|
||||
@@ -7591,6 +7601,20 @@
|
||||
"action_match": "Match",
|
||||
"action_book": "Book",
|
||||
"action_booking": "Booking…",
|
||||
"ignore_action": "Ignore",
|
||||
"action_unignore": "Restore",
|
||||
"band_ignored": "Ignored",
|
||||
"ignored_count_line": "{count, plural, =1 {1 ignored event} other {# ignored events}}.",
|
||||
"show_ignored": "Show",
|
||||
"hide_ignored": "Hide",
|
||||
"ignore_confirm_title": "Ignore this tax account event?",
|
||||
"ignore_confirm_body": "{text}, {amount} ({date}) is hidden from the list without being booked. Use for events that are not business events of the company, for example rows from before the first fiscal year. You can restore it at any time.",
|
||||
"ignore_confirm_cta": "Ignore",
|
||||
"ignore_confirm_cancel": "Cancel",
|
||||
"ignore_failed": "Could not ignore the event",
|
||||
"unignore_failed": "Could not restore the event",
|
||||
"ignored_toast_title": "Event ignored",
|
||||
"ignored_undo": "Undo",
|
||||
"booked_toast_title": "Posted",
|
||||
"booked_toast_description": "Voucher {voucher} was created.",
|
||||
"booked_toast_show": "Show voucher",
|
||||
|
||||
@@ -3397,6 +3397,7 @@
|
||||
"book_anyway": "Bokför ändå",
|
||||
"book": "Bokför",
|
||||
"match_to_voucher": "Matcha mot verifikat",
|
||||
"ignore": "Ignorera",
|
||||
"suggestion_line": "Bokförs mot {account}",
|
||||
"select_row": "Välj skattekontohändelse"
|
||||
},
|
||||
@@ -3408,6 +3409,7 @@
|
||||
"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.",
|
||||
"ef_private_tax_hint": "Avdragen skatt på en enskild firmas skattekonto är oftast skatt som en arbetsgivare dragit från din privata lön och ingen affärshändelse i firman, så raden kan ignoreras eller bokföras manuellt.",
|
||||
"confirm_book": "Bokför",
|
||||
"open_draft": "Öppna som utkast",
|
||||
"match_cta": "Matcha mot verifikat",
|
||||
@@ -5770,6 +5772,14 @@
|
||||
"skv_err_not_settled": "ännu inte genomförd",
|
||||
"skv_err_commit_failed": "utkast skapat, ej bokfört",
|
||||
"skv_err_other": "misslyckades",
|
||||
"skv_ignore_confirm_title": "Ignorera skattekontohändelsen?",
|
||||
"skv_ignore_confirm_body": "{text}, {amount} ({date}) döljs från listan utan att bokföras. Använd för händelser som inte är firmans affärshändelser, till exempel rader från tiden före första räkenskapsåret. Du kan återställa den under Skattekonto när som helst.",
|
||||
"skv_ignore_confirm_cta": "Ignorera",
|
||||
"skv_ignore_confirm_cancel": "Avbryt",
|
||||
"skv_ignore_failed": "Kunde inte ignorera händelsen",
|
||||
"skv_ignore_undo_failed": "Kunde inte återställa händelsen",
|
||||
"skv_ignored_title": "Händelsen ignorerad",
|
||||
"skv_ignore_undo": "Ångra",
|
||||
"mode_all": "Alla",
|
||||
"mode_review": "Granska förslag",
|
||||
"review_attn_body": "{count, plural, one {# historisk banktransaktion matchar} other {# historiska banktransaktioner matchar}} din importerade bokföring.",
|
||||
@@ -7591,6 +7601,20 @@
|
||||
"action_match": "Matcha",
|
||||
"action_book": "Bokför",
|
||||
"action_booking": "Bokför…",
|
||||
"ignore_action": "Ignorera",
|
||||
"action_unignore": "Återställ",
|
||||
"band_ignored": "Ignorerade",
|
||||
"ignored_count_line": "{count, plural, =1 {1 ignorerad händelse} other {# ignorerade händelser}}.",
|
||||
"show_ignored": "Visa",
|
||||
"hide_ignored": "Dölj",
|
||||
"ignore_confirm_title": "Ignorera skattekontohändelsen?",
|
||||
"ignore_confirm_body": "{text}, {amount} ({date}) döljs från listan utan att bokföras. Använd för händelser som inte är firmans affärshändelser, till exempel rader från tiden före första räkenskapsåret. Du kan återställa den när som helst.",
|
||||
"ignore_confirm_cta": "Ignorera",
|
||||
"ignore_confirm_cancel": "Avbryt",
|
||||
"ignore_failed": "Kunde inte ignorera händelsen",
|
||||
"unignore_failed": "Kunde inte återställa händelsen",
|
||||
"ignored_toast_title": "Händelsen ignorerad",
|
||||
"ignored_undo": "Ångra",
|
||||
"booked_toast_title": "Bokförd",
|
||||
"booked_toast_description": "Verifikat {voucher} skapades.",
|
||||
"booked_toast_show": "Visa verifikat",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Migration: skattekonto_transactions.is_ignored
|
||||
--
|
||||
-- Skattekonto rows are an external mirror of Skatteverket's ledger: they must
|
||||
-- never be deleted (same policy as imported bank transactions, see
|
||||
-- lib/transactions/origin.ts), but until now they also had no way OUT of the
|
||||
-- work list. A row that predates the company's first fiscal year (typical for
|
||||
-- an enskild firma, whose personal skattekonto history predates the company)
|
||||
-- can never be booked: findFiscalPeriod refuses (PERIOD_LOCKED), no route
|
||||
-- deletes, and the row was visible forever.
|
||||
--
|
||||
-- This copies the transactions.is_ignored precedent (20260529190000):
|
||||
-- is_ignored = true -> "hide from the skattekonto work list, never going to
|
||||
-- book it". Fully reversible (is_ignored = false); no journal entry was
|
||||
-- ever created, so there is nothing to reverse.
|
||||
--
|
||||
-- RLS: the existing company-scoped UPDATE policy on skattekonto_transactions
|
||||
-- already covers this column (USING/WITH CHECK on company membership), so no
|
||||
-- policy change is needed.
|
||||
|
||||
ALTER TABLE public.skattekonto_transactions
|
||||
ADD COLUMN IF NOT EXISTS is_ignored BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- An ignored row has no journal entry. Without this constraint a
|
||||
-- book -> ignore race could leave the row both booked AND hidden from the
|
||||
-- list, silently diverging the 1630 ledger from Skatteverket's mirror.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'skattekonto_transactions_is_ignored_no_journal_entry'
|
||||
AND conrelid = 'public.skattekonto_transactions'::regclass
|
||||
) THEN
|
||||
ALTER TABLE public.skattekonto_transactions
|
||||
ADD CONSTRAINT skattekonto_transactions_is_ignored_no_journal_entry
|
||||
CHECK (is_ignored = false OR journal_entry_id IS NULL);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Partial index: most rows are is_ignored=false; only the small ignored
|
||||
-- slice is looked up by this flag (the "N ignorerade" count line).
|
||||
CREATE INDEX IF NOT EXISTS idx_skattekonto_transactions_is_ignored
|
||||
ON public.skattekonto_transactions (company_id, is_ignored)
|
||||
WHERE is_ignored = true;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Migration: skattekonto_rules.requires_employer
|
||||
--
|
||||
-- The 'avdragen skatt,personalskatt,a-skatt' seed rule maps unconditionally
|
||||
-- to 2710 (Personalskatt) for every entity type. For an aktiebolag that is
|
||||
-- always right: a personalskatt line on the company's skattekonto implies
|
||||
-- payroll. For an enskild firma WITHOUT registered employees it is wrong:
|
||||
-- the EF's skattekonto is the owner's personal account, and "Avdragen skatt"
|
||||
-- there is almost always A-skatt an outside employer withheld from the
|
||||
-- owner's personal salary, not an affarshandelse of the firm. Crediting 2710
|
||||
-- would fabricate a payroll liability the firm never had.
|
||||
--
|
||||
-- The distinguishing signal is dynamic per company (does it actually employ
|
||||
-- anyone?), which the static counter_account_ef column cannot express. So the
|
||||
-- gate is data-driven: rules flagged requires_employer only apply to an
|
||||
-- enskild firma when company_settings.employer_registered is true (the same
|
||||
-- signal that gates AGI reminders, 20260717151000). An AB is unaffected; an
|
||||
-- employer-registered EF keeps 2710. The code gate lives in
|
||||
-- extensions/general/skatteverket/lib/skattekonto-booking.ts.
|
||||
--
|
||||
-- Follows the 20260817120100 precedent: update the NULL-company system seed
|
||||
-- row AND any per-company clones of the same pattern.
|
||||
|
||||
ALTER TABLE public.skattekonto_rules
|
||||
ADD COLUMN IF NOT EXISTS requires_employer BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
UPDATE public.skattekonto_rules
|
||||
SET requires_employer = true
|
||||
WHERE pattern = 'avdragen skatt,personalskatt,a-skatt'
|
||||
AND requires_employer IS DISTINCT FROM true;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -24,6 +24,10 @@ export interface StoredSkattekontoTransaction {
|
||||
belopp_kronofogden: number | null
|
||||
status: 'booked' | 'upcoming'
|
||||
journal_entry_id: string | null
|
||||
/** User's explicit "hide from the work list, never going to book it".
|
||||
* Mirrors transactions.is_ignored; an ignored row never has a
|
||||
* journal_entry_id (DB CHECK, migration 20260819200000). */
|
||||
is_ignored: boolean
|
||||
source: 'api' | 'file_import'
|
||||
file_import_id: string | null
|
||||
imported_at: string
|
||||
@@ -90,6 +94,14 @@ export interface SkattekontoBookingSuggestion {
|
||||
export interface SkattekontoTransactionWithSuggestion extends StoredSkattekontoTransaction {
|
||||
match_suggestion?: SkattekontoMatchSuggestion | null
|
||||
booking_suggestion?: SkattekontoBookingSuggestion | null
|
||||
/**
|
||||
* Why booking_suggestion is null despite a rule matching the text.
|
||||
* 'requires_employer': the matched rule is employer-gated and this is an
|
||||
* enskild firma without employer_registered, so "Avdragen skatt" is most
|
||||
* likely the owner's private A-skatt, not the firm's payroll liability.
|
||||
* The UI shows a distinct hint instead of the generic "no rule matched".
|
||||
*/
|
||||
booking_gate?: 'requires_employer' | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,6 +121,7 @@ export interface SkattekontoBatchRowResult {
|
||||
| 'PERIOD_LOCKED'
|
||||
| 'ALREADY_BOOKED'
|
||||
| 'NOT_SETTLED'
|
||||
| 'ROW_IGNORED'
|
||||
| 'TRANSACTION_NOT_FOUND'
|
||||
| 'COMMIT_FAILED'
|
||||
| 'UNKNOWN'
|
||||
|
||||
Reference in New Issue
Block a user