diff --git a/apps/web/src/lib/kiloclaw/instance-lifecycle.ts b/apps/web/src/lib/kiloclaw/instance-lifecycle.ts index f3678d6c5..3f538c4a2 100644 --- a/apps/web/src/lib/kiloclaw/instance-lifecycle.ts +++ b/apps/web/src/lib/kiloclaw/instance-lifecycle.ts @@ -21,6 +21,7 @@ const INSTANCE_LIFECYCLE_ACTOR = { actorType: 'system', actorId: 'web-instance-lifecycle', } as const; +const INSTANCE_DESTROYED_REASON = 'instance_destroyed'; type ActiveInstance = { id: string; @@ -70,6 +71,58 @@ function subscriptionFilterForUser(kiloUserId: string, instanceId?: string) { ); } +export async function clearSubscriptionLifecycleAfterInstanceDestroy(params: { + actorUserId: string; + kiloUserId: string; + instanceId: string; +}): Promise { + await db.transaction(async tx => { + const [subscription] = await tx + .select() + .from(kiloclaw_subscriptions) + .where(subscriptionFilterForUser(params.kiloUserId, params.instanceId)) + .limit(1); + + if (!subscription) { + return; + } + + const clearFields: { destruction_deadline: null; suspended_at?: null } = { + destruction_deadline: null, + }; + + if (subscription.status !== 'past_due') { + clearFields.suspended_at = null; + } + + const [updatedSubscription] = await tx + .update(kiloclaw_subscriptions) + .set(clearFields) + .where(eq(kiloclaw_subscriptions.id, subscription.id)) + .returning(); + + const clearedSuspension = + subscription.destruction_deadline !== null || + (subscription.status !== 'past_due' && subscription.suspended_at !== null); + + if (!updatedSubscription || !clearedSuspension) { + return; + } + + await insertKiloClawSubscriptionChangeLog(tx, { + subscriptionId: subscription.id, + actor: { + actorType: 'user', + actorId: params.actorUserId, + }, + action: 'status_changed', + reason: INSTANCE_DESTROYED_REASON, + before: subscription, + after: updatedSubscription, + }); + }); +} + async function clearAutoResumeState( kiloUserId: string, options: { diff --git a/apps/web/src/routers/kiloclaw-router.test.ts b/apps/web/src/routers/kiloclaw-router.test.ts index 3888d3790..106e5758a 100644 --- a/apps/web/src/routers/kiloclaw-router.test.ts +++ b/apps/web/src/routers/kiloclaw-router.test.ts @@ -1,16 +1,19 @@ process.env.STRIPE_KILOCLAW_COMMIT_PRICE_ID ||= 'price_commit'; process.env.STRIPE_KILOCLAW_STANDARD_PRICE_ID ||= 'price_standard'; process.env.STRIPE_KILOCLAW_STANDARD_INTRO_PRICE_ID ||= 'price_standard_intro'; +process.env.KILOCLAW_API_URL ||= 'https://claw.test'; +process.env.KILOCLAW_INTERNAL_API_SECRET ||= 'test-secret'; -import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; import { cleanupDbForTest, db } from '@/lib/drizzle'; import { createCallerFactory } from '@/lib/trpc/init'; -import { kiloclawRouter } from '@/routers/kiloclaw-router'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { kiloclaw_inbound_email_aliases, kiloclaw_inbound_email_reserved_aliases, kiloclaw_instances, + kiloclaw_subscription_change_log, + kiloclaw_subscriptions, } from '@kilocode/db/schema'; import { eq } from 'drizzle-orm'; @@ -20,6 +23,7 @@ type AnyMock = jest.Mock<(...args: any[]) => any>; type KiloClawClientMock = { KiloClawInternalClient: AnyMock; __getStatusMock: AnyMock; + __destroyMock: AnyMock; }; jest.mock('@/lib/stripe-client', () => { @@ -60,11 +64,23 @@ jest.mock('next/headers', () => { }; }); +jest.mock('@/lib/config.server', () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = jest.requireActual('@/lib/config.server'); + return { + ...actual, + KILOCLAW_API_URL: 'https://claw.test', + KILOCLAW_INTERNAL_API_SECRET: 'test-secret', + }; +}); + jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => { const getStatusMock = jest.fn(); + const destroyMock = jest.fn(); return { KiloClawInternalClient: jest.fn().mockImplementation(() => ({ getStatus: getStatusMock, + destroy: destroyMock, })), KiloClawApiError: class KiloClawApiError extends Error { statusCode: number; @@ -76,19 +92,30 @@ jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => { } }, __getStatusMock: getStatusMock, + __destroyMock: destroyMock, }; }); -const createCaller = createCallerFactory(kiloclawRouter); +let createCaller: (ctx: { user: Awaited> }) => { + getStatus: () => Promise; + cycleInboundEmailAddress: () => Promise<{ inboundEmailAddress: string }>; + destroy: () => Promise<{ ok: true }>; +}; const kiloclawClientMock = jest.requireMock( '@/lib/kiloclaw/kiloclaw-internal-client' ); +beforeAll(async () => { + const mod = await import('@/routers/kiloclaw-router'); + createCaller = createCallerFactory(mod.kiloclawRouter); +}); + describe('kiloclawRouter getStatus', () => { beforeEach(async () => { await cleanupDbForTest(); kiloclawClientMock.KiloClawInternalClient.mockClear(); kiloclawClientMock.__getStatusMock.mockReset(); + kiloclawClientMock.__destroyMock.mockReset(); }); it('returns a no-instance sentinel without querying the legacy worker path', async () => { @@ -127,7 +154,7 @@ describe('kiloclawRouter getStatus', () => { botNature: null, botVibe: null, botEmoji: null, - workerUrl: 'https://claw.kilo.ai', + workerUrl: 'https://claw.test', name: null, instanceId: null, inboundEmailAddress: null, @@ -162,3 +189,79 @@ describe('kiloclawRouter getStatus', () => { expect(rows.filter(row => row.retired_at === null)).toHaveLength(1); }); }); + +describe('kiloclawRouter destroy', () => { + beforeEach(async () => { + await cleanupDbForTest(); + kiloclawClientMock.KiloClawInternalClient.mockClear(); + kiloclawClientMock.__destroyMock.mockReset(); + kiloclawClientMock.__destroyMock.mockResolvedValue({ ok: true }); + jest.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + }); + + it('clears subscription destruction lifecycle and writes changelog', async () => { + const user = await insertTestUser({ + google_user_email: `kiloclaw-destroy-test-${Math.random()}@example.com`, + }); + const instanceId = crypto.randomUUID(); + await db.insert(kiloclaw_instances).values({ + id: instanceId, + user_id: user.id, + sandbox_id: `ki_${instanceId.replace(/-/g, '')}`, + }); + await db.insert(kiloclaw_subscriptions).values({ + user_id: user.id, + instance_id: instanceId, + plan: 'standard', + status: 'active', + suspended_at: '2026-04-10T00:00:00.000Z', + destruction_deadline: '2026-04-12T00:00:00.000Z', + }); + + const caller = createCaller({ user }); + const result = await caller.destroy(); + + expect(result).toEqual({ ok: true }); + + const [subscription] = await db + .select() + .from(kiloclaw_subscriptions) + .where(eq(kiloclaw_subscriptions.instance_id, instanceId)) + .limit(1); + + expect(subscription.suspended_at).toBeNull(); + expect(subscription.destruction_deadline).toBeNull(); + + const logs = await db + .select() + .from(kiloclaw_subscription_change_log) + .where(eq(kiloclaw_subscription_change_log.subscription_id, subscription.id)); + + expect(logs).toHaveLength(1); + expect(logs[0]).toEqual( + expect.objectContaining({ + actor_type: 'user', + actor_id: user.id, + action: 'status_changed', + reason: 'instance_destroyed', + }) + ); + expect(logs[0]?.before_state).toEqual( + expect.objectContaining({ + suspended_at: expect.stringContaining('2026-04-10'), + destruction_deadline: expect.stringContaining('2026-04-12'), + }) + ); + expect(logs[0]?.after_state).toEqual( + expect.objectContaining({ + suspended_at: null, + destruction_deadline: null, + }) + ); + }); +}); diff --git a/apps/web/src/routers/kiloclaw-router.ts b/apps/web/src/routers/kiloclaw-router.ts index e057216f4..336a019f8 100644 --- a/apps/web/src/routers/kiloclaw-router.ts +++ b/apps/web/src/routers/kiloclaw-router.ts @@ -53,6 +53,7 @@ import { getPersonalProvisionLockKey, withKiloclawProvisionContextLock, } from '@/lib/kiloclaw/provision-lock'; +import { clearSubscriptionLifecycleAfterInstanceDestroy } from '@/lib/kiloclaw/instance-lifecycle'; import { dayjs } from '@/lib/kilo-pass/dayjs'; import { @@ -2042,34 +2043,12 @@ export const kiloclawRouter = createTRPCRouter({ // send warning emails or attempt a redundant destroy. // Current billing row stays anchored to destroyed instance until // reprovision bootstrap creates successor row on next provision. - // Only clear suspended_at for non-past_due subscriptions — nulling it - // on a past_due row would re-enable access without fixing payment. if (destroyedRow) { - const [sub] = await db - .select({ status: kiloclaw_subscriptions.status }) - .from(kiloclaw_subscriptions) - .where( - and( - eq(kiloclaw_subscriptions.user_id, ctx.user.id), - eq(kiloclaw_subscriptions.instance_id, destroyedRow.id) - ) - ) - .limit(1); - const clearFields: { destruction_deadline: null; suspended_at?: null } = { - destruction_deadline: null, - }; - if (sub && sub.status !== 'past_due') { - clearFields.suspended_at = null; - } - await db - .update(kiloclaw_subscriptions) - .set(clearFields) - .where( - and( - eq(kiloclaw_subscriptions.user_id, ctx.user.id), - eq(kiloclaw_subscriptions.instance_id, destroyedRow.id) - ) - ); + await clearSubscriptionLifecycleAfterInstanceDestroy({ + actorUserId: ctx.user.id, + kiloUserId: ctx.user.id, + instanceId: destroyedRow.id, + }); } // Clear lifecycle emails so they can fire again if the user re-provisions. diff --git a/apps/web/src/routers/organizations/organization-kiloclaw-router.test.ts b/apps/web/src/routers/organizations/organization-kiloclaw-router.test.ts new file mode 100644 index 000000000..c0615711d --- /dev/null +++ b/apps/web/src/routers/organizations/organization-kiloclaw-router.test.ts @@ -0,0 +1,168 @@ +process.env.KILOCLAW_API_URL ||= 'https://claw.test'; +process.env.KILOCLAW_INTERNAL_API_SECRET ||= 'test-secret'; + +import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { cleanupDbForTest, db } from '@/lib/drizzle'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { createOrganization } from '@/lib/organizations/organizations'; +import { + kiloclaw_instances, + kiloclaw_subscription_change_log, + kiloclaw_subscriptions, +} from '@kilocode/db/schema'; +import { eq } from 'drizzle-orm'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyMock = jest.Mock<(...args: any[]) => any>; + +type KiloClawClientMock = { + __destroyMock: AnyMock; +}; + +jest.mock('@/lib/stripe-client', () => ({ + client: { + subscriptions: { retrieve: jest.fn(), update: jest.fn(), list: jest.fn() }, + subscriptionSchedules: { + create: jest.fn(), + update: jest.fn(), + release: jest.fn(), + retrieve: jest.fn(), + }, + checkout: { sessions: { create: jest.fn(), list: jest.fn(), expire: jest.fn() } }, + billingPortal: { sessions: { create: jest.fn() } }, + invoices: { list: jest.fn() }, + }, +})); + +jest.mock('next/headers', () => { + const fn = jest.fn as (...args: unknown[]) => AnyMock; + return { + cookies: fn().mockResolvedValue({ get: fn() }), + headers: fn().mockReturnValue(new Map()), + }; +}); + +jest.mock('@/lib/config.server', () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = jest.requireActual('@/lib/config.server'); + return { + ...actual, + KILOCLAW_API_URL: 'https://claw.test', + KILOCLAW_INTERNAL_API_SECRET: 'test-secret', + }; +}); + +jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => { + const destroyMock = jest.fn(); + return { + KiloClawInternalClient: jest.fn().mockImplementation(() => ({ + destroy: destroyMock, + })), + KiloClawApiError: class KiloClawApiError extends Error { + statusCode: number; + responseBody: string; + constructor(statusCode: number, responseBody: string) { + super(`KiloClawApiError: ${statusCode}`); + this.statusCode = statusCode; + this.responseBody = responseBody; + } + }, + __destroyMock: destroyMock, + }; +}); + +const kiloclawClientMock = jest.requireMock( + '@/lib/kiloclaw/kiloclaw-internal-client' +); +let createCallerForUser: (userId: string) => Promise<{ + organizations: { + kiloclaw: { + destroy: (input: { organizationId: string }) => Promise<{ ok: true }>; + }; + }; +}>; + +beforeAll(async () => { + const mod = await import('@/routers/test-utils'); + createCallerForUser = mod.createCallerForUser; +}); + +describe('organization kiloclaw destroy', () => { + beforeEach(async () => { + await cleanupDbForTest(); + kiloclawClientMock.__destroyMock.mockReset(); + kiloclawClientMock.__destroyMock.mockResolvedValue({ ok: true }); + jest.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + }); + + it('clears organization subscription destruction lifecycle and writes changelog', async () => { + const user = await insertTestUser({ + google_user_email: `org-kiloclaw-destroy-${Math.random()}@example.com`, + }); + const organization = await createOrganization('Org KiloClaw Destroy Test', user.id); + const instanceId = crypto.randomUUID(); + + await db.insert(kiloclaw_instances).values({ + id: instanceId, + user_id: user.id, + organization_id: organization.id, + sandbox_id: `ki_${instanceId.replace(/-/g, '')}`, + }); + await db.insert(kiloclaw_subscriptions).values({ + user_id: user.id, + instance_id: instanceId, + plan: 'standard', + status: 'active', + suspended_at: '2026-04-10T00:00:00.000Z', + destruction_deadline: '2026-04-12T00:00:00.000Z', + }); + + const caller = await createCallerForUser(user.id); + const result = await caller.organizations.kiloclaw.destroy({ + organizationId: organization.id, + }); + + expect(result).toEqual({ ok: true }); + + const [subscription] = await db + .select() + .from(kiloclaw_subscriptions) + .where(eq(kiloclaw_subscriptions.instance_id, instanceId)) + .limit(1); + + expect(subscription.suspended_at).toBeNull(); + expect(subscription.destruction_deadline).toBeNull(); + + const logs = await db + .select() + .from(kiloclaw_subscription_change_log) + .where(eq(kiloclaw_subscription_change_log.subscription_id, subscription.id)); + + expect(logs).toHaveLength(1); + expect(logs[0]).toEqual( + expect.objectContaining({ + actor_type: 'user', + actor_id: user.id, + action: 'status_changed', + reason: 'instance_destroyed', + }) + ); + expect(logs[0]?.before_state).toEqual( + expect.objectContaining({ + suspended_at: expect.stringContaining('2026-04-10'), + destruction_deadline: expect.stringContaining('2026-04-12'), + }) + ); + expect(logs[0]?.after_state).toEqual( + expect.objectContaining({ + suspended_at: null, + destruction_deadline: null, + }) + ); + }); +}); diff --git a/apps/web/src/routers/organizations/organization-kiloclaw-router.ts b/apps/web/src/routers/organizations/organization-kiloclaw-router.ts index a0c095293..b7dfb6646 100644 --- a/apps/web/src/routers/organizations/organization-kiloclaw-router.ts +++ b/apps/web/src/routers/organizations/organization-kiloclaw-router.ts @@ -23,7 +23,6 @@ import { kiloclaw_version_pins, kiloclaw_image_catalog, kiloclaw_cli_runs, - kiloclaw_subscriptions, } from '@kilocode/db/schema'; import { and, eq, desc, sql } from 'drizzle-orm'; import type { KiloClawDashboardStatus, KiloCodeConfigResponse } from '@/lib/kiloclaw/types'; @@ -40,6 +39,7 @@ import { restoreDestroyedInstance, workerInstanceId, } from '@/lib/kiloclaw/instance-registry'; +import { clearSubscriptionLifecycleAfterInstanceDestroy } from '@/lib/kiloclaw/instance-lifecycle'; import { getOrganizationProvisionLockKey, withKiloclawProvisionContextLock, @@ -511,15 +511,11 @@ export const organizationKiloclawRouter = createTRPCRouter({ } try { - await db - .update(kiloclaw_subscriptions) - .set({ destruction_deadline: null }) - .where( - and( - eq(kiloclaw_subscriptions.user_id, ctx.user.id), - eq(kiloclaw_subscriptions.instance_id, instance.id) - ) - ); + await clearSubscriptionLifecycleAfterInstanceDestroy({ + actorUserId: ctx.user.id, + kiloUserId: ctx.user.id, + instanceId: instance.id, + }); } catch (cleanupError) { console.error('[organization-kiloclaw] Post-destroy cleanup failed:', cleanupError); } diff --git a/services/kiloclaw-billing/src/lifecycle.test.ts b/services/kiloclaw-billing/src/lifecycle.test.ts index 8ce7835b9..44d368aad 100644 --- a/services/kiloclaw-billing/src/lifecycle.test.ts +++ b/services/kiloclaw-billing/src/lifecycle.test.ts @@ -1049,6 +1049,216 @@ describe('credit renewal sweep affiliate tracking', () => { ]); }); + it('marks auto-top-up-triggered period and writes changelog before triggering top-up', async () => { + const renewalAt = '2026-04-09T10:00:00.000Z'; + const beforeRow = { + id: 'sub-1', + user_id: 'user-1', + email: 'user-1@example.com', + instance_id: 'instance-1', + instance_row_id: 'instance-1', + organization_id: null, + instance_destroyed_at: null, + plan: 'standard', + status: 'active', + credit_renewal_at: renewalAt, + current_period_end: renewalAt, + cancel_at_period_end: false, + scheduled_plan: null, + commit_ends_at: null, + past_due_since: null, + suspended_at: null, + auto_resume_attempt_count: 0, + auto_top_up_triggered_for_period: null, + total_microdollars_acquired: 1_000_000, + microdollars_used: 900_000, + auto_top_up_enabled: true, + kilo_pass_threshold: null, + next_credit_expiration_at: null, + user_updated_at: '2026-04-09T09:00:00.000Z', + }; + const afterRow = { + ...beforeRow, + auto_top_up_triggered_for_period: renewalAt, + }; + const { db, inserts, updates } = createMockDb([[beforeRow]], { + updateReturningRows: [[afterRow]], + }); + mockGetWorkerDb.mockReturnValue(db); + + const fetch = vi.fn(async (_request: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(typeof init?.body === 'string' ? init.body : '{}') as { + action: string; + input: Record; + }; + + switch (body.action) { + case 'project_pending_kilo_pass_bonus': + return new Response(JSON.stringify({ projectedBonusMicrodollars: 0 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + case 'trigger_user_auto_top_up': + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + default: + throw new Error(`Unexpected side effect action: ${body.action}`); + } + }); + vi.spyOn(globalThis, 'fetch').mockImplementation(fetch); + + const summary = await runSweep( + createEnv(vi.fn()), + { + runId: 'dadadada-dada-4ada-8ada-dadadadadada', + sweep: 'credit_renewal', + }, + 1 + ); + + expect(summary.credit_renewals_auto_top_up).toBe(1); + expect(summary.credit_renewals).toBe(0); + expect(summary.errors).toBe(0); + expect(updates).toEqual([{ auto_top_up_triggered_for_period: renewalAt }]); + expect(inserts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + actor_id: 'billing-lifecycle-job', + action: 'status_changed', + reason: 'credit_renewal_auto_top_up_marked', + }), + ]) + ); + + const sideEffectCalls = fetch.mock.calls.map( + ([, init]) => + JSON.parse(typeof init?.body === 'string' ? init.body : '{}') as { + action: string; + input: Record; + } + ); + + expect(sideEffectCalls).toEqual([ + { + action: 'project_pending_kilo_pass_bonus', + input: { + userId: 'user-1', + microdollarsUsed: 9_900_000, + kiloPassThreshold: null, + }, + }, + { + action: 'trigger_user_auto_top_up', + input: { + user: { + id: 'user-1', + total_microdollars_acquired: 1_000_000, + microdollars_used: 900_000, + auto_top_up_enabled: true, + next_credit_expiration_at: null, + updated_at: '2026-04-09T09:00:00.000Z', + }, + }, + }, + ]); + }); + + it('skips auto-top-up trigger when marker update loses concurrent race', async () => { + const renewalAt = '2026-04-09T10:00:00.000Z'; + const beforeRow = { + id: 'sub-1', + user_id: 'user-1', + email: 'user-1@example.com', + instance_id: 'instance-1', + instance_row_id: 'instance-1', + organization_id: null, + instance_destroyed_at: null, + plan: 'standard', + status: 'active', + credit_renewal_at: renewalAt, + current_period_end: renewalAt, + cancel_at_period_end: false, + scheduled_plan: null, + commit_ends_at: null, + past_due_since: null, + suspended_at: null, + auto_resume_attempt_count: 0, + auto_top_up_triggered_for_period: null, + total_microdollars_acquired: 1_000_000, + microdollars_used: 900_000, + auto_top_up_enabled: true, + kilo_pass_threshold: null, + next_credit_expiration_at: null, + user_updated_at: '2026-04-09T09:00:00.000Z', + }; + const { db, inserts, updates } = createMockDb([[beforeRow]], { + updateReturningRows: [[]], + }); + mockGetWorkerDb.mockReturnValue(db); + + const fetch = vi.fn(async (_request: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(typeof init?.body === 'string' ? init.body : '{}') as { + action: string; + input: Record; + }; + + switch (body.action) { + case 'project_pending_kilo_pass_bonus': + return new Response(JSON.stringify({ projectedBonusMicrodollars: 0 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + case 'trigger_user_auto_top_up': + throw new Error('trigger_user_auto_top_up should not run after lost marker race'); + default: + throw new Error(`Unexpected side effect action: ${body.action}`); + } + }); + vi.spyOn(globalThis, 'fetch').mockImplementation(fetch); + + const summary = await runSweep( + createEnv(vi.fn()), + { + runId: 'dededede-dede-4ede-8ede-dededededede', + sweep: 'credit_renewal', + }, + 1 + ); + + expect(summary.credit_renewals_auto_top_up).toBe(0); + expect(summary.credit_renewals).toBe(0); + expect(summary.errors).toBe(0); + expect(updates).toEqual([{ auto_top_up_triggered_for_period: renewalAt }]); + expect(inserts).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + reason: 'credit_renewal_auto_top_up_marked', + }), + ]) + ); + + const sideEffectCalls = fetch.mock.calls.map( + ([, init]) => + JSON.parse(typeof init?.body === 'string' ? init.body : '{}') as { + action: string; + input: Record; + } + ); + + expect(sideEffectCalls).toEqual([ + { + action: 'project_pending_kilo_pass_bonus', + input: { + userId: 'user-1', + microdollarsUsed: 9_900_000, + kiloPassThreshold: null, + }, + }, + ]); + }); + it('skips organization-managed rows in personal credit renewal sweep', async () => { const { db, txInserts, txUpdates } = createMockDb([ [ diff --git a/services/kiloclaw-billing/src/lifecycle.ts b/services/kiloclaw-billing/src/lifecycle.ts index 0636c8acb..a5581faf2 100644 --- a/services/kiloclaw-billing/src/lifecycle.ts +++ b/services/kiloclaw-billing/src/lifecycle.ts @@ -1269,10 +1269,31 @@ async function processCreditRenewalRow( } if (row.auto_top_up_enabled && !row.auto_top_up_triggered_for_period) { - await database + const [updated] = await database .update(kiloclaw_subscriptions) .set({ auto_top_up_triggered_for_period: renewalAt }) - .where(eq(kiloclaw_subscriptions.id, row.id)); + .where( + and( + eq(kiloclaw_subscriptions.id, row.id), + isNull(kiloclaw_subscriptions.auto_top_up_triggered_for_period) + ) + ) + .returning(); + + if (!updated) { + return; + } + + await insertLifecycleChangeLogBestEffort(database, { + subscriptionId: row.id, + action: 'status_changed', + reason: 'credit_renewal_auto_top_up_marked', + before: { + ...updated, + auto_top_up_triggered_for_period: null, + }, + after: updated, + }); try { await triggerUserAutoTopUp(env, context, {