Skip to content

Commit 2c91db8

Browse files
fix(claw): resolve instance constraint race in invoice settlement (#1692)
1 parent ef08a18 commit 2c91db8

File tree

1 file changed

+49
-1
lines changed

1 file changed

+49
-1
lines changed

src/lib/kiloclaw/credit-billing.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ export async function applyStripeFundedKiloClawPeriod(params: {
209209
const commitEndsAt = plan === 'commit' ? periodEnd : null;
210210

211211
// If the row doesn't exist yet (settlement arrived before subscription.created),
212-
// look up the user's active instance so the INSERT path can populate instance_id.
212+
// look up the user's active instance so we can populate instance_id.
213213
let instanceId = existingRow?.instance_id ?? null;
214214
if (!existingRow) {
215215
const [activeInstance] = await tx
@@ -218,9 +218,57 @@ export async function applyStripeFundedKiloClawPeriod(params: {
218218
.where(and(eq(kiloclaw_instances.user_id, userId), isNull(kiloclaw_instances.destroyed_at)))
219219
.limit(1);
220220
instanceId = activeInstance?.id ?? null;
221+
222+
// The instance may already have a subscription row (e.g. a trial, or a row
223+
// inserted by subscription.created arriving just before us). Inserting a new
224+
// row keyed on stripe_subscription_id would violate the partial unique index
225+
// UQ_kiloclaw_subscriptions_instance. Update the existing instance row
226+
// in-place instead, setting the stripe_subscription_id and converting it to
227+
// hybrid state. See KILOCODE-WEB-1JJF.
228+
if (instanceId) {
229+
const [instanceRow] = await tx
230+
.select({
231+
suspended_at: kiloclaw_subscriptions.suspended_at,
232+
scheduled_plan: kiloclaw_subscriptions.scheduled_plan,
233+
})
234+
.from(kiloclaw_subscriptions)
235+
.where(eq(kiloclaw_subscriptions.instance_id, instanceId))
236+
.limit(1);
237+
238+
if (instanceRow) {
239+
wasSuspended = !!instanceRow.suspended_at;
240+
resolvedInstanceId = instanceId;
241+
const shouldClearSchedule = instanceRow.scheduled_plan === plan;
242+
243+
await tx
244+
.update(kiloclaw_subscriptions)
245+
.set({
246+
stripe_subscription_id: stripeSubscriptionId,
247+
payment_source: 'credits',
248+
status: 'active',
249+
plan,
250+
current_period_start: periodStart,
251+
current_period_end: periodEnd,
252+
credit_renewal_at: periodEnd,
253+
commit_ends_at: commitEndsAt,
254+
past_due_since: null,
255+
auto_top_up_triggered_for_period: null,
256+
...(shouldClearSchedule
257+
? { scheduled_plan: null, scheduled_by: null, stripe_schedule_id: null }
258+
: {}),
259+
})
260+
.where(eq(kiloclaw_subscriptions.instance_id, instanceId));
261+
262+
return;
263+
}
264+
}
221265
}
222266

223267
// Upsert the subscription row to hybrid state, keyed on stripe_subscription_id.
268+
// Reached when: (a) a row already exists for this stripe_subscription_id (normal
269+
// renewal), or (b) no row exists for either stripe_subscription_id or instance_id
270+
// (first settlement with no prior instance row — rare but possible if the instance
271+
// was destroyed between checkout and webhook delivery).
224272
await tx
225273
.insert(kiloclaw_subscriptions)
226274
.values({

0 commit comments

Comments
 (0)