feat(billing): add monthly billing cycle for Team and Enterprise seats#1609
Merged
jeanduplessis merged 24 commits intomainfrom Mar 31, 2026
Merged
feat(billing): add monthly billing cycle for Team and Enterprise seats#1609jeanduplessis merged 24 commits intomainfrom
jeanduplessis merged 24 commits intomainfrom
Conversation
edd2aef to
d6b2084
Compare
Contributor
Code Review SummaryStatus: 5 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)CRITICAL
WARNING
Other Observations (not in diff)None. Files Reviewed (52 files)
Fix these issues in Kilo Cloud Reviewed by gpt-5.4-20260305 · 5,572,415 tokens |
…criptions The credit-on-subscription-creation feature was already disabled via ENABLE_ORG_CREATION_FREE_CREDITS=false. Remove the feature flag, the credit-granting block inside handleSubscriptionEventInternal, and all associated imports, test helpers, and tests.
Add monthly billing option for team/enterprise seats (/ per month) alongside existing annual pricing. Align spec structure with kiloclaw-billing spec: add Role of This Document, Definitions, Changelog sections, and wrap long lines for readability.
Interactive HTML prototype covering all monthly billing UI changes: - Upgrade dialog with Monthly/Annual toggle and dynamic pricing - Subscription overview cards for monthly and annual contexts - Billing cycle change confirmation with Now/New comparison layout - Seat change modals with live +/- controls, cost preview, validation - Pending cycle change banner with cancel action Deployed to https://monthly-billing-prototype.pages.dev/monthly-billing.html
…se seats Implement monthly billing alongside existing annual billing per the spec changelog 2026-03-26. Users now select monthly or annual billing at checkout, with cycle-specific Stripe prices resolved from env vars. Backend: - Add pricing constants for both cycles (Teams /, Enterprise /) - Add BillingCycle type with DB/API/Stripe boundary mapping - Replace product.default_price lookup with explicit getPriceIdForPlanAndCycle - Populate billing_cycle column from Stripe subscription interval - Add changeBillingCycle/cancelBillingCycleChange endpoints via Stripe subscription schedules (takes effect at renewal, no proration) - Expand schedule on subscription retrieval for pending change detection Frontend: - Add Monthly/Annual pill toggle with Save 17% badge to UpgradeTrialDialog - Dynamic PlanCard billing label (Billed monthly/Billed annually) - Switch to Monthly/Annual button in SubscriptionQuickActions - BillingCycleChangeDialog with cost comparison and effective date - Pending cycle change banner in SubscriptionOverviewCard with cancel Tests: - 3 tests for billing_cycle tracking (monthly, yearly, null fallback) - 2 tests for billingCycle schema validation on checkout endpoint
…le-change, and seat preview - Preserve billing cycle when resubscribing ended monthly orgs - Map all subscription items in cycle-change schedule phases - Release orphaned schedules when phase update fails - Infer pending cycle from current interval (phase prices aren't expanded) - Use billing-cycle-aware seat rates in change-seat cost preview
…, and UI parity - Identify paid seat item by known price IDs instead of assuming items[0] - Preserve subscription-level discounts (promotion codes) in schedule phases - Allow billing managers to stop cancellation and resubscribe - Show pending cycle and renewal info in subscription overview cards - Sum all items' quantities when resubscribing ended subscriptions - Verify schedule has 2 phases before releasing in cancelBillingCycleChange
…ems[0] When a subscription has mixed paid/free seat items, the free-seat item can sort first. The pendingCycleChange detection now compares ALL phase2 prices against the current subscription's price set, and derives the billing interval from the first item with a recurring interval. Also consolidates all items[0] interval lookups into a single currentBillingInterval computed value.
…splay - Add getPlanForPriceId() to derive plan from the live Stripe price ID instead of org.plan which can drift from the actual subscription - Show 'Changes at renewal' instead of stale dollar amount when a billing cycle change is pending (phase 2 prices aren't expanded) - Sum all items for next-payment display in the non-pending case too
…ge dialog - Filter to paid items (unit_amount > 0) when computing resubscribe seat count to avoid converting free seats into paid ones - Derive seat count and interval from paid seat item in QuickActions - Add inferPlanFromUnitAmount() to derive plan tier from Stripe price instead of org.plan which can drift from the live subscription
…refactors - Enforce require_seats on all new orgs; add seat-usage gating to trial middleware and member invitations - Add organization_membership_removals table for audit/tombstone tracking of removed members - Add model/provider deny-list enforcement for enterprise orgs - Harden billing-cycle detection, resubscribe defaults, and seat-change logic to correctly handle mixed paid/free subscription items - GDPR: integrate membership removals into softDeleteUser - KISS: consolidate price constants into SEAT_PRICING lookup table, extract shared subscription helpers (canManageBilling, findPaidSeatItem, paidSeatQuantity), rename organizationOwnerProcedure to organizationBillingProcedure, unify billing-cycle converters, extract detectPendingCycleChange, tighten Stripe error detection
…seed resubscribe defaults
… and cancel safety - Derive paid-only seat count from Stripe when prefilling resubscribe checkout, falling back to DB total if retrieval fails (P1) - Extract billing cycle from paid seat line item (unit_amount > 0) instead of items[0] which may be a free promo item (P2) - Sort ended purchases by expires_at instead of created_at so resubscribe picks the correct subscription regardless of webhook delivery order (P3) - Defer schedule release until cancel succeeds to avoid silently dropping a pending billing-cycle change on transient errors (P4) - Store actual Stripe subscription status instead of collapsing all non-ended states to active - Consolidate repeated inline as-casts in SubscriptionOverviewCard - Remove stale config comment, simplify trial-utils docblock, precompute effectiveDate in SubscriptionQuickActions
… disable agents - Clamp resubscribe default seat count to at least current usedSeats so orgs that grew while unsubscribed aren't sent to checkout with fewer seats than they need - Use organizationBillingProcedure (no trial gate) for auto-fix toggleAgent and auto-triage saveConfig; enforce trial only when enabling so hard-expired orgs can still disable running agents
The useEffect that seeds resubscribe checkout now waits for both resubscribeDefaults and seatUsage queries to resolve before setting the seat count, ensuring the clamp against current usage is always accurate regardless of query resolution order.
…e choice Guard the resubscribe-defaults seeding effect with a useRef flag so it only runs once. Later seatUsage query refetches no longer trigger setBillingCycle, which was overriding the user's manual cycle toggle.
User-facing billing cycle and seat count controls now mark the seededFromDefaults ref, so the one-time seeding effect is skipped if the user interacted with the form before queries resolved.
… dynamic plan label Move metadata from subscriptionSchedules.create() to .update() to comply with Stripe's from_subscription constraint. Allow ended subscriptions to be fetched so the resubscribe UI renders. Dynamically set plan title based on org plan (Enterprise vs Teams). Polish effective-date fallback copy and button styles.
ef26a41 to
9f9025c
Compare
Contributor
|
How has this been tested? I don't see any new products in test mode in stripe |
jrf0110
reviewed
Mar 30, 2026
RSO
approved these changes
Mar 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR implements seat-based billing enforcement, membership audit tracking, and enterprise model restrictions, then applies a comprehensive KISS refactoring pass across the billing/subscription codebase.
Seat Billing Enforcement
require_seats: true(migration 0064)unit_amount > 0on client,KNOWN_SEAT_PRICE_IDSon server)Membership Audit Tombstones
organization_membership_removalstable (migration 0065) tracks who was removed, when, by whom, and their previous rolesoftDeleteUserupdated to delete/anonymize tombstone recordsEnterprise Model Restrictions
getEffectiveModelRestrictions()enforces model/provider deny lists only for enterprise orgsKISS Refactoring
inferPlanFromUnitAmount()withSEAT_PRICINGlookup table andseatPrice(plan, cycle)helpercanManageBilling(),findPaidSeatItem(),paidSeatQuantity(),detectPendingCycleChange()billingCycleFromDbandbillingCycleFromStripeIntervalintotoBillingCycle()organizationOwnerProcedure→organizationBillingProcedure(14 files) to reflect actual role permissionsBillingCycleChangeDialog.getPricing()from 28 lines to 5.includes('already')fallbackuseOrganizationWithMembersdependency fromUpgradeTrialDialogReview Fixes (from automated review pass)
organization_membership_removalsnot insoftDeleteUserpaidSeatCount→defaultSeatCountin resubscribe-defaults APIremoved_bynot passed in member removal routerVerification
pnpm typecheck— passes with zero errorspnpm test— 3343 tests pass; 3 test failures caused by branch changes were identified and fixed:stripe-3ds.test.ts: Updated mock items to includeprice.idforKNOWN_SEAT_PRICE_IDSorganization-subscription-event.test.ts: MockedretrieveSubscriptionfor H1 guard testdefaults/route.test.ts: Setplan: 'enterprise'on mock orgs that test deny-list enforcementkiloclaw-billing-router.test.tstimeout) is pre-existing/flakyManual Verification Plan
# Manual Verification Plan: Monthly Billing for Teams & EnterprisePrerequisites (All Journeys)
.dev-port)4242 4242 4242 4242(any future expiry, any CVC)Part A: Teams Subscription Paths
Journey A1: New Org → Teams Monthly Subscription
Pre-setup: None (fresh user).
Steps:
/organizations/new./organizations/{orgId}/welcome?firstTime=1./organizations/{orgId}/subscription.End-state validation:
1/3. Next Payment:$54.00. Days Until Renewal: ~30.organizationstableplan = 'teams',seat_count = 3.organization_seats_purchasessubscription_status = 'active',seat_count = 3,billing_cycle = 'monthly',amount_usd = 5400(cents).month, price = Teams Monthly price ID, quantity = 3. Metadata:type=seats,planType=teams.Journey A2: New Org → Teams Annual Subscription
Pre-setup: None (fresh user).
Steps:
/organizations/new./organizations/{orgId}/subscriptionand trigger the upgrade dialog.End-state validation:
1/5. Next Payment reflects annual amount.organizationsplan = 'teams',seat_count = 5.organization_seats_purchasesbilling_cycle = 'yearly',seat_count = 5,amount_usd = 90000.year, Teams Annual price ID.Journey A3: Teams Monthly → Switch to Annual Billing
Pre-setup: Complete Journey A1 (active Teams Monthly subscription with 3 seats).
Steps:
/organizations/{orgId}/subscription.$54/mo→ $45/mo (billed as $540/yr).End-state validation:
Journey A4: Teams Annual → Switch to Monthly Billing
Pre-setup: Complete Journey A2 (active Teams Annual subscription with 5 seats).
Steps:
/organizations/{orgId}/subscription.$75/mo→ $90/mo.End-state validation:
Journey A5: Cancel Pending Billing Cycle Change (Teams)
Pre-setup: Complete Journey A3 or A4 (pending billing cycle change exists).
Steps:
End-state validation:
canceledor removed).Journey A6: Teams Monthly → Cancel → Resubscribe (Preserves Monthly)
Pre-setup: Active Teams Monthly subscription (e.g., from Journey A1).
Steps:
End-state validation:
organization_seats_purchasesrow withbilling_cycle = 'monthly'. Organizationseat_countrestored.Journey A7: Teams Monthly → Mid-Subscription Seat Increase
Pre-setup: Active Teams Monthly subscription with 3 seats.
Steps:
End-state validation:
1/5. Next Payment updated to reflect 5 seats.organizationsseat_count = 5.organization_seats_purchasesseat_count = 5,billing_cycle = 'monthly'.Journey A8: Teams Monthly → Mid-Subscription Seat Decrease
Pre-setup: Active Teams Monthly subscription with 5 seats, 2 seats in use.
Steps:
End-state validation:
2/5(NOT2/3— downgrade deferred to next period). Seat Count Change Notification appears: "Next billing cycle your total seats will drop from 5 to 3".organizationsseat_count = 5(unchanged until next period).proration_behavior: none. Change effective at period end.Part B: Enterprise Subscription Paths
Journey B1: New Org → Enterprise Monthly Subscription
Pre-setup: None (fresh user).
Steps:
/organizations/{orgId}/subscriptionand trigger the upgrade dialog.currentPlan= enterprise and the dialog initializesselectedPlantocurrentPlan).End-state validation:
1/2. Next Payment:$144.00.organizationsplan = 'enterprise',seat_count = 2.organization_seats_purchasesbilling_cycle = 'monthly',seat_count = 2,amount_usd = 14400.month, Enterprise Monthly price ID, quantity = 2.Journey B2: New Org → Enterprise Annual Subscription
Pre-setup: None (fresh user).
Steps:
End-state validation:
1/10.plan = 'enterprise',seat_count = 10, purchasebilling_cycle = 'yearly',amount_usd = 720000.Journey B3: Enterprise Monthly → Switch to Annual Billing
Pre-setup: Active Enterprise Monthly subscription (e.g., Journey B1, 2 seats).
Steps:
$144/mo→ $120/mo (billed $1,440/yr)End-state validation:
Journey B4: Enterprise Annual → Switch to Monthly Billing
Pre-setup: Active Enterprise Annual subscription (Journey B2, 10 seats).
Steps:
$600/mo→ $720/moEnd-state validation:
Journey B5: Cancel Pending Billing Cycle Change (Enterprise)
Pre-setup: Complete Journey B3 or B4.
Identical steps and validation as Journey A5 but for Enterprise pricing.
Journey B6: Enterprise Monthly → Cancel → Resubscribe (Preserves Monthly)
Pre-setup: Active Enterprise Monthly subscription.
Steps:
End-state validation:
billing_cycle = 'monthly'. Orgplan = 'enterprise',seat_countrestored.Journey B7: Enterprise Monthly → Mid-Subscription Seat Increase
Pre-setup: Active Enterprise Monthly, 2 seats.
Steps:
End-state validation:
1/5.seat_count = 5. New purchase row.Journey B8: Enterprise Monthly → Mid-Subscription Seat Decrease
Pre-setup: Active Enterprise Monthly, 5 seats, 2 in use.
Steps:
End-state validation:
2/5. "Seat Count Change Scheduled" notification: "Next billing cycle your total seats will drop from 5 to 3".Part C: Cross-Plan Journeys (Monthly-Specific)
Journey C1: Enterprise Trial → Choose Teams Monthly at Checkout
Pre-setup: New org (starts on Enterprise trial).
Steps:
End-state validation:
organizationsplan = 'teams'(changed from enterprise).planType = 'teams'.Journey C2: Upgrade Dialog Defaults — Verify Resubscribe Seeding
Pre-setup: Org that previously had a Teams Monthly subscription (now ended) with 5 paid seats.
Steps:
max(previousPaidSeats, currentUsedSeats).End-state validation: Visual confirmation only — the dialog correctly seeds from
getResubscribeDefaults.Part D: Access Control Verification (Applies to Both Plans)
Journey D1: Billing Manager Can Manage Monthly Subscription
Pre-setup: Active monthly subscription. Invite a user with
billing_managerrole.Steps:
/organizations/{orgId}/subscription.Journey D2: Regular Member Cannot Manage Subscription
Pre-setup: Active monthly subscription. Invite a user with
memberrole.Steps:
/organizations/{orgId}/subscription.Database Queries for Validation
Summary Matrix
Visual Changes
Recording: https://cln.sh/WXNR7wNZ
Reviewer Notes
SEAT_PRICINGfor backward compatibility — no external consumers need updatingorganizationBillingProcedurerename is purely cosmetic (same['owner', 'billing_manager']role check) but touches 14 router filesrequire_seatsDB default change (migration 0064) only affects code paths that omit the column on insert; all production insert paths explicitly set the valuetoBillingCycleaccepts the union of DB ('yearly') and Stripe ('year') values; old function names are retained as typed aliases