From ced8e38fe8088ef7317c0fe1f80b7c448efe8380 Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Tue, 5 May 2026 13:11:31 -0600 Subject: [PATCH] Adds a Subsciription Started email for credit-based billing --- apps/web/src/lib/kiloclaw/credit-billing.ts | 19 +++++-- apps/web/src/lib/purchase-emails.test.ts | 56 +++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/apps/web/src/lib/kiloclaw/credit-billing.ts b/apps/web/src/lib/kiloclaw/credit-billing.ts index 680ff7508..2515dbf84 100644 --- a/apps/web/src/lib/kiloclaw/credit-billing.ts +++ b/apps/web/src/lib/kiloclaw/credit-billing.ts @@ -1167,6 +1167,8 @@ export async function enrollWithCredits(params: { const now = new Date(); const periodMonths = plan === 'commit' ? 6 : 1; const periodEnd = addMonths(now, periodMonths); + const periodStartIso = now.toISOString(); + const periodEndIso = periodEnd.toISOString(); const periodKey = format(now, 'yyyy-MM'); const categoryPrefix = plan === 'commit' @@ -1229,8 +1231,6 @@ export async function enrollWithCredits(params: { .limit(1); // 5c: Upsert subscription row as pure credit - const nowIso = now.toISOString(); - const periodEndIso = periodEnd.toISOString(); const commitEndsAt = plan === 'commit' ? periodEndIso : null; const [mutatedSubscription] = await tx .insert(kiloclaw_subscriptions) @@ -1240,7 +1240,7 @@ export async function enrollWithCredits(params: { payment_source: 'credits', status: 'active', plan, - current_period_start: nowIso, + current_period_start: periodStartIso, current_period_end: periodEndIso, credit_renewal_at: periodEndIso, stripe_subscription_id: null, @@ -1259,7 +1259,7 @@ export async function enrollWithCredits(params: { payment_source: 'credits', status: 'active', plan, - current_period_start: nowIso, + current_period_start: periodStartIso, current_period_end: periodEndIso, credit_renewal_at: periodEndIso, stripe_subscription_id: null, @@ -1355,6 +1355,17 @@ export async function enrollWithCredits(params: { await autoResumeIfSuspended(userId, instanceId); } + if (shouldSendSubscriptionStartedEmailForActivation(existingSub?.status ?? null)) { + await maybeSendKiloClawSubscriptionStartedEmail({ + userId, + instanceId, + plan, + amountCents: Math.round(costMicrodollars / 10_000), + periodStart: periodStartIso, + periodEnd: periodEndIso, + }); + } + logInfo('Credit enrollment completed', { user_id: userId, instanceId, diff --git a/apps/web/src/lib/purchase-emails.test.ts b/apps/web/src/lib/purchase-emails.test.ts index 1507ef751..56aa6f1ed 100644 --- a/apps/web/src/lib/purchase-emails.test.ts +++ b/apps/web/src/lib/purchase-emails.test.ts @@ -1,3 +1,5 @@ +process.env.STRIPE_KILOCLAW_STANDARD_INTRO_PRICE_ID ||= 'price_standard_intro'; + import { eq, and } from 'drizzle-orm'; import { kiloclaw_email_log, @@ -272,6 +274,25 @@ describe('applyStripeFundedKiloClawPeriod subscription-started email', () => { return rows.length; } + async function seedCreditEnrollmentAnchor(userId: string) { + const [instance] = await db + .insert(kiloclaw_instances) + .values({ + user_id: userId, + sandbox_id: `test-sandbox-${crypto.randomUUID()}`, + }) + .returning(); + await db.insert(kiloclaw_subscriptions).values({ + user_id: userId, + instance_id: instance.id, + plan: 'trial', + status: 'trialing', + trial_started_at: new Date().toISOString(), + trial_ends_at: new Date(Date.now() + 7 * 86_400_000).toISOString(), + }); + return instance; + } + test('trialing trial → Stripe settlement sends one subscription-started email and writes the log row', async () => { const user = await insertTestUser({}); const stripeSubscriptionId = `sub_trialing_${crypto.randomUUID()}`; @@ -298,6 +319,41 @@ describe('applyStripeFundedKiloClawPeriod subscription-started email', () => { expect(await countEmailLogRows(user.id, instance.id)).toBe(1); }); + test('trialing trial → credit enrollment sends one subscription-started email and writes the log row', async () => { + const user = await insertTestUser({ total_microdollars_acquired: 50_000_000 }); + const instance = await seedCreditEnrollmentAnchor(user.id); + + const mod = await import('@/lib/kiloclaw/credit-billing'); + await mod.enrollWithCredits({ + userId: user.id, + instanceId: instance.id, + plan: 'standard', + hadPaidSubscription: false, + }); + + expect(countSubscriptionStartedSends()).toBe(1); + expect(await countEmailLogRows(user.id, instance.id)).toBe(1); + + const [subscription] = await db + .select() + .from(kiloclaw_subscriptions) + .where(eq(kiloclaw_subscriptions.instance_id, instance.id)) + .limit(1); + const [emailLog] = await db + .select() + .from(kiloclaw_email_log) + .where( + and( + eq(kiloclaw_email_log.user_id, user.id), + eq(kiloclaw_email_log.instance_id, instance.id), + eq(kiloclaw_email_log.email_type, KILOCLAW_SUBSCRIPTION_STARTED_EMAIL_TYPE) + ) + ) + .limit(1); + + expect(emailLog?.period_start).toBe(subscription.current_period_start); + }); + test('canceled trial → Stripe settlement sends one subscription-started email', async () => { const user = await insertTestUser({}); const stripeSubscriptionId = `sub_canceled_trial_${crypto.randomUUID()}`;