Files
accounted/lib/reconciliation/signoff.ts
T
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

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

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

261 lines
9.6 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { ISO_DATE_RE } from '@/lib/invariants'
import { eventBus } from '@/lib/events/bus'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import { todayIsoUtc } from '@/lib/dates/iso'
import { parseAccountKey, type ReconciliationSignoff, type ReconciliationStatus } from './schemas'
import { getAccountStatus } from './service'
import { getLatestSignoff, getSignoffById, insertSignoff, stampReopen } from './signoff-store'
const log = createLogger('reconciliation/signoff')
/**
* Sign-off ("Markera som avstämd t.o.m. <datum>") and reopen on one
* reconcilable account. The policy layer over signoff-store.ts: a sign-off
* is refused unless the engine says the account is reconciled through that
* date, or the signer explicitly overrides with a note. Writes nothing to
* the ledger; the row is the attestation the overview, the Hem row and an
* auditor read.
*
* Same function for the dashboard routes, the v1 API and the MCP executor,
* so the rule is one rule.
*/
export type SignoffErrorCode =
| 'INVALID_DATE'
| 'DATE_IN_FUTURE'
| 'NOT_FETCHED_THROUGH'
| 'OUTSIDE_UNKNOWN'
| 'NOT_RECONCILED'
| 'NOTE_REQUIRED'
| 'ALREADY_SIGNED_OFF'
| 'SIGNOFF_NOT_FOUND'
| 'ALREADY_REOPENED'
| 'SIGNOFF_RACE'
| 'EXTERNAL_BALANCE_NOT_ALLOWED'
export class ReconciliationSignoffError extends Error {
readonly code: SignoffErrorCode
constructor(message: string, code: SignoffErrorCode) {
super(message)
this.name = 'ReconciliationSignoffError'
this.code = code
}
}
export interface SignoffInput {
/** Inclusive ISO date the account is asserted reconciled through. */
through_date: string
/** Free text; required when signing with an unexplained difference (force). */
note?: string | null
/** Sign even though the engine reports an unexplained difference or an unknown outside balance. Needs a note. */
force?: boolean
/**
* The balance per the signer's underlag, in ledger sign (liabilities
* negative). Only for manual accounts without a system specification; the
* bank, the skattekonto and the reskontra/semesterskuld accounts have their
* own outside truth and refuse it.
*/
external_balance?: number | null
}
export interface SignoffOptions {
dryRun?: boolean
/** ISO date for "today" (tests). */
today?: string
}
export interface SignoffPreview {
account_key: string
through_date: string
external_balance: number | null
ledger_balance: number | null
unexplained_difference: number | null
is_reconciled: boolean
forced: boolean
/** The active sign-off this one supersedes in the rail, when any (earlier through_date). */
previous_through_date: string | null
}
export type SignoffResult =
| { dry_run: true; would_sign: SignoffPreview }
| { dry_run: false; signoff: ReconciliationSignoff }
/**
* Sign one account off through a date. Returns null when the account key does
* not resolve for this company (callers map that to 404); throws
* ReconciliationSignoffError for every policy refusal.
*/
export async function signOffAccount(
supabase: SupabaseClient,
companyId: string,
userId: string,
accountKey: string,
input: SignoffInput,
options: SignoffOptions = {},
): Promise<SignoffResult | null> {
const parsed = parseAccountKey(accountKey)
if (!parsed) return null
const today = options.today ?? todayIsoUtc()
const throughDate = input.through_date
if (!ISO_DATE_RE.test(throughDate) || Number.isNaN(Date.parse(throughDate))) {
throw new ReconciliationSignoffError('Ogiltigt datum. Ange ÅÅÅÅ-MM-DD.', 'INVALID_DATE')
}
if (throughDate > today) {
throw new ReconciliationSignoffError('Du kan inte stämma av framåt i tiden.', 'DATE_IN_FUTURE')
}
const note = input.note?.trim() ? input.note.trim() : null
const force = input.force === true
if (force && !note) {
throw new ReconciliationSignoffError(
'Skriv en rad om varför du signerar trots att allt inte är förklarat.',
'NOTE_REQUIRED',
)
}
// The engine's view through the requested date. The skattekonto bridge is
// anchored at the saldo snapshot, so the date cannot pass it; the bank
// bridge is a period movement, so the window simply ends on the date.
const status: ReconciliationStatus | null = await getAccountStatus(supabase, companyId, accountKey, {
today,
windowTo: throughDate,
})
if (!status) return null
const asOfDate = status.as_of.slice(0, 10)
if (status.kind === 'skattekonto' && throughDate > asOfDate) {
throw new ReconciliationSignoffError(
`Skattekontot är hämtat t.o.m. ${asOfDate}. Hämta igen innan du stämmer av ett senare datum.`,
'NOT_FETCHED_THROUGH',
)
}
// A stated outside balance is the manual adapter's outside truth for the
// date. Where the system keeps a specification (or a feed) the stated
// number would only hide a real difference, so it is refused there.
if (input.external_balance != null) {
if (status.kind !== 'manual' || status.manual?.specification) {
throw new ReconciliationSignoffError(
'Kontot har redan en sanning utanför bokföringen (bank, Skatteverket, reskontra eller beräkning). Ange inget saldo manuellt; signera med en notering om något avviker.',
'EXTERNAL_BALANCE_NOT_ALLOWED',
)
}
const external = roundOre(input.external_balance)
const difference = status.ledger_balance == null ? null : roundOre(status.ledger_balance - external)
status.external_balance = external
status.difference = difference
status.unexplained_difference = difference
status.is_reconciled = difference != null && Math.abs(difference) < 0.005
}
const unexplained = status.unexplained_difference
const reconciled = unexplained != null && Math.abs(unexplained) < 0.005
if (!reconciled && !force) {
if (unexplained == null) {
throw new ReconciliationSignoffError(
'Saldot utanför bokföringen är okänt, så kontot kan inte stämmas av. Hämta det först, eller signera med en notering.',
'OUTSIDE_UNKNOWN',
)
}
throw new ReconciliationSignoffError(
'Kontot har en oförklarad differens. Koppla eller bokför raderna först, eller signera med en notering.',
'NOT_RECONCILED',
)
}
const latest = await getLatestSignoff(supabase, companyId, accountKey)
if (latest && latest.through_date >= throughDate) {
throw new ReconciliationSignoffError(
`Kontot är redan avstämt t.o.m. ${latest.through_date}. Öppna den signeringen igen om du vill ändra.`,
'ALREADY_SIGNED_OFF',
)
}
const preview: SignoffPreview = {
account_key: accountKey,
through_date: throughDate,
external_balance: status.external_balance,
ledger_balance: status.ledger_balance,
unexplained_difference: unexplained,
is_reconciled: reconciled,
forced: !reconciled,
previous_through_date: latest?.through_date ?? null,
}
if (options.dryRun) return { dry_run: true, would_sign: preview }
let signoff: ReconciliationSignoff
try {
signoff = await insertSignoff(supabase, companyId, {
account_key: accountKey,
through_date: throughDate,
external_balance: status.external_balance,
ledger_balance: status.ledger_balance,
unexplained_difference: unexplained,
note,
signed_by: userId,
})
} catch (err) {
// The partial unique index turns a concurrent identical sign-off into a
// constraint error; surface it as a race rather than a 500.
const message = err instanceof Error ? err.message : String(err)
if (/ux_account_reconciliations_active|duplicate key/i.test(message)) {
throw new ReconciliationSignoffError('Kontot signerades precis av någon annan. Ladda om.', 'SIGNOFF_RACE')
}
throw err
}
try {
await eventBus.emit({
type: 'reconciliation.signed_off',
payload: {
accountKey,
signoffId: signoff.id,
throughDate,
unexplainedDifference: unexplained,
userId,
companyId,
},
})
} catch (err) {
log.warn('reconciliation.signed_off emit failed', { companyId, accountKey, error: err instanceof Error ? err.message : String(err) })
}
return { dry_run: false, signoff }
}
/**
* Reopen a sign-off (the undo). The row stays as history with the reopen
* stamp; the account shows its previous active sign-off, if any, afterwards.
*/
export async function reopenSignoff(
supabase: SupabaseClient,
companyId: string,
userId: string,
accountKey: string,
signoffId: string,
input: { reason?: string | null } = {},
): Promise<ReconciliationSignoff | null> {
const parsed = parseAccountKey(accountKey)
if (!parsed) return null
const existing = await getSignoffById(supabase, companyId, accountKey, signoffId)
if (!existing) {
throw new ReconciliationSignoffError('Signeringen hittades inte.', 'SIGNOFF_NOT_FOUND')
}
if (existing.reopened_at) {
throw new ReconciliationSignoffError('Signeringen är redan öppnad igen.', 'ALREADY_REOPENED')
}
const reason = input.reason?.trim() ? input.reason.trim() : null
const updated = await stampReopen(supabase, companyId, signoffId, { reopened_by: userId, reason })
if (!updated) {
throw new ReconciliationSignoffError('Signeringen öppnades precis av någon annan.', 'SIGNOFF_RACE')
}
try {
await eventBus.emit({
type: 'reconciliation.reopened',
payload: { accountKey, signoffId, throughDate: updated.through_date, reason, userId, companyId },
})
} catch (err) {
log.warn('reconciliation.reopened emit failed', { companyId, accountKey, error: err instanceof Error ? err.message : String(err) })
}
return updated
}