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
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user