* 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>
261 lines
9.6 KiB
TypeScript
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
|
|
}
|