diff --git a/.env.test b/.env.test index cbe5b1343..42cb5bd5e 100644 --- a/.env.test +++ b/.env.test @@ -79,7 +79,6 @@ STRIPE_KILOCLAW_EARLYBIRD_COUPON_ID=coupon_test_kiloclaw_earlybird STRIPE_KILOCLAW_COMMIT_PRICE_ID=price_test_kiloclaw_commit STRIPE_KILOCLAW_STANDARD_PRICE_ID=price_test_kiloclaw_standard STRIPE_KILOCLAW_STANDARD_FIRST_MONTH_COUPON_ID=coupon_test_kiloclaw_standard_first_month -STRIPE_KILOCLAW_BILLING_START= # Stripe publishable key for client-side 3DS authentication NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_invalid_mock_key diff --git a/.specs/kiloclaw-billing.md b/.specs/kiloclaw-billing.md index b7eebc57f..1f512fccb 100644 --- a/.specs/kiloclaw-billing.md +++ b/.specs/kiloclaw-billing.md @@ -45,9 +45,10 @@ lapses, with email notifications at each stage. ### Standard Plan Introductory Pricing -1. New standard plan subscribers who do not have a prior canceled +1. New standard plan subscribers who do not have a prior canceled paid subscription MUST receive an introductory price for their first - billing period. Returning subscribers with a previously canceled + billing period. A canceled trial does not count as a prior paid + subscription. Returning subscribers with a previously canceled paid subscription MUST receive the regular standard price. 2. The system MUST automatically transition introductory-price subscribers to the regular standard price at the end of the @@ -97,23 +98,18 @@ lapses, with email notifications at each stage. 2. The system MUST allow checkout when the existing subscription status is trialing or canceled. 3. The system MUST verify with the payment provider that no subscription - in active or trialing (delayed-billing) status already exists for the - customer before creating a new checkout session, to guard against - concurrent checkouts. This check does not cover provider-side - subscriptions in past-due status. + in active or trialing status already exists for the customer before + creating a new checkout session, to guard against concurrent + checkouts. This check does not cover provider-side subscriptions in + past-due status. 4. The system MUST allow promotional codes on checkout for both plans. 5. For standard plan checkout, the system MUST use the introductory - price when the user has no prior canceled subscription, and the - regular price when the user has a previously canceled subscription - (see Standard Plan Introductory Pricing). -6. When a configurable billing start date is set and is in the future, - the system MUST create the subscription with a delayed billing period - that begins on that date. -7. When the billing start date is unset or is in the past, the system - MUST start billing immediately with no delayed period. -8. The system SHOULD include referral tracking data in checkout sessions + price when the user has no prior canceled paid subscription, and the + regular price when the user has a previously canceled paid + subscription (see Standard Plan Introductory Pricing). +6. The system SHOULD include referral tracking data in checkout sessions when a referral cookie is present. -9. The system SHOULD attempt to expire open checkout sessions tagged as +7. The system SHOULD attempt to expire open checkout sessions tagged as KiloClaw before creating a new checkout session, so users who abandoned a previous checkout can start fresh. Expiration is best-effort: errors from the payment provider (e.g. the session was @@ -129,9 +125,8 @@ lapses, with email notifications at each stage. the subscription to the standard plan. 2. When a commit subscription is created, the system MUST record a commit-period end date six calendar months from the billing start. - When a delayed-billing period is configured, the six months MUST - start from the delayed-billing end date, not from subscription - creation. + For pre-launch subscriptions that had a delayed-billing trial_end, + the six months starts from that trial boundary. 3. When a subscription update is received and the commit-period end date is in the past, the system MUST extend it by six calendar months from the previous boundary, keeping the subscription on the @@ -293,8 +288,9 @@ lapses, with email notifications at each stage. ### Payment Provider Status Mapping 1. When the payment provider reports a subscription as "trialing" - (delayed billing), the system MUST map this to active status - internally, since delayed billing is not a product-level trial. + (e.g. pre-launch delayed billing), the system MUST map this to + active status internally, since delayed billing is not a + product-level trial. 2. When the payment provider reports "incomplete" or "paused" status, the system MUST map these to terminal statuses (unpaid or canceled respectively). @@ -334,6 +330,15 @@ lapses, with email notifications at each stage. ### Changelog +#### 2026-03-21 -- Remove delayed-billing start date + +- Removed configurable billing start date (`STRIPE_KILOCLAW_BILLING_START`) + and associated checkout rules (former rules 6–7). New subscriptions now + always bill immediately. +- Pre-launch subscriptions created with a delayed `trial_end` are still + handled correctly; the trialing→active status mapping remains until those + subscriptions transition. + #### 2026-03-20 -- Promotional codes and introductory pricing Previous values: diff --git a/AGENTS.md b/AGENTS.md index 8bd62e8c0..a8c97726f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,3 +52,7 @@ When creating or updating a pull request, you **must** follow the PR template in - Do not leave HTML comments from the template in the final description — replace them with actual content. - PR descriptions must be accurate and valuable to reviewers. Generic or boilerplate descriptions waste reviewer time. - Review all commits on the branch (not just the latest) when writing the summary. + +## KiloClaw Billing + +Before making **any** changes related to KiloClaw billing — including bug fixes, new features, refactors, or reviews — you **must** first read the billing spec at `.specs/kiloclaw-billing.md`. This applies to all code paths that touch billing logic, pricing, invoicing, usage metering, or payment flows. diff --git a/src/lib/config.server.ts b/src/lib/config.server.ts index 80e580304..b762cb003 100644 --- a/src/lib/config.server.ts +++ b/src/lib/config.server.ts @@ -187,18 +187,6 @@ export const STRIPE_KILOCLAW_STANDARD_INTRO_PRICE_ID = getEnvVariable( 'STRIPE_KILOCLAW_STANDARD_INTRO_PRICE_ID' ); -// KiloClaw Billing — ISO 8601 date after which new checkouts are billed immediately -// (before this date, new subscriptions get a delayed trial_end so billing starts on launch day). -// Validated at startup so a malformed value causes a clear error instead of silently -// falling back to immediate billing. -const rawBillingStart = getEnvVariable('STRIPE_KILOCLAW_BILLING_START'); -if (rawBillingStart && Number.isNaN(new Date(rawBillingStart).getTime())) { - throw new Error( - `Invalid STRIPE_KILOCLAW_BILLING_START: '${rawBillingStart}'. Must be a valid ISO 8601 date or left empty.` - ); -} -export const STRIPE_KILOCLAW_BILLING_START = rawBillingStart; - // KiloClaw Billing Enforcement — opt-in gate for subscription/trial/earlybird checks. // When false (default), all billing gates are no-ops so users are never blocked. export const KILOCLAW_BILLING_ENFORCEMENT = diff --git a/src/lib/kiloclaw/stripe-handlers.ts b/src/lib/kiloclaw/stripe-handlers.ts index d58a376bd..e64edc04b 100644 --- a/src/lib/kiloclaw/stripe-handlers.ts +++ b/src/lib/kiloclaw/stripe-handlers.ts @@ -82,8 +82,11 @@ const STRIPE_TO_CLAW_STATUS: Record = { /** * Map a Stripe subscription status to our internal status. - * Only called for paid plans (commit/standard). Subscriptions created with - * trial_end (delayed billing) arrive as 'trialing' — treat as active. + * Only called for paid plans (commit/standard). Pre-launch subscriptions + * were created with a delayed trial_end — treat 'trialing' as active. + * + * TODO: Remove the trialing→active mapping once all pre-launch trial_end + * subscriptions have transitioned (after ~2026-03-23). */ function mapStripeStatus(stripeStatus: string): KiloClawSubscriptionStatus { if (stripeStatus === 'trialing') return 'active'; @@ -439,9 +442,9 @@ export async function handleKiloClawSubscriptionCreated(params: { // Captured after the stale guard so stale events don't auto-resume wasSuspended = !!existingRow?.suspended_at; - // For commit plans, derive commit_ends_at. If the subscription has a - // delayed-billing trial_end, the 6-month commit term starts after the - // trial boundary, not at subscription creation time. + // For commit plans, derive commit_ends_at. Pre-launch subscriptions + // had a delayed-billing trial_end — the 6-month commit term starts + // after the trial boundary, not at subscription creation time. const commitEndsAt = plan === 'commit' ? addMonths( diff --git a/src/routers/kiloclaw-billing-router.test.ts b/src/routers/kiloclaw-billing-router.test.ts index a68dec724..d3182b214 100644 --- a/src/routers/kiloclaw-billing-router.test.ts +++ b/src/routers/kiloclaw-billing-router.test.ts @@ -2,7 +2,6 @@ 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.STRIPE_KILOCLAW_BILLING_START ||= '2026-03-23T00:00:00Z'; import { describe, @@ -308,6 +307,28 @@ describe('createSubscriptionCheckout', () => { expect(callArgs.discounts).toBeUndefined(); }); + it('uses the intro price for users whose trial expired without a paid subscription', async () => { + await db.insert(kiloclaw_subscriptions).values({ + user_id: user.id, + plan: 'trial', + status: 'canceled', + }); + + stripeMock.checkout.sessions.create.mockResolvedValue({ + url: 'https://checkout.stripe.com/test', + }); + + const caller = await createCallerForUser(user.id); + await caller.kiloclaw.createSubscriptionCheckout({ plan: 'standard' }); + + const callArgs = stripeMock.checkout.sessions.create.mock.calls[0]?.[0] as Record< + string, + unknown + >; + // A canceled trial is not a prior paid subscription — should get intro price + expect(callArgs.line_items).toEqual([{ price: 'price_standard_intro', quantity: 1 }]); + }); + it('uses allow_promotion_codes for commit plan', async () => { stripeMock.checkout.sessions.create.mockResolvedValue({ url: 'https://checkout.stripe.com/test', diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts index 62813e325..85cb64310 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -17,7 +17,6 @@ import { KILOCLAW_API_URL, STRIPE_KILOCLAW_EARLYBIRD_PRICE_ID, STRIPE_KILOCLAW_EARLYBIRD_COUPON_ID, - STRIPE_KILOCLAW_BILLING_START, KILOCLAW_BILLING_ENFORCEMENT, } from '@/lib/config.server'; import { db } from '@/lib/drizzle'; @@ -1194,7 +1193,7 @@ export const kiloclawRouter = createTRPCRouter({ // Reject checkout if any non-ended subscription exists (active, past_due, unpaid). // The trialing status is exempted so trial users can convert to paid. const [existing] = await db - .select({ status: kiloclaw_subscriptions.status }) + .select({ status: kiloclaw_subscriptions.status, plan: kiloclaw_subscriptions.plan }) .from(kiloclaw_subscriptions) .where(eq(kiloclaw_subscriptions.user_id, ctx.user.id)) .limit(1); @@ -1230,10 +1229,12 @@ export const kiloclawRouter = createTRPCRouter({ staleKiloClawSessions.map(s => stripe.checkout.sessions.expire(s.id).catch(() => {})) ); - // New standard subscribers get the intro price; returning (canceled) standard - // subscribers and all commit subscribers get the regular price. + // New standard subscribers get the intro price; returning subscribers who + // previously had a paid subscription get the regular price. A canceled trial + // (plan === 'trial') does not count as a prior paid subscription. + const hadPaidSubscription = existing?.status === 'canceled' && existing.plan !== 'trial'; const priceId = - input.plan === 'standard' && existing?.status !== 'canceled' + input.plan === 'standard' && !hadPaidSubscription ? getStripePriceIdForClawPlanIntro('standard') : getStripePriceIdForClawPlan(input.plan); @@ -1252,10 +1253,6 @@ export const kiloclawRouter = createTRPCRouter({ cancel_url: `${APP_URL}/claw?checkout=cancelled`, subscription_data: { metadata: { type: 'kiloclaw', plan: input.plan, kiloUserId: ctx.user.id }, - ...(STRIPE_KILOCLAW_BILLING_START && - Date.now() < new Date(STRIPE_KILOCLAW_BILLING_START).getTime() - ? { trial_end: Math.floor(new Date(STRIPE_KILOCLAW_BILLING_START).getTime() / 1000) } - : {}), }, metadata: { type: 'kiloclaw', plan: input.plan, kiloUserId: ctx.user.id }, });