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:
co-authored by
Claude Fable 5
parent
c9625fa45c
commit
0510d4c13f
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user