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
+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,
}