fix(import): SIE bulk-delete on service client + provider/reporting/b… (#724)

* fix(import): SIE bulk-delete on service client + provider/reporting/banking fixes

Rebuilt branch onto main as a single commit.

- import: run SIE bulk-delete RPCs on the service client to escape the 8s
  statement_timeout; undo_sie_import now takes an explicit actor (p_user_id)
  so its owner/admin gate works when auth.uid() is NULL on the service
  client (migration 20260624120000) + pg-real regression test
- providers: distinguish missing Fortnox license from expired connection;
  provider_consent_tokens PK regression test
- reports: include unmapped BAS expense groups in the income statement
- enable-banking: reconnect closed/expired bank sessions in place
- bookkeeping: surface linked invoices as underlag on the verifikat view
- scripts: track BL cleanup/diagnostic tooling; data files (*.csv) are
  git-ignored and consentId is now a required arg with no silent default

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(import): add Cache-Control header to journal entry references response

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-14 23:40:26 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent db8983ba9e
commit 43925bc2d3
37 changed files with 2456 additions and 1152 deletions
@@ -181,6 +181,58 @@ class TransactionsFetchError extends Error {
}
}
/**
* Normalized signatures (uppercase, non-alphanumerics stripped) of the
* responses Enable Banking — or the upstream ASPSP via Enable Banking's
* envelope — returns when the PSD2 session can no longer be used: the consent
* was closed, expired, or invalidated bank-side. Spelling and casing vary by
* bank (CLOSED_SESSION, EXPIRED_SESSION, SESSION_EXPIRED / session_expired,
* INVALID_SESSION, SESSION_NOT_FOUND, WRONG_SESSION_STATUS, and the plain
* "Session is closed" message), so we match the whole family. A dead session
* is unrecoverable by retrying — the user must re-authorize.
*/
const SESSION_DEAD_NEEDLES = [
'CLOSEDSESSION', // CLOSED_SESSION
'SESSIONCLOSED', // "session closed"
'SESSIONISCLOSED', // "Session is closed"
'SESSIONEXPIRED', // SESSION_EXPIRED / session_expired
'EXPIREDSESSION', // EXPIRED_SESSION
'INVALIDSESSION', // INVALID_SESSION
'SESSIONNOTFOUND', // SESSION_NOT_FOUND
'WRONGSESSIONSTATUS', // WRONG_SESSION_STATUS
] as const
/**
* Whether a failed transactions response signals a dead PSD2 session (vs. a
* transient error or a config-level auth failure). Only 401/403 with a
* session-expiry signal in the body counts — a bare 401 "Unauthorized" is an
* app-credential problem, not a closed consent, and must NOT be misread as
* "reconnect the bank". The match is deterministic: normalize the body and
* test for any known session-dead needle.
*/
export function isSessionExpiredResponse(status: number, body: string): boolean {
if (status !== 401 && status !== 403) return false
const normalized = body.toUpperCase().replace(/[^A-Z0-9]/g, '')
return SESSION_DEAD_NEEDLES.some(needle => normalized.includes(needle))
}
/**
* Thrown when a transactions fetch fails because the PSD2 session is dead
* (closed/expired/invalid). Distinct from TransactionsFetchError so the sync
* handler can flip the connection to 'expired' and prompt re-authorization
* instead of surfacing a raw error the user can't act on. Carries the status
* and raw body for logging. See isSessionExpiredResponse for the codes covered.
*/
export class SessionExpiredError extends Error {
constructor(
readonly status: number,
readonly body: string
) {
super(`Bank session expired (${status}): ${body}`)
this.name = 'SessionExpiredError'
}
}
// API Helper
async function authenticatedFetch(
@@ -521,6 +573,9 @@ export async function getAccountTransactions(
strategy,
hasContinuationKey: !!continuationKey,
})
if (isSessionExpiredResponse(response.status, body)) {
throw new SessionExpiredError(response.status, body)
}
throw new TransactionsFetchError(response.status, body)
}
@@ -771,6 +826,9 @@ export async function getAllTransactionsWithRaw(
page,
hasContinuationKey: !!continuationKey,
})
if (isSessionExpiredResponse(response.status, body)) {
throw new SessionExpiredError(response.status, body)
}
throw new Error(`Failed to get transactions (${response.status}): ${body}`)
}