Pricing Models
SaaS4Builders supports three pricing models out of the box. Each model determines how customers are charged, how quantities are managed, and how plan changes (upgrades/downgrades) are handled.
The PricingType Enum
Every plan has a pricing type defined by the PricingType enum:
enum PricingType: string
{
case Flat = 'flat';
case Seat = 'seat';
case Usage = 'usage';
public function supportsQuantity(): bool
{
return $this === self::Seat;
}
public function requiresUsageData(): bool
{
return $this === self::Usage;
}
}
The helper methods drive behavior throughout the billing system — supportsQuantity() enables seat counting on checkout, while requiresUsageData() activates metered billing integration.
Flat Pricing
Fixed price per billing cycle. The simplest model — every subscriber pays the same amount regardless of team size or usage.
| Aspect | Behavior |
|---|---|
| Quantity | Always 1 |
| Checkout | Standard Stripe Checkout with fixed price |
| Plan changes | Proration supported (upgrade/downgrade between flat plans in same currency) |
| Mid-cycle changes | Prorated based on remaining days |
| Currency | Set at subscription creation, immutable |
When to use: Feature-tier plans (Starter, Pro, Enterprise) where pricing doesn't scale with usage or team size.
How It Works
When a tenant subscribes to a flat plan:
- The
CreateSubscriptionaction validates billing readiness and plan availability - A Stripe Checkout session is created with quantity fixed to
1 - After successful payment, Stripe creates the subscription
- The
checkout.session.completedwebhook syncs the subscription locally
Plan changes between flat plans use calendar-day proration. For example, upgrading from a 30 EUR/month plan to a 60 EUR/month plan mid-cycle calculates:
Daily Rate (old) = 3000 cents / 30 days = 100 cents/day
Daily Rate (new) = 6000 cents / 30 days = 200 cents/day
Credit = 100 × remaining_days
Charge = 200 × remaining_days
Net = Charge - Credit
Seat-Based Pricing
Price per team member per billing cycle. The subscription cost scales with the number of users in the tenant.
| Aspect | Behavior |
|---|---|
| Quantity | Equals number of team members (minimum 1) |
| Checkout | Stripe Checkout with quantity = current member count |
| Plan changes | Proration supported between seat plans in same currency |
| Seat sync | Automatic quantity updates when members are added/removed |
| Billing awareness | Frontend shows seat cost impact before inviting members |
When to use: Plans where pricing should scale linearly with team size (per-user pricing).
How It Works
Seat-based plans require a team_members feature entitlement to function. This entitlement defines the seat quota — the maximum number of team members allowed on the plan.
When a tenant subscribes to a seat-based plan:
- The
CreateSubscriptionaction checks that the plan has ateam_membersfeature configured - Quantity is forced to the current member count:
max(1, $tenant->users()->count()) - Stripe creates the subscription with
quantity × unit_price
// Seat quantity is forced to actual member count
if ($data->plan->pricing_type === PricingType::Seat) {
$quantity = max(1, $data->tenant->users()->count());
}
If the team_members feature is missing from the plan, a SeatPricingMisconfiguredException is thrown — this catches misconfigured plans before they reach Stripe.
Mid-Cycle Seat Changes
When team members are added or removed, the SyncSeatQuantity action updates the Stripe subscription quantity. The proration behavior is controlled by the SeatProrationBehavior enum:
enum SeatProrationBehavior: string
{
case CreateProrations = 'create_prorations'; // Prorate immediately
case None = 'none'; // No proration, apply at next cycle
case AlwaysInvoice = 'always_invoice'; // Generate invoice immediately
}
Frontend: Seat Billing Info
The useSeatBilling() composable provides reactive seat data for the UI:
const {
seatBilling, // Full seat billing data
currentSeats, // Current team member count
seatLimit, // Maximum seats allowed (from entitlement)
availableSeats, // Remaining seats before limit
isAtLimit, // true when no seats available
isNearLimit, // true when 1-2 seats remaining
formattedPricePerSeat, // e.g., "€29.99/seat"
formattedTotalCost, // e.g., "€149.95/month"
} = useSeatBilling()
The UI uses this data to show billing impact when inviting new members — the BillingSeatInfo and BillingSeatUpgrade components display seat usage and cost.
Usage-Based Pricing
Metered billing based on actual consumption. Charges are calculated by Stripe based on usage events recorded against meters.
| Aspect | Behavior |
|---|---|
| Quantity | Driven by usage events, not fixed |
| Checkout | Standard Stripe Checkout (subscription created, usage tracked separately) |
| Plan changes | Not supported — cancel and resubscribe |
| Metering | Usage events recorded via API with idempotency keys |
| Aggregation | Sum, max, count, or last_value per meter |
| Quotas | Optional hard/soft limits per feature |
When to use: API calls, storage, compute time, or any resource where pricing should reflect actual consumption.
Meters and Usage Events
Usage-based plans track consumption through meters. Each meter represents a measurable resource (API calls, storage GB, emails sent) and defines:
- Aggregation type — How events are combined:
sum,max,count, orlast_value - Reset interval — When the counter resets:
monthly,weekly,daily, ornone - Quota enforcement — How limits are enforced:
none,soft(warning only), orhard(blocks usage)
Recording Usage
Usage events are recorded via the API with an optional idempotency key to prevent double-counting:
POST /api/v1/tenant/{tenantId}/usage
{
"meter_code": "api_calls",
"quantity": 1,
"idempotency_key": "req-abc-123",
"recorded_at": "2026-03-15T10:30:00Z",
"metadata": { "endpoint": "/api/v1/reports" }
}
Frontend: Usage Tracking
The useUsage() composable provides reactive access to usage data, quotas, and cost estimates:
const {
usageSummary, // All meters with current usage
quotaStatus, // Quota status per meter (ok, warning, exceeded)
meters, // MeterUsageSummaryItem[]
hasMeters, // Whether the plan has usage meters
metersAtLimit, // Meters that have exceeded their quota
metersWarning, // Meters approaching their quota
hasQuotaIssues, // true when any meter has issues
usageCost, // Estimated cost breakdown
estimatedTotal, // Total estimated cost in cents
} = useUsage()
The UsageMeterCard, UsageQuotaAlert, and UsageSummaryList components visualize usage data and alert users when quotas are approached or exceeded.
Limitations
Usage-based plans have specific constraints in the current release:
- No proration — You cannot upgrade or downgrade between usage-based plans. Cancel the current subscription and create a new one.
- No mixing — A plan is either flat, seat-based, or usage-based. You cannot combine pricing types within a single plan.
- Stripe metering — Usage events are ultimately processed by Stripe's metered billing. The local usage tracking serves for quota enforcement and real-time display.
Plan Structure: Plans, Prices, Features, and Entitlements
The billing catalog is organized as a hierarchy:
Product
└── Plan
├── PlanPrice (one per currency: EUR, USD, CHF, ...)
├── Entitlement → Feature (boolean or quota)
└── Entitlement → Feature (boolean or quota)
Plans
A Plan defines the pricing configuration:
- Pricing type —
flat,seat, orusage - Billing interval — Defined by
IntervalUnit(day, week, month, year) andIntervalCount - Trial days — Optional trial period before first charge
- Sort order — Display order on pricing pages
enum IntervalUnit: string
{
case Day = 'day';
case Week = 'week';
case Month = 'month';
case Year = 'year';
}
The BillingInterval value object combines unit and count for flexible intervals like "every 3 months" or "every 1 year":
final readonly class BillingInterval
{
public function __construct(
public IntervalUnit $unit,
public int $count,
) {}
}
Plan Prices
Each plan has one PlanPrice per supported currency. A plan is only available to subscribe in currencies where a price has been explicitly defined — there is no currency conversion.
For example, a Pro plan might have:
- EUR: 2999 cents (€29.99/month)
- USD: 3499 cents ($34.99/month)
- CHF: 3299 cents (CHF 32.99/month)
PlanNotAvailableInCurrencyException. You must define prices explicitly for every currency you want to support. See Currency Rules for details.Features and Entitlements
Features represent functional capabilities (e.g., "API Access", "Custom Domains", "Team Members"). They are global — not tied to any specific plan.
Entitlements connect plans to features with a type and optional quota:
| Entitlement Type | Behavior | Example |
|---|---|---|
boolean | Feature is either enabled or disabled | "Custom Domains: enabled" |
quota | Feature has a numeric limit | "Team Members: up to 10" |
For seat-based plans, the team_members feature entitlement is mandatory — it defines the seat quota and drives the canAddMembers logic in the team management system. See Seat-Based Billing Integration for how teams and billing interact.
Frontend: Pricing Page Display
The usePublicCatalog() composable powers the public-facing pricing page. It fetches plans from the unauthenticated catalog endpoint and transforms them into display-ready objects:
const {
plans, // Raw PublicPlan[]
availableIntervals, // e.g., ['month:1', 'year:1']
displayPlansByInterval, // Plans grouped by interval
monthlyDisplayPlans, // PricingDisplayPlan[] for monthly toggle
yearlyDisplayPlans, // PricingDisplayPlan[] for yearly toggle
maxSavings, // Max % savings between monthly/yearly (e.g., 20)
isLoading,
isEmpty,
} = usePublicCatalog({ currency: 'EUR', locale: 'en' })
The PricingDisplayPlan type provides everything needed for rendering:
interface PricingDisplayPlan {
id: string
slug: string
title: string
description: string | null
price: string // Formatted: "€29.99"
priceCents: number // Raw: 2999
currency: string // "EUR"
billingCycle: string // "monthly"
features: PublicEntitlement[]
pricingType: PricingType
trialDays: number
sortOrder: number
}
The public catalog endpoint (GET /api/v1/catalog/plans) requires no authentication, making it suitable for pricing pages that are visible to unauthenticated visitors.
Status Constants
The frontend defines status group constants used throughout the billing UI:
// Subscription status groups
export const ACTIVE_STATUSES = ['active', 'trialing'] as const
export const CANCELLABLE_STATUSES = ['active', 'trialing', 'past_due'] as const
export const PROBLEM_STATUSES = ['past_due', 'unpaid', 'incomplete', 'incomplete_expired'] as const
// Invoice status groups
export const UNPAID_INVOICE_STATUSES = ['draft', 'open'] as const
export const PAID_INVOICE_STATUSES = ['paid'] as const
// Color mappings for UI badges
export const SUBSCRIPTION_STATUS_COLORS = {
active: 'success',
trialing: 'info',
past_due: 'warning',
canceled: 'neutral',
unpaid: 'error',
paused: 'neutral',
incomplete: 'warning',
incomplete_expired: 'error',
} as const
These constants drive the BillingSubscriptionStatus and BillingInvoiceStatusBadge components.
What's Next
- Stripe Integration — How plans connect to Stripe Prices and how checkout works
- Subscriptions & Lifecycle — How plan changes, proration, and cancellation work
- Currency Rules — Multi-currency plan pricing and invariants
- Seat-Based Billing Integration — How team size drives billing
Billing Overview
Architecture and philosophy of the SaaS4Builders billing system: Stripe integration, billing modes, pricing models, and domain structure.
Stripe Integration
How SaaS4Builders integrates with Stripe: API setup, the provider abstraction, checkout flow, customer management, price sync, and billing portal.