Skip to content

Commit 6572f77

Browse files
fix(billing): move auto-intro metadata from schedule create to update (#1389)
2 parents f997e3c + ae08f82 commit 6572f77

3 files changed

Lines changed: 52 additions & 16 deletions

File tree

src/lib/kiloclaw/stripe-handlers.ts

Lines changed: 50 additions & 16 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,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,

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)