@@ -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 ,
0 commit comments