Custom Billing Logic
The billing system is designed for extensibility. It uses interface-based architecture (contracts) and the strategy pattern so you can add pricing models, swap payment providers, and change invoicing behavior without rewriting existing code.
This page shows you how to extend billing within the established patterns.
Billing Architecture at a Glance
The billing system is organized around a central concept: Billing Mode. The billing mode determines who is responsible for invoicing and tax compliance.
enum BillingMode: string
{
case StripeManagedInvoicing = 'stripe_managed';
case PlatformManaged = 'platform_managed';
case MorManaged = 'mor_managed';
public function isPlatformManaged(): bool
{
return $this === self::PlatformManaged;
}
public function isStripeManagedInvoicing(): bool
{
return $this === self::StripeManagedInvoicing;
}
public function isSupported(): bool
{
return in_array($this, [self::StripeManagedInvoicing, self::PlatformManaged], true);
}
public static function default(): self
{
return self::StripeManagedInvoicing;
}
}
| Mode | Who Invoices | Who Handles Tax | Status |
|---|---|---|---|
stripe_managed | Stripe | Stripe Tax | V1 Default |
platform_managed | Your SaaS internally | Stripe Tax API | Supported |
mor_managed | Merchant of Record | MoR provider | Not implemented |
The billing system resolves behavior through three strategy interfaces, each backed by a resolver that dispatches to the correct implementation based on the active billing mode:
InvoicingStrategyInterface— How invoices are generatedProviderIdResolverInterface— How external provider IDs (Stripe IDs) are resolvedInvoiceQueryInterface— How invoices are queried (from Stripe API or local database)
See Billing Overview for the full billing concepts.
Adding a New Pricing Model
The boilerplate ships with three pricing types: flat, seat-based, and usage-based.
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;
}
}
To add a new pricing type (for example, tiered pricing), follow these steps:
Step 1: Extend the Enum
Add a new case to PricingType and update the helper methods:
enum PricingType: string
{
case Flat = 'flat';
case Seat = 'seat';
case Usage = 'usage';
case Tiered = 'tiered'; // New pricing type
public function supportsQuantity(): bool
{
return $this === self::Seat;
}
public function requiresUsageData(): bool
{
return in_array($this, [self::Usage, self::Tiered], true);
}
public function supportsTiers(): bool
{
return $this === self::Tiered;
}
}
Step 2: Update the Billing Calculator
The BillingCalculatorInterface defines how prices are calculated:
interface BillingCalculatorInterface
{
public function calculatePrice(
Plan $plan,
string $currency,
int $quantity = 1,
?array $usageData = null
): PriceCalculation;
public function calculateProration(
Subscription $currentSubscription,
Plan $newPlan,
string $currency,
\DateTimeInterface $changeDate
): ProrationCalculation;
public function calculateUsagePrice(
Plan $plan,
string $currency,
array $meterSnapshots
): PriceCalculation;
public function getNextBillingAmount(Subscription $subscription): Money;
}
The InternalBillingCalculator dispatches to the correct calculation method using a match statement on PricingType:
public function calculatePrice(
Plan $plan,
string $currency,
int $quantity = 1,
?array $usageData = null
): PriceCalculation {
$currency = $this->normalizeCurrency($currency);
$planPrice = $this->getPlanPriceOrFail($plan, $currency);
return match ($plan->pricing_type) {
PricingType::Flat => $this->calculateFlatPrice($plan, $planPrice, $currency),
PricingType::Seat => $this->calculateSeatPrice($plan, $planPrice, $currency, $quantity),
PricingType::Usage => $this->calculateUsagePrice($plan, $currency, $usageData ?? []),
PricingType::Tiered => $this->calculateTieredPrice($plan, $planPrice, $currency, $usageData ?? []),
};
}
Add your calculation method to InternalBillingCalculator:
private function calculateTieredPrice(
Plan $plan,
PlanPrice $planPrice,
string $currency,
array $usageData
): PriceCalculation {
// Your tiered pricing logic here
// Return a PriceCalculation with the computed amounts
}
Step 3: Update Frontend Schema
Add the new pricing type to the Zod enum in the frontend:
export const pricingTypeSchema = z.enum(['flat', 'seat', 'usage', 'tiered'])
Step 4: Add Tests
Write tests for your new pricing calculation:
public function test_it_calculates_tiered_price(): void
{
$plan = Plan::factory()->create(['pricing_type' => PricingType::Tiered]);
PlanPrice::factory()->for($plan)->create([
'currency' => 'USD',
'amount_cents' => 1000,
]);
$result = $this->calculator->calculatePrice(
$plan,
'USD',
quantity: 1,
usageData: ['units' => 150]
);
$this->assertInstanceOf(PriceCalculation::class, $result);
$this->assertEquals('USD', $result->currency);
}
See Pricing Models for how the existing pricing types work.
The Strategy Pattern
The BillingServiceProvider uses a strategy resolver pattern to route billing operations to the correct implementation based on the active billing mode. Here is how the resolvers are wired:
// Invoicing strategy — determines how invoices are generated
$this->app->singleton(
InvoicingStrategyResolver::class,
fn ($app) => new InvoicingStrategyResolver(
$app->make(BillingModeResolver::class),
[
'stripe_managed' => $app->make(PlatformManagedInvoicingStrategy::class),
'platform_managed' => $app->make(PlatformManagedInvoicingStrategy::class),
]
)
);
$this->app->bind(
InvoicingStrategyInterface::class,
fn ($app) => $app->make(InvoicingStrategyResolver::class)->resolve()
);
// Provider ID resolution — how external IDs are resolved
$this->app->singleton(
ProviderIdResolverResolver::class,
fn ($app) => new ProviderIdResolverResolver(
$app->make(BillingModeResolver::class),
[
'stripe_managed' => $app->make(PlatformManagedProviderIdResolver::class),
'platform_managed' => $app->make(PlatformManagedProviderIdResolver::class),
]
)
);
$this->app->bind(
ProviderIdResolverInterface::class,
fn ($app) => $app->make(ProviderIdResolverResolver::class)->resolve()
);
// Invoice query — determines where invoices are fetched from
$this->app->singleton(
InvoiceQueryResolver::class,
fn ($app) => new InvoiceQueryResolver(
$app->make(BillingModeResolver::class),
[
'stripe_managed' => $app->make(StripeInvoiceQuery::class),
'platform_managed' => $app->make(LocalInvoiceQuery::class),
]
)
);
$this->app->bind(
InvoiceQueryInterface::class,
fn ($app) => $app->make(InvoiceQueryResolver::class)->resolve()
);
Adding a New Strategy
To add a new billing mode (for example, a Merchant of Record mode):
- Create implementation classes for each strategy interface:
Infrastructure/Billing/Strategies/MorManagedInvoicingStrategy.phpInfrastructure/Billing/Services/MorManagedProviderIdResolver.phpInfrastructure/Billing/Queries/MorInvoiceQuery.php
- Add them to the resolver arrays in
BillingServiceProvider:
'mor_managed' => $app->make(MorManagedInvoicingStrategy::class),
- Update
BillingMode::isSupported()to include the new mode:
public function isSupported(): bool
{
return in_array($this, [
self::StripeManagedInvoicing,
self::PlatformManaged,
self::MorManaged, // Now supported
], true);
}
- Set the billing mode in your environment configuration.
Preparing for a New Payment Provider
The entire billing system depends on interfaces, not on Stripe directly. To add a new payment provider, you implement the PaymentGatewayInterface:
interface PaymentGatewayInterface
{
/**
* Create or retrieve a customer in the payment provider.
*
* @return string The provider's customer ID
*/
public function ensureCustomer(Tenant $tenant): string;
/**
* Create a checkout session for subscription signup.
*/
public function createCheckoutSession(CreateCheckoutData $data): CheckoutSession;
/**
* Charge a payment method directly.
*/
public function charge(
string $customerId,
Money $amount,
string $paymentMethodId,
?string $description = null
): ChargeResult;
/**
* Refund a charge (full or partial).
*/
public function refund(string $chargeId, ?Money $amount = null): RefundResult;
/**
* Create a subscription in the provider.
*/
public function createSubscription(
string $customerId,
string $priceId,
array $options = []
): array;
/**
* Cancel a subscription.
*/
public function cancelSubscription(
string $subscriptionId,
bool $immediately = false
): void;
/**
* Resume a subscription scheduled for cancellation.
*/
public function resumeSubscription(string $subscriptionId): void;
/**
* Update subscription (change plan, quantity).
*/
public function updateSubscription(
string $subscriptionId,
array $params
): array;
/**
* Retrieve a subscription with expanded items.
*/
public function retrieveSubscription(string $subscriptionId): array;
/**
* List payment methods for a customer.
*/
public function listPaymentMethods(string $customerId): array;
/**
* Get the billing portal URL for self-service management.
*/
public function getBillingPortalUrl(string $customerId, string $returnUrl): string;
}
Implementation Steps
- Create the provider directory:
backend/app/Infrastructure/Billing/Providers/NewProvider/
├── NewProviderPaymentGateway.php # Implements PaymentGatewayInterface
├── NewProviderClient.php # SDK wrapper
├── NewProviderConfig.php # Configuration
└── NewProviderMapper.php # External → Domain DTO mapping
- Implement the interface. Each method maps to an operation in your payment provider's API. Use mapper classes to transform provider-specific responses into the domain DTOs (
CheckoutSession,ChargeResult, etc.). - Swap the binding in
BillingServiceProvider:
$this->app->bind(PaymentGatewayInterface::class, NewProviderPaymentGateway::class);
- Update webhook handling. The
WebhookServiceProviderregisters Stripe-specific event handlers. You will need equivalent handlers for your new provider's webhook events. - Test with a fake gateway. Create a
FakePaymentGatewaythat implementsPaymentGatewayInterfacewith deterministic responses, and bind it in your testsetUp(). This lets you run the full billing flow without calling a real provider.
PaymentGatewayInterface binding is global.See Stripe Integration and Infrastructure Layer for how the current Stripe implementation works.
Customizing the Checkout Flow
The checkout flow is controlled by the CreateCheckoutData DTO:
final readonly class CreateCheckoutData
{
public function __construct(
public Tenant $tenant,
public PlanPrice $planPrice,
public string $successUrl,
public string $cancelUrl,
public int $quantity = 1,
public ?int $trialDays = null,
public ?string $couponCode = null,
) {}
}
To customize checkout:
- Add trial days — Pass
trialDayswhen creating the checkout data. The payment gateway creates a trial period before the first charge. - Add coupon support — Pass
couponCodeto apply a Stripe coupon to the checkout session. - Add custom metadata — Extend the DTO with additional fields and pass them through to the
PaymentGatewayInterface::createCheckoutSession()implementation. - Custom success/cancel URLs — The URLs are passed from the frontend. Modify the checkout composable to change the redirect destinations.
For Stripe-specific customization (appearance, additional fields, customer portal), refer to the Stripe Checkout documentation. The StripePaymentGateway::createCheckoutSession() method in backend/app/Infrastructure/Billing/Providers/Stripe/StripePaymentGateway.php is where the Stripe API call happens.
See Subscriptions & Lifecycle for how subscriptions are created after checkout.
V1 Limitations
The billing system in V1 has intentional constraints. These are design decisions that keep the system simple and correct:
| Limitation | Details |
|---|---|
| Single payment provider | Stripe only. The interface-based architecture supports adding providers, but only one can be active. |
| No refunds | Neither partial nor full refunds are implemented. Use the Stripe dashboard for manual refunds. |
| No credit notes | Credit notes are not supported. |
| Currency locked at creation | A subscription's currency is set when the subscription is created and never changes. No cross-currency operations are performed. |
| No manual invoice editing | Invoices are immutable after creation. In stripe_managed mode, Stripe is the invoice authority. |
| No MoR mode | The mor_managed billing mode is defined in the enum but not implemented. |
| No multi-subscription | One active subscription per tenant. Upgrading or downgrading replaces the current subscription. |
See Currency Rules for the full currency invariants.
Adding a Feature
End-to-end walkthrough for adding a new domain feature to SaaS4Builders: backend layers, frontend modules, routing, i18n, and testing.
Extending the Frontend
How to add pages, create components, extend composables, work with Nuxt UI, add locales, and customize styles in the SaaS4Builders frontend.