fix(reconciliation): judge bank sign-off from the fiscal period start and show the refusal (#2200)

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>
This commit is contained in:
Mattsson
2026-09-02 23:11:42 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent c72b8bcee1
commit 51b68afc87
13 changed files with 390 additions and 25 deletions
+1
View File
@@ -1517,4 +1517,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-02] Accounted Connect direction (founder decision after a fork was resold): the ledger stays AGPL with no licence change and no ee/ split; provider integration logic moves behind the connector (hosted app/api/connect/* today, a separate Connect service later) one upstream at a time on the existing connector keys, ledger and entitlement sync. Rejected: FSL/BSL relicensing (58 public forks keep the AGPL version; DCO-only contributions cannot be relicensed without consent) and closed first-party extensions alone (the code still ships to every self-hoster, and the provider extensions do not honour the Extension API boundary).
[2026-09-02] Peppol connector proxy (#2177) is operation-shaped (lookup/submit/status/evidence/recipient/inbound), NOT a path passthrough like the bank proxy: Qvalia URLs embed Arcim's partner and account numbers, the account is shared by every hosted company and every instance so reads must be scoped to what the caller owns, and Qvalia's inbound "read" endpoint marks documents read for the whole account. Ownership is bound to (key, company_ref); participants a key may publish are recorded on the key at issuance (connector_keys.peppol_participants) because the hosted side cannot otherwise know which organisations an instance legitimately hosts. Inbound is served from the hosted archive, never by calling Qvalia on the instance's behalf.
[2026-09-02] The connector wire contract is an MIT package (packages/connect-contract, @accounted/connect-contract) consumed in-repo from source through a tsconfig/vitest alias (#2179), and check:guards ratchets the set of files naming a provider API host (#2178): the open repo keeps the contract and the manual file paths, either side of the connection can be implemented outside it, and the grandfathered provider-host set may only shrink. Declined a NOT VALID + later VALIDATE pair for the ledger service CHECK: connector_connections has zero prod rows until keys are issued.
[2026-09-02] Sign-off refusals: registered the ReconciliationSignoffError codes in structured-errors with a new thrown_message_sv flag instead of returning err.message from the routes: check:guards forbids raw caught-error messages in user-visible sinks, and the registry keeps the codes discoverable for agents while the dialog still gets the runtime text (dates, amounts).
[2026-09-02] Nyckeltal "Resultat per månad" shows the exact per-month figures as an always-on list under the bars (#2198), not behind an "Anpassa" toggle: a preference would touch the type, the PUT schema, the strict preferences-body validator, the dialog and its tests for a switch nobody turns off. Per-bar compact labels are conditional on a glyph-width fit rule and fall back to the single latest label, so they never overlap. Left alone: the monthly path counts only posted entries while the year-total path also counts reversed originals (pinned as intended in tests/pg/kpi-report-aggregates-rpc.pg.test.ts), so a same-year storno makes the sum of months differ from Nettoresultat; visible as numbers now, founder call whether to align the two.
@@ -41,8 +41,10 @@ export const GET = withRouteContext<{ params: Promise<{ accountKey: string }> }>
* POST /api/reconciliation/accounts/{accountKey}/signoff
*
* "Markera som avstämd t.o.m. <datum>". Body { through_date, note?, force?,
* dry_run? }. Refused (400 + code) unless the account is reconciled through
* the date, or force + note is given.
* dry_run? }. Refused (400 + code, plus details.unexplained_difference on
* NOT_RECONCILED) unless the account is reconciled through the date, or
* force + note is given. The dialog sends dry_run first so what it shows is
* exactly what the real call will judge.
*/
export const POST = withRouteContext<{ params: Promise<{ accountKey: string }> }>(
'reconciliation.accounts.signoff.create',
@@ -82,7 +84,15 @@ export const POST = withRouteContext<{ params: Promise<{ accountKey: string }> }
} catch (err) {
if (err instanceof ReconciliationSignoffError) {
const status = err.code === 'SIGNOFF_NOT_FOUND' ? 404 : err.code === 'SIGNOFF_RACE' ? 409 : 400
return NextResponse.json({ error: getErrorMessage(err), code: err.code }, { status })
// The refusal text reaches the user as written (the codes are
// registered with thrown_message_sv; before that the mapper fell
// through to "Något gick fel. Försök igen."). details (the unexplained
// amount on NOT_RECONCILED) lets the dialog name the difference when
// it previews the sign-off with dry_run.
return NextResponse.json(
{ error: getErrorMessage(err), code: err.code, ...(err.details ? { details: err.details } : {}) },
{ status },
)
}
throw err
}
@@ -136,14 +136,26 @@ describe('dashboard sign-off routes', () => {
})
it('POST maps policy refusals to 400 + code, races to 409, and a null result to 404', async () => {
signMock.mockRejectedValueOnce(new ReconciliationSignoffError('oförklarat', 'NOT_RECONCILED'))
signMock.mockRejectedValueOnce(
new ReconciliationSignoffError('oförklarat', 'NOT_RECONCILED', { unexplained_difference: 53717 }),
)
const refused = await signPOST(
createMockRequest('http://localhost/api/reconciliation/accounts/skattekonto/signoff', { method: 'POST', body: { through_date: '2026-07-31' } }),
p({ accountKey: 'skattekonto' }),
)
expect(refused.status).toBe(400)
const refusedBody = (await parseJsonResponse<{ code: string }>(refused)).body
const refusedBody = (await parseJsonResponse<{ code: string; error: string; details?: unknown }>(refused)).body
expect(refusedBody.code).toBe('NOT_RECONCILED')
expect(refusedBody.error).toBe('oförklarat')
// The dialog previews with dry_run and reads the amount from here.
expect(refusedBody.details).toEqual({ unexplained_difference: 53717 })
signMock.mockRejectedValueOnce(new ReconciliationSignoffError('okänt', 'OUTSIDE_UNKNOWN'))
const unknown = await signPOST(
createMockRequest('http://localhost/api/reconciliation/accounts/skattekonto/signoff', { method: 'POST', body: { through_date: '2026-07-31' } }),
p({ accountKey: 'skattekonto' }),
)
expect((await parseJsonResponse<{ details?: unknown }>(unknown)).body.details).toBeUndefined()
signMock.mockRejectedValueOnce(new ReconciliationSignoffError('race', 'SIGNOFF_RACE'))
const raced = await signPOST(
@@ -18,8 +18,8 @@ import { readV1JsonBody } from '@/lib/api/v1/body'
import { AccountKeySchema, ReconciliationSignoffSchema } from '@/lib/reconciliation/schemas'
import { listSignoffs } from '@/lib/reconciliation/signoff-store'
import { ReconciliationSignoffError, signOffAccount } from '@/lib/reconciliation/signoff'
import { ISO_DATE_RE } from '@/lib/invariants'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { ISO_DATE_RE } from '@/lib/invariants'
const SignoffRequest = z.object({
through_date: z.string().regex(ISO_DATE_RE),
@@ -186,7 +186,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; accountKey:
: 'VALIDATION_ERROR'
return v1ErrorResponseFromCode(v1Code, ctx.log, {
requestId: ctx.requestId,
details: { code: err.code, message: getErrorMessage(err) },
details: { code: err.code, message: getErrorMessage(err), ...(err.details ? err.details : {}) },
})
}
return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
+37 -3
View File
@@ -21,7 +21,7 @@ import type {
ReconciliationStatus,
} from '@/lib/reconciliation/schemas'
import type { SkattekontoBatchRowResult, SkattekontoTransactionWithSuggestion } from '@/types/skatteverket'
import { SignoffDialog, type SignoffSubmitInput } from './SignoffDialog'
import { SignoffDialog, type SignoffPreviewResult, type SignoffSubmitInput } from './SignoffDialog'
import { ReconciliationUnderlag } from './ReconciliationUnderlag'
import { MatcherPreview, type MatcherMatch } from './MatcherPreview'
import { InfoTooltip } from '@/components/ui/info-tooltip'
@@ -256,14 +256,47 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window,
}
}
// A policy refusal carries a code and a Swedish reason written for the
// signer; show that reason as-is. The generic mapper does not recognise it
// and used to replace it with a "request contains invalid data" string.
function signoffRefusal(json: Record<string, unknown>, statusCode: number): string {
return typeof json.code === 'string' && typeof json.error === 'string' && json.error.trim()
? json.error
: getUserErrorMessage(json, { statusCode })
}
// The same call with dry_run: the server judges the exact sign-off (its own
// window, from the fiscal period start to the date) and the dialog shows
// that verdict instead of the page tile's number, which is scoped to the
// period or range picked above and can differ.
async function previewSignoff(input: SignoffSubmitInput): Promise<SignoffPreviewResult> {
const res = await fetch(`${base}/signoff`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...input, dry_run: true }),
})
const json = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (res.ok) {
const preview = (json.data as { would_sign?: { unexplained_difference?: number | null } } | undefined)?.would_sign
return { kind: 'ok', unexplained: preview?.unexplained_difference ?? null }
}
const code = typeof json.code === 'string' ? json.code : null
if (code === 'NOT_RECONCILED') {
const details = json.details as { unexplained_difference?: number | null } | undefined
return { kind: 'needs_force', unexplained: details?.unexplained_difference ?? null }
}
if (code === 'OUTSIDE_UNKNOWN') return { kind: 'needs_force', unexplained: null }
return { kind: 'blocked', message: signoffRefusal(json, res.status) }
}
async function submitSignoff(input: SignoffSubmitInput) {
const res = await fetch(`${base}/signoff`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
const json = await res.json().catch(() => ({}))
if (!res.ok) return getUserErrorMessage(json, { statusCode: res.status })
const json = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (!res.ok) return signoffRefusal(json, res.status)
setSignoffOpen(false)
toast({ title: t('toast_signed_off', { date: formatDate(input.through_date) }) })
await refresh()
@@ -805,6 +838,7 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window,
currency={currency}
askExternalBalance={isManual && !specification}
ledgerBalance={status.ledger_balance}
onPreview={previewSignoff}
onSubmit={submitSignoff}
/>
</div>
+74 -12
View File
@@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
@@ -26,6 +26,13 @@ import { roundOre } from '@/lib/money'
* the booked balance is then what needs explaining. The policy lives in
* lib/reconciliation/signoff.ts; this dialog only collects the input and
* shows the server's refusal verbatim.
*
* Whether the override is needed comes from the server, not from the page
* tile: on open and on every date change the dialog previews the exact
* sign-off (dry run), because the server judges its own window (fiscal
* period start to the date) while the tile is scoped to whatever period or
* range the page has picked. Judging from the tile let the signer press
* Signera on a sign-off the server then refused.
*/
export interface SignoffSubmitInput {
through_date: string
@@ -34,6 +41,14 @@ export interface SignoffSubmitInput {
external_balance?: number | null
}
export type SignoffPreviewResult =
/** The server would sign as-is; `unexplained` is what it would record. */
| { kind: 'ok'; unexplained: number | null }
/** The server refuses without the override: an unexplained difference (null = the outside balance is unknown). */
| { kind: 'needs_force'; unexplained: number | null }
/** Refused for a reason the override does not lift (already signed through a later date, date in the future, ...). */
| { kind: 'blocked'; message: string }
interface SignoffDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -42,12 +57,15 @@ interface SignoffDialogProps {
defaultDate: string
/** Latest possible date (today, or the skattekonto snapshot date). */
maxDate: string
/** The page tile's number: the starting point until the preview answers. */
unexplained: number | null
currency: string
/** Ask for the balance per underlag (manual accounts without a system specification). */
askExternalBalance?: boolean
/** The booked balance the stated one is compared with. */
ledgerBalance?: number | null
/** Dry-run the sign-off for a date; the verdict decides whether the override is asked for. */
onPreview?: (input: SignoffSubmitInput) => Promise<SignoffPreviewResult>
/** Returns an error message to show inline, or null on success. */
onSubmit: (input: SignoffSubmitInput) => Promise<string | null>
}
@@ -69,6 +87,7 @@ export function SignoffDialog({
currency,
askExternalBalance = false,
ledgerBalance = null,
onPreview,
onSubmit,
}: SignoffDialogProps) {
const t = useTranslations('reconciliation')
@@ -78,13 +97,15 @@ export function SignoffDialog({
const [external, setExternal] = useState('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
// With a stated balance the difference is against the booked balance;
// without one the engine's number (or "unknown") decides.
const stated = askExternalBalance ? parseAmount(external) : null
const effectiveUnexplained =
stated != null && ledgerBalance != null ? roundOre(ledgerBalance - stated) : unexplained
const needsForce = effectiveUnexplained == null || Math.abs(effectiveUnexplained) >= 0.005
const [preview, setPreview] = useState<SignoffPreviewResult | null>(null)
const [previewing, setPreviewing] = useState(false)
// Only the latest preview may land: a quick date edit fires two requests
// and the slower one must not overwrite the newer verdict.
const previewSeq = useRef(0)
// The parent passes a fresh function each render; reading it through a ref
// keeps the preview effect keyed on the date alone.
const onPreviewRef = useRef(onPreview)
onPreviewRef.current = onPreview
// Reset per opening so a second sign-off does not inherit the last one's
// note or override choice.
@@ -95,10 +116,50 @@ export function SignoffDialog({
setForce(false)
setExternal('')
setError(null)
setPreview(null)
}
}, [open, defaultDate])
const canSubmit = !busy && date.length === 10 && (!needsForce || (force && note.trim().length > 0))
// Ask the server what it would do with this exact date. A manual account
// that takes a stated balance is judged locally against the booked balance
// below, so it is not previewed (the preview would only say "unknown").
useEffect(() => {
const previewFn = onPreviewRef.current
if (!open || !previewFn || askExternalBalance || date.length !== 10) return
const seq = ++previewSeq.current
setPreviewing(true)
previewFn({ through_date: date, note: null, force: false })
.then((result) => {
if (seq === previewSeq.current) setPreview(result)
})
.catch(() => {
// Network failure: fall back to the tile's number rather than block.
if (seq === previewSeq.current) setPreview(null)
})
.finally(() => {
if (seq === previewSeq.current) setPreviewing(false)
})
}, [open, date, askExternalBalance])
// With a stated balance the difference is against the booked balance;
// otherwise the server's preview decides, and until it has answered the
// engine's number from the page tile (or "unknown") stands in.
const stated = askExternalBalance ? parseAmount(external) : null
const blocked = preview?.kind === 'blocked' ? preview.message : null
const previewUnexplained = preview && preview.kind !== 'blocked' ? preview.unexplained : unexplained
const effectiveUnexplained =
stated != null && ledgerBalance != null ? roundOre(ledgerBalance - stated) : previewUnexplained
const needsForce =
preview?.kind === 'ok' && stated == null
? false
: effectiveUnexplained == null || Math.abs(effectiveUnexplained) >= 0.005
const canSubmit =
!busy &&
!previewing &&
blocked === null &&
date.length === 10 &&
(!needsForce || (force && note.trim().length > 0))
async function submit() {
setBusy(true)
@@ -134,6 +195,7 @@ export function SignoffDialog({
onChange={(e) => setDate(e.target.value)}
className="tabular-nums"
/>
{previewing && <p className="text-[12px] text-muted-foreground">{t('signoff_preview_pending')}</p>}
</div>
{askExternalBalance && (
<div className="space-y-1.5">
@@ -153,7 +215,7 @@ export function SignoffDialog({
</p>
</div>
)}
{needsForce && (
{blocked === null && needsForce && (
<div className="space-y-2 rounded-lg bg-warning/10 px-3 py-2.5 text-[13px] text-foreground">
<p>
{effectiveUnexplained == null
@@ -178,9 +240,9 @@ export function SignoffDialog({
rows={3}
/>
</div>
{error && (
{(error ?? blocked) && (
<p role="alert" className="text-[13px] text-destructive">
{error}
{error ?? blocked}
</p>
)}
</div>
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { getErrorMessage } from '../get-error-message'
import { getErrorEntry } from '../structured-errors'
import { ReconciliationSignoffError, type SignoffErrorCode } from '@/lib/reconciliation/signoff'
/**
* The sign-off refusals are composed in Swedish at the throw site (a date, an
* amount). Before these codes were registered, getErrorMessage() fell through
* to its generic fallback and the dialog showed "Något gick fel. Försök igen."
* for a refused sign-off.
*/
const CODES: 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',
]
describe('reconciliation sign-off codes in the error registry', () => {
it('registers every code with a Swedish and an English message', () => {
for (const code of CODES) {
const entry = getErrorEntry(code)
expect(entry, code).toBeDefined()
expect(entry?.message_sv, code).toMatch(/\S/)
expect(entry?.message_en, code).toMatch(/\S/)
expect(entry?.thrown_message_sv, code).toBe(true)
}
})
it('passes the thrown Swedish text through verbatim, runtime detail included', () => {
const err = new ReconciliationSignoffError(
'Skattekontot är hämtat t.o.m. 2026-08-20. Hämta igen innan du stämmer av ett senare datum.',
'NOT_FETCHED_THROUGH',
)
expect(getErrorMessage(err)).toBe(
'Skattekontot är hämtat t.o.m. 2026-08-20. Hämta igen innan du stämmer av ett senare datum.',
)
const refused = new ReconciliationSignoffError(
'Kontot har en oförklarad differens. Koppla eller bokför raderna först, eller signera med en notering.',
'NOT_RECONCILED',
{ unexplained_difference: 53717 },
)
expect(getErrorMessage(refused)).toBe(
'Kontot har en oförklarad differens. Koppla eller bokför raderna först, eller signera med en notering.',
)
expect(getErrorMessage(refused)).not.toMatch(/Något gick fel/)
})
it('gives English users the registry text', () => {
const err = new ReconciliationSignoffError('Kontot har en oförklarad differens.', 'NOT_RECONCILED')
expect(getErrorMessage(err, { locale: 'en' })).toBe(getErrorEntry('NOT_RECONCILED')?.message_en)
})
})
+3 -1
View File
@@ -519,9 +519,11 @@ export function getErrorMessage(
// Known codes without a dynamic branch above (e.g. CANNOT_REVERSE_STORNO)
// carry raw English engine messages: prefer the registry's Swedish
// message so no typed code surfaces English in a Swedish UI.
// A code flagged thrown_message_sv composes its Swedish text at the
// throw site (a date, an amount): that text wins over the static entry.
if (locale === 'sv' && typeof structured.code === 'string' && !isSwedishUserMessage(structured.message)) {
const entry = getErrorEntry(structured.code)
if (entry?.message_sv) return entry.message_sv
if (entry?.message_sv && !entry.thrown_message_sv) return entry.message_sv
}
return structured.message
}
+87
View File
@@ -35,6 +35,14 @@ export interface StructuredErrorEntry {
* locked) MUST stay false: retrying won't change the outcome.
*/
retryable?: boolean
/**
* When true, the thrower composes the Swedish message at runtime (a date,
* an amount) and getErrorMessage() passes that message through verbatim;
* message_sv is only the static fallback for an envelope that carries no
* message. Without this flag a registered code always resolves to
* message_sv, which would drop the runtime detail.
*/
thrown_message_sv?: boolean
}
// ─────────────────────────────────────────────────────────────────
@@ -4316,6 +4324,84 @@ const WEBSHOP_ORDERS: Record<string, StructuredErrorEntry> = {
},
}
// ─────────────────────────────────────────────────────────────────
// Reconciliation sign-off (lib/reconciliation/signoff.ts)
// ─────────────────────────────────────────────────────────────────
// Policy refusals from signOffAccount / reopenSignoff. Shipped as-is on the
// dashboard, v1 and MCP surfaces before this registry knew them, so the code
// names stay. The thrower's Swedish text is the message (thrown_message_sv):
// before that, getErrorMessage() fell through to its generic fallback and the
// user read "Något gick fel. Försök igen." for a refused sign-off.
const RECONCILIATION_SIGNOFF: Record<string, StructuredErrorEntry> = {
INVALID_DATE: {
httpStatus: 400,
message_sv: 'Ogiltigt datum. Ange ÅÅÅÅ-MM-DD.',
message_en: 'Invalid date. Use YYYY-MM-DD.',
thrown_message_sv: true,
},
DATE_IN_FUTURE: {
httpStatus: 400,
message_sv: 'Du kan inte stämma av framåt i tiden.',
message_en: 'The through date cannot be in the future.',
thrown_message_sv: true,
},
NOT_FETCHED_THROUGH: {
httpStatus: 400,
message_sv: 'Skattekontot är inte hämtat t.o.m. det datumet. Hämta igen innan du stämmer av ett senare datum.',
message_en: 'The skattekonto has not been fetched through that date. Fetch it again before signing off a later date.',
thrown_message_sv: true,
},
OUTSIDE_UNKNOWN: {
httpStatus: 400,
message_sv: '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.',
message_en: 'The outside balance is unknown, so the account cannot be reconciled. Fetch it first, or sign with force and a note.',
thrown_message_sv: true,
},
NOT_RECONCILED: {
httpStatus: 400,
message_sv: 'Kontot har en oförklarad differens. Koppla eller bokför raderna först, eller signera med en notering.',
message_en: 'The account has an unexplained difference. Link or book the rows first, or sign with force and a note.',
thrown_message_sv: true,
},
NOTE_REQUIRED: {
httpStatus: 400,
message_sv: 'Skriv en rad om varför du signerar trots att allt inte är förklarat.',
message_en: 'A note is required when signing with force.',
thrown_message_sv: true,
},
ALREADY_SIGNED_OFF: {
httpStatus: 409,
message_sv: 'Kontot är redan avstämt t.o.m. ett senare datum. Öppna den signeringen igen om du vill ändra.',
message_en: 'The account is already signed off through that date or later. Reopen that sign-off to change it.',
thrown_message_sv: true,
},
SIGNOFF_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Signeringen hittades inte.',
message_en: 'The sign-off was not found.',
thrown_message_sv: true,
},
ALREADY_REOPENED: {
httpStatus: 409,
message_sv: 'Signeringen är redan öppnad igen.',
message_en: 'The sign-off is already reopened.',
thrown_message_sv: true,
},
SIGNOFF_RACE: {
httpStatus: 409,
message_sv: 'Kontot signerades precis av någon annan. Ladda om.',
message_en: 'Someone else just changed this sign-off. Reload and try again.',
thrown_message_sv: true,
},
EXTERNAL_BALANCE_NOT_ALLOWED: {
httpStatus: 400,
message_sv: '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.',
message_en: 'The account already has an outside truth (bank, Skatteverket, ledger or calculation). Do not state a balance; sign with a note if something differs.',
thrown_message_sv: true,
},
}
const NODE_SYSTEM: Record<string, StructuredErrorEntry> = {
ECONNREFUSED: NETWORK_TRANSIENT_ENTRY,
ECONNRESET: NETWORK_TRANSIENT_ENTRY,
@@ -4373,6 +4459,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
...ASSETS,
...DIMENSION,
...WEBSHOP_ORDERS,
...RECONCILIATION_SIGNOFF,
...NODE_SYSTEM,
}
@@ -336,6 +336,55 @@ describe('signOffAccount', () => {
).rejects.toMatchObject({ code: 'SIGNOFF_RACE' })
})
it('judges a bank account from the start of the fiscal period that covers the date', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
// A September-to-August company: judging from 1 January would drop the
// opening balance and the autumn movements the page window includes.
enqueue({ data: { period_start: '2025-09-01' } })
statusMock.mockResolvedValue(
status({ account_key: 'bank:11111111-1111-4111-8111-111111111111', kind: 'bank', account_number: '1930', as_of: '2026-09-02T10:00:00Z' }),
)
await signOffAccount(
supabase as never,
COMPANY,
USER,
'bank:11111111-1111-4111-8111-111111111111',
{ through_date: '2026-08-31' },
{ today: '2026-09-02' },
)
expect(statusMock).toHaveBeenCalledWith(supabase, COMPANY, 'bank:11111111-1111-4111-8111-111111111111', {
today: '2026-09-02',
windowFrom: '2025-09-01',
windowTo: '2026-08-31',
})
})
it('leaves the window default alone when no fiscal period covers the date, and never for non-bank keys', async () => {
const { supabase } = createQueuedMockSupabase()
await signOffAccount(
supabase as never,
COMPANY,
USER,
'bank:11111111-1111-4111-8111-111111111111',
{ through_date: '2026-07-31' },
{ today: TODAY },
)
expect(statusMock).toHaveBeenLastCalledWith(supabase, COMPANY, 'bank:11111111-1111-4111-8111-111111111111', {
today: TODAY,
windowTo: '2026-07-31',
})
await signOffAccount(supabase as never, COMPANY, USER, 'skattekonto', { through_date: '2026-07-31' }, { today: TODAY })
expect(statusMock).toHaveBeenLastCalledWith(supabase, COMPANY, 'skattekonto', { today: TODAY, windowTo: '2026-07-31' })
})
it('names the unexplained amount on a NOT_RECONCILED refusal so a dialog can show it', async () => {
const { supabase } = createQueuedMockSupabase()
statusMock.mockResolvedValue(status({ unexplained_difference: 53717, is_reconciled: false }))
await expect(
signOffAccount(supabase as never, COMPANY, USER, 'skattekonto', { through_date: '2026-07-31' }, { today: TODAY }),
).rejects.toMatchObject({ code: 'NOT_RECONCILED', details: { unexplained_difference: 53717 } })
})
it('404s (null) when the status says the account does not exist for the company', async () => {
const { supabase } = createQueuedMockSupabase()
statusMock.mockResolvedValue(null)
+48 -2
View File
@@ -35,12 +35,19 @@ export type SignoffErrorCode =
| '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
constructor(message: string, 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
}
}
@@ -82,6 +89,40 @@ 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
@@ -116,10 +157,14 @@ export async function signOffAccount(
// 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.
// 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)
@@ -160,6 +205,7 @@ export async function signOffAccount(
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 },
)
}
+1
View File
@@ -7770,6 +7770,7 @@
"signoff_date": "Reconciled through",
"signoff_note": "Note",
"signoff_note_placeholder": "Optional. Required when something is unexplained.",
"signoff_preview_pending": "Checking against the ledger…",
"signoff_unexplained_warning": "{amount} is not explained. You can sign anyway with a note, but it shows in the history.",
"signoff_force": "Sign despite the unexplained difference",
"signoff_confirm": "Sign",
+1
View File
@@ -7770,6 +7770,7 @@
"signoff_date": "Avstämd t.o.m.",
"signoff_note": "Notering",
"signoff_note_placeholder": "Valfritt. Obligatoriskt om något är oförklarat.",
"signoff_preview_pending": "Kontrollerar mot bokföringen…",
"signoff_unexplained_warning": "{amount} är inte förklarat. Du kan signera ändå med en notering, men det syns i historiken.",
"signoff_force": "Signera trots oförklarad differens",
"signoff_confirm": "Signera",