Billing Overview
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
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.
enum BillingMode: string
{
case StripeManagedInvoicing = 'stripe_managed';
case PlatformManaged = 'platform_managed';
case MorManaged = 'mor_managed';
public static function default(): self
{
return self::StripeManagedInvoicing;
}
}
| Mode | Invoicing Authority | Status | Description |
|---|---|---|---|
stripe_managed | Stripe | Active | Stripe invoices all pricing types. Your SaaS is seller of record. |
platform_managed | Internal (flat/seat) + Stripe (usage) | Planned | Your application generates invoices for flat and seat plans internally. |
mor_managed | External MoR | Not implemented | A Merchant of Record handles invoicing and tax externally. |
Billing Components at a Glance
The billing system is composed of several interconnected subsystems:
| Component | What It Does | Learn More |
|---|---|---|
| Catalog | Products, Plans, Features, Entitlements — the pricing configuration | Pricing Models |
| Subscriptions | Tenant-to-plan binding with full lifecycle management | Subscriptions & Lifecycle |
| Checkout | Stripe Checkout sessions for subscription creation and plan upgrades | Stripe Integration |
| Invoices | Invoice sync from Stripe, PDF download, status tracking | Invoices |
| Webhooks | Event-driven state sync from Stripe (subscriptions, invoices, payments) | Webhooks |
| Tax | Stripe Tax integration for automatic tax calculation | Tax Configuration |
| Currency | Multi-currency support with strict isolation rules | Currency 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:
| Interface | Purpose | Implementation |
|---|---|---|
PaymentGatewayInterface | Subscription management, checkout, customer creation | StripePaymentGateway |
TaxProviderInterface | Tax calculation, VAT validation, reverse charge detection | StripeTaxProvider |
BillingCalculatorInterface | Price calculation, proration, usage pricing | InternalBillingCalculator |
InvoiceGeneratorInterface | Invoice creation, PDF generation, finalization | InternalInvoiceGenerator |
ProviderIdResolverInterface | Maps internal IDs to Stripe IDs | PlatformManagedProviderIdResolver |
InvoiceQueryInterface | Invoice listing and retrieval | StripeInvoiceQuery / 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.
// 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:
| Composable | Purpose |
|---|---|
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:
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:
| Limitation | Rationale |
|---|---|
| No refunds | Complex accounting implications (tax adjustments, credit notes). Planned for a future milestone. |
| No credit notes | Invoices are immutable legal documents. Credit note support requires additional accounting logic. |
| No multi-provider billing | Stripe-only. The provider abstraction exists but adding providers prematurely increases complexity. |
| No currency conversion | Each plan price must exist natively in the target currency. No FX rates, no conversion. |
| No cross-currency plan changes | Subscription currency is frozen at creation. Change currency by canceling and resubscribing. |
| No manual invoice editing | Invoices are immutable after finalization. This preserves audit integrity. |
| No dynamic tax overrides | Tax calculation is fully delegated to Stripe Tax. No manual rate overrides. |
What's Next
- Pricing Models — How flat, seat-based, and usage-based pricing work
- Stripe Integration — Stripe setup, checkout flow, and the provider abstraction
- Subscriptions & Lifecycle — Subscription states, transitions, and plan changes
- Invoices — Invoice sync, PDF download, and status tracking
- Tax Configuration — Stripe Tax setup and responsibilities
- Webhooks — Event-driven state synchronization
- Currency Rules — Multi-currency invariants and enforcement