Skip to content

Commit d6b2084

Browse files
committed
feat(billing): add monthly billing cycle option for Team and Enterprise 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
1 parent afdbc8a commit d6b2084

17 files changed

+1219
-41
lines changed

.env.test

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ WORKOS_API_KEY=invalid-mock-workos-key
3434
WORKOS_CLIENT_ID=invalid-mock-workos-client-id
3535
STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID='STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID'
3636
STRIPE_ENTERPRISE_SUBSCRIPTION_PRODUCT_ID='STRIPE_ENTERPRISE_SUBSCRIPTION_PRODUCT_ID'
37+
STRIPE_TEAMS_MONTHLY_PRICE_ID='price_test_teams_monthly'
38+
STRIPE_TEAMS_ANNUAL_PRICE_ID='price_test_teams_annual'
39+
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID='price_test_enterprise_monthly'
40+
STRIPE_ENTERPRISE_ANNUAL_PRICE_ID='price_test_enterprise_annual'
3741
USER_DEPLOYMENTS_API_BASE_URL=''
3842
USER_DEPLOYMENTS_API_AUTH_KEY=''
3943
SESSION_INGEST_WORKER_URL=''

.plans/monthly-billing-plan.md

Lines changed: 373 additions & 0 deletions
Large diffs are not rendered by default.

plans/monthly-billing-plan.md

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -155,14 +155,41 @@ Changes to `PlanCard`:
155155
- For annual: "Billed annually ($180/yr)" / "($720/yr)"
156156
- For monthly: "Billed monthly"
157157

158-
### Step 7: Update the subscription overview to show billing cycle correctly
158+
### Step 7: Update the subscription overview for pending cycle changes
159159

160160
**File**: `src/components/organizations/subscription/SubscriptionOverviewCard.tsx`
161161

162-
The overview card already displays `formatBillingInterval()` from the Stripe
163-
subscription's `price.recurring.interval` — this should work for both
164-
monthly and annual subscriptions without changes. Verify it renders correctly
165-
for monthly subscriptions.
162+
The overview card derives "Next Payment" amount and "Billing Cycle" label
163+
from `subscription.items[0]`. That reflects the _current_ cycle, which is
164+
wrong when a billing cycle change is scheduled — e.g., a monthly→annual
165+
switch would still show the monthly price and "Monthly" label even though
166+
the next invoice will charge the annual rate.
167+
168+
When a schedule is active (`subscription.schedule` is non-null), the card
169+
must read phase-2 of the schedule to show accurate upcoming-cycle data:
170+
171+
1. **Detect pending schedule**: Check `subscription.schedule`. When truthy,
172+
fetch the schedule via `client.subscriptionSchedules.retrieve(scheduleId)`
173+
(or expand it on the subscription fetch — see below).
174+
2. **Extract phase-2 data**: Read `schedule.phases[1]` to get the upcoming
175+
price ID, quantity, and start date.
176+
3. **Override display values**: When a pending cycle change exists:
177+
- **Next Payment** should show the phase-2 price × quantity, not the
178+
current line item amount.
179+
- **Billing Cycle** should show the upcoming interval (e.g., "Annual"
180+
instead of "Monthly") with a qualifier like "(starting {date})".
181+
- Alternatively, show both: current cycle with a banner/annotation
182+
indicating the upcoming change.
183+
4. **API support**: The `get` query on the subscription router currently
184+
returns the raw Stripe subscription. Either:
185+
- **(a)** Expand `schedule` on the subscription retrieve call
186+
(`expand: ['schedule']`) so the frontend has phase data, or
187+
- **(b)** Add a separate query/field that returns pending schedule info.
188+
Option (a) is simpler since it's one Stripe call with an expand.
189+
190+
For subscriptions _without_ a pending schedule, the current
191+
`formatBillingInterval()` logic works correctly for both monthly and annual
192+
intervals — no changes needed there.
166193

167194
### Step 8: Implement billing cycle change endpoint
168195

@@ -219,10 +246,17 @@ it later.
219246

220247
Add a "Switch to Monthly/Annual" action in the subscription management UI:
221248

222-
1. Show the action only for owners/billing managers.
223-
2. If a schedule is active on the subscription, show "Pending: switching to
224-
{cycle} at next renewal" with a "Cancel" button.
225-
3. On click, call the `changeBillingCycle` mutation.
249+
1. Show the action only for owners/billing managers with an active
250+
subscription that has no pending cancellation.
251+
2. If a schedule is active on the subscription (detected via the expanded
252+
schedule data from Step 7), show a "Pending: switching to {cycle} on
253+
{date}" banner with a "Cancel" button that calls
254+
`cancelBillingCycleChange`.
255+
3. Hide the "Switch" action while a schedule is already pending (one
256+
change at a time).
257+
4. On click, call the `changeBillingCycle` mutation. The target cycle is
258+
the opposite of the current one (derived from
259+
`subscription.items[0].price.recurring.interval`).
226260

227261
### Step 10: Update email notifications
228262

@@ -282,7 +316,7 @@ needed.
282316
| `src/routers/organizations/organization-subscription-router.ts` | Modify | Add `billingCycle` to checkout input; add `changeBillingCycle` and `cancelBillingCycleChange` mutations |
283317
| `src/components/organizations/UpgradeTrialDialog.tsx` | Modify | Add billing cycle toggle, dynamic pricing |
284318
| `src/components/organizations/subscription/PlanCard.tsx` | Modify | Accept `billingCycle` prop, dynamic label |
285-
| `src/components/organizations/subscription/SubscriptionOverviewCard.tsx` | Modify | Show pending cycle change indicator |
319+
| `src/components/organizations/subscription/SubscriptionOverviewCard.tsx` | Modify | Read phase-2 schedule data for pending cycle changes; override Next Payment and Billing Cycle display |
286320
| `.env.test` / `.env.example` | Modify | Add new price ID env vars |
287321
| Test files (3-4 files) | Modify | Add billing cycle test cases |
288322

@@ -304,7 +338,16 @@ needed.
304338
cycle change is scheduled, we should release the schedule first. The
305339
cancel handler should check for and release active schedules.
306340

307-
4. **Promotion codes / discounts**: The checkout already supports
341+
4. **Overview card shows stale data during pending schedule**: Until the
342+
schedule takes effect, `subscription.items[0]` reflects the current
343+
cycle's price and interval. Without expanding the schedule and reading
344+
phase-2, the "Next Payment" and "Billing Cycle" fields will be wrong.
345+
Step 7 addresses this, but it requires either expanding the schedule on
346+
the subscription retrieve call or making a second Stripe API call. The
347+
expand approach adds no extra latency but increases response payload
348+
size.
349+
350+
5. **Promotion codes / discounts**: The checkout already supports
308351
`allow_promotion_codes: true`. Monthly prices may have different
309352
promotion code eligibility in Stripe. This is a Stripe configuration
310353
concern, not a code concern.

src/app/api/organizations/hooks.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,26 @@ export function useStopOrganizationSubscriptionCancellation() {
390390
);
391391
}
392392

393+
export function useChangeBillingCycle() {
394+
const trpc = useTRPC();
395+
const onSuccess = useInvalidateAllOrganizationData();
396+
return useMutation(
397+
trpc.organizations.subscription.changeBillingCycle.mutationOptions({
398+
onSuccess,
399+
})
400+
);
401+
}
402+
403+
export function useCancelBillingCycleChange() {
404+
const trpc = useTRPC();
405+
const onSuccess = useInvalidateAllOrganizationData();
406+
return useMutation(
407+
trpc.organizations.subscription.cancelBillingCycleChange.mutationOptions({
408+
onSuccess,
409+
})
410+
);
411+
}
412+
393413
export function useUpdateOrganizationSeatCount() {
394414
const trpc = useTRPC();
395415
const onSuccess = useInvalidateAllOrganizationData();

src/components/organizations/UpgradeTrialDialog.tsx

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import { useState } from 'react';
44
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
55
import { PlanCard } from './subscription/PlanCard';
66
import { Button } from '@/components/Button';
7-
import type { OrganizationPlan } from '@/lib/organizations/organization-types';
7+
import type { BillingCycle, OrganizationPlan } from '@/lib/organizations/organization-types';
88
import {
9-
TEAM_SEAT_PRICE_MONTHLY_USD,
10-
ENTERPRISE_SEAT_PRICE_MONTHLY_USD,
9+
TEAM_SEAT_PRICE_MONTHLY_BILLED_MONTHLY_USD,
10+
TEAM_SEAT_PRICE_MONTHLY_BILLED_ANNUALLY_USD,
11+
ENTERPRISE_SEAT_PRICE_MONTHLY_BILLED_MONTHLY_USD,
12+
ENTERPRISE_SEAT_PRICE_MONTHLY_BILLED_ANNUALLY_USD,
1113
} from '@/lib/organizations/constants';
1214
import {
1315
useOrganizationSubscriptionLink,
@@ -53,8 +55,18 @@ export function UpgradeTrialDialog({
5355
container,
5456
}: UpgradeTrialDialogProps) {
5557
const [selectedPlan, setSelectedPlan] = useState<OrganizationPlan>(currentPlan);
58+
const [billingCycle, setBillingCycle] = useState<BillingCycle>('annual');
5659
const [isPurchasing, setIsPurchasing] = useState(false);
5760

61+
const teamPrice =
62+
billingCycle === 'monthly'
63+
? TEAM_SEAT_PRICE_MONTHLY_BILLED_MONTHLY_USD
64+
: TEAM_SEAT_PRICE_MONTHLY_BILLED_ANNUALLY_USD;
65+
const enterprisePrice =
66+
billingCycle === 'monthly'
67+
? ENTERPRISE_SEAT_PRICE_MONTHLY_BILLED_MONTHLY_USD
68+
: ENTERPRISE_SEAT_PRICE_MONTHLY_BILLED_ANNUALLY_USD;
69+
5870
const { data: orgData } = useOrganizationWithMembers(organizationId);
5971
const subscriptionLink = useOrganizationSubscriptionLink();
6072
const hog = usePostHog();
@@ -70,6 +82,7 @@ export function UpgradeTrialDialog({
7082
hog?.capture('trial_upgrade_purchase_clicked', {
7183
organizationId,
7284
selectedPlan,
85+
billingCycle,
7386
seatCount: orgData.members.length,
7487
});
7588

@@ -79,6 +92,7 @@ export function UpgradeTrialDialog({
7992
seats: orgData.members.length,
8093
cancelUrl: window.location.href,
8194
plan: selectedPlan,
95+
billingCycle,
8296
});
8397

8498
if (result.url) {
@@ -106,11 +120,47 @@ export function UpgradeTrialDialog({
106120
</p>
107121
</div>
108122

123+
{/* Billing Cycle Toggle */}
124+
<div className="flex items-center justify-center gap-3">
125+
<div className="inline-flex rounded-lg bg-muted p-1">
126+
<button
127+
type="button"
128+
onClick={() => setBillingCycle('monthly')}
129+
className={`rounded-md px-5 py-1.5 text-sm font-medium transition-all ${
130+
billingCycle === 'monthly'
131+
? 'bg-blue-600 text-white'
132+
: 'text-muted-foreground hover:text-foreground'
133+
}`}
134+
>
135+
Monthly
136+
</button>
137+
<button
138+
type="button"
139+
onClick={() => setBillingCycle('annual')}
140+
className={`rounded-md px-5 py-1.5 text-sm font-medium transition-all ${
141+
billingCycle === 'annual'
142+
? 'bg-blue-600 text-white'
143+
: 'text-muted-foreground hover:text-foreground'
144+
}`}
145+
>
146+
Annual
147+
</button>
148+
</div>
149+
<span
150+
className={`rounded-full border border-green-400/30 bg-green-400/10 px-2.5 py-0.5 text-xs font-semibold text-green-400 transition-opacity ${
151+
billingCycle === 'annual' ? 'opacity-100' : 'opacity-30'
152+
}`}
153+
>
154+
Save 17%
155+
</span>
156+
</div>
157+
109158
{/* Plan Cards */}
110159
<div className="flex justify-center gap-4">
111160
<PlanCard
112161
plan="teams"
113-
pricePerMonth={TEAM_SEAT_PRICE_MONTHLY_USD}
162+
pricePerMonth={teamPrice}
163+
billingCycle={billingCycle}
114164
features={TEAMS_FEATURES}
115165
isSelected={selectedPlan === 'teams'}
116166
currentPlan={currentPlan}
@@ -119,7 +169,8 @@ export function UpgradeTrialDialog({
119169

120170
<PlanCard
121171
plan="enterprise"
122-
pricePerMonth={ENTERPRISE_SEAT_PRICE_MONTHLY_USD}
172+
pricePerMonth={enterprisePrice}
173+
billingCycle={billingCycle}
123174
features={ENTERPRISE_FEATURES}
124175
isSelected={selectedPlan === 'enterprise'}
125176
currentPlan={currentPlan}

0 commit comments

Comments
 (0)