Skip to content
SaaS4Builders
Billing

Pricing Models

How flat-rate, seat-based, and usage-based pricing work in SaaS4Builders: enums, plan configuration, entitlements, and frontend display.

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:

backend/app/Domain/Billing/Enums/PricingType.php
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.

AspectBehavior
QuantityAlways 1
CheckoutStandard Stripe Checkout with fixed price
Plan changesProration supported (upgrade/downgrade between flat plans in same currency)
Mid-cycle changesProrated based on remaining days
CurrencySet 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:

  1. The CreateSubscription action validates billing readiness and plan availability
  2. A Stripe Checkout session is created with quantity fixed to 1
  3. After successful payment, Stripe creates the subscription
  4. The checkout.session.completed webhook 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.

AspectBehavior
QuantityEquals number of team members (minimum 1)
CheckoutStripe Checkout with quantity = current member count
Plan changesProration supported between seat plans in same currency
Seat syncAutomatic quantity updates when members are added/removed
Billing awarenessFrontend 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:

  1. The CreateSubscription action checks that the plan has a team_members feature configured
  2. Quantity is forced to the current member count: max(1, $tenant->users()->count())
  3. Stripe creates the subscription with quantity × unit_price
backend/app/Application/Billing/Actions/CreateSubscription.php
// 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:

backend/app/Domain/Billing/Enums/SeatProrationBehavior.php
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:

frontend/features/core/docs/billing/composables/useSeatBilling.ts
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.

AspectBehavior
QuantityDriven by usage events, not fixed
CheckoutStandard Stripe Checkout (subscription created, usage tracked separately)
Plan changesNot supported — cancel and resubscribe
MeteringUsage events recorded via API with idempotency keys
AggregationSum, max, count, or last_value per meter
QuotasOptional 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, or last_value
  • Reset interval — When the counter resets: monthly, weekly, daily, or none
  • Quota enforcement — How limits are enforced: none, soft (warning only), or hard (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:

frontend/features/core/docs/billing/composables/useUsage.ts
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 typeflat, seat, or usage
  • Billing interval — Defined by IntervalUnit (day, week, month, year) and IntervalCount
  • Trial days — Optional trial period before first charge
  • Sort order — Display order on pricing pages
backend/app/Domain/Billing/Enums/IntervalUnit.php
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":

backend/app/Domain/Billing/ValueObjects/BillingInterval.php
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)
If a plan doesn't have a price in the customer's chosen currency, the subscription will fail with a 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 TypeBehaviorExample
booleanFeature is either enabled or disabled"Custom Domains: enabled"
quotaFeature 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:

frontend/features/core/docs/billing/composables/usePublicCatalog.ts
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:

frontend/features/core/docs/billing/schemas.ts
// 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