Skip to content
SaaS4Builders
Billing

Billing Overview

Architecture and philosophy of the SaaS4Builders billing system: Stripe integration, billing modes, pricing models, and domain structure.

SaaS4Builders ships a production-ready billing system built around Stripe as the payment and invoicing backbone. The architecture is designed with a clear separation between domain logic and provider implementation, so you can understand exactly what Stripe handles and what your application controls.


Billing Philosophy

The billing system follows one core principle: Stripe is the invoicing authority.

In the default configuration (stripe_managed mode), Stripe generates all invoices, processes all payments, and manages subscription lifecycle events. Your application maintains a local copy of billing data synced via webhooks, and runs shadow calculations for validation — but Stripe's data is always the source of truth.

This means:

  • Invoice numbers and PDFs come from Stripe
  • Subscription state changes are driven by Stripe webhooks
  • Tax calculation is delegated to Stripe Tax
  • Your application never generates authoritative invoices in this mode
The internal billing engine exists as infrastructure for a future platform_managed mode where your application generates its own invoices for flat and seat-based plans. In the current release, it serves as a validation and reconciliation layer only.

Billing Modes

The system supports three billing modes, controlled by the BillingMode enum. Only stripe_managed is active in the current release.

backend/app/Domain/Billing/Enums/BillingMode.php
enum BillingMode: string
{
    case StripeManagedInvoicing = 'stripe_managed';
    case PlatformManaged = 'platform_managed';
    case MorManaged = 'mor_managed';

    public static function default(): self
    {
        return self::StripeManagedInvoicing;
    }
}
ModeInvoicing AuthorityStatusDescription
stripe_managedStripeActiveStripe invoices all pricing types. Your SaaS is seller of record.
platform_managedInternal (flat/seat) + Stripe (usage)PlannedYour application generates invoices for flat and seat plans internally.
mor_managedExternal MoRNot implementedA Merchant of Record handles invoicing and tax externally.
Changing billing mode is a structural migration, not a runtime toggle. The mode affects how invoices are generated, how taxes are calculated, and where the source of truth lives. Do not switch modes without a planned migration.

Billing Components at a Glance

The billing system is composed of several interconnected subsystems:

ComponentWhat It DoesLearn More
CatalogProducts, Plans, Features, Entitlements — the pricing configurationPricing Models
SubscriptionsTenant-to-plan binding with full lifecycle managementSubscriptions & Lifecycle
CheckoutStripe Checkout sessions for subscription creation and plan upgradesStripe Integration
InvoicesInvoice sync from Stripe, PDF download, status trackingInvoices
WebhooksEvent-driven state sync from Stripe (subscriptions, invoices, payments)Webhooks
TaxStripe Tax integration for automatic tax calculationTax Configuration
CurrencyMulti-currency support with strict isolation rulesCurrency Rules

Architecture Overview

The billing domain follows the same modular architecture as the rest of the application, with four distinct layers:

backend/app/
├── Domain/Billing/              # Core domain logic (no framework dependencies)
│   ├── Contracts/               # Interfaces: PaymentGatewayInterface, TaxProviderInterface, etc.
│   ├── DTO/                     # Domain transfer objects: CheckoutSession, ProrationCalculation, etc.
│   ├── Enums/                   # PricingType, BillingMode, SubscriptionStatus, InvoiceStatus, etc.
│   ├── Exceptions/              # Domain exceptions: CurrencyMismatchException, BillingNotReadyException
│   ├── Services/                # BillingReadinessChecker, PlanEligibilityChecker, CurrencyRegistry
│   └── ValueObjects/            # BillingInterval (IntervalUnit + count)
│
├── Application/Billing/         # Use cases
│   ├── Actions/                 # CreateSubscription, CancelSubscription, ChangePlan, ResumeSubscription
│   ├── Queries/                 # GetSubscription, ListInvoices, ListPlans, GetInvoicePdf
│   └── DTO/                     # Input DTOs: CreateSubscriptionData, CancelSubscriptionData, ChangePlanData
│
├── Infrastructure/Billing/      # External implementations
│   ├── Providers/Stripe/        # StripePaymentGateway, StripeTaxProvider, StripeClient
│   ├── Services/                # InternalBillingCalculator, InvoiceNumberGenerator
│   ├── Webhooks/                # WebhookDispatcher, event handlers
│   └── Queries/                 # StripeInvoiceQuery, LocalInvoiceQuery
│
└── Http/Controllers/Api/V1/Tenant/   # Thin controllers
    ├── SubscriptionController.php
    ├── InvoiceController.php
    ├── CheckoutController.php
    ├── BillingPortalController.php
    ├── SeatBillingController.php
    └── UsageController.php

Key Domain Contracts

The billing domain defines interfaces that decouple business logic from external providers:

InterfacePurposeImplementation
PaymentGatewayInterfaceSubscription management, checkout, customer creationStripePaymentGateway
TaxProviderInterfaceTax calculation, VAT validation, reverse charge detectionStripeTaxProvider
BillingCalculatorInterfacePrice calculation, proration, usage pricingInternalBillingCalculator
InvoiceGeneratorInterfaceInvoice creation, PDF generation, finalizationInternalInvoiceGenerator
ProviderIdResolverInterfaceMaps internal IDs to Stripe IDsPlatformManagedProviderIdResolver
InvoiceQueryInterfaceInvoice listing and retrievalStripeInvoiceQuery / LocalInvoiceQuery

Actions and queries depend on these interfaces, never on Stripe classes directly. This means you can test billing logic with fake implementations and extend the system with additional providers in the future.

The Money Value Object

All monetary values in the billing system use the Money value object. Raw integers and floats are never used for money operations.

backend/app/Domain/Shared/ValueObjects/Money.php
// Creating Money instances
$price = Money::cents(2999, 'EUR');     // €29.99
$zero  = Money::zero('USD');            // $0.00
$major = Money::fromMajor(29.99, 'EUR'); // €29.99

// Arithmetic (with automatic currency guards)
$total = $price->add(Money::cents(500, 'EUR'));      // €34.99
$half  = $price->divide(2);                           // €15.00 (rounded)
$tax   = $price->percentage(20);                      // €6.00

// This throws CurrencyMismatchException:
$price->add(Money::cents(500, 'USD')); // EUR + USD = error

All amounts are stored in minor units (cents). The Money object enforces currency matching on every operation — attempting to add EUR to USD throws a CurrencyMismatchException.


Frontend Architecture

The frontend billing feature lives in frontend/features/core/docs/billing/ and follows the standard vertical slice pattern:

frontend/features/core/docs/billing/
├── api/                    # useBillingApi(), usePublicCatalogApi()
├── components/             # BillingSubscriptionCard, BillingInvoiceList, BillingCancelModal, etc.
├── composables/            # useSubscription, useInvoices, usePlans, useCheckout, useSeatBilling, etc.
├── schemas.ts              # Zod schemas for runtime validation
├── types.ts                # TypeScript types derived from schemas
└── index.ts                # Barrel exports

Key composables provide reactive state and actions:

ComposablePurpose
useSubscription()Current subscription data + computed helpers (isActive, canCancel, statusColor)
useInvoices()Paginated invoice list with PDF download
usePlans()Available plans for the current currency
useCheckout()Checkout session creation + plan change with proration preview
useBillingPortal()Redirect to Stripe's self-service billing portal
useSeatBilling()Seat count, limits, and cost for seat-based plans
useUsage()Usage meters, quotas, and cost estimates
usePublicCatalog()Public plan listing for pricing pages (unauthenticated)

Supported Pricing Models

The boilerplate supports three pricing models out of the box:

backend/app/Domain/Billing/Enums/PricingType.php
enum PricingType: string
{
    case Flat = 'flat';    // Fixed price per billing cycle
    case Seat = 'seat';   // Price × number of team members
    case Usage = 'usage'; // Metered billing via Stripe
}

Each model has different implications for checkout, proration, and lifecycle management. See Pricing Models for a detailed breakdown.


V1 Limitations

The current release has intentional limitations — these are deliberate design decisions, not missing features:

LimitationRationale
No refundsComplex accounting implications (tax adjustments, credit notes). Planned for a future milestone.
No credit notesInvoices are immutable legal documents. Credit note support requires additional accounting logic.
No multi-provider billingStripe-only. The provider abstraction exists but adding providers prematurely increases complexity.
No currency conversionEach plan price must exist natively in the target currency. No FX rates, no conversion.
No cross-currency plan changesSubscription currency is frozen at creation. Change currency by canceling and resubscribing.
No manual invoice editingInvoices are immutable after finalization. This preserves audit integrity.
No dynamic tax overridesTax calculation is fully delegated to Stripe Tax. No manual rate overrides.
These limitations are enforced at the domain level with explicit exceptions. Attempting to work around them (e.g., manually modifying invoice records) will trigger domain errors.

What's Next