Skip to content

Commit 7695b79

Browse files
committed
Fix subscription time fold race condition
1 parent c73d80f commit 7695b79

2 files changed

Lines changed: 51 additions & 28 deletions

File tree

apps/backend/src/lib/payments/schema/phase-1/subscription-timefold-algo.ts

Lines changed: 48 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,19 @@ export function getSubscriptionTimeFoldReducerSql(): string {
177177
'outstandingGrants', ${initOutstandingGrants},
178178
'repeatCount', to_jsonb(0)
179179
)`;
180+
const initialHasRepeatSchedule = `(
181+
EXISTS (
182+
SELECT 1
183+
FROM jsonb_each(${initialState}->'itemRepeatSchedule') AS "sched"
184+
WHERE "sched"."value"->>'nextRepeatMillis' != 'null'
185+
AND "sched"."value"->'nextRepeatMillis' IS NOT NULL
186+
)
187+
)`;
188+
const initialShouldEmitImmediateEnd = `(
189+
${initialState}->>'endedAtMillis' != 'null'
190+
AND ${initialState}->'endedAtMillis' IS NOT NULL
191+
AND NOT ${initialHasRepeatSchedule}
192+
)`;
180193

181194
// ── subscription-start event row ──
182195
const startEventItemGrants = `(
@@ -222,15 +235,23 @@ export function getSubscriptionTimeFoldReducerSql(): string {
222235
)`;
223236

224237
const nextTimestampFromState = (stateSql: string) => `(
225-
SELECT (to_timestamp(
226-
LEAST(
227-
${soonestRepeatFromState(stateSql)},
228-
CASE WHEN ${stateSql}->>'endedAtMillis' != 'null' AND ${stateSql}->'endedAtMillis' IS NOT NULL
238+
SELECT CASE
239+
WHEN "nextMillis"."millis" IS NULL THEN NULL::timestamptz
240+
ELSE to_timestamp("nextMillis"."millis" / 1000.0)
241+
END
242+
FROM (
243+
SELECT MIN("candidate"."millis") AS "millis"
244+
FROM (
245+
SELECT ${soonestRepeatFromState(stateSql)} AS "millis"
246+
UNION ALL
247+
SELECT CASE
248+
WHEN ${stateSql}->>'endedAtMillis' != 'null' AND ${stateSql}->'endedAtMillis' IS NOT NULL
229249
THEN (${stateSql}->>'endedAtMillis')::numeric
230-
ELSE NULL
231-
END
232-
) / 1000.0
233-
))
250+
ELSE NULL::numeric
251+
END AS "millis"
252+
) AS "candidate"
253+
WHERE "candidate"."millis" IS NOT NULL
254+
) AS "nextMillis"
234255
)`;
235256

236257
// ── item-grant-repeat event ──
@@ -363,7 +384,7 @@ export function getSubscriptionTimeFoldReducerSql(): string {
363384

364385
// ── subscription-end event ──
365386
// Expire all outstanding grants with expiresWhen="when-purchase-expires"
366-
const endItemQuantityChangesToExpire = `(
387+
const endItemQuantityChangesToExpire = (stateSql: string) => `(
367388
SELECT COALESCE(jsonb_agg(
368389
jsonb_build_object(
369390
'transactionId', "g"->'txnId',
@@ -372,27 +393,27 @@ export function getSubscriptionTimeFoldReducerSql(): string {
372393
'quantity', "g"->'quantity'
373394
)
374395
), '[]'::jsonb)
375-
FROM jsonb_array_elements(${S}->'outstandingGrants') AS "g"
396+
FROM jsonb_array_elements(${stateSql}->'outstandingGrants') AS "g"
376397
WHERE "g"->>'expiresWhen' = 'when-purchase-expires'
377398
)`;
378399

379-
const endEventRow = `jsonb_build_object(
400+
const endEventRowFromState = (stateSql: string) => `jsonb_build_object(
380401
'type', '"subscription-end"'::jsonb,
381-
'subscriptionId', ${S}->'subscriptionId',
382-
'tenancyId', ${S}->'tenancyId',
383-
'customerId', ${S}->'customerId',
384-
'customerType', ${S}->'customerType',
385-
'productId', ${S}->'productId',
386-
'productLineId', ${S}->'productLineId',
387-
'quantity', ${S}->'quantity',
402+
'subscriptionId', ${stateSql}->'subscriptionId',
403+
'tenancyId', ${stateSql}->'tenancyId',
404+
'customerId', ${stateSql}->'customerId',
405+
'customerType', ${stateSql}->'customerType',
406+
'productId', ${stateSql}->'productId',
407+
'productLineId', ${stateSql}->'productLineId',
408+
'quantity', ${stateSql}->'quantity',
388409
'startProductGrantRef', jsonb_build_object(
389-
'transactionId', ${S}->'startTxnId',
390-
'entryIndex', ${S}->'startProductGrantEntryIndex'
410+
'transactionId', ${stateSql}->'startTxnId',
411+
'entryIndex', ${stateSql}->'startProductGrantEntryIndex'
391412
),
392-
'itemQuantityChangesToExpire', ${endItemQuantityChangesToExpire},
393-
'paymentProvider', ${S}->'paymentProvider',
394-
'effectiveAtMillis', ${S}->'endedAtMillis',
395-
'createdAtMillis', ${S}->'endedAtMillis'
413+
'itemQuantityChangesToExpire', ${endItemQuantityChangesToExpire(stateSql)},
414+
'paymentProvider', ${stateSql}->'paymentProvider',
415+
'effectiveAtMillis', ${stateSql}->'endedAtMillis',
416+
'createdAtMillis', ${stateSql}->'endedAtMillis'
396417
)`;
397418

398419
// ── Combine into reducer ──
@@ -405,12 +426,14 @@ export function getSubscriptionTimeFoldReducerSql(): string {
405426
END AS "newState",
406427
407428
CASE
429+
WHEN ${T} IS NULL AND ${initialShouldEmitImmediateEnd} THEN jsonb_build_array(${startEventRow}, ${endEventRowFromState(initialState)})
408430
WHEN ${T} IS NULL THEN jsonb_build_array(${startEventRow})
409-
WHEN ${isEndEvent} THEN jsonb_build_array(${endEventRow})
431+
WHEN ${isEndEvent} THEN jsonb_build_array(${endEventRowFromState(S)})
410432
ELSE jsonb_build_array(${igrEventRow})
411433
END AS "newRowsData",
412434
413435
CASE
436+
WHEN ${T} IS NULL AND ${initialShouldEmitImmediateEnd} THEN NULL::timestamptz
414437
WHEN ${T} IS NULL THEN ${nextTimestampFromState(initialState)}
415438
WHEN ${isEndEvent} THEN NULL::timestamptz
416439
ELSE ${nextTimestampFromState(igrNewState)}

claude/CLAUDE-KNOWLEDGE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@ Q: What caused the Explore Apps hover layout shift?
370370
A: The app link wrapper in `docs-mintlify/snippets/docs-apps-home-grid.jsx` used `hover:-translate-y-0.5`, which makes tiles physically move on hover and looks like layout jank. Removing the translate/transform from the wrapper keeps hover effects without perceived shifting.
371371

372372
Q: Why can "grant premium after base in same product line" still show old + new item quantities (5 instead of 3)?
373-
A: Canceling the old subscription only enqueues the subscription-end timefold event; without processing due queue work in the same request, reads can still see both sub-start grants. In `grantProductToCustomer`, set cancellation `endedAt/currentPeriodEnd` to an immediate-past timestamp and call `SELECT public.bulldozer_timefold_process_queue()` when a conflicting product-line subscription was canceled to make revocation visible immediately.
373+
A: The subscription TimeFold reducer could fail to schedule/emit the end event for non-repeating products. In `subscription-timefold-algo.ts`, fix `nextTimestampFromState` to take `MIN` over non-null candidates (repeat timestamp and `endedAtMillis`), and when a subscription is already ended with no repeat schedule, emit `[subscription-start, subscription-end]` in the initial reducer run with `nextTimestamp = NULL`.
374374

375-
Q: Should a TimeFold input row edit require calling `bulldozer_timefold_process_queue()` to see immediate effects?
376-
A: No. The correct fix is in `declareTimeFoldTable` trigger recomputation: use a recomputation cutoff of `GREATEST(now(), lastProcessedAt)` so row-change-trigger recomputes the edited row's state chain through all due timestamps up to now. This makes immediate subscription-end/item-expiry effects visible without manual queue processing in business logic.
375+
Q: Why did subscription row edits fail to queue/process end events for products with `repeat: "never"`?
376+
A: In `subscription-timefold-algo.ts`, `nextTimestampFromState` used `LEAST(soonestRepeat, endedAtMillis)`. For non-repeating products `soonestRepeat` is null, so the result could become null and no end scheduling happened. Fix by computing `MIN` over non-null timestamp candidates (repeat and endedAt). Also, for already-ended subscriptions with no repeat schedule, emit `[subscription-start, subscription-end]` in the initial reducer run and set `nextTimestamp` to null, so immediate cancellations are reflected without depending on queue advancement.

0 commit comments

Comments
 (0)