* fix(enable-banking): read BBAN from AccountIdentification.other and store it on the account Enable Banking has no top-level `bban` key on AccountIdentification: a Swedish BBAN (clearing + account number) arrives as `other.identification` with `other.scheme_name = 'BBAN'`, or in `all_account_ids`. The client typed `bban?: string` and read `.bban`, so the value was always undefined: no connected account ever carried its clearing + account number, and domestic counterparty accounts on transactions were dropped. Type the identifiers per the OpenAPI spec, add extractBban() and pickAccountIdentifier(), read counterparty identifiers through the scheme list (IBAN, then BBAN/BGNR/PGNR, then anything), and store `bban` on StoredAccount from the OAuth callback. The external_id dedup scope stays IBAN-then-uid and is untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * feat(invoices): named payee accounts on cash_accounts with a default per currency A company had exactly one set of payment instructions per invoice currency (company_settings.invoice_payment_accounts), picked by currency alone. A second SEK bank account, or a second bankgiro number, had nowhere to live. cash_accounts is already the per-company bank-account entity. Migration 20260903150000 adds the payee fields (bankgiro, plusgiro, clearing + account number, BBAN, BIC, Swish, foreign routing) plus invoice_payee, a small invoice_payee_defaults table (one default account per currency; one account may be the default for several currencies, a SEK account with an IBAN is the usual EUR payee), and a SECURITY DEFINER mirror that rewrites the legacy map and the SEK bank columns from the default accounts. Every existing reader (PDF, email, reminders, v1, MCP) keeps working; the three writers that only touched legacy columns (PUT /api/settings, v1 settings, MCP update_company_settings) now write through to the default account, so what an agent sets is what the PDF prints. Peppol PaymentMeans is built from the resolver instead of the raw legacy column. bg_pg is dropped (never read or written; NULL on every prod and staging row). Backfill lands only on existing cash accounts (primary, IBAN match, or the only enabled account in the currency). Entries with no target stay in the map as the resolver fallback and get an attach action in settings. New: POST /api/cash-accounts (manual bank account on the next free 19xx), PATCH /api/cash-accounts/[id] payee fields (owner/admin), GET/PUT /api/cash-accounts/payee-defaults. Settings page rewritten as an account list with per-currency defaults. Behandlingshistorik and the full archive cover the new table and columns. Verified on staging: migration applied (11 defaults landed), mirror trigger observed rewriting company_settings from a payee edit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * feat(invoices): choose which bank account an invoice is paid to, frozen at issue Migration 20260903160000 adds invoices.payment_cash_account_id (FK to cash_accounts, SET NULL) and invoices.payment_details, the payee fields frozen when the account is chosen and refreshed at issue. Resolver: resolveInvoicePaymentAccount / companyWithInvoicePaymentAccount / assertInvoicePaymentAccountForRender take an optional override, and hasRequiredInvoicePaymentAccount reads it from the invoice row, so every surface (PDF, Swish QR, email, reminders, payment confirmation, Peppol, recurring, staged MCP send) prints the frozen payee when one exists and the company default per currency otherwise. Invoices that never chose an account behave exactly as before. Issue paths (mark-sent, send, v1 send, v1 mark-sent, Peppol send, recurring, MCP send and mark-sent) refresh the snapshot from the account as it is at issue; a chosen account that is disabled, un-flagged or unusable for the currency blocks with INVOICE_SEND_PAYMENT_ACCOUNT_INVALID. Writers: dashboard POST/PATCH, v1 create/update and MCP create_invoice accept payment_cash_account_id and validate it against the company's payee accounts (INVOICE_PAYEE_ACCOUNT_INVALID). Credit notes inherit the original's payee; copies carry the choice; preview-pdf renders the chosen account. The editor shows "Betalas till" under the currency when the company has two or more usable payee accounts for that currency. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * feat(invoices): book manual payments on the invoice's chosen bank account Manual mark-paid (dashboard, v1, MCP gnubok_mark_invoice_as_paid) and the booking dialog's proposed lines debited 1930 regardless of which bank account the invoice asked to be paid to. They now resolve the chosen payee account's ledger account (resolveInvoiceSettlementAccount) and fall back to 1930 only when no account was chosen or the row is gone. Bank-transaction matching keeps debiting the account the money landed on and does not filter by the chosen account; between equal-confidence candidates it prefers the invoice that asked to be paid to the landing account. Scores are untouched, so nothing new auto-matches. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * chore(invoices): keep the payload-size and phantom-column ceilings after the payee work Shorten the new gnubok_create_invoice argument description (tools/list payload was 29 bytes over the 60 kB budget), inline the cash-account payee UPDATE/INSERT payloads and the settings select strings as literals so the phantom-column scanner can read their columns, and reuse ACCOUNT_NUMBER_RE instead of a hand-rolled copy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * fix(invoices): harden the payee model after review (admin-only payee columns, separate payee IBAN, company-scoped FK) Review findings from CodeRabbit, Superagent, the Swedish accounting review and three skeptic passes, resolved in one batch: Schema (both migrations are unshipped and edited in place): - cash_accounts.payee_iban: the printed IBAN is its own column. iban stays the bank identity written by every sync and used to re-pair on reconnect, so a sync can no longer rewrite an invoice instruction or resurrect a cleared IBAN. The backfill copies each currency entry verbatim onto the target account (IBAN match first, then primary), so every invoice keeps printing exactly what it printed before; the bank IBAN is never pushed onto invoices that did not carry one. - Payee columns are owner/admin-only at the database (BEFORE trigger, service role exempt): cash_accounts is member-writable for bank sync, and the SECURITY DEFINER mirror would otherwise have let a member rewrite where customers pay. - Revoking an account as payee or disabling it drops its defaults; deleting a default drops that currency from the map and clears the legacy SEK columns (an admin saying "nothing to print" must not keep printing a closed account). The mirror leaves the legacy SEK columns alone when the map has no SEK entry, so legacy-only companies are never wiped by a mirror run for another currency. - Audit and mirror triggers fire on the same column set; anon and authenticated can no longer execute the trigger-only definer functions. - invoices.payment_cash_account_id is a composite same-company FK with SET NULL scoped to the account column. Code: - Only 19xx bank accounts can be payee: PATCH, the defaults PUT (which now also requires enabled, payee-flagged and usable for the currency), resolveInvoicePayeeChoice, and the mark-paid settlement resolver (which also refuses disabled rows and logs every fallback to 1930). - createManualBankAccount excludes every ledger slot any row already holds (findFreeLedgerAccount treats a manual holder as free; this path inserts). - The legacy settings writers (PUT /api/settings, v1, MCP) write through to the account BEFORE updating company_settings and fail the request on error; the account is written before it is adopted as default so the mirror never sees an empty payee. - snapshotInvoicePayee: dry runs no longer persist; a failed snapshot write blocks issue (INVOICE_PAYEE_SNAPSHOT_FAILED). v1 mark-sent/mark-paid projections carry the payee columns; v1 create validates the payee before the dry-run return and echoes it in the preview. - pickAccountIdentifier: supplementary IBAN wins over a primary BBAN, and non-account schemes (card PANs) are never persisted. - Editor shows the payee select for a single usable account with no default; the booking dialog waits for cash accounts before proposing lines; a failed default write no longer hides a created account. - Behandlingshistorik names the account on created/deleted defaults. - Regenerated skills/accounted-api; MCP argument description trimmed under the tools/list payload ceiling. Declined: clearing legacy columns via a forward migration (the mirror now does it on delete); Swedish review's "show the debit account in the mark-paid UI" (the booking dialog already proposes and lets the user edit the debit line); manual ledger collision (UNIQUE exists, and the create path now rejects it with a clear error); Peppol aligning to the PDF value for companies whose legacy column had drifted from the map (the PDF is the customer-facing document; both now agree). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * fix(invoices): read NEW.invoice_payee only on the cash_accounts branch of the mirror trigger trg_mirror_invoice_payee_defaults fires for both tables; plpgsql resolves record fields per expression, so the combined condition failed with "record new has no field invoice_payee" whenever a default row changed, which took down every pg-real case on the payee tables. The revoke/disable check now sits inside its own TG_TABLE_NAME branch. The MCP settings executor test mocks the payee write-through like the settings route test already does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * fix(invoices): keep member disables from revoking payee defaults, gate payee on 1920-1999, fit the MCP payload Cycle 3 of /resolve-pr on #2233. Superagent P1: the SECURITY DEFINER mirror trigger deleted an admin's invoice_payee_defaults rows whenever cash_accounts.enabled flipped to false, and enabled is member-writable (the bank picker's "Synkas ej"), so a member could undo an admin's payee decision. The trigger now drops defaults only on the admin-only invoice_payee true -> false revoke; the mirror trigger's WHEN no longer lists enabled. Disabled accounts stay out of the pick lists and the send gate already refuses an invoice that chose one. Applied to staging as the same function + trigger definition and probed inside a rolled-back block: disable keeps the default and the mirrored bankgiro, revoke clears both. pg-real: the admin-guard test ran three expectations inside one withUserContext transaction; the first raise aborted it and the next statement failed with "current transaction is aborted". One transaction per expectation now, and the member case also flips enabled to prove the column stays member-level. Swedish review: payee eligibility was /^19\d\d$/, which admits 1910 Kassa and the 1911-1919 tills. A customer pays to a giro or bank account, so isBankCashAccount, CreateCashAccountSchema.ledger_account and the PATCH route now require BAS 1920-1999; tests cover 1910 and 1919. Unit tests (3/4): the tools/list payload guard read 60 025, then 60 014 tokens after main merged #2166 and #2163 alongside this branch. The ceiling is not bumped and no read on this surface is a demotion candidate, so gnubok_create_invoice drops payment_cash_account_id; agent-created invoices print the per-currency default and v1 REST plus the editor keep the field. Recorded in DECISIONS.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * chore(migrations): move invoices_payment_cash_account to 20260903183000 after colliding with main's KPI migration origin/main merged 20260903160000_kpi_monthly_include_reversed_originals while this branch held the same version; identical versions abort the Supabase apply. Staging's schema_migrations row was moved to the new version with the file. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * fix(invoices): gate invoice_payee on BAS 1920-1999 at the database, and unblock the typecheck ratchet Cycle 4 of /resolve-pr on #2233, on Emil's go. Swedish review: the 1920-1999 payee rule lived only in the routes. The cash_accounts_payee_admin_only trigger now also refuses invoice_payee on any other ledger (INVOICE_PAYEE_ACCOUNT_INVALID, 23514), whoever writes it, and the backfill only targets giro/bank rows, so a company whose single enabled cash_accounts row is a Stripe clearing account keeps its legacy bankgiro in company_settings instead of landing it on 1686. pg test covers insert and update on 1686 and 1910; the function was applied to staging and probed. Typecheck ratchet: main is red from two merges that landed with failing Checks, and every branch that syncs it inherits the errors. - #2242 added POST(req) calls to the fiscal-periods route test without the route params argument withRouteContext handlers take (25 errors in the file, baseline 23). All 25 calls now pass createMockRouteParams({}). - #2247 made SyncResult.requestedFromDate and historyNarrowed required; the 13 mockedSync results in the enable-banking accounts-route test lacked them. They now carry a fixed date and historyNarrowed: false. Both files' tests pass unchanged in behaviour. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(migrations): move invoices_payment_cash_account to 20260903193000 after colliding with main's party_promotion origin/main merged 20260903183000_party_promotion while this branch held the same version. Staging's schema_migrations row must follow (pending: the Supabase MCP was disconnected at the time of this commit). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1916 lines
85 KiB
TypeScript
1916 lines
85 KiB
TypeScript
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
|
import { NextResponse } from 'next/server'
|
|
import {
|
|
startAuthorization,
|
|
getASPSPs,
|
|
getPreferredAuthMethodDetails,
|
|
deleteSession,
|
|
isSandboxMode,
|
|
SessionExpiredError,
|
|
AspspUnavailableError,
|
|
REAUTH_REQUIRED_MESSAGE,
|
|
SYNC_FAILED_MESSAGE,
|
|
BANK_UNAVAILABLE_MESSAGE,
|
|
type ASPSP,
|
|
} from './lib/api-client'
|
|
import { syncAccountTransactions } from './lib/sync'
|
|
import { triggerConnectionSync } from './lib/trigger-sync'
|
|
import { findReusableSessions, countLiveSiblings } from './lib/session-sharing'
|
|
import {
|
|
runUnattendedReconciliationSweep,
|
|
toSweepSummary,
|
|
} from '@/lib/reconciliation/unattended-sweep'
|
|
import {
|
|
runReconciliation,
|
|
DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
|
|
} from '@/lib/reconciliation/bank-reconciliation'
|
|
import { resolveCashAccountScope } from '@/lib/reconciliation/cash-account-scope'
|
|
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
|
|
import { requireCapability } from '@/lib/entitlements/has-capability'
|
|
import { CAPABILITY } from '@/lib/entitlements/keys'
|
|
import type { StoredAccount } from './types'
|
|
import type { Transaction } from '@/types'
|
|
|
|
// Per-user limits keep one tenant from spamming any single bank handler.
|
|
// Sliding 60s windows: generous enough for legitimate retry, tight enough
|
|
// to prevent UUID probing or status-machine abuse.
|
|
const RATE_LIMIT_ACCOUNTS = { maxRequests: 20, windowMs: 60_000 }
|
|
const RATE_LIMIT_SYNC = { maxRequests: 10, windowMs: 60_000 }
|
|
const RATE_LIMIT_DISCONNECT = { maxRequests: 10, windowMs: 60_000 }
|
|
const RATE_LIMIT_ATTACH = { maxRequests: 10, windowMs: 60_000 }
|
|
|
|
const MAX_ENABLED_UIDS = 50
|
|
|
|
/**
|
|
* Enable Banking (PSD2) extension
|
|
*
|
|
* Provides automatic bank transaction sync via PSD2 open banking.
|
|
* This is an opt-in extension: uncomment the import in loader.ts to activate.
|
|
*
|
|
* Required environment variables:
|
|
* - ENABLE_BANKING_APP_ID
|
|
* - ENABLE_BANKING_PRIVATE_KEY (base64-encoded PEM)
|
|
*
|
|
* Optional:
|
|
* - ENABLE_BANKING_API_URL (default https://api.enablebanking.com; api.tilisy.com = sandbox)
|
|
* - ENABLE_BANKING_PSU_TYPE (default business)
|
|
*/
|
|
export const enableBankingExtension: Extension = {
|
|
id: 'enable-banking',
|
|
name: 'Enable Banking (PSD2)',
|
|
version: '1.0.0',
|
|
|
|
settingsPanel: {
|
|
label: 'Bankintegration (PSD2)',
|
|
path: '/settings/banking',
|
|
},
|
|
|
|
// Registry-resolved services for core callers (core cannot import
|
|
// @/extensions). Contract: lib/bank-sync/trigger-sync-contract.ts.
|
|
services: {
|
|
// Agent-triggered sync behind POST /api/v1/.../bank-connections/{id}/sync.
|
|
triggerConnectionSync,
|
|
},
|
|
|
|
apiRoutes: [
|
|
{
|
|
method: 'GET',
|
|
path: '/banks',
|
|
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
|
const log = ctx?.log ?? console
|
|
try {
|
|
// Detect PSU type from company entity_type
|
|
let psuType: 'personal' | 'business' = 'business'
|
|
if (ctx?.companyId && ctx?.supabase) {
|
|
const { data: company } = await ctx.supabase
|
|
.from('companies')
|
|
.select('entity_type')
|
|
.eq('id', ctx.companyId)
|
|
.single()
|
|
if (company?.entity_type === 'enskild_firma') {
|
|
psuType = 'personal'
|
|
}
|
|
}
|
|
|
|
const aspsps = await getASPSPs('SE', psuType)
|
|
const banks = aspsps.map((aspsp: ASPSP) => ({
|
|
name: aspsp.name,
|
|
country: aspsp.country,
|
|
logo: aspsp.logo,
|
|
bic: aspsp.bic,
|
|
}))
|
|
return NextResponse.json({ banks, psu_type: psuType, sandbox: isSandboxMode() })
|
|
} catch (error) {
|
|
log.error('Error fetching banks:', error)
|
|
return NextResponse.json({
|
|
banks: [
|
|
{ name: 'Nordea', country: 'SE', bic: 'NDEASESS' },
|
|
{ name: 'SEB', country: 'SE', bic: 'ESSESESS' },
|
|
{ name: 'Swedbank', country: 'SE', bic: 'SWEDSESS' },
|
|
{ name: 'Handelsbanken', country: 'SE', bic: 'HANDSESS' },
|
|
],
|
|
sandbox: isSandboxMode(),
|
|
})
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// Live PSD2 sessions the user already holds in their OTHER companies that
|
|
// still have unclaimed accounts. Drives the "reuse this connection" offer:
|
|
// several ASPSPs allow one active AIS session per PSU, so authorizing the
|
|
// same bank again for a second company kills the first company's feed.
|
|
// An empty list is the normal case and renders no offer at all.
|
|
method: 'GET',
|
|
path: '/reusable-sessions',
|
|
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
|
const log = ctx?.log ?? console
|
|
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
if (!ctx?.companyId) {
|
|
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
|
|
}
|
|
|
|
try {
|
|
const sessions = await findReusableSessions(supabase, user.id, ctx.companyId)
|
|
// Never ship session_id to the browser: it is the bearer of the PSD2
|
|
// consent. The client only needs to name the offer and post back the
|
|
// source connection id, which is re-validated server-side on attach.
|
|
return NextResponse.json({
|
|
sessions: sessions.map(s => ({
|
|
connection_id: s.connectionId,
|
|
company_id: s.companyId,
|
|
company_name: s.companyName,
|
|
bank_name: s.bankName,
|
|
consent_expires: s.consentExpires,
|
|
available_account_count: s.availableAccounts.length,
|
|
})),
|
|
})
|
|
} catch (error) {
|
|
log.error('[enable-banking] Failed to list reusable sessions', error)
|
|
return NextResponse.json({ sessions: [] })
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// Attach the ACTIVE company to a session authorized for another of the
|
|
// user's companies. No BankID, no new /auth call, nothing revoked: the
|
|
// new row shares session_id + consent_expires and carries only the
|
|
// accounts no company has claimed. Lands in 'pending_selection' so the
|
|
// existing AccountPickerDialog does the ledger mapping, IBAN-aware.
|
|
method: 'POST',
|
|
path: '/attach',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
const log = ctx?.log ?? console
|
|
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
if (!ctx?.companyId) {
|
|
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
|
|
}
|
|
const companyId = ctx.companyId
|
|
|
|
const blocked = await requireCapability(supabase, companyId, CAPABILITY.bank_sync)
|
|
if (blocked) return blocked
|
|
|
|
const rl = await checkRateLimit({
|
|
prefix: 'enable-banking:attach',
|
|
identifier: user.id,
|
|
...RATE_LIMIT_ATTACH,
|
|
})
|
|
if (!rl.ok) return rl.response!
|
|
|
|
const { connection_id } = await request.json()
|
|
if (!connection_id) {
|
|
return NextResponse.json({ error: 'connection_id is required' }, { status: 400 })
|
|
}
|
|
|
|
try {
|
|
// Re-derive the offer server-side rather than trusting the posted id.
|
|
// findReusableSessions re-checks ownership (same user), that the
|
|
// source is a DIFFERENT company, that its session is active with a
|
|
// live consent, and which accounts are genuinely unclaimed.
|
|
const sessions = await findReusableSessions(supabase, user.id, companyId)
|
|
const source = sessions.find(s => s.connectionId === connection_id)
|
|
if (!source) {
|
|
return NextResponse.json(
|
|
{ error: 'No reusable session available for this connection' },
|
|
{ status: 404 }
|
|
)
|
|
}
|
|
|
|
// A company already syncing this bank must go through reconnect, not
|
|
// attach: a second live row for the same provider would sync the same
|
|
// accounts twice into one set of books.
|
|
const { data: existingForCompany } = await supabase
|
|
.from('bank_connections')
|
|
.select('id')
|
|
.eq('company_id', companyId)
|
|
.eq('provider', source.provider)
|
|
.in('status', ['active', 'pending_selection'])
|
|
.limit(1)
|
|
.maybeSingle()
|
|
|
|
if (existingForCompany) {
|
|
return NextResponse.json(
|
|
{ error: 'This company is already connected to that bank' },
|
|
{ status: 409 }
|
|
)
|
|
}
|
|
|
|
const { data: created, error: insertError } = await supabase
|
|
.from('bank_connections')
|
|
.insert({
|
|
user_id: user.id,
|
|
company_id: companyId,
|
|
provider: source.provider,
|
|
bank_name: source.bankName,
|
|
session_id: source.sessionId,
|
|
psu_type: source.psuType,
|
|
consent_expires: source.consentExpires,
|
|
accounts_data: source.availableAccounts,
|
|
status: 'pending_selection',
|
|
})
|
|
.select('id')
|
|
.single()
|
|
|
|
if (insertError || !created) {
|
|
log.error('[enable-banking] Failed to attach shared session', {
|
|
message: insertError?.message,
|
|
sourceConnectionId: source.connectionId,
|
|
companyId,
|
|
})
|
|
return NextResponse.json({ error: 'Failed to reuse connection' }, { status: 500 })
|
|
}
|
|
|
|
log.info('[enable-banking] Attached company to an existing PSD2 session', {
|
|
connectionId: created.id,
|
|
sourceConnectionId: source.connectionId,
|
|
companyId,
|
|
bankName: source.bankName,
|
|
accountCount: source.availableAccounts.length,
|
|
})
|
|
|
|
// This company gains access to bank data, so it is a consent grant
|
|
// from an audit standpoint even though no new consent was signed
|
|
// (ASVS V16 / GDPR Art.30), same event the callback emits.
|
|
try {
|
|
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
|
|
await emit({
|
|
type: 'bank_connection.consent_granted',
|
|
payload: {
|
|
connectionId: created.id,
|
|
bankName: source.bankName ?? null,
|
|
accountCount: source.availableAccounts.length,
|
|
consentExpiresAt: source.consentExpires ?? null,
|
|
userId: user.id,
|
|
companyId,
|
|
},
|
|
})
|
|
} catch (emitError) {
|
|
log.error('[enable-banking] Failed to emit consent_granted on attach', emitError)
|
|
}
|
|
|
|
return NextResponse.json({
|
|
connection_id: created.id,
|
|
account_count: source.availableAccounts.length,
|
|
})
|
|
} catch (error) {
|
|
log.error('[enable-banking] Attach failed', error)
|
|
return NextResponse.json({ error: 'Failed to reuse connection' }, { status: 500 })
|
|
}
|
|
},
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/connect',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
const log = ctx?.log ?? console
|
|
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
if (!ctx?.companyId) {
|
|
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
|
|
}
|
|
const companyId = ctx.companyId
|
|
|
|
const blocked = await requireCapability(supabase, companyId, CAPABILITY.bank_sync)
|
|
if (blocked) return blocked
|
|
|
|
const { aspsp_name, aspsp_country, psu_type: explicitPsuType, connection_id: reconnectId, force_new: forceNew } = await request.json()
|
|
|
|
// Reconnect mode: re-authorize an EXISTING connection in place (no
|
|
// disconnect required). The aspsp identity falls back to the stored row
|
|
// when the client omits it, so a closed/expired session can be renewed
|
|
// with one click. A fresh connect still needs the bank name + country.
|
|
const isReconnect = !!reconnectId
|
|
if (!isReconnect && (!aspsp_name || !aspsp_country)) {
|
|
return NextResponse.json(
|
|
{ error: 'aspsp_name and aspsp_country are required' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
try {
|
|
// For reconnect, load the existing connection up front (company-scoped)
|
|
// so we can revoke its dead session and reuse its bank identity.
|
|
let existing:
|
|
| { id: string; bank_name: string; provider: string; session_id: string | null; psu_type: string | null }
|
|
| null = null
|
|
if (isReconnect) {
|
|
const { data, error: findErr } = await supabase
|
|
.from('bank_connections')
|
|
.select('id, bank_name, provider, session_id, psu_type')
|
|
.eq('id', reconnectId)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
if (findErr || !data) {
|
|
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
|
|
}
|
|
existing = data
|
|
}
|
|
|
|
// Resolve the aspsp identity. For a reconnect the bank is already
|
|
// known, so derive it authoritatively from the stored row and IGNORE
|
|
// any client-supplied aspsp_name/aspsp_country: the client
|
|
// (BankSyncNowButton) derives the country by string-splitting the
|
|
// provider slug, and trusting that back is needless attack surface
|
|
// (compliance: ASVS V8.2.1/V4.5). A fresh connect has no stored row,
|
|
// so it uses the client values (already required+validated above).
|
|
// The provider slug ends with the country code, e.g. "nordea-se".
|
|
const resolvedAspspName = isReconnect ? existing?.bank_name : aspsp_name
|
|
const resolvedAspspCountry = isReconnect
|
|
? existing?.provider?.split('-').pop()?.toUpperCase() || 'SE'
|
|
: aspsp_country
|
|
if (!resolvedAspspName) {
|
|
return NextResponse.json(
|
|
{ error: 'aspsp_name and aspsp_country are required' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
// Detect PSU type: explicit override > stored type (reconnect) >
|
|
// company entity_type > default 'business'. Reusing the stored type on
|
|
// reconnect is the key fix: re-deriving from entity_type would flip a
|
|
// working 'personal' connection (e.g. an AB owner who signs with a
|
|
// personal Mobile BankID) back to 'business' on every consent renewal,
|
|
// failing at the bank's signing step. The client can still pass an
|
|
// explicit psu_type to switch account type in place.
|
|
let psuType: 'personal' | 'business' = 'business'
|
|
if (explicitPsuType === 'personal' || explicitPsuType === 'business') {
|
|
psuType = explicitPsuType
|
|
} else if (isReconnect && (existing?.psu_type === 'personal' || existing?.psu_type === 'business')) {
|
|
psuType = existing.psu_type
|
|
} else {
|
|
const { data: company } = await supabase
|
|
.from('companies')
|
|
.select('entity_type')
|
|
.eq('id', companyId)
|
|
.single()
|
|
if (company?.entity_type === 'enskild_firma') {
|
|
psuType = 'personal'
|
|
}
|
|
}
|
|
|
|
// Resolve the bank's preferred auth method. Handelsbanken (and some
|
|
// other Swedish banks) expose Mobile BankID only as a hidden DECOUPLED
|
|
// method; without pinning it, Enable Banking defaults to the REDIRECT
|
|
// method, which for Handelsbanken *corporate* PSUs cannot complete
|
|
// with Mobile BankID: the user approves in the app and then hits an
|
|
// error. Only hidden methods applicable to this psu_type are pinned;
|
|
// banks whose decoupled method is visible (e.g. Lunar) get undefined
|
|
// so their own working default flow runs untouched.
|
|
const preferredMethod = await getPreferredAuthMethodDetails(
|
|
resolvedAspspName,
|
|
resolvedAspspCountry,
|
|
psuType
|
|
)
|
|
const authMethod = preferredMethod?.name
|
|
|
|
log.info('[enable-banking] Starting bank connection', {
|
|
user_id: user.id,
|
|
bank: resolvedAspspName,
|
|
country: resolvedAspspCountry,
|
|
psu_type: psuType,
|
|
auth_method: authMethod ?? '(aspsp default)',
|
|
// Chosen method's metadata, so prod logs can verify per-bank pinning
|
|
// behavior after deploy (hidden-only + psu_types selection). A
|
|
// pinned method with no psu_types (the documented Handelsbanken
|
|
// shape) applies to all PSU types and logs '(all)': the
|
|
// '(aspsp default)' sentinel is reserved for the unpinned case,
|
|
// where it would otherwise contradict auth_method on the same line.
|
|
auth_method_approach: preferredMethod?.approach ?? '(aspsp default)',
|
|
auth_method_hidden: preferredMethod?.hidden_method ?? '(aspsp default)',
|
|
auth_method_psu_types: preferredMethod
|
|
? (preferredMethod.psu_types ?? '(all)')
|
|
: '(aspsp default)',
|
|
reconnect: isReconnect,
|
|
})
|
|
|
|
// Reject if there's already a recent pending connection for this user+bank
|
|
// to prevent double-click race conditions that confuse the bank's consent
|
|
// flow. Skipped for reconnect: that deliberately re-authorizes a known row.
|
|
if (!isReconnect) {
|
|
const { data: recentPending } = await supabase
|
|
.from('bank_connections')
|
|
.select('id, created_at')
|
|
.eq('company_id', companyId)
|
|
.eq('bank_name', resolvedAspspName)
|
|
.eq('status', 'pending')
|
|
.order('created_at', { ascending: false })
|
|
.limit(1)
|
|
.maybeSingle()
|
|
|
|
if (recentPending) {
|
|
const pendingAge = Date.now() - new Date(recentPending.created_at).getTime()
|
|
const STALE_THRESHOLD_MS = 30 * 1000 // 30 seconds: long enough to cover the redirect handoff, short enough that an abandoned attempt doesn't block the user
|
|
|
|
if (pendingAge < STALE_THRESHOLD_MS) {
|
|
log.info('[enable-banking] Rejecting duplicate connect: recent pending exists', {
|
|
existing_id: recentPending.id,
|
|
age_ms: pendingAge,
|
|
})
|
|
return NextResponse.json(
|
|
{ error: 'En anslutning pågår redan. Vänta och försök igen.' },
|
|
{ status: 409 }
|
|
)
|
|
}
|
|
}
|
|
|
|
// Sweep failed attempts that never became a live connection:
|
|
// stale 'pending' rows (abandoned redirects past the threshold)
|
|
// and 'error' rows left by earlier denied/failed connects.
|
|
// DELETE instead of marking 'error': a parked 'error' row renders
|
|
// forever as an "Åtgärd krävs" card, so a failed attempt followed
|
|
// by a successful retry showed up as two connections to the same
|
|
// bank. The session_id/accounts_data guards protect established
|
|
// connections (anything that ever completed the callback has
|
|
// accounts_data); never-activated rows have no dependents, and
|
|
// the transactions/cash_accounts FKs are ON DELETE SET NULL.
|
|
const { data: sweptRows } = await supabase
|
|
.from('bank_connections')
|
|
.delete()
|
|
.eq('company_id', companyId)
|
|
.eq('bank_name', resolvedAspspName)
|
|
.in('status', ['pending', 'error'])
|
|
.is('session_id', null)
|
|
.is('accounts_data', null)
|
|
.select('id')
|
|
|
|
if (sweptRows?.length) {
|
|
log.info('[enable-banking] Swept never-activated connection attempts', {
|
|
count: sweptRows.length,
|
|
bank: resolvedAspspName,
|
|
})
|
|
}
|
|
|
|
// A fresh connect while a DEAD-BUT-ESTABLISHED connection to the
|
|
// same bank exists (expired/error/pending_selection) is almost
|
|
// always a renewal that should go through the reconnect path: a
|
|
// second row duplicates the connection and used to strand the old
|
|
// one in "Åtgärd krävs" forever. 409 with the existing id lets
|
|
// the client offer "förnya i stället". An ACTIVE row never
|
|
// triggers the guard: two legitimate logins at the same bank
|
|
// (disjoint account sets, e.g. privat + företag) must remain
|
|
// creatable through the UI, and the callback's supersede leaves
|
|
// non-overlapping account sets alone. force_new stays as the
|
|
// deliberate escape hatch. Runs AFTER the sweep so a
|
|
// never-activated zombie cannot block a legitimate fresh connect.
|
|
if (forceNew !== true) {
|
|
const { data: establishedRow } = await supabase
|
|
.from('bank_connections')
|
|
.select('id, status')
|
|
.eq('company_id', companyId)
|
|
.eq('bank_name', resolvedAspspName)
|
|
.in('status', ['expired', 'error', 'pending_selection'])
|
|
.order('created_at', { ascending: false })
|
|
.limit(1)
|
|
.maybeSingle()
|
|
|
|
if (establishedRow) {
|
|
log.info('[enable-banking] Rejecting fresh connect: dead connection needs renewal', {
|
|
existing_id: establishedRow.id,
|
|
existing_status: establishedRow.status,
|
|
bank: resolvedAspspName,
|
|
})
|
|
return NextResponse.json(
|
|
{
|
|
error: `Du har redan en koppling till ${resolvedAspspName} som behöver förnyas. Använd Förnya samtycke på kopplingen i stället.`,
|
|
code: 'EXISTING_CONNECTION',
|
|
existing_connection_id: establishedRow.id,
|
|
},
|
|
{ status: 409 }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
const redirectUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/extensions/enable-banking/callback`
|
|
|
|
// Generate cryptographic state token for CSRF protection
|
|
const oauthState = crypto.randomUUID()
|
|
|
|
if (isReconnect && existing) {
|
|
// Persist the CSRF state to the existing row BEFORE asking the bank
|
|
// to start an authorization. The OAuth callback locates this row only
|
|
// by oauth_state, so writing it first guarantees that once the bank
|
|
// holds a session bound to this state a matching row already exists.
|
|
// If startAuthorization ran first and this UPDATE then failed, the
|
|
// bank session would be orphaned with no row to complete it.
|
|
//
|
|
// Reuse the SAME row: the callback drives it back to
|
|
// pending_selection → active, so existing transactions and the
|
|
// cash_accounts mirror stay linked. Deliberately keep status
|
|
// 'expired' (NOT 'pending') during the round-trip: this row's
|
|
// created_at is old and the cron deletes stale 'pending' rows after
|
|
// 1h: a reconnect must not be eligible for that. Staying 'expired'
|
|
// also keeps it visible in "Åtgärd krävs" so an abandoned reconnect
|
|
// is recoverable. The callback's oauth_state lookup accepts 'expired'.
|
|
const { error: stateError } = await supabase
|
|
.from('bank_connections')
|
|
.update({
|
|
oauth_state: oauthState,
|
|
status: 'expired',
|
|
// session_id is deliberately KEPT here. The callback needs the
|
|
// session being replaced to carry the renewed consent across to
|
|
// sibling companies sharing it (lib/session-sharing.ts); nulling
|
|
// it made the renewal invisible and left the siblings pointing
|
|
// at a session the bank had just superseded. 'expired' already
|
|
// marks the row dead, and the probe pass skips expired rows.
|
|
error_message: null,
|
|
psu_type: psuType,
|
|
})
|
|
.eq('id', existing.id)
|
|
.eq('company_id', companyId)
|
|
|
|
if (stateError) {
|
|
log.error('[enable-banking] Database error staging reconnect state', {
|
|
errorMessage: stateError.message,
|
|
errorCode: stateError.code,
|
|
connection_id: existing.id,
|
|
user_id: user.id,
|
|
})
|
|
throw new Error(`Failed to update connection: ${stateError.message}`)
|
|
}
|
|
|
|
// Best-effort revoke the dead consent at Enable Banking. A
|
|
// closed/expired session is often already gone, so a failure here is
|
|
// expected and non-fatal: the new authorization supersedes it.
|
|
// Logged at WARN so a systematic revoke failure is visible to
|
|
// monitoring (compliance: ASVS V16 / ISO 27001 A.8.15).
|
|
// Never revoke a session other companies still hold. On a shared
|
|
// consent this revoke would kill their feeds instantly, before the
|
|
// replacement session exists, and permanently if the user abandons
|
|
// the bank flow. The callback moves the siblings onto the new
|
|
// session once it lands; the superseded one lapses on its own.
|
|
let oldSessionShared = false
|
|
if (existing.session_id) {
|
|
const { createServiceClient } = await import('@/lib/supabase/server')
|
|
const serviceSupabase = await createServiceClient()
|
|
oldSessionShared =
|
|
(await countLiveSiblings(serviceSupabase, existing.session_id, existing.id)) > 0
|
|
if (oldSessionShared) {
|
|
log.info('[enable-banking] Old session shared with other companies: not revoking', {
|
|
connection_id: existing.id,
|
|
})
|
|
}
|
|
}
|
|
|
|
if (existing.session_id && !oldSessionShared) {
|
|
try {
|
|
await deleteSession(existing.session_id)
|
|
} catch (revokeError) {
|
|
log.warn('[enable-banking] Old session revoke skipped (likely already expired)', {
|
|
message: revokeError instanceof Error ? revokeError.message : String(revokeError),
|
|
connection_id: existing.id,
|
|
})
|
|
}
|
|
}
|
|
|
|
const { url, authorization_id } = await startAuthorization(
|
|
resolvedAspspName,
|
|
resolvedAspspCountry,
|
|
redirectUrl,
|
|
oauthState,
|
|
psuType,
|
|
authMethod,
|
|
companyId
|
|
)
|
|
|
|
// Record the bank's authorization_id for audit/traceability. The
|
|
// callback matches on oauth_state alone (already persisted above), so
|
|
// a failure here cannot orphan the flow: log and continue.
|
|
const { error: authIdError } = await supabase
|
|
.from('bank_connections')
|
|
.update({ authorization_id })
|
|
.eq('id', existing.id)
|
|
.eq('company_id', companyId)
|
|
|
|
if (authIdError) {
|
|
log.warn('[enable-banking] Could not persist authorization_id on reconnect (non-fatal)', {
|
|
errorMessage: authIdError.message,
|
|
connection_id: existing.id,
|
|
})
|
|
}
|
|
|
|
return NextResponse.json({
|
|
connection_id: existing.id,
|
|
authorization_url: url,
|
|
})
|
|
}
|
|
|
|
// Fresh connect: create the bank authorization, then persist the new
|
|
// row carrying its oauth_state so the callback can find it.
|
|
const { url, authorization_id } = await startAuthorization(
|
|
resolvedAspspName,
|
|
resolvedAspspCountry,
|
|
redirectUrl,
|
|
oauthState,
|
|
psuType,
|
|
authMethod,
|
|
companyId
|
|
)
|
|
|
|
const { data: connection, error } = await supabase
|
|
.from('bank_connections')
|
|
.insert({
|
|
company_id: companyId,
|
|
user_id: user.id,
|
|
provider: `${resolvedAspspName.toLowerCase().replace(/\s+/g, '-')}-${resolvedAspspCountry.toLowerCase()}`,
|
|
bank_name: resolvedAspspName,
|
|
authorization_id,
|
|
oauth_state: oauthState,
|
|
status: 'pending',
|
|
psu_type: psuType,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (error) {
|
|
log.error('[enable-banking] Database error storing connection', {
|
|
errorMessage: error.message,
|
|
errorCode: error.code,
|
|
errorDetails: error.details,
|
|
user_id: user.id,
|
|
bank: resolvedAspspName,
|
|
})
|
|
throw new Error(`Failed to store connection: ${error.message}`)
|
|
}
|
|
|
|
return NextResponse.json({
|
|
connection_id: connection.id,
|
|
authorization_url: url,
|
|
})
|
|
} catch (error) {
|
|
log.error('[enable-banking] Connect handler error', {
|
|
message: error instanceof Error ? error.message : String(error),
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
name: error instanceof Error ? error.name : undefined,
|
|
user_id: user.id,
|
|
aspsp_name,
|
|
aspsp_country,
|
|
})
|
|
return NextResponse.json(
|
|
{ error: error instanceof Error ? error.message : 'Connection failed' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/sync',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
const log = ctx?.log ?? console
|
|
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
if (!ctx?.companyId) {
|
|
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
|
|
}
|
|
const companyId = ctx.companyId
|
|
|
|
const blocked = await requireCapability(supabase, companyId, CAPABILITY.bank_sync)
|
|
if (blocked) return blocked
|
|
|
|
const rl = await checkRateLimit({
|
|
prefix: 'enable-banking:sync',
|
|
identifier: user.id,
|
|
...RATE_LIMIT_SYNC,
|
|
})
|
|
if (!rl.ok) return rl.response!
|
|
|
|
// Default 120 days: most callers (Sync Now button, post-activation gap fill)
|
|
// want a deep refresh, not a 30-day blip. Cron uses 7-day incrementals separately.
|
|
// Bank may still cap at ~90 days per PSD2 without fresh SCA: asking for more
|
|
// is harmless and surfaces whatever the ASPSP is willing to return.
|
|
const { connection_id, days_back: rawDaysBack = 120 } = await request.json()
|
|
const days_back = Math.min(Math.max(1, rawDaysBack), 365)
|
|
|
|
const { data: connection, error: connectionError } = await supabase
|
|
.from('bank_connections')
|
|
.select('*')
|
|
.eq('id', connection_id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (connectionError || !connection) {
|
|
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
|
|
}
|
|
|
|
// 'error' is retryable: a transient upstream failure (e.g. ASPSP_ERROR)
|
|
// parks the connection in 'error' while the PSD2 session is still
|
|
// alive, so the UI's "Försök igen" must be allowed through; a
|
|
// successful sync below restores 'active'. 'expired' stays rejected:
|
|
// a dead consent needs re-authorization via /connect, not a retry.
|
|
if (connection.status !== 'active' && connection.status !== 'error') {
|
|
return NextResponse.json({ error: 'Connection is not active' }, { status: 400 })
|
|
}
|
|
|
|
try {
|
|
// Keep the full list for write-back; sync only the enabled subset.
|
|
// undefined enabled === true for back-compat with rows that predate
|
|
// the per-account toggle.
|
|
const allAccounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
|
|
const accounts = allAccounts.filter(a => a.enabled !== false)
|
|
|
|
if (accounts.length === 0) {
|
|
return NextResponse.json(
|
|
{ error: 'Inga konton är valda för synkning. Öppna "Hantera konton" för att aktivera minst ett.' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
const toDate = new Date().toISOString().split('T')[0]
|
|
const fromDate = new Date(Date.now() - days_back * 24 * 60 * 60 * 1000)
|
|
.toISOString()
|
|
.split('T')[0]
|
|
const syncStartedAt = new Date().toISOString()
|
|
|
|
// Use ctx.services.ingestTransactions when available
|
|
const ingestFn = ctx?.services.ingestTransactions
|
|
|
|
// Detect SIE overlap: skip auto-categorization if the sync range
|
|
// overlaps with a completed SIE import to prevent double-booking.
|
|
// Reconciliation still links bank transactions to existing GL lines.
|
|
const { data: sieOverlap } = await supabase
|
|
.from('sie_imports')
|
|
.select('id')
|
|
.eq('company_id', companyId)
|
|
.eq('status', 'completed')
|
|
.gte('fiscal_year_end', fromDate)
|
|
.limit(1)
|
|
.maybeSingle()
|
|
|
|
// Check if user is a viewer: viewers get rawInsertOnly (no categorization)
|
|
const { data: membership } = await supabase
|
|
.from('company_members')
|
|
.select('role')
|
|
.eq('company_id', companyId)
|
|
.eq('user_id', user.id)
|
|
.maybeSingle()
|
|
const isViewer = membership?.role === 'viewer'
|
|
|
|
// Use strategy=longest when the caller asks for >= 30 days of history
|
|
// (initial sync, manual backfill). Short windows get the implicit
|
|
// default since there's no older data to surface.
|
|
const syncOptions = {
|
|
...(sieOverlap ? { skipAutoCategorization: true } : {}),
|
|
...(isViewer ? { rawInsertOnly: true } : {}),
|
|
...(days_back >= 30 ? { strategy: 'longest' as const } : {}),
|
|
}
|
|
|
|
if (sieOverlap) {
|
|
log.info('SIE import overlap detected: suppressing auto-categorization', {
|
|
sieImportId: sieOverlap.id,
|
|
fromDate,
|
|
toDate,
|
|
})
|
|
}
|
|
const results = await Promise.all(
|
|
accounts.map(account => syncAccountTransactions(
|
|
supabase,
|
|
companyId,
|
|
user.id,
|
|
connection.id,
|
|
account,
|
|
fromDate,
|
|
toDate,
|
|
ingestFn,
|
|
syncOptions
|
|
))
|
|
)
|
|
|
|
const totalImported = results.reduce((sum, r) => sum + r.imported, 0)
|
|
const totalDuplicates = results.reduce((sum, r) => sum + r.duplicates, 0)
|
|
|
|
// When SIE overlap is detected, run a batch reconciliation sweep.
|
|
// The greedy algorithm considers all candidates globally (highest-
|
|
// confidence first) and catches matches the inline per-transaction
|
|
// pass may have missed due to processing order. One scoped run per
|
|
// enabled cash account (issue #1298): the pooled run could persist a
|
|
// cross-account journal_entry_id.
|
|
// Skip for viewers: reconciliation updates transactions which viewers cannot do.
|
|
if (sieOverlap && totalImported > 0 && !isViewer) {
|
|
try {
|
|
const reconResult = await runUnattendedReconciliationSweep(
|
|
supabase,
|
|
companyId,
|
|
user.id,
|
|
{ dateFrom: fromDate, dateTo: toDate },
|
|
)
|
|
// Stamp the outcome so the UI can render "Vi matchade X av Y" and
|
|
// the review surface knows there is something to granska.
|
|
await supabase
|
|
.from('bank_connections')
|
|
.update({
|
|
last_sie_sweep: toSweepSummary(reconResult, {
|
|
dateFrom: fromDate,
|
|
dateTo: toDate,
|
|
}),
|
|
})
|
|
.eq('id', connection.id)
|
|
if (reconResult.applied > 0 || reconResult.skippedBelowThreshold > 0) {
|
|
log.info('Post-sync batch reconciliation matched additional transactions', {
|
|
applied: reconResult.applied,
|
|
skippedBelowThreshold: reconResult.skippedBelowThreshold,
|
|
accounts: reconResult.accounts.map((a) => ({
|
|
accountNumber: a.accountNumber,
|
|
applied: a.applied,
|
|
skippedBelowThreshold: a.skippedBelowThreshold,
|
|
})),
|
|
})
|
|
}
|
|
} catch {
|
|
// Non-critical: transactions remain uncategorized for manual review
|
|
}
|
|
}
|
|
|
|
const syncedAt = new Date().toISOString()
|
|
// Mirror refreshed balances into cash_accounts: the Bank-page source
|
|
// picker and the reconciliation status read that table, and without
|
|
// this the balance there froze at connect time.
|
|
{
|
|
const { updateBalancesFromSync } = await import('@/lib/cash-accounts/service')
|
|
await updateBalancesFromSync(
|
|
supabase,
|
|
companyId,
|
|
connection.id,
|
|
allAccounts.map((a) => ({
|
|
external_uid: a.uid,
|
|
balance: a.balance,
|
|
available_balance: a.available_balance,
|
|
balance_updated_at: a.balance_updated_at,
|
|
})),
|
|
)
|
|
}
|
|
await supabase
|
|
.from('bank_connections')
|
|
.update({
|
|
accounts_data: allAccounts,
|
|
last_synced_at: syncedAt,
|
|
// A successful sync proves the session works again: recover an
|
|
// 'error' connection to 'active' (so the cron picks it up again)
|
|
// and clear any stale failure message from the settings panel.
|
|
...(connection.status === 'error' ? { status: 'active' } : {}),
|
|
...(connection.status === 'error' || connection.error_message
|
|
? { error_message: null }
|
|
: {}),
|
|
})
|
|
.eq('id', connection.id)
|
|
|
|
if (totalImported > 0) {
|
|
const { data: syncedTransactions } = await supabase
|
|
.from('transactions')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
.eq('bank_connection_id', connection.id)
|
|
.gte('created_at', syncStartedAt)
|
|
.order('created_at', { ascending: false })
|
|
.limit(totalImported)
|
|
|
|
if (syncedTransactions && syncedTransactions.length > 0) {
|
|
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
|
|
await emit({
|
|
type: 'transaction.synced',
|
|
payload: { transactions: syncedTransactions as Transaction[], userId: user.id, companyId },
|
|
})
|
|
}
|
|
}
|
|
|
|
// A bank that refused the requested window answered a narrower one;
|
|
// say so instead of reporting a truncated sync as complete (#2202).
|
|
// history_from is the LATEST effective date across the accounts:
|
|
// the date from which every account is complete.
|
|
const narrowedFrom = results
|
|
.filter((r) => r.historyNarrowed && r.effectiveFromDate)
|
|
.map((r) => r.effectiveFromDate as string)
|
|
const historyFrom = narrowedFrom.length > 0
|
|
? narrowedFrom.reduce((a, b) => (a > b ? a : b))
|
|
: null
|
|
|
|
return NextResponse.json({
|
|
imported: totalImported,
|
|
duplicates: totalDuplicates,
|
|
last_synced_at: syncedAt,
|
|
requested_from: fromDate,
|
|
history_narrowed: historyFrom !== null,
|
|
history_from: historyFrom,
|
|
})
|
|
} catch (error) {
|
|
// The bank refused a window it has answered before, or every
|
|
// narrower one: not a dead session and not a broken connection, so
|
|
// the row is left alone (no 'error', no renewal advice) and the
|
|
// client is told to try again later (#2202).
|
|
if (error instanceof AspspUnavailableError) {
|
|
log.warn('[enable-banking] Sync: bank unavailable, narrowing cannot help', {
|
|
reason: error.reason,
|
|
dateFrom: error.dateFrom,
|
|
status: error.status,
|
|
body: error.body,
|
|
user_id: user.id,
|
|
connection_id,
|
|
bankName: connection.bank_name,
|
|
})
|
|
return NextResponse.json(
|
|
{
|
|
error: BANK_UNAVAILABLE_MESSAGE,
|
|
code: 'BANK_UNAVAILABLE',
|
|
retryable: true,
|
|
connection_id: connection.id,
|
|
},
|
|
{ status: 503 }
|
|
)
|
|
}
|
|
|
|
log.error('[enable-banking] Sync handler error', {
|
|
message: error instanceof Error ? error.message : String(error),
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
name: error instanceof Error ? error.name : undefined,
|
|
user_id: user.id,
|
|
connection_id,
|
|
connectionStatus: connection.status,
|
|
bankName: connection.bank_name,
|
|
})
|
|
|
|
// A dead PSD2 session (closed/expired/invalid consent) can't be fixed
|
|
// by retrying: the user must re-authorize. Flip the connection to
|
|
// 'expired' so the UI surfaces the reconnect affordance, and tell the
|
|
// client re-auth is required (reauth_required) so it can offer a
|
|
// one-click "Förnya anslutning" instead of a dead-end error. No
|
|
// disconnect needed: /connect reconnects this same connection in place.
|
|
if (error instanceof SessionExpiredError) {
|
|
await supabase
|
|
.from('bank_connections')
|
|
.update({ status: 'expired', error_message: REAUTH_REQUIRED_MESSAGE })
|
|
.eq('id', connection.id)
|
|
.eq('company_id', companyId)
|
|
return NextResponse.json(
|
|
{
|
|
error: REAUTH_REQUIRED_MESSAGE,
|
|
code: 'SESSION_EXPIRED',
|
|
reauth_required: true,
|
|
connection_id: connection.id,
|
|
},
|
|
{ status: 409 }
|
|
)
|
|
}
|
|
|
|
// Non-session failure: the settings panel toasts this error verbatim
|
|
// and renders error_message on the connection card, so both must be
|
|
// the short Swedish message: the raw Enable Banking body (an English
|
|
// JSON envelope) is already in the server log above. Refresh the
|
|
// stored error_message on rows already in 'error' so a failed retry
|
|
// replaces any stale raw body persisted by older code.
|
|
if (connection.status === 'error') {
|
|
await supabase
|
|
.from('bank_connections')
|
|
.update({ error_message: SYNC_FAILED_MESSAGE })
|
|
.eq('id', connection.id)
|
|
.eq('company_id', companyId)
|
|
}
|
|
|
|
return NextResponse.json({ error: SYNC_FAILED_MESSAGE }, { status: 500 })
|
|
}
|
|
},
|
|
},
|
|
{
|
|
method: 'PATCH',
|
|
path: '/accounts',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
const log = ctx?.log ?? console
|
|
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
// company_id must come from the verified extension context, never fall
|
|
// back to user.id (which is a different identifier dimension and would
|
|
// silently mis-scope queries in multi-tenant deployments).
|
|
if (!ctx?.companyId) {
|
|
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
|
|
}
|
|
const companyId = ctx.companyId
|
|
|
|
const rl = await checkRateLimit({
|
|
prefix: 'enable-banking:accounts',
|
|
identifier: user.id,
|
|
...RATE_LIMIT_ACCOUNTS,
|
|
})
|
|
if (!rl.ok) return rl.response!
|
|
|
|
const body = await request.json().catch(() => null)
|
|
const connection_id = body?.connection_id
|
|
const enabled_uids = body?.enabled_uids
|
|
const rawLookback = body?.initial_lookback_days
|
|
const rawLookbackFromDate = body?.initial_lookback_from_date
|
|
const account_mappings = body?.account_mappings
|
|
|
|
if (typeof connection_id !== 'string' || !connection_id) {
|
|
return NextResponse.json({ error: 'connection_id krävs' }, { status: 400 })
|
|
}
|
|
if (!Array.isArray(enabled_uids) || !enabled_uids.every(u => typeof u === 'string')) {
|
|
return NextResponse.json({ error: 'enabled_uids måste vara en lista av strängar' }, { status: 400 })
|
|
}
|
|
if (enabled_uids.length === 0) {
|
|
return NextResponse.json(
|
|
{ error: 'Välj minst ett konto, eller koppla bort banken om inga konton ska synkas.' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
if (enabled_uids.length > MAX_ENABLED_UIDS) {
|
|
return NextResponse.json(
|
|
{ error: `Max ${MAX_ENABLED_UIDS} konton per anslutning.` },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
// account_mappings is optional. When present, it's an array of
|
|
// { uid, ledger_account } pairs that route per-account ingest to a
|
|
// specific BAS account (e.g. EUR account → 1932 instead of the default 1930).
|
|
// Restrict to BAS class 19 (kassa/bank). Accepting e.g. 3001 (revenue)
|
|
// or 2640 (input VAT) here would silently misroute every bank-side
|
|
// journal-entry leg into a revenue/VAT account, corrupting both the
|
|
// ledger and momsdeklaration. The chart-of-accounts existence check
|
|
// below is necessary but not sufficient: those accounts likely do
|
|
// exist in the chart, but they're the wrong class.
|
|
const BAS_ACCOUNT_PATTERN = /^19[0-9]{2}$/
|
|
type AccountMapping = { uid: string; ledger_account?: string | null }
|
|
let mappings: AccountMapping[] = []
|
|
if (account_mappings !== undefined) {
|
|
if (!Array.isArray(account_mappings)) {
|
|
return NextResponse.json(
|
|
{ error: 'account_mappings måste vara en lista' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
for (const m of account_mappings) {
|
|
if (!m || typeof m !== 'object' || typeof m.uid !== 'string') {
|
|
return NextResponse.json(
|
|
{ error: 'account_mappings: varje post kräver uid (sträng)' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
if (m.ledger_account != null && (typeof m.ledger_account !== 'string' || !BAS_ACCOUNT_PATTERN.test(m.ledger_account))) {
|
|
return NextResponse.json(
|
|
{ error: 'account_mappings: ledger_account måste vara ett BAS-konto i klass 19 (1900-1999)' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
}
|
|
mappings = account_mappings as AccountMapping[]
|
|
}
|
|
|
|
// initial_lookback_days only applies on the pending_selection→active transition.
|
|
// Default 120; clamp to [30, 365]. Ignored for selection edits.
|
|
// PSD2 obliges ASPSPs to ~90 days without fresh SCA, but many Swedish banks
|
|
// return more if asked: request 120 and accept whatever the bank gives back.
|
|
//
|
|
// If the client sent initial_lookback_from_date (preferred for fiscal-year-anchored
|
|
// backfills), derive days from that and reject future dates outright. Otherwise
|
|
// fall back to initial_lookback_days (or the 120-day default).
|
|
if (typeof rawLookbackFromDate === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(rawLookbackFromDate)) {
|
|
const from = new Date(rawLookbackFromDate + 'T00:00:00Z')
|
|
if (!Number.isFinite(from.getTime())) {
|
|
return NextResponse.json(
|
|
{ error: 'initial_lookback_from_date är inte ett giltigt datum.' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
const diffMs = Date.now() - from.getTime()
|
|
const daysFromDate = Math.ceil(diffMs / (24 * 60 * 60 * 1000))
|
|
if (daysFromDate <= 0) {
|
|
return NextResponse.json(
|
|
{ error: 'initial_lookback_from_date måste ligga i det förflutna.' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
}
|
|
const initialLookbackDays = (() => {
|
|
if (typeof rawLookbackFromDate === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(rawLookbackFromDate)) {
|
|
const from = new Date(rawLookbackFromDate + 'T00:00:00Z')
|
|
// Future/invalid dates already rejected above; days > 0 is guaranteed.
|
|
const diffMs = Date.now() - from.getTime()
|
|
const days = Math.ceil(diffMs / (24 * 60 * 60 * 1000))
|
|
return Math.min(365, Math.max(1, days))
|
|
}
|
|
const n = typeof rawLookback === 'number' && Number.isFinite(rawLookback) ? rawLookback : 120
|
|
return Math.min(365, Math.max(30, Math.round(n)))
|
|
})()
|
|
|
|
const { data: connection, error: connectionError } = await supabase
|
|
.from('bank_connections')
|
|
.select('id, status, accounts_data, bank_name')
|
|
.eq('id', connection_id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (connectionError || !connection) {
|
|
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
|
|
}
|
|
|
|
if (connection.status !== 'pending_selection' && connection.status !== 'active') {
|
|
return NextResponse.json(
|
|
{ error: 'Anslutningen kan inte konfigureras i nuvarande status.' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
const existing = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
|
|
const knownUids = new Set(existing.map(a => a.uid))
|
|
const unknownUids = enabled_uids.filter(uid => !knownUids.has(uid))
|
|
if (unknownUids.length > 0) {
|
|
return NextResponse.json(
|
|
{ error: 'Ett eller flera konton kunde inte hittas.', unknown_uids: unknownUids },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
// Mirror the enabled_uids guard for account_mappings: without this,
|
|
// a typo'd UID in the mapping list is silently dropped (the entry
|
|
// never lands in the resulting accounts_data) while the response is
|
|
// still 200, leaving the client to believe the mapping was applied.
|
|
const unknownMappingUids = mappings.map(m => m.uid).filter(uid => !knownUids.has(uid))
|
|
if (unknownMappingUids.length > 0) {
|
|
return NextResponse.json(
|
|
{ error: 'account_mappings innehåller okända konto-uid.', unknown_uids: unknownMappingUids },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
// Verify any provided ledger_account values actually exist in the
|
|
// company's chart of accounts. Prevents users from typing arbitrary
|
|
// numbers via the API and breaking journal entry creation later.
|
|
const requestedLedgerAccounts = mappings
|
|
.map(m => m.ledger_account)
|
|
.filter((a): a is string => typeof a === 'string')
|
|
if (requestedLedgerAccounts.length > 0) {
|
|
const { data: chartRows } = await supabase
|
|
.from('chart_of_accounts')
|
|
.select('account_number')
|
|
.eq('company_id', companyId)
|
|
.in('account_number', requestedLedgerAccounts)
|
|
const validAccountNumbers = new Set((chartRows || []).map(r => r.account_number as string))
|
|
const invalid = requestedLedgerAccounts.filter(a => !validAccountNumbers.has(a))
|
|
if (invalid.length > 0) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Ett eller flera bokföringskonton finns inte i kontoplanen.',
|
|
invalid_accounts: invalid,
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
}
|
|
|
|
const enabledSet = new Set(enabled_uids)
|
|
const mappingsByUid = new Map(mappings.map(m => [m.uid, m]))
|
|
const updatedAccounts: StoredAccount[] = existing.map(a => {
|
|
const mapping = mappingsByUid.get(a.uid)
|
|
const next: StoredAccount = {
|
|
...a,
|
|
enabled: enabledSet.has(a.uid),
|
|
// Apply ledger_account from mapping when present. Explicit null clears it.
|
|
// Absent mapping leaves the existing ledger_account untouched (back-compat
|
|
// with selection-edit calls that don't include account_mappings).
|
|
...(mapping
|
|
? { ledger_account: mapping.ledger_account ?? undefined }
|
|
: {}),
|
|
}
|
|
// Enabling an account is the deliberate takeover the callback's
|
|
// guard flags exist to force: once made, the flags are stale (the
|
|
// account syncs HERE now) and would keep rendering a false
|
|
// "synkas i annat bolag" note in every later picker.
|
|
if (next.enabled) {
|
|
delete next.claimed_by_company_id
|
|
delete next.claimed_by_company_name
|
|
delete next.deselected_elsewhere
|
|
}
|
|
return next
|
|
})
|
|
|
|
// Accounts the callback guard left disabled AND unmirrored (no ledger
|
|
// anywhere) stay that way through a save that does not enable them:
|
|
// allocating a 19xx slot and upserting a cash_accounts row for a
|
|
// still-disabled claimed account would recreate exactly the state the
|
|
// guard exists to prevent (another company's IBAN and name in this
|
|
// company's chart and routing table), one screen after the callback
|
|
// avoided it. Disabled accounts that already have a ledger or a
|
|
// mirrored row keep the existing behavior: their row's enabled flag
|
|
// must still flip off.
|
|
const neverMirroredDisabledUids = new Set<string>()
|
|
|
|
// Resolve the effective mirror ledger for every account up front and
|
|
// reject collisions with a 400 — the mirror pass below writes into
|
|
// cash_accounts, whose UNIQUE (company_id, ledger_account) constraint
|
|
// would otherwise fail per-account and get swallowed, leaving accounts
|
|
// silently unmirrored.
|
|
const { resolvePsd2LedgerAccount, upsertFromPsd2, getRevokedConnectionIds, normalizeIban } =
|
|
await import('@/lib/cash-accounts/service')
|
|
|
|
const { data: companyCashRows } = await supabase
|
|
.from('cash_accounts')
|
|
.select('id, external_uid, bank_connection_id, ledger_account, iban')
|
|
.eq('company_id', companyId)
|
|
const cashRows = (companyCashRows ?? []) as Array<{
|
|
id: string
|
|
external_uid: string | null
|
|
bank_connection_id: string | null
|
|
ledger_account: string
|
|
iban: string | null
|
|
}>
|
|
|
|
// Rows that already represent one of THIS connection's accounts, matched
|
|
// on IBAN rather than on the provider uid. After a re-authorization (new
|
|
// uids) or a fresh connect to an already-connected bank (new connection
|
|
// row), these are the user's own mappings wearing a stale owner: they
|
|
// must not be treated as another bank's territory, and the mirror pass
|
|
// below promotes them in place instead of inserting a second row.
|
|
const rowByIban = new Map<string, { id: string; ledger_account: string }>()
|
|
for (const r of cashRows) {
|
|
const normalized = normalizeIban(r.iban)
|
|
if (normalized && !rowByIban.has(normalized)) {
|
|
rowByIban.set(normalized, { id: r.id, ledger_account: r.ledger_account })
|
|
}
|
|
}
|
|
const reuseRowByUid = new Map<string, { id: string; ledger_account: string }>()
|
|
for (const a of updatedAccounts) {
|
|
const normalized = normalizeIban(a.iban)
|
|
const row = normalized ? rowByIban.get(normalized) : undefined
|
|
if (row) reuseRowByUid.set(a.uid, row)
|
|
}
|
|
const ownIbanRowIds = new Set([...reuseRowByUid.values()].map(r => r.id))
|
|
const existingLedgerByUid = new Map(
|
|
cashRows
|
|
.filter(r => r.bank_connection_id === connection.id && r.external_uid)
|
|
.map(r => [r.external_uid as string, r.ledger_account])
|
|
)
|
|
// Slots held by OTHER connections' PSD2 accounts — an explicit mapping
|
|
// onto one of those would violate the unique constraint. Manual rows
|
|
// are not foreign: upsertFromPsd2 promotes them in place. Rows held by
|
|
// a REVOKED connection are not foreign either: those are orphaned
|
|
// leftovers (disconnect predating the claim release, or a lost demote)
|
|
// and upsertFromPsd2 promotes them in place too. Excluding them here
|
|
// is the self-heal path for companies whose bank was disconnected
|
|
// before disconnect started releasing ledger claims.
|
|
const foreignConnectionIds = [
|
|
...new Set(
|
|
cashRows
|
|
.filter(r => r.bank_connection_id !== null && r.bank_connection_id !== connection.id)
|
|
.map(r => r.bank_connection_id as string)
|
|
),
|
|
]
|
|
const revokedConnectionIds = await getRevokedConnectionIds(
|
|
supabase,
|
|
companyId,
|
|
foreignConnectionIds
|
|
)
|
|
const foreignConnectedLedgers = new Set(
|
|
cashRows
|
|
.filter(
|
|
r =>
|
|
r.bank_connection_id !== null &&
|
|
r.bank_connection_id !== connection.id &&
|
|
!revokedConnectionIds.has(r.bank_connection_id) &&
|
|
// Same IBAN as one of this connection's accounts: the same
|
|
// physical account under a stale owner, not a foreign claim.
|
|
!ownIbanRowIds.has(r.id)
|
|
)
|
|
.map(r => r.ledger_account)
|
|
)
|
|
|
|
const effectiveLedgerByUid = new Map<string, string>()
|
|
const usedLedgers = new Set<string>()
|
|
const duplicateLedgers = new Set<string>()
|
|
const conflictingLedgers = new Set<string>()
|
|
|
|
// First pass: accounts with an explicit or previously mirrored ledger.
|
|
for (const a of updatedAccounts) {
|
|
const ledger = a.ledger_account ?? existingLedgerByUid.get(a.uid)
|
|
if (!ledger) continue
|
|
if (usedLedgers.has(ledger)) duplicateLedgers.add(ledger)
|
|
if (foreignConnectedLedgers.has(ledger) && existingLedgerByUid.get(a.uid) !== ledger) {
|
|
conflictingLedgers.add(ledger)
|
|
}
|
|
usedLedgers.add(ledger)
|
|
effectiveLedgerByUid.set(a.uid, ledger)
|
|
}
|
|
|
|
if (duplicateLedgers.size > 0) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Flera bankkonton kan inte bokföras på samma konto. Välj olika bokföringskonton.',
|
|
duplicate_accounts: [...duplicateLedgers],
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
if (conflictingLedgers.size > 0) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Bokföringskontot används redan av ett bankkonto från en annan bankanslutning.',
|
|
conflicting_accounts: [...conflictingLedgers],
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
// Second pass: allocate a free slot for accounts with no ledger at all
|
|
// (legacy connections mirrored before allocation existed, or mappings
|
|
// explicitly cleared). Allocation failure must never block selection
|
|
// save — fall back to the pre-allocator behavior (1930) and let the
|
|
// mirror pass surface any collision per-account, as before.
|
|
for (const a of updatedAccounts) {
|
|
if (effectiveLedgerByUid.has(a.uid)) continue
|
|
// See neverMirroredDisabledUids above: a disabled account that has
|
|
// never held a ledger or a mirrored row gets neither allocated nor
|
|
// mirrored by this save.
|
|
if (!enabledSet.has(a.uid) && !reuseRowByUid.has(a.uid)) {
|
|
neverMirroredDisabledUids.add(a.uid)
|
|
continue
|
|
}
|
|
let allocated: string | null = null
|
|
try {
|
|
const resolved = await resolvePsd2LedgerAccount(supabase, companyId, user.id, {
|
|
iban: a.iban,
|
|
currency: a.currency,
|
|
accountName: a.name,
|
|
exclude: usedLedgers,
|
|
})
|
|
allocated = resolved?.ledgerAccount ?? null
|
|
} catch (allocErr) {
|
|
log.warn('[enable-banking] ledger allocation failed on selection save', {
|
|
connectionId: connection.id,
|
|
uid: a.uid,
|
|
error: allocErr instanceof Error ? allocErr.message : String(allocErr),
|
|
})
|
|
}
|
|
const ledger = allocated ?? '1930'
|
|
usedLedgers.add(ledger)
|
|
effectiveLedgerByUid.set(a.uid, ledger)
|
|
}
|
|
|
|
// accounts_data mirrors the resolved assignment so the picker
|
|
// pre-fills reality on the next open. Skipped disabled accounts keep
|
|
// no assignment: their slot is only claimed if they are ever enabled.
|
|
for (const a of updatedAccounts) {
|
|
if (neverMirroredDisabledUids.has(a.uid)) continue
|
|
a.ledger_account = effectiveLedgerByUid.get(a.uid)
|
|
}
|
|
|
|
// State machine: only transition pending_selection → active. Once
|
|
// active, the status field is omitted from the update so the same
|
|
// endpoint can be reused to change account selection without
|
|
// re-asserting a transition that has already happened.
|
|
const updatePayload: { accounts_data: StoredAccount[]; status?: 'active' } = {
|
|
accounts_data: updatedAccounts,
|
|
}
|
|
if (connection.status === 'pending_selection') {
|
|
updatePayload.status = 'active'
|
|
}
|
|
|
|
const { error: updateError } = await supabase
|
|
.from('bank_connections')
|
|
.update(updatePayload)
|
|
.eq('id', connection.id)
|
|
|
|
if (updateError) {
|
|
log.error('[enable-banking] Failed to update account selection', {
|
|
errorMessage: updateError.message,
|
|
connectionId: connection.id,
|
|
userId: user.id,
|
|
companyId,
|
|
})
|
|
return NextResponse.json({ error: 'Kunde inte spara kontoval' }, { status: 500 })
|
|
}
|
|
|
|
// Mirror the user's selection into cash_accounts so routing decisions
|
|
// and reconciliation pick up the new enabled state + ledger mapping
|
|
// without reading the JSONB column.
|
|
{
|
|
for (const a of updatedAccounts) {
|
|
// Never-mirrored disabled accounts (callback-guard leftovers the
|
|
// user did not enable) get no cash_accounts row: see above.
|
|
if (neverMirroredDisabledUids.has(a.uid)) continue
|
|
const ledgerAccount = a.ledger_account ?? '1930'
|
|
// Only reuse the IBAN-matched row when it already sits on the
|
|
// ledger we are about to write. If the user deliberately remapped
|
|
// the account to a different BAS number, promoting the old row
|
|
// would move a row out from under the ledger it still holds.
|
|
const reuseRow = reuseRowByUid.get(a.uid)
|
|
const reuseCashAccountId =
|
|
reuseRow && reuseRow.ledger_account === ledgerAccount ? reuseRow.id : null
|
|
try {
|
|
await upsertFromPsd2(supabase, companyId, {
|
|
bank_connection_id: connection.id,
|
|
external_uid: a.uid,
|
|
currency: a.currency,
|
|
ledger_account: ledgerAccount,
|
|
iban: a.iban ?? null,
|
|
bban: a.bban ?? null,
|
|
name: a.name ?? null,
|
|
balance: a.balance ?? null,
|
|
available_balance: a.available_balance ?? null,
|
|
balance_updated_at: a.balance_updated_at ?? null,
|
|
enabled: a.enabled ?? true,
|
|
reuse_cash_account_id: reuseCashAccountId,
|
|
})
|
|
} catch (cashErr) {
|
|
log.error('[enable-banking] Failed to mirror cash_account on selection save', {
|
|
connectionId: connection.id,
|
|
uid: a.uid,
|
|
error: cashErr instanceof Error ? cashErr.message : String(cashErr),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
const newStatus = updatePayload.status ?? connection.status
|
|
log.info('[enable-banking] Account selection saved', {
|
|
connectionId: connection.id,
|
|
enabledCount: enabled_uids.length,
|
|
totalCount: existing.length,
|
|
previousStatus: connection.status,
|
|
newStatus,
|
|
userId: user.id,
|
|
companyId,
|
|
})
|
|
|
|
try {
|
|
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
|
|
await emit({
|
|
type: 'bank_connection.account_selection_changed',
|
|
payload: {
|
|
connectionId: connection.id,
|
|
bankName: (connection as { bank_name?: string | null }).bank_name ?? null,
|
|
previousStatus: connection.status,
|
|
newStatus,
|
|
enabledCount: enabled_uids.length,
|
|
totalCount: existing.length,
|
|
userId: user.id,
|
|
companyId,
|
|
},
|
|
})
|
|
} catch (emitError) {
|
|
log.error('[enable-banking] Failed to emit account selection event', {
|
|
errorMessage: emitError instanceof Error ? emitError.message : String(emitError),
|
|
connectionId: connection.id,
|
|
userId: user.id,
|
|
companyId,
|
|
})
|
|
}
|
|
|
|
// Initial backfill on activation. Run inline so the user has data the
|
|
// moment they finish account selection: no 24h cron wait. Failures
|
|
// here don't fail the PATCH; the cron will retry on its next run
|
|
// (gated on initial_sync_completed_at IS NULL).
|
|
let initialSyncSummary: {
|
|
imported: number
|
|
duplicates: number
|
|
auto_matched: number
|
|
requested_from: string
|
|
returned_min_date: string | null
|
|
returned_max_date: string | null
|
|
} | null = null
|
|
let initialSyncError: string | null = null
|
|
|
|
if (connection.status === 'pending_selection') {
|
|
const accountsToSync = updatedAccounts.filter(a => a.enabled !== false)
|
|
const toDate = new Date().toISOString().split('T')[0]
|
|
const fromDate = new Date(Date.now() - initialLookbackDays * 24 * 60 * 60 * 1000)
|
|
.toISOString()
|
|
.split('T')[0]
|
|
|
|
// Same guard the manual /sync route and the cron apply. This path also
|
|
// runs on RENEWAL (reconnect resets status to pending_selection), and a
|
|
// fresh consent often makes the bank release history the first connect
|
|
// never delivered, straight over an already-bookkept period. Without
|
|
// the guard those rows would be auto-categorized into brand-new
|
|
// verifikat (double-booking) instead of being linked to the ones that
|
|
// already describe them.
|
|
const { data: sieOverlap } = await supabase
|
|
.from('sie_imports')
|
|
.select('id')
|
|
.eq('company_id', companyId)
|
|
.eq('status', 'completed')
|
|
.gte('fiscal_year_end', fromDate)
|
|
.limit(1)
|
|
.maybeSingle()
|
|
|
|
const { data: membership } = await supabase
|
|
.from('company_members')
|
|
.select('role')
|
|
.eq('company_id', companyId)
|
|
.eq('user_id', user.id)
|
|
.maybeSingle()
|
|
const isViewer = membership?.role === 'viewer'
|
|
|
|
log.info('[enable-banking] Starting inline initial backfill', {
|
|
connectionId: connection.id,
|
|
accountCount: accountsToSync.length,
|
|
lookbackDays: initialLookbackDays,
|
|
fromDate,
|
|
toDate,
|
|
sieOverlap: Boolean(sieOverlap),
|
|
})
|
|
|
|
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
|
|
try {
|
|
const ingestFn = ctx?.services.ingestTransactions
|
|
const syncPromise = Promise.all(
|
|
accountsToSync.map(account => syncAccountTransactions(
|
|
supabase,
|
|
companyId,
|
|
user.id,
|
|
connection.id,
|
|
account,
|
|
fromDate,
|
|
toDate,
|
|
ingestFn,
|
|
{
|
|
strategy: 'longest',
|
|
...(sieOverlap ? { skipAutoCategorization: true } : {}),
|
|
...(isViewer ? { rawInsertOnly: true } : {}),
|
|
}
|
|
))
|
|
)
|
|
// If the timeout wins the race, the underlying Promise.all keeps
|
|
// running. Without a registered handler, a late rejection from the
|
|
// bank API would surface as an unhandledRejection: Node 22 (the
|
|
// self-hosted Docker runtime) terminates the process by default on
|
|
// those, taking the whole server down. The cron retries the
|
|
// backfill via initial_sync_completed_at IS NULL, so a no-op
|
|
// catch is the right policy here.
|
|
syncPromise.catch(() => {})
|
|
|
|
const TIMEOUT_MS = 60_000
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
timeoutHandle = setTimeout(() => reject(new Error('initial_sync_timeout')), TIMEOUT_MS)
|
|
})
|
|
const results = await Promise.race([syncPromise, timeoutPromise])
|
|
|
|
const totalImported = results.reduce((sum, r) => sum + r.imported, 0)
|
|
const totalDuplicates = results.reduce((sum, r) => sum + r.duplicates, 0)
|
|
|
|
// Min/max booking date across all synced accounts
|
|
const minDates = results.map(r => r.returnedMinBookingDate).filter((d): d is string => !!d)
|
|
const maxDates = results.map(r => r.returnedMaxBookingDate).filter((d): d is string => !!d)
|
|
const returnedMin = minDates.length > 0 ? minDates.reduce((a, b) => (a < b ? a : b)) : null
|
|
const returnedMax = maxDates.length > 0 ? maxDates.reduce((a, b) => (a > b ? a : b)) : null
|
|
|
|
// Post-backfill reconciliation sweep, mirroring the manual /sync
|
|
// route: link just-imported rows to the verifikat that already
|
|
// describe them so a re-released period does not resurface as
|
|
// hundreds of "ohanterade" transactions. Unlike the /sync and cron
|
|
// sweeps this runs once per enabled ledger account with a resolved
|
|
// cash-account scope: the pooled unscoped form can cross-link
|
|
// accounts (#1290/#1298). Both a thrown scope resolution AND an
|
|
// unresolved cash-account row (found: false) skip that account's
|
|
// sweep instead of widening to the pooled form.
|
|
//
|
|
// The window opens at the OLDEST booking date the bank actually
|
|
// returned when that is older than the requested fromDate: some
|
|
// ASPSPs over-return history, and rows outside the requested window
|
|
// would otherwise be ingested but never swept.
|
|
const sweepDateFrom = returnedMin && returnedMin < fromDate ? returnedMin : fromDate
|
|
// The sweep writes bank-feed metadata only (transactions.journal_entry_id,
|
|
// reconciliation_method, is_business): journal tables are never touched,
|
|
// so BFL immutability and period locks (which guard journal entries) are
|
|
// not in play. Links to opening-balance verifikat are blocked by the
|
|
// check_transaction_link_not_opening_balance trigger, and every link is
|
|
// reversible via unlinkReconciliation without any ledger write.
|
|
let totalAutoMatched = 0
|
|
if (sieOverlap && totalImported > 0 && !isViewer) {
|
|
// Filter, not `?? undefined`: an undefined accountNumber makes
|
|
// resolveCashAccountScope fall back to the primary account with
|
|
// includeUnassigned=true, which is the pooled form this block must
|
|
// never widen to. The allocator gives every enabled account a
|
|
// concrete ledger_account, so nothing is skipped in practice.
|
|
const ledgerAccounts = Array.from(
|
|
new Set(
|
|
accountsToSync
|
|
.map(a => a.ledger_account)
|
|
.filter((l): l is string => typeof l === 'string' && l.length > 0)
|
|
)
|
|
)
|
|
for (const ledgerAccount of ledgerAccounts) {
|
|
try {
|
|
const scope = await resolveCashAccountScope(supabase, companyId, ledgerAccount)
|
|
if (!scope.found) {
|
|
log.warn('[enable-banking] No cash_accounts row for ledger account; skipping its reconciliation sweep', {
|
|
connectionId: connection.id,
|
|
ledgerAccount,
|
|
})
|
|
continue
|
|
}
|
|
const reconResult = await runReconciliation(supabase, companyId, user.id, {
|
|
dateFrom: sweepDateFrom,
|
|
dateTo: toDate,
|
|
accountNumber: scope.accountNumber,
|
|
currency: scope.currency,
|
|
cashAccountId: scope.cashAccountId,
|
|
includeUnassigned: scope.includeUnassigned,
|
|
// Unattended run: nobody reviews a dry-run first, so never
|
|
// commit low-confidence (fuzzy / date-range) matches.
|
|
confidenceThreshold: DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
|
|
// ...but don't DROP them either: the 0.75-0.89 band feeds
|
|
// the "Granska förslag" review surface.
|
|
persistSuggestions: true,
|
|
})
|
|
totalAutoMatched += reconResult.applied
|
|
if (reconResult.applied > 0 || reconResult.skippedBelowThreshold > 0) {
|
|
log.info('[enable-banking] Post-backfill reconciliation linked imported rows to existing verifikat', {
|
|
connectionId: connection.id,
|
|
accountNumber: scope.accountNumber,
|
|
applied: reconResult.applied,
|
|
skippedBelowThreshold: reconResult.skippedBelowThreshold,
|
|
total: reconResult.matches.length,
|
|
})
|
|
}
|
|
} catch {
|
|
// Non-critical: rows stay unmatched for manual review.
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mirror the balances the backfill just fetched into cash_accounts.
|
|
// accounts_data is deliberately NOT re-written here (see below), so
|
|
// without this the balances fetched during the initial sync would
|
|
// reach neither store until the next scheduled sync.
|
|
try {
|
|
const { updateBalancesFromSync } = await import('@/lib/cash-accounts/service')
|
|
await updateBalancesFromSync(
|
|
supabase,
|
|
companyId,
|
|
connection.id,
|
|
updatedAccounts.map((a) => ({
|
|
external_uid: a.uid,
|
|
balance: a.balance,
|
|
available_balance: a.available_balance,
|
|
balance_updated_at: a.balance_updated_at,
|
|
})),
|
|
)
|
|
} catch (mirrorErr) {
|
|
log.error('[enable-banking] Balance mirror after initial backfill failed', {
|
|
connectionId: connection.id,
|
|
error: mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr),
|
|
})
|
|
}
|
|
|
|
const completedAt = new Date().toISOString()
|
|
// Don't re-write accounts_data here: the first update already wrote it.
|
|
// Including it again races with any concurrent writer (e.g. cron firing in
|
|
// the sub-60s window) and would silently overwrite their changes.
|
|
const { error: metaUpdateError } = await supabase
|
|
.from('bank_connections')
|
|
.update({
|
|
last_synced_at: completedAt,
|
|
initial_sync_completed_at: completedAt,
|
|
initial_sync_requested_from: fromDate,
|
|
initial_sync_returned_min_date: returnedMin,
|
|
initial_sync_returned_max_date: returnedMax,
|
|
initial_sync_lookback_days: initialLookbackDays,
|
|
})
|
|
.eq('id', connection.id)
|
|
|
|
if (metaUpdateError) {
|
|
// The sync itself succeeded (transactions are ingested) but we
|
|
// couldn't persist that. Falsely reporting success would tell the
|
|
// client "imported N transactions" while the DB still has
|
|
// initial_sync_completed_at = NULL, causing the cron to re-run a
|
|
// 90-day backfill next morning. Surface this as initial_sync_error
|
|
// so the UI shows a "background sync needs retry" warning, and the
|
|
// cron's gate (initial_sync_completed_at IS NULL) will self-heal.
|
|
initialSyncError = `metadata_update_failed: ${metaUpdateError.message}`
|
|
log.error('[enable-banking] Failed to persist initial_sync metadata after backfill', {
|
|
connectionId: connection.id,
|
|
error: metaUpdateError.message,
|
|
userId: user.id,
|
|
companyId,
|
|
})
|
|
} else {
|
|
initialSyncSummary = {
|
|
imported: totalImported,
|
|
duplicates: totalDuplicates,
|
|
auto_matched: totalAutoMatched,
|
|
requested_from: fromDate,
|
|
returned_min_date: returnedMin,
|
|
returned_max_date: returnedMax,
|
|
}
|
|
|
|
log.info('[enable-banking] Inline initial backfill complete', {
|
|
connectionId: connection.id,
|
|
...initialSyncSummary,
|
|
})
|
|
}
|
|
} catch (syncError) {
|
|
initialSyncError = syncError instanceof Error ? syncError.message : String(syncError)
|
|
log.error('[enable-banking] Inline initial backfill failed: cron will retry', {
|
|
connectionId: connection.id,
|
|
error: initialSyncError,
|
|
userId: user.id,
|
|
companyId,
|
|
})
|
|
} finally {
|
|
if (timeoutHandle) clearTimeout(timeoutHandle)
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
enabled_count: enabled_uids.length,
|
|
total_count: existing.length,
|
|
...(initialSyncSummary ? { initial_sync: initialSyncSummary } : {}),
|
|
...(initialSyncError ? { initial_sync_error: initialSyncError } : {}),
|
|
})
|
|
},
|
|
},
|
|
{
|
|
method: 'DELETE',
|
|
path: '/disconnect',
|
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
|
const log = ctx?.log ?? console
|
|
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
if (!ctx?.companyId) {
|
|
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
|
|
}
|
|
const companyId = ctx.companyId
|
|
|
|
const rl = await checkRateLimit({
|
|
prefix: 'enable-banking:disconnect',
|
|
identifier: user.id,
|
|
...RATE_LIMIT_DISCONNECT,
|
|
})
|
|
if (!rl.ok) return rl.response!
|
|
|
|
const { connection_id } = await request.json()
|
|
|
|
if (!connection_id) {
|
|
return NextResponse.json({ error: 'connection_id is required' }, { status: 400 })
|
|
}
|
|
|
|
const { data: connection, error: findError } = await supabase
|
|
.from('bank_connections')
|
|
.select('id, session_id, status, bank_name')
|
|
.eq('id', connection_id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (findError || !connection) {
|
|
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
|
|
}
|
|
|
|
// Revoke the PSD2 consent only when no other company still depends on
|
|
// it. Sessions are shared across a user's companies (see
|
|
// lib/session-sharing.ts), so a blind revoke here would silently take
|
|
// down a sibling company's bank feed: exactly the failure this feature
|
|
// exists to remove. countLiveSiblings needs the service client because
|
|
// RLS hides a sibling living in a company the user has since left, and
|
|
// an unseen sibling would read as "safe to revoke".
|
|
let sharedWithSiblings = false
|
|
if (connection.session_id) {
|
|
const { createServiceClient } = await import('@/lib/supabase/server')
|
|
const serviceSupabase = await createServiceClient()
|
|
const siblingCount = await countLiveSiblings(
|
|
serviceSupabase,
|
|
connection.session_id,
|
|
connection.id,
|
|
)
|
|
sharedWithSiblings = siblingCount > 0
|
|
if (sharedWithSiblings) {
|
|
log.info('[enable-banking] Session still in use by other companies: skipping revoke', {
|
|
connectionId: connection.id,
|
|
siblingCount,
|
|
userId: user.id,
|
|
companyId,
|
|
})
|
|
}
|
|
}
|
|
|
|
if (connection.session_id && !sharedWithSiblings) {
|
|
try {
|
|
await deleteSession(connection.session_id)
|
|
} catch (error) {
|
|
// The revoke is best-effort: an expired or already-closed session
|
|
// is the normal case here, and the disconnect continues either
|
|
// way, so this is a warning and not an error.
|
|
log.warn('[enable-banking] Failed to revoke PSD2 session (may be expired)', {
|
|
message: error instanceof Error ? error.message : String(error),
|
|
sessionId: connection.session_id,
|
|
connectionId: connection_id,
|
|
connectionStatus: connection.status,
|
|
userId: user.id,
|
|
companyId,
|
|
})
|
|
}
|
|
}
|
|
|
|
const { error: updateError } = await supabase
|
|
.from('bank_connections')
|
|
.update({ status: 'revoked', session_id: null })
|
|
.eq('id', connection.id)
|
|
|
|
if (updateError) {
|
|
log.error('[enable-banking] Failed to mark connection revoked', {
|
|
errorMessage: updateError.message,
|
|
connectionId: connection.id,
|
|
userId: user.id,
|
|
companyId,
|
|
})
|
|
return NextResponse.json({ error: 'Failed to disconnect' }, { status: 500 })
|
|
}
|
|
|
|
// Release the connection's ledger claims by demoting its cash_accounts
|
|
// rows to manual (bank_connection_id = null). The rows themselves stay:
|
|
// transactions.cash_account_id and the ledger history reference them,
|
|
// and upsertFromPsd2 promotes a manual holder in place on reconnect so
|
|
// the same bank lands back on its original BAS account (e.g. 1930)
|
|
// instead of overflowing to the next free slot.
|
|
const { error: releaseError } = await supabase
|
|
.from('cash_accounts')
|
|
.update({ bank_connection_id: null })
|
|
.eq('company_id', companyId)
|
|
.eq('bank_connection_id', connection.id)
|
|
|
|
if (releaseError) {
|
|
// Don't fail the disconnect: the connection is already revoked, and
|
|
// the allocator / collision guard also skip revoked connections, so
|
|
// the orphaned rows self-heal on the next picker save.
|
|
log.error('[enable-banking] Failed to release cash_accounts ledger claims on disconnect', {
|
|
errorMessage: releaseError.message,
|
|
connectionId: connection.id,
|
|
userId: user.id,
|
|
companyId,
|
|
})
|
|
}
|
|
|
|
try {
|
|
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
|
|
await emit({
|
|
type: 'bank_connection.revoked',
|
|
payload: {
|
|
connectionId: connection.id,
|
|
bankName: (connection as { bank_name?: string | null }).bank_name ?? null,
|
|
userId: user.id,
|
|
companyId,
|
|
},
|
|
})
|
|
} catch (emitError) {
|
|
log.error('[enable-banking] Failed to emit revoke event', {
|
|
errorMessage: emitError instanceof Error ? emitError.message : String(emitError),
|
|
connectionId: connection.id,
|
|
userId: user.id,
|
|
companyId,
|
|
})
|
|
}
|
|
|
|
return NextResponse.json({ success: true })
|
|
},
|
|
},
|
|
],
|
|
|
|
eventHandlers: [],
|
|
}
|