Files
accounted/supabase/migrations/20260819200000_skattekonto_transactions_is_ignored.sql
Jakob Wennberg 4cf227001d 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>
2026-08-20 10:22:13 +02:00

46 lines
2.0 KiB
SQL

-- 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';