Skip to content

Commit ae08f82

Browse files
committed
fix(billing): tag all schedule origins and extract claimIfAutoIntro helper
Add metadata.origin to user-switch and kilo-pass-switch schedule creation paths so they are never mistaken for auto-intro orphans. Extract the duplicated orphan-detect-and-claim logic into a shared claimIfAutoIntro() helper to prevent the heuristic from drifting between ensureAutoIntroSchedule and handleAutoIntroCreateRace.
1 parent 706139b commit ae08f82

3 files changed

Lines changed: 26 additions & 32 deletions

File tree

src/lib/kiloclaw/stripe-handlers.ts

Lines changed: 24 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,28 @@ async function persistAutoIntroSchedule(scheduleId: string, userId: string): Pro
163163
.where(eq(kiloclaw_subscriptions.user_id, userId));
164164
}
165165

166+
/**
167+
* Determine whether a schedule is auto-intro (already tagged) or a claimable
168+
* orphan (untagged, single-phase — likely a half-created auto-intro where
169+
* create succeeded but the update that sets metadata + phases never ran).
170+
* If orphaned, tags it as auto-intro before returning. Returns true when the
171+
* schedule should be treated as auto-intro, false otherwise.
172+
*/
173+
async function claimIfAutoIntro(schedule: Stripe.SubscriptionSchedule): Promise<boolean> {
174+
if (schedule.metadata?.origin === 'auto-intro') return true;
175+
176+
// Only claim untagged schedules with a single phase (the from_subscription
177+
// default). Schedules with 2+ phases were already configured by another code
178+
// path (user plan switch, kilo-pass) and must not be claimed.
179+
const isOrphan = !schedule.metadata?.origin && schedule.phases.length === 1;
180+
if (!isOrphan) return false;
181+
182+
await stripe.subscriptionSchedules.update(schedule.id, {
183+
metadata: { origin: 'auto-intro' },
184+
});
185+
return true;
186+
}
187+
166188
/**
167189
* Validate that an auto-intro schedule has the expected 2-phase structure
168190
* (phase 1 = current price, phase 2 = regular standard price). If the schedule
@@ -247,24 +269,7 @@ export async function ensureAutoIntroSchedule(
247269
if (!scheduleId) return;
248270
const schedule = await stripe.subscriptionSchedules.retrieve(scheduleId);
249271

250-
const isAutoIntro = schedule.metadata?.origin === 'auto-intro';
251-
252-
// A schedule created via from_subscription without metadata AND with only
253-
// 1 phase is likely a half-created auto-intro schedule (create succeeded
254-
// but the subsequent update that sets metadata + phases hasn't run yet, or
255-
// the worker died before it could). We require exactly 1 phase because
256-
// createAutoIntroSchedule sets metadata and phases in the same update call
257-
// — so a fully-updated schedule always has both. User-initiated plan
258-
// switches and kilo-pass changes also create untagged schedules but their
259-
// update writes 2+ phases, so the phase-count guard avoids claiming them.
260-
const isUntagged = !schedule.metadata?.origin && schedule.phases.length === 1;
261-
if (isUntagged) {
262-
await stripe.subscriptionSchedules.update(schedule.id, {
263-
metadata: { origin: 'auto-intro' },
264-
});
265-
}
266-
267-
if (isAutoIntro || isUntagged) {
272+
if (await claimIfAutoIntro(schedule)) {
268273
const valid = await validateOrRepairAutoIntroSchedule(schedule, stripeSubscriptionId, userId);
269274
if (!valid) {
270275
logError('Auto-intro schedule is unrecoverable, skipping', {
@@ -391,20 +396,7 @@ async function handleAutoIntroCreateRace(
391396

392397
const existingSchedule = await stripe.subscriptionSchedules.retrieve(refetchedScheduleId);
393398

394-
// The race winner may not have tagged the schedule yet (metadata is set in
395-
// a separate update call). Only claim untagged schedules that still have a
396-
// single phase — the from_subscription default. Schedules with 2+ phases
397-
// were already fully configured by another code path (user plan switch,
398-
// kilo-pass) and must not be claimed.
399-
const isAutoIntro = existingSchedule.metadata?.origin === 'auto-intro';
400-
const isUntagged = !existingSchedule.metadata?.origin && existingSchedule.phases.length === 1;
401-
if (isUntagged) {
402-
await stripe.subscriptionSchedules.update(existingSchedule.id, {
403-
metadata: { origin: 'auto-intro' },
404-
});
405-
}
406-
407-
if (isAutoIntro || isUntagged) {
399+
if (await claimIfAutoIntro(existingSchedule)) {
408400
const valid = await validateOrRepairAutoIntroSchedule(
409401
existingSchedule,
410402
stripeSubscriptionId,

src/routers/kilo-pass-router.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -824,6 +824,7 @@ export const kiloPassRouter = createTRPCRouter({
824824
}
825825

826826
const updatedSchedule = await stripe.subscriptionSchedules.update(schedule.id, {
827+
metadata: { origin: 'kilo-pass-switch' },
827828
// We want the subscription to continue normally after the final phase starts.
828829
// Without this, Stripe may require the last phase to specify `duration`/`end_date`.
829830
end_behavior: 'release',

src/routers/kiloclaw-router.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1476,6 +1476,7 @@ export const kiloclawRouter = createTRPCRouter({
14761476
}
14771477

14781478
await stripe.subscriptionSchedules.update(schedule.id, {
1479+
metadata: { origin: 'user-switch' },
14791480
end_behavior: 'release',
14801481
phases: [
14811482
{

0 commit comments

Comments
 (0)