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
@@ -4,6 +4,7 @@ import {
hasCapability,
requireCapability,
capabilityBlockedResponse,
getCompanyIdsWithCapability,
getCompanyEntitlements,
} from '../has-capability'
import { CAPABILITY, PAID_CAPABILITIES } from '../keys'
@@ -134,6 +135,68 @@ describe('hasCapability', () => {
})
})
describe('getCompanyIdsWithCapability', () => {
const directCompanyId = '11111111-1111-4111-8111-111111111111'
const firmCompanyId = '22222222-2222-4222-8222-222222222222'
const expiredCompanyId = '33333333-3333-4333-8333-333333333333'
const disabledCompanyId = '44444444-4444-4444-8444-444444444444'
const teamId = '55555555-5555-4555-8555-555555555555'
it('resolves direct and firm grants before excluding expired and disabled companies', async () => {
const supabase = makeSupabase({
companies: {
data: [
{ id: directCompanyId, team_id: null },
{ id: firmCompanyId, team_id: teamId },
{ id: expiredCompanyId, team_id: null },
{ id: disabledCompanyId, team_id: null },
],
},
capability_grants: {
data: [
{ company_id: directCompanyId, team_id: null, expires_at: null },
{ company_id: null, team_id: teamId, expires_at: iso(60_000) },
{ company_id: expiredCompanyId, team_id: null, expires_at: iso(-60_000) },
{ company_id: disabledCompanyId, team_id: null, expires_at: null },
],
},
company_capability_config: { data: [{ company_id: disabledCompanyId }] },
})
const result = await getCompanyIdsWithCapability(
supabase,
[directCompanyId, firmCompanyId, expiredCompanyId, disabledCompanyId],
CAPABILITY.bank_sync,
)
expect([...result].sort()).toEqual([directCompanyId, firmCompanyId].sort())
})
it('returns every valid requested company when the paywall is bypassed', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
const supabase = makeSupabase({})
const result = await getCompanyIdsWithCapability(
supabase,
[directCompanyId, directCompanyId, 'not-a-uuid'],
CAPABILITY.skatteverket,
)
expect([...result]).toEqual([directCompanyId])
})
it('throws on a database error so a cron run cannot silently skip every payer', async () => {
const supabase = makeSupabase({
companies: { data: null, error: { message: 'connection reset' } },
company_capability_config: { data: [] },
})
await expect(
getCompanyIdsWithCapability(supabase, [directCompanyId], CAPABILITY.bank_sync),
).rejects.toThrow('Failed to resolve capability company scopes: connection reset')
})
})
describe('requireCapability', () => {
it('returns null (proceed) when the company has the capability', async () => {
const supabase = makeSupabase({
+106 -1
View File
@@ -57,6 +57,111 @@ function isUuid(v: string): boolean {
return UUID_RE.test(v)
}
const CAPABILITY_SCOPE_CHUNK_SIZE = 100
function chunksOf<T>(values: T[], size: number): T[][] {
const chunks: T[][] = []
for (let index = 0; index < values.length; index += size) {
chunks.push(values.slice(index, index + size))
}
return chunks
}
function grantIsActive(expiresAt: string | null, now: number): boolean {
return expiresAt === null || new Date(expiresAt).getTime() > now
}
/**
* Resolve a cron or batch work list before applying its processing limit.
*
* This is the bulk counterpart to hasCapability(): company grants and firm
* grants both cascade, expired grants do not, and an explicit company-level
* disable wins. Queries are chunked to keep PostgREST URLs bounded. Any query
* failure throws so background jobs report a failed run instead of silently
* treating every paying company as ineligible.
*/
export async function getCompanyIdsWithCapability(
supabase: SupabaseClient,
companyIds: readonly string[],
key: CapabilityKey,
): Promise<Set<string>> {
const validCompanyIds = [...new Set(companyIds.filter(isUuid))]
if (validCompanyIds.length === 0) return new Set()
if (isPaywallBypassed()) return new Set(validCompanyIds)
type CompanyScope = { id: string; team_id: string | null }
type GrantScope = {
company_id: string | null
team_id: string | null
expires_at: string | null
}
type DisabledConfig = { company_id: string }
const companies: CompanyScope[] = []
const disabledConfigs: DisabledConfig[] = []
for (const chunk of chunksOf(validCompanyIds, CAPABILITY_SCOPE_CHUNK_SIZE)) {
const [{ data: companyRows, error: companiesError }, { data: configRows, error: configError }] =
await Promise.all([
supabase.from('companies').select('id, team_id').in('id', chunk),
supabase
.from('company_capability_config')
.select('company_id')
.eq('capability_key', key)
.eq('enabled', false)
.in('company_id', chunk),
])
if (companiesError) throw new Error(`Failed to resolve capability company scopes: ${companiesError.message}`)
if (configError) throw new Error(`Failed to resolve capability config: ${configError.message}`)
companies.push(...((companyRows ?? []) as CompanyScope[]))
disabledConfigs.push(...((configRows ?? []) as DisabledConfig[]))
}
const teamIds = [...new Set(companies.map(company => company.team_id).filter((id): id is string => !!id))]
const grants: GrantScope[] = []
for (const chunk of chunksOf(validCompanyIds, CAPABILITY_SCOPE_CHUNK_SIZE)) {
const { data, error } = await supabase
.from('capability_grants')
.select('company_id, team_id, expires_at')
.eq('capability_key', key)
.in('company_id', chunk)
if (error) throw new Error(`Failed to resolve company capability grants: ${error.message}`)
grants.push(...((data ?? []) as GrantScope[]))
}
for (const chunk of chunksOf(teamIds, CAPABILITY_SCOPE_CHUNK_SIZE)) {
const { data, error } = await supabase
.from('capability_grants')
.select('company_id, team_id, expires_at')
.eq('capability_key', key)
.in('team_id', chunk)
if (error) throw new Error(`Failed to resolve firm capability grants: ${error.message}`)
grants.push(...((data ?? []) as GrantScope[]))
}
const now = Date.now()
const activeCompanyGrants = new Set<string>()
const activeTeamGrants = new Set<string>()
for (const grant of grants) {
if (!grantIsActive(grant.expires_at, now)) continue
if (grant.company_id) activeCompanyGrants.add(grant.company_id)
if (grant.team_id) activeTeamGrants.add(grant.team_id)
}
const disabledCompanyIds = new Set(disabledConfigs.map(config => config.company_id))
return new Set(
companies
.filter(company =>
!disabledCompanyIds.has(company.id) &&
(activeCompanyGrants.has(company.id) ||
(company.team_id !== null && activeTeamGrants.has(company.team_id))),
)
.map(company => company.id),
)
}
export async function hasCapability(
supabase: SupabaseClient,
companyId: string,
@@ -88,7 +193,7 @@ export async function hasCapability(
const now = Date.now()
const entitled = (grants ?? []).some((g) => {
const exp = (g as { expires_at: string | null }).expires_at
return exp === null || new Date(exp).getTime() > now
return grantIsActive(exp, now)
})
if (!entitled) return false