Files
accounted/types/skatteverket.ts
T
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

136 lines
4.8 KiB
TypeScript

/**
* Skatteverket data shapes used by core UI (the /transactions page lives
* in core, but renders skattekonto rows alongside bank tx). The DB table
* `skattekonto_transactions` lives in core migrations even when the
* skatteverket extension is disabled: the extension only owns the API
* that populates it. Keeping these types in core means components can
* render the table's shape without depending on the extension module.
*
* If skatteverket is disabled, the API returns 503 and the UI just sees
* an empty list: the types remain valid descriptors of the schema.
*/
/** Row shape for the `skattekonto_transactions` table (DB → app). */
export interface StoredSkattekontoTransaction {
id: string
company_id: string
transaktionsidentitet: number | null
dedup_key: string
transaktionsdatum: string
forfallodatum: string | null
ranteberakningsdatum: string | null
transaktionstext: string
belopp_skatteverket: number
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
updated_at: string
}
/** Row shape for the `skattekonto_file_imports` tracking table (DB → app). */
export interface SkattekontoFileImportRecord {
id: string
company_id: string
/** Importing user; null after that user's account is deleted. */
user_id: string | null
filename: string
file_hash: string
file_variant: 'csv' | 'skv'
row_count: number
imported_count: number
duplicate_count: number
promoted_count: number
date_from: string | null
date_to: string | null
closing_saldo: number | null
status: 'pending' | 'processing' | 'completed' | 'failed'
error_message: string | null
created_at: string
updated_at: string
}
/**
* Single best candidate verifikat for an unmatched SKV row. Attached by
* the `/skattekonto/transaktioner` endpoint when exactly one strong match
* exists, so the UI can offer a one-click "koppla till A12" hint instead
* of forcing the user to open the full Matcha-dialog.
*/
export interface SkattekontoMatchSuggestion {
journal_entry_id: string
voucher_number: number | null
voucher_series: string | null
entry_date: string
description: string
status: 'draft' | 'posted' | 'reversed'
}
/**
* The deterministic counter-account a "Bokför" on this row would use,
* resolved from `skattekonto_rules` server-side. Lets the list show what a
* booking will do ("Bokförs mot 8314 Skattefria ränteintäkter") and drives
* bulk-booking eligibility. `account_name` comes from the BAS reference and
* may be null for custom accounts; `label` is the matched rule's label.
*/
export interface SkattekontoBookingSuggestion {
account: string
account_name?: string | null
label?: string | null
}
/**
* API response variant: stored row plus optional auto-match suggestion.
* `match_suggestion` is optional because kommande/upcoming rows skip the
* enrichment step entirely (no journal entry can match a future event).
* `booking_suggestion` is likewise only computed for unbooked genomförda
* rows: undefined means "not computed", null means "no rule matched".
*/
export interface SkattekontoTransactionWithSuggestion extends StoredSkattekontoTransaction {
match_suggestion?: SkattekontoMatchSuggestion | null
booking_suggestion?: SkattekontoBookingSuggestion | null
/**
* 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
}
/**
* Per-row outcome from POST /skattekonto/transaktioner/bokfor-batch.
* `journal_entry_id` is present on success AND on COMMIT_FAILED (the draft
* was created and stays linked; only the commit step failed).
*/
export interface SkattekontoBatchRowResult {
id: string
ok: boolean
journal_entry_id?: string
voucher_number?: number | null
voucher_series?: string | null
error_code?:
| 'NO_COUNTER_ACCOUNT'
| 'NO_FISCAL_PERIOD'
| 'PERIOD_LOCKED'
| 'ALREADY_BOOKED'
| 'NOT_SETTLED'
| 'ROW_IGNORED'
| 'TRANSACTION_NOT_FOUND'
| 'COMMIT_FAILED'
| 'UNKNOWN'
error_message?: string
}
/** Response envelope body for the bokfor-batch endpoint. */
export interface SkattekontoBatchResult {
results: SkattekontoBatchRowResult[]
summary: { total: number; succeeded: number; failed: number }
}