feat(billing): local-currency plan prices with a regional discount on Solo
Merged
RI
4d ago+2166+2166+0123456789012345678901234567890123456789-141-141-012345678901234567890123456789
Summary
Plan prices in the user's local currency, with a regional (PPP) discount on Solo only. Only amounts that exist on the Stripe prices are shown or charged; nothing is converted client-side.
- One price per plan and interval: each plan has a Stripe price with the same USD amount as before, plus ~120 currencies at the market exchange rate. Users can pick any currency. A customer Stripe already bills in a currency is locked to it.
- Solo regional discount. A Stripe price has one amount per currency, so euro countries need separate prices. Solo has four discount tiers (85 / 70 / 60 / 50 % of the full price), listed in
plans.regional_price_ids.country_price_tier.tsmaps countries to tiers from the World Bank 2025 price level, never above the full price. Examples: Germany/UK 85 %, France/Italy 70 %, Spain/Portugal 60 %, Croatia/Poland/India/Brazil 50 %. Maker, Team, Enterprise and credits cost the same everywhere. - No VPN discounts. The tier granted is the most expensive one among the visitor IP, the billing address and the saved card countries.
- A discounted checkout requires a billing address.
- On
customer.subscription.created, and when a subscription activates or changes card, the webhook checks the paying card and the billing address. If either is from a pricier region, it moves the subscription to that region's price and invoices the difference (proration_behavior: always_invoice). If nothing proves the region, the full price applies.
- Visible discount. The Plans page shows the full price struck through, a "{n}% regional discount" badge, and the card/address requirement. The trial plan picker strikes through the full price too.
- Trial auto-start charges the same tier, using the saved card.
private/plan_prices?orgId=returns the tier's amounts plusdiscountandregularPrices. It is cached per billing account, tier and price version, and returnscomplete: falseon partial catalogs (the page shows Retry and checkout stays disabled).- Existing subscriptions keep resolving.
alt_price_m_ids/alt_price_y_idshold replaced and regional prices, synced fromregional_price_idsby a trigger, so subscriptions still resolve as monthly/yearly.
Release
- Stripe, both accounts: 16 full prices (all plans) plus 8 Solo discount prices are active. Unused tier and PPP sets are archived. Old prices stay active for existing subscribers.
- Migration:
20261027120000_plan_alt_price_ids.sqladds the columns and trigger, then switchesplans. It only touches rows still on the old live IDs, so it is a no-op on local/test databases. - Rollback: point
plansback at the old IDs and clearregional_price_ids.
Test plan
-
bunx vitest run tests/regional-price.unit.test.ts tests/plan-prices.unit.test.ts tests/stripe-redirects.unit.test.ts tests/stripe-trial-plan.unit.test.ts tests/stripe-event.unit.test.ts - pgTAP
90_test_plan_alt_price_ids.sql(11 assertions) - Release migration dry-run on simulated prod rows (Solo gets 4 tiers, Maker none)
-
bun typecheck,bun lint:ox, eslint, typos - After release:
- Plans page from FR / DE / US / IN shows the right Solo discount
- Checkout with a foreign card gets corrected to the right price
- An existing USD subscriber stays on USD
๐ค Generated with Claude Code
Summary by CodeRabbit
- New Features
- Added currency-aware plan pricing to organization billing settings, with a selector for available currencies.
- Plan prices, discounts, monthly and yearly breakdowns, and Enterprise MAU pricing display in the selected currency.
- The selected currency carries through to subscription checkout, which only accepts currencies supported by the plan and billing account.
- Accounts with a locked currency see the applicable restriction. Added currency availability notices and a retry option if prices fail to load.
- Added country-based regional discounts to eligible plan and trial pricing, with regular prices shown alongside discounted trial prices.
- Checkout collects a billing address when required for regional pricing.