Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 26 additions & 21 deletions .specs/kiloclaw-billing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 0 additions & 12 deletions src/lib/config.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
13 changes: 8 additions & 5 deletions src/lib/kiloclaw/stripe-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,11 @@ const STRIPE_TO_CLAW_STATUS: Record<string, KiloClawSubscriptionStatus> = {

/**
* 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';
Expand Down Expand Up @@ -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_endthe 6-month commit term starts
// after the trial boundary, not at subscription creation time.
const commitEndsAt =
plan === 'commit'
? addMonths(
Expand Down
23 changes: 22 additions & 1 deletion src/routers/kiloclaw-billing-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
15 changes: 6 additions & 9 deletions src/routers/kiloclaw-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand All @@ -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 },
});
Expand Down
Loading