Skip to content

feat(billing): add monthly billing cycle for Team and Enterprise seats#1609

Open
jeanduplessis wants to merge 21 commits intomainfrom
team-enterprise-monthly-plan
Open

feat(billing): add monthly billing cycle for Team and Enterprise seats#1609
jeanduplessis wants to merge 21 commits intomainfrom
team-enterprise-monthly-plan

Conversation

@jeanduplessis
Copy link
Copy Markdown
Contributor

@jeanduplessis jeanduplessis commented Mar 26, 2026

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

  • All new organizations now default to require_seats: true (migration 0064)
  • Trial middleware and member invitation flows gate on seat availability
  • Seat-change, resubscribe, and billing-cycle-change logic hardened to correctly handle mixed paid/free subscription items (uses unit_amount > 0 on client, KNOWN_SEAT_PRICE_IDS on server)

Membership Audit Tombstones

  • New organization_membership_removals table (migration 0065) tracks who was removed, when, by whom, and their previous role
  • Tombstone records prevent webhook-driven re-addition of intentionally removed members
  • GDPR: softDeleteUser updated to delete/anonymize tombstone records

Enterprise Model Restrictions

  • getEffectiveModelRestrictions() enforces model/provider deny lists only for enterprise orgs
  • Defaults route returns appropriate errors when all models are blocked

KISS Refactoring

  • Replaced 8 individual price constants + inferPlanFromUnitAmount() with SEAT_PRICING lookup table and seatPrice(plan, cycle) helper
  • Extracted shared helpers: canManageBilling(), findPaidSeatItem(), paidSeatQuantity(), detectPendingCycleChange()
  • Unified billingCycleFromDb and billingCycleFromStripeInterval into toBillingCycle()
  • Renamed organizationOwnerProcedureorganizationBillingProcedure (14 files) to reflect actual role permissions
  • Simplified BillingCycleChangeDialog.getPricing() from 28 lines to 5
  • Tightened Stripe error detection: removed loose .includes('already') fallback
  • Removed unused useOrganizationWithMembers dependency from UpgradeTrialDialog

Review Fixes (from automated review pass)

  • Fixed GDPR gap: new organization_membership_removals not in softDeleteUser
  • Fixed billing-cycle detection reading free items instead of paid items
  • Renamed misleading paidSeatCountdefaultSeatCount in resubscribe-defaults API
  • Fixed removed_by not passed in member removal router

Verification

  • pnpm typecheck — passes with zero errors
  • pnpm test — 3343 tests pass; 3 test failures caused by branch changes were identified and fixed:
    • stripe-3ds.test.ts: Updated mock items to include price.id for KNOWN_SEAT_PRICE_IDS
    • organization-subscription-event.test.ts: Mocked retrieveSubscription for H1 guard test
    • defaults/route.test.ts: Set plan: 'enterprise' on mock orgs that test deny-list enforcement
  • Remaining 1 failure (kiloclaw-billing-router.test.ts timeout) is pre-existing/flaky

Visual Changes

N/A

Reviewer Notes

  • Legacy price constant exports are preserved as derived values from SEAT_PRICING for backward compatibility — no external consumers need updating
  • organizationBillingProcedure rename is purely cosmetic (same ['owner', 'billing_manager'] role check) but touches 14 router files
  • The require_seats DB default change (migration 0064) only affects code paths that omit the column on insert; all production insert paths explicitly set the value
  • toBillingCycle accepts the union of DB ('yearly') and Stripe ('year') values; old function names are retained as typed aliases

…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
@jeanduplessis jeanduplessis force-pushed the team-enterprise-monthly-plan branch from edd2aef to d6b2084 Compare March 28, 2026 18:17
@jeanduplessis jeanduplessis changed the title docs: add monthly billing UI prototype and implementation plan feat(billing): add monthly billing cycle for Team and Enterprise seats Mar 28, 2026
…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
@jeanduplessis jeanduplessis marked this pull request as ready for review March 28, 2026 18:37
@kilo-code-bot
Copy link
Copy Markdown
Contributor

kilo-code-bot bot commented Mar 28, 2026

Code Review Summary

Status: 5 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 4
SUGGESTION 0
Issue Details (click to expand)

CRITICAL

File Line Issue
src/routers/organizations/organization-members-router.ts 49 Billing managers can still invite/remove members and promote accounts to owner.

WARNING

File Line Issue
src/components/organizations/subscription/SubscriptionQuickActions.tsx 181 Cost previews still derive the plan from org.data.plan, so DB/Stripe drift shows the wrong prices.
src/lib/organizations/organization-seats.ts 199 Stripe lookup failures still fail open in the duplicate-subscription guard.
src/lib/stripe.ts 1293 The cancel fallback still releases attached schedules after any cancel error, so transient Stripe failures can drop a pending billing-cycle change.
src/routers/organizations/organization-settings-router.ts 187 Billing managers can still edit non-billing organization settings.
Other Observations (not in diff)

Issues found in unchanged code that cannot receive inline comments:

File Line Issue
src/components/organizations/subscription/SubscriptionQuickActions.tsx 181 Cost previews still derive the plan from org.data.plan, so DB/Stripe drift shows the wrong prices.
src/lib/organizations/organization-seats.ts 199 Stripe lookup failures still fail open in the duplicate-subscription guard.
src/lib/stripe.ts 1293 The cancel fallback still releases attached schedules after any cancel error, so transient Stripe failures can drop a pending billing-cycle change.
src/routers/organizations/organization-members-router.ts 49 Billing managers can still invite/remove members and promote accounts to owner.
src/routers/organizations/organization-settings-router.ts 187 Billing managers can still edit non-billing organization settings.
Files Reviewed (1 file)
  • src/components/organizations/UpgradeTrialDialog.tsx - no new issues

Fix these issues in Kilo Cloud


Reviewed by gpt-5.4-20260305 · 203,885 tokens

…, 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
… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant