A user with a September-to-August fiscal year could not sign off 1930: the dialog let them press Signera, the server refused, and the dialog showed "Något gick fel. Försök igen." Three defects, one flow: - signOffAccount judged a bank account over the calendar year from 1 January (the getAccountStatus default) while the page the signer looked at was scoped to the fiscal period. The sign-off now resolves the fiscal period covering through_date and judges from its start, for every caller (dashboard, v1, MCP, pending-operation executor). - The dialog decided whether the "sign anyway" override was needed from the page tile, which can be scoped to a narrower range. It now previews the exact sign-off with dry_run on open and on every date change, and NOT_RECONCILED carries the unexplained amount in details so the warning can name it. - The routes passed the refusal through getErrorMessage(), which did not know the sign-off codes and replaced the Swedish text with its generic fallback. The codes are now in the structured error registry with a thrown_message_sv flag: the mapper passes the thrower's text (dates, amounts) through verbatim and English users get message_en. Claude-Session: https://claude.ai/code/session_01YDH3nqA8QtMA8WKTKZCMsA Signed-off-by: Emil <emilmattsson14@gmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
307 lines
11 KiB
TypeScript
307 lines
11 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 interface SignoffErrorDetails {
|
|
/** NOT_RECONCILED: the unexplained difference the engine saw for the window, so a dialog can name the amount. */
|
|
unexplained_difference?: number | null
|
|
}
|
|
|
|
export class ReconciliationSignoffError extends Error {
|
|
readonly code: SignoffErrorCode
|
|
readonly details: SignoffErrorDetails | null
|
|
constructor(message: string, code: SignoffErrorCode, details: SignoffErrorDetails | null = null) {
|
|
super(message)
|
|
this.name = 'ReconciliationSignoffError'
|
|
this.code = code
|
|
this.details = details
|
|
}
|
|
}
|
|
|
|
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 }
|
|
|
|
/**
|
|
* The start of the fiscal period that covers `throughDate`, or null when no
|
|
* period does (or the read fails). The bank bridge is a period movement, so
|
|
* its lower bound decides the verdict: without this the sign-off judged the
|
|
* calendar year from 1 January, while the page the signer looked at was
|
|
* scoped to the fiscal period. A company with a broken fiscal year (September
|
|
* to August) then saw the dialog allow a sign-off the server refused.
|
|
*/
|
|
async function fiscalPeriodStartFor(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
throughDate: string,
|
|
): Promise<string | null> {
|
|
const { data, error } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('period_start')
|
|
.eq('company_id', companyId)
|
|
.lte('period_start', throughDate)
|
|
.gte('period_end', throughDate)
|
|
.order('period_start', { ascending: false })
|
|
.limit(1)
|
|
.maybeSingle()
|
|
if (error) {
|
|
log.warn('fiscal period lookup failed for sign-off window', {
|
|
companyId,
|
|
throughDate,
|
|
error: error.message,
|
|
})
|
|
return null
|
|
}
|
|
const start = (data as { period_start?: string | null } | null)?.period_start
|
|
return typeof start === 'string' && ISO_DATE_RE.test(start) ? start : null
|
|
}
|
|
|
|
/**
|
|
* 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 runs from the start of the
|
|
// fiscal period that covers the date (the opening balance) to the date. A
|
|
// manual account is a balance and ignores the lower bound.
|
|
const windowFrom = parsed.kind === 'bank' ? await fiscalPeriodStartFor(supabase, companyId, throughDate) : null
|
|
const status: ReconciliationStatus | null = await getAccountStatus(supabase, companyId, accountKey, {
|
|
today,
|
|
windowTo: throughDate,
|
|
...(windowFrom ? { windowFrom } : {}),
|
|
})
|
|
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',
|
|
{ unexplained_difference: unexplained },
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|