fix(sync): stop expired trials from starving automatic bank and skattekonto sync (#1376)

The bank sync and skattekonto sync crons fetched the 50 oldest
connection/token rows and only then checked entitlements per item, so
expired-trial rows permanently occupied every batch slot and entitled
companies were never synced automatically.

Fetch all candidate rows, resolve capability grants in bulk via the new
getCompanyIdsWithCapability() (company and firm grants cascade, expired
grants excluded, explicit per-company disable wins), and apply the
50-item run cap after filtering. Entitlement query failures now fail the
run instead of silently skipping every company.

Fixes #563

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-03 17:32:39 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent c9625fa45c
commit 0510d4c13f
7 changed files with 468 additions and 45 deletions
@@ -25,7 +25,7 @@ const mocks = vi.hoisted(() => ({
createClient: vi.fn(),
probeSessionHealth: vi.fn(),
syncAccountTransactions: vi.fn(),
hasCapability: vi.fn(),
getCompanyIdsWithCapability: vi.fn(),
runReconciliation: vi.fn(),
}))
@@ -46,7 +46,7 @@ vi.mock('@/extensions/general/enable-banking/lib/sync', () => ({
}))
vi.mock('@/lib/entitlements/has-capability', () => ({
hasCapability: (...args: unknown[]) => mocks.hasCapability(...args),
getCompanyIdsWithCapability: (...args: unknown[]) => mocks.getCompanyIdsWithCapability(...args),
}))
vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({
@@ -101,7 +101,7 @@ function makeClient(state: ClientState) {
}
const chain: Record<string, unknown> = {}
const passthrough = ['select', 'not', 'lt', 'gte', 'order', 'limit']
const passthrough = ['select', 'not', 'lt', 'gte', 'order', 'limit', 'range']
for (const method of passthrough) chain[method] = vi.fn(() => chain)
chain.eq = vi.fn((col: string, value: unknown) => {
filters[col] = value
@@ -156,7 +156,9 @@ beforeEach(() => {
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-key'
state = { active: [], probeCandidates: [], updates: [] }
mocks.createClient.mockImplementation(() => makeClient(state))
mocks.hasCapability.mockResolvedValue(true)
mocks.getCompanyIdsWithCapability.mockImplementation(
async (_supabase: unknown, companyIds: string[]) => new Set(companyIds),
)
mocks.syncAccountTransactions.mockResolvedValue({ imported: 0, duplicates: 0, errors: 0 })
mocks.probeSessionHealth.mockResolvedValue('unknown')
})
@@ -256,7 +258,7 @@ describe('GET /api/extensions/enable-banking/sync/cron: session health probe', (
// The silent skip that let a dead connection sit at 'active' for days.
state.active = [connection()]
state.probeCandidates = [connection()]
mocks.hasCapability.mockResolvedValue(false)
mocks.getCompanyIdsWithCapability.mockResolvedValue(new Set())
mocks.probeSessionHealth.mockResolvedValue('dead')
await GET(cronRequest())
@@ -266,6 +268,37 @@ describe('GET /api/extensions/enable-banking/sync/cron: session health probe', (
expect(state.updates[0].payload).toMatchObject({ status: 'expired' })
})
it('selects an entitled connection after fifty ineligible queue rows', async () => {
state.active = [
...Array.from({ length: 50 }, (_, index) => connection({
id: `free-${index}`,
company_id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
})),
connection({ id: 'paid-connection', company_id: '11111111-1111-4111-8111-111111111111' }),
]
mocks.getCompanyIdsWithCapability.mockResolvedValue(
new Set(['11111111-1111-4111-8111-111111111111']),
)
const response = await GET(cronRequest())
expect(response.status).toBe(200)
expect(mocks.syncAccountTransactions).toHaveBeenCalledTimes(1)
expect(mocks.syncAccountTransactions.mock.calls[0][3]).toBe('paid-connection')
await expect(response.json()).resolves.toMatchObject({ processed: 1 })
})
it('applies the fifty-connection cap after entitlement filtering', async () => {
state.active = Array.from({ length: 51 }, (_, index) => connection({
id: `paid-${index}`,
company_id: `11111111-1111-4111-8111-${String(index).padStart(12, '0')}`,
}))
await GET(cronRequest())
expect(mocks.syncAccountTransactions).toHaveBeenCalledTimes(50)
})
it('probes a connection whose accounts are all deselected', async () => {
// This branch reports 'synced' without writing last_synced_at, so the row
// looks fresh forever.
@@ -20,15 +20,18 @@ import {
generateConsentExpiryEmailSubject,
} from '@/lib/email/consent-notification-templates'
import { ensureInitialized } from '@/lib/init'
import { hasCapability } from '@/lib/entitlements/has-capability'
import { getCompanyIdsWithCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { withCronContext } from '@/lib/api/with-cron-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getBranding } from '@/lib/branding/service'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { StoredAccount } from '@/extensions/general/enable-banking/types'
ensureInitialized()
const MAX_CONNECTIONS_PER_RUN = 50
/**
* GET /api/extensions/enable-banking/sync/cron
* Automatic daily bank transaction sync
@@ -64,21 +67,42 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
ctx.log.info('cleaned up stale pending connections', { count: stalePending.length })
}
const { data: connections, error: connError } = await supabase
.from('bank_connections')
.select('*')
.eq('status', 'active')
.order('last_synced_at', { ascending: true, nullsFirst: true })
.limit(50)
if (connError) {
ctx.log.error('failed to fetch bank connections', connError, {
message: connError.message,
code: connError.code,
})
return errorResponse(connError, ctx.log, { requestId: ctx.requestId })
let candidateConnections
let entitledCompanyIds
try {
candidateConnections = await fetchAllRows(
({ from, to }) => supabase
.from('bank_connections')
.select('*')
.eq('status', 'active')
.order('last_synced_at', { ascending: true, nullsFirst: true })
.order('id', { ascending: true })
.range(from, to),
{ dedupeBy: connection => connection.id },
)
entitledCompanyIds = await getCompanyIdsWithCapability(
supabase,
candidateConnections.map(connection => connection.company_id),
CAPABILITY.bank_sync,
)
} catch (error) {
ctx.log.error('failed to build entitled bank sync work list', error as Error)
return errorResponse(error, ctx.log, { requestId: ctx.requestId })
}
// Apply the batch limit only after entitlement filtering. Otherwise old
// free-tier rows can permanently occupy the first 50 queue positions and
// prevent every paying connection behind them from syncing.
const connections = candidateConnections
.filter(connection => entitledCompanyIds.has(connection.company_id))
.slice(0, MAX_CONNECTIONS_PER_RUN)
ctx.log.info('bank sync work list built', {
candidates: candidateConnections.length,
entitledCompanies: entitledCompanyIds.size,
selected: connections.length,
})
// No early return on an empty set: the health probe below still has work to
// do (a company whose only connection is parked in 'pending_selection' has
// nothing to sync but can absolutely have a dead session).
@@ -107,17 +131,12 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
const notifyKey = (c: { user_id: string; session_id: string | null }) =>
`${c.user_id}:${c.session_id ?? 'none'}`
for (const connection of connections ?? []) {
for (const connection of connections) {
if (Date.now() - startTime > TIME_BUDGET_MS) {
ctx.log.info('time budget reached', { processedSoFar: results.length })
break
}
if (!(await hasCapability(supabase, connection.company_id, CAPABILITY.bank_sync))) {
ctx.log.info('skip: capability not entitled', { companyId: connection.company_id })
continue
}
try {
const daysLeft = getDaysUntilExpiry(connection.consent_expires)
const isExpired = daysLeft !== null && daysLeft <= 0