@@ -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,7 +269,7 @@ export async function ensureAutoIntroSchedule(
247269 if ( ! scheduleId ) return ;
248270 const schedule = await stripe . subscriptionSchedules . retrieve ( scheduleId ) ;
249271
250- if ( schedule . metadata ?. origin === 'auto-intro' ) {
272+ if ( await claimIfAutoIntro ( schedule ) ) {
251273 const valid = await validateOrRepairAutoIntroSchedule ( schedule , stripeSubscriptionId , userId ) ;
252274 if ( ! valid ) {
253275 logError ( 'Auto-intro schedule is unrecoverable, skipping' , {
@@ -297,7 +319,6 @@ async function createAutoIntroSchedule(
297319 try {
298320 newSchedule = await stripe . subscriptionSchedules . create ( {
299321 from_subscription : stripeSubscriptionId ,
300- metadata : { origin : 'auto-intro' } ,
301322 } ) ;
302323 } catch ( error ) {
303324 await handleAutoIntroCreateRace ( error , stripeSubscriptionId , userId ) ;
@@ -317,19 +338,31 @@ async function createAutoIntroSchedule(
317338 return ;
318339 }
319340
320- await stripe . subscriptionSchedules . update ( newSchedule . id , {
321- phases : [
322- {
323- items : [ { price : phase1Price } ] ,
324- start_date : currentPhase . start_date ,
325- end_date : currentPhase . end_date ,
326- } ,
327- {
328- items : [ { price : getStripePriceIdForClawPlan ( 'standard' ) } ] ,
329- } ,
330- ] ,
331- end_behavior : 'release' ,
332- } ) ;
341+ try {
342+ await stripe . subscriptionSchedules . update ( newSchedule . id , {
343+ metadata : { origin : 'auto-intro' } ,
344+ phases : [
345+ {
346+ items : [ { price : phase1Price } ] ,
347+ start_date : currentPhase . start_date ,
348+ end_date : currentPhase . end_date ,
349+ } ,
350+ {
351+ items : [ { price : getStripePriceIdForClawPlan ( 'standard' ) } ] ,
352+ } ,
353+ ] ,
354+ end_behavior : 'release' ,
355+ } ) ;
356+ } catch ( error ) {
357+ // Release the half-created schedule so retry can start fresh — without
358+ // metadata, recovery paths cannot identify it as auto-intro.
359+ try {
360+ await stripe . subscriptionSchedules . release ( newSchedule . id ) ;
361+ } catch {
362+ // best-effort cleanup
363+ }
364+ throw error ;
365+ }
333366
334367 await persistAutoIntroSchedule ( newSchedule . id , userId ) ;
335368}
@@ -362,7 +395,8 @@ async function handleAutoIntroCreateRace(
362395 } ) ;
363396
364397 const existingSchedule = await stripe . subscriptionSchedules . retrieve ( refetchedScheduleId ) ;
365- if ( existingSchedule . metadata ?. origin === 'auto-intro' ) {
398+
399+ if ( await claimIfAutoIntro ( existingSchedule ) ) {
366400 const valid = await validateOrRepairAutoIntroSchedule (
367401 existingSchedule ,
368402 stripeSubscriptionId ,
0 commit comments