Skip to content
SaaS4Builders
Customization

Custom Billing Logic

How to extend the billing system: adding pricing models, preparing for new payment providers, customizing checkout, and working within V1 constraints.

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.

backend/app/Domain/Billing/Enums/BillingMode.php
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;
    }
}
ModeWho InvoicesWho Handles TaxStatus
stripe_managedStripeStripe TaxV1 Default
platform_managedYour SaaS internallyStripe Tax APISupported
mor_managedMerchant of RecordMoR providerNot implemented
Changing billing mode is a structural migration, not a runtime toggle. It affects how subscriptions, invoices, and tax calculations work across the entire platform.

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 generated
  • ProviderIdResolverInterface — How external provider IDs (Stripe IDs) are resolved
  • InvoiceQueryInterface — 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.

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;
    }
}

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:

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

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

backend/app/Infrastructure/Billing/Services/InternalBillingCalculator.php
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:

frontend/features/core/docs/billing/schemas.ts
export const pricingTypeSchema = z.enum(['flat', 'seat', 'usage', 'tiered'])

Step 4: Add Tests

Write tests for your new pricing calculation:

backend/tests/Unit/Infrastructure/Billing/Services/InternalBillingCalculatorTest.php
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:

backend/app/Providers/BillingServiceProvider.php
// 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):

  1. Create implementation classes for each strategy interface:
    • Infrastructure/Billing/Strategies/MorManagedInvoicingStrategy.php
    • Infrastructure/Billing/Services/MorManagedProviderIdResolver.php
    • Infrastructure/Billing/Queries/MorInvoiceQuery.php
  2. Add them to the resolver arrays in BillingServiceProvider:
'mor_managed' => $app->make(MorManagedInvoicingStrategy::class),
  1. 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);
}
  1. Set the billing mode in your environment configuration.
The strategy resolver pattern means you only need to implement the interfaces and register the implementations. The rest of the billing system dispatches to the correct strategy automatically.

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:

backend/app/Domain/Billing/Contracts/PaymentGatewayInterface.php
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

  1. 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
  1. 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.).
  2. Swap the binding in BillingServiceProvider:
$this->app->bind(PaymentGatewayInterface::class, NewProviderPaymentGateway::class);
  1. Update webhook handling. The WebhookServiceProvider registers Stripe-specific event handlers. You will need equivalent handlers for your new provider's webhook events.
  2. Test with a fake gateway. Create a FakePaymentGateway that implements PaymentGatewayInterface with deterministic responses, and bind it in your test setUp(). This lets you run the full billing flow without calling a real provider.
V1 supports a single payment provider at a time. Running two providers simultaneously (e.g., Stripe for some tenants, another for others) is not supported in the current architecture. The 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:

backend/app/Domain/Billing/DTO/CreateCheckoutData.php
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 trialDays when creating the checkout data. The payment gateway creates a trial period before the first charge.
  • Add coupon support — Pass couponCode to 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:

LimitationDetails
Single payment providerStripe only. The interface-based architecture supports adding providers, but only one can be active.
No refundsNeither partial nor full refunds are implemented. Use the Stripe dashboard for manual refunds.
No credit notesCredit notes are not supported.
Currency locked at creationA subscription's currency is set when the subscription is created and never changes. No cross-currency operations are performed.
No manual invoice editingInvoices are immutable after creation. In stripe_managed mode, Stripe is the invoice authority.
No MoR modeThe mor_managed billing mode is defined in the enum but not implemented.
No multi-subscriptionOne active subscription per tenant. Upgrading or downgrading replaces the current subscription.
These limitations are by design for V1. The interface-based architecture and strategy pattern provide the extension points to lift these constraints in future versions without refactoring the core billing logic.

See Currency Rules for the full currency invariants.