How pricing works across Setpoint: court bookings, memberships, credits, tiers, programs, and Stripe integration. Written for the team to understand and share.
InternalLast updated 2026-07-31·Owner: Product
How pricing works across Setpoint: court bookings, memberships, credits, tiers, programs, and Stripe integration. Written for the team to understand and share.
How are court booking prices determined?
Prices follow a strict priority cascade — the most specific rule wins. The system checks rules in this order: (1) specific court + sport + day + time window, (2) court + sport + day, (3) sport + day + time window, down to (4) facility-wide default. Each rule has a priority number; highest priority match wins. Within a single time window, the price is the exact stored amount for the booked duration (60 / 90 / 120 minutes — auto mode keeps those proportional to the hourly rate; fixed mode lets each duration be set independently). Bookings that cross two or more time windows are priced by summing each segment (see peak/off-peak below).
How does peak vs off-peak pricing work?
Time-of-day pricing is handled through pricing rules with start_time and end_time fields. For example, a rule with start_time=07:00 and end_time=11:00 at 800/hour and another with start_time=11:00 and end_time=15:00 at 500/hour. A booking that stays entirely inside one window uses that window’s exact duration price (e.g. 09:00 for 2 hours → 1,600). A booking that crosses windows is split at the boundary and charged per segment from each window’s hourly rate — e.g. 10:00–12:00 → 800 + 500 = 1,300; 10:00–11:30 → 800 + 250 = 1,050. Admin preview, the public site, and TPS/OpenRally all use the same calculation.
How do holiday prices work?
Holiday rules are flagged with is_holiday=true and are completely separate from the regular cascade. Regular cascade rules filter out holiday rules (WHERE is_holiday=false). This allows admins to create isolated holiday-only pricing without affecting weekday/weekend rules.
How do subscriptions and credit tiers work together?
They run in parallel. A customer can hold both a subscription AND a credit tier; whichever discount is larger applies. If only one applies, that one applies. If neither applies, no discount.
Subscription: Player subscribes to a plan (e.g., Pro at 1,000/month) with a fixed discount percentage (e.g., 20% off bookings), billed recurring via Stripe.
Credit tier: Player tops up credits (like buying store credit). The more they top up over 36 months, the higher their tier (Club, Pro, All-Star, Hall of Fame). Each tier has its own court_discount_pct.
Pre-2026-05-01 the facility had to pick one via a settings.membership_model toggle (DEC-183 retired it).
How does the subscription side work?
Admin creates membership plans with: name, billing period (monthly/quarterly/annual), price, discount percentage, and optional max bookings per period. Player subscribes via Stripe Checkout (recurring billing). The discount is applied automatically at booking time. If the plan has max_bookings_per_period, the system tracks how many bookings the member has used in the current billing period.
How does the credit-tier side work?
Player tops up credits via Stripe payment. The topped-up amount goes into a spendable balance AND counts toward tier progression. Tiers are calculated from total top-ups in a rolling 36-month window. Each tier has its own discount percentages for courts, programs, and merchandise. Admin configures tier thresholds (e.g., Pro requires 300,000 in top-ups over 36 months).
How are member discounts applied to bookings?
When a member books, the system checks two things in order:
(1) Rule-level member price: if the pricing rule has a specific member_price_amount set, use that exact price.
(2) Plan/tier discount: if no rule-level price, apply the membership plan's discount_percentage (Model A) or the tier's court_discount_pct (Model B).
The booking UI shows both the regular price (strikethrough) and the member price in green.
When a user tops up credits, where does the money go?
The topped-up amount is added to the user's club_credits.balance_amount — this is real money they can spend on bookings. At the same time, that top-up counts toward their tier progression (the transaction row has type='topup' in the ledger). So credits serve double duty: spendable balance AND tier progress tracker.
Is there a maximum top-up amount?
No upper limit. Minimum is 500 in the facility's smallest currency unit (e.g., 500 satang = 5 THB). Facilities can configure presets (e.g., 1,000 / 5,000 / 10,000) with optional bonus credits. Users can also enter custom amounts above the minimum.
Do bonus credits count toward tier progression?
Yes. Both the main topup and the preset bonus are recorded as type='topup' transactions on the same paid payment row, so calculate_credit_tier() sums them together. The bonus row is tagged source='system' (vs 'customer_payment' or 'admin' for the main row) so reporting can distinguish what the customer actually paid from what the facility comped — but for tier math, every credit the customer received contributes equally.
When does tier status update?
Immediately after payment confirms. The calculate_credit_tier() function runs right after the top-up is recorded, so the user sees their new tier instantly. Tier is recalculated fresh each time — it's not cached.
Do credits expire?
The credit BALANCE never expires — money stays in the user's wallet indefinitely until spent.
However, the TIER CALCULATION uses a rolling 36-month window. Old top-ups that fall outside this window stop counting toward tier progression. So a user could drop a tier if they don't maintain their top-up volume, but they keep every cent of unspent balance.
Can users spend credits on bookings today?
Not yet from the player-facing web app — the only payment option for players is Stripe (credit card). However, the backend fully supports credit payments: the admin booking endpoint accepts payment_method='credits' and correctly deducts from balance with refund-on-cancel support. The admin UI has a 'Pay with Credits' toggle but it's not wired to the backend yet. Completing the player-facing 'Pay with Credits' option is a planned feature.
How are programs/classes priced?
Programs have a fixed price per enrollment (not duration-based like court bookings). Two price levels: price_amount (regular) and optional member_price_amount (for members). Free programs (price_amount = 0) auto-enroll without payment. Paid programs use Stripe PaymentIntents — player pays at enrollment time.
How are prices stored in the database?
All monetary amounts are stored in the currency's smallest subunit (Stripe convention): cents for USD/EUR, satang for THB, whole yen for JPY. Example: 1,400 THB is stored as 140000 (satang). Display conversion uses formatPrice(amount, currency) from packages/common/src/currency.ts. Each facility has a currency setting — all prices for that facility use that currency.
What Stripe objects are used for each payment type?
Court bookings: PaymentIntent (one-time charge via Stripe Elements on our page).
Memberships: Checkout Session in subscription mode (redirects to Stripe's hosted page, recurring).
Credit top-ups: PaymentIntent (one-time charge via Stripe Elements on our page).
Program enrollment: PaymentIntent (same pattern as court bookings).
Membership plans lazy-create Stripe Products and Prices on first subscribe — no upfront Stripe setup needed when admin creates a plan.
What happens when a booking is cancelled?
Cancellation is checked against the facility's cancellation policy (free_cancellation_hours). If within the free window, the booking status changes to 'cancelled'. If paid with credits, credits are automatically refunded to the user's balance. If paid with Stripe, there is currently NO automatic refund — refunds are handled manually via the Stripe dashboard. Automated Stripe refunds are a planned feature.
What happens when a membership is cancelled?
The membership continues until the end of the current billing period (cancel_at_period_end in Stripe). The user keeps their discount until the period ends. After that, Stripe stops charging — the membership row is updated to 'expired' when the next Stripe webhook event for that subscription fires (customer.subscription.updated / .deleted). Note: an expire_memberships() SQL function exists but is NOT scheduled by any pg_cron job today — see /arch/discounts-snapshot (gap #10). No prorated refund is issued.