Skip to content
SaaS4Builders
Billing

Stripe Integration

How SaaS4Builders integrates with Stripe: API setup, the provider abstraction, checkout flow, customer management, price sync, and billing portal.

SaaS4Builders uses Stripe as its payment provider through a clean abstraction layer. All Stripe interactions go through domain interfaces, making the billing logic testable and the provider potentially replaceable.


Stripe Setup

Environment Variables

Configure Stripe in your backend .env file:

STRIPE_KEY=pk_test_xxx              # Stripe publishable key
STRIPE_SECRET=sk_test_xxx           # Stripe secret key
STRIPE_WEBHOOK_SECRET=whsec_xxx     # Webhook signing secret
STRIPE_TAX_ENABLED=false            # Enable Stripe Tax (optional)
Always use test mode keys (pk_test_, sk_test_) during development. Stripe provides a complete test environment with test card numbers and simulated webhooks. Switch to live keys only for production deployment. See Environment Configuration for the full variable reference.

Webhook Endpoint

Stripe webhooks are received at a single endpoint:

POST /api/v1/webhooks/stripe

This endpoint is protected by the VerifyStripeWebhookSignature middleware, which validates the webhook signature using your STRIPE_WEBHOOK_SECRET. No Sanctum authentication is required — Stripe calls this endpoint directly.

To configure the webhook in your Stripe Dashboard (or via CLI for local testing):

  1. Go to Developers > Webhooks in your Stripe Dashboard
  2. Add endpoint URL: https://your-domain.com/api/v1/webhooks/stripe
  3. Select the events listed in the Webhooks section
  4. Copy the signing secret to STRIPE_WEBHOOK_SECRET

For local development, use the Stripe CLI to forward webhooks:

stripe listen --forward-to http://localhost:8000/api/v1/webhooks/stripe

The Provider Abstraction

All Stripe interactions are routed through the PaymentGatewayInterface. Controllers and actions never call Stripe classes directly.

backend/app/Domain/Billing/Contracts/PaymentGatewayInterface.php
interface PaymentGatewayInterface
{
    public function ensureCustomer(Tenant $tenant): string;

    public function createCheckoutSession(CreateCheckoutData $data): CheckoutSession;

    public function cancelSubscription(string $subscriptionId, bool $immediately = false): void;

    public function resumeSubscription(string $subscriptionId): void;

    public function updateSubscription(string $subscriptionId, array $params): array;

    public function retrieveSubscription(string $subscriptionId): array;

    public function getBillingPortalUrl(string $customerId, string $returnUrl): string;

    // ... charge, refund, listPaymentMethods
}

The implementation lives at backend/app/Infrastructure/Billing/Providers/Stripe/StripePaymentGateway.php. It wraps the Stripe PHP SDK and handles all API communication, error mapping, and data transformation.

Why the Abstraction Matters

  • Testability — Tests bind a FakePaymentGateway instead of hitting Stripe. Actions and queries are tested against the interface, not the provider.
  • Extensibility — Adding a new provider means implementing the interface and binding it in the service container. No business logic changes needed.
  • Isolation — Stripe SDK details (API versions, parameter formats, error codes) stay contained in the infrastructure layer.

Customer Management

Before any billing operation, the tenant needs a Stripe Customer. The ensureCustomer() method creates one if needed or retrieves the existing ID:

backend/app/Infrastructure/Billing/Providers/Stripe/StripePaymentGateway.php
public function ensureCustomer(Tenant $tenant): string

This method:

  1. Checks the external_billing_references table for an existing Stripe Customer ID
  2. If found, returns it immediately
  3. If not, creates a new Stripe Customer with the tenant's billing email and metadata
  4. Stores the mapping in external_billing_references for future lookups

The implementation handles race conditions — if two requests try to create a customer simultaneously, only one Stripe Customer is created and the duplicate is detected.

External Billing References

All Stripe ID mappings are stored in the external_billing_references table. The ExternalReferenceType enum defines the reference types:

backend/app/Domain/Billing/Enums/ExternalReferenceType.php
enum ExternalReferenceType: string
{
    case Customer = 'customer';
    case Subscription = 'subscription';
    case SubscriptionItem = 'subscription_item';
    case Invoice = 'invoice';
    case PaymentIntent = 'payment_intent';
    case Price = 'price';
    case WebhookEvent = 'webhook_event';
    // ...
}

The ProviderIdResolverInterface provides typed access to these references:

backend/app/Domain/Billing/Contracts/ProviderIdResolverInterface.php
interface ProviderIdResolverInterface
{
    public function getCustomerId(Tenant $tenant): string;
    public function getSubscriptionId(Subscription $subscription): string;
    public function getPriceId(PlanPrice $planPrice): string;
    // ... and nullable variants
}

Checkout Flow

Subscription creation uses Stripe Checkout — a hosted payment page managed by Stripe. This handles payment method collection, 3D Secure authentication, and PCI compliance without you touching card data.

The Flow

1. User selects plan + currency on your pricing page
2. Frontend calls POST /api/v1/tenant/{tenantId}/checkout
3. Backend creates a Stripe Checkout Session
4. Frontend redirects the user to Stripe's hosted checkout page
5. User completes payment on Stripe
6. Stripe redirects user back to your success URL
7. Stripe sends checkout.session.completed webhook
8. Backend creates the subscription from the webhook data

Backend: CreateSubscription Action

The CreateSubscription action orchestrates the checkout process:

backend/app/Application/Billing/Actions/CreateSubscription.php
final class CreateSubscription
{
    public function __construct(
        private PaymentGatewayInterface $gateway,
        private BillingReadinessChecker $checker,
    ) {}

    public function viaCheckout(CreateSubscriptionData $data): CheckoutSession
    {
        // 1. Check billing readiness (tenant must have billing_email, country, etc.)
        if (! $this->checker->isReady($data->tenant)) {
            throw BillingNotReadyException::forTenant(
                $data->tenant->id,
                $this->checker->getMissingFields($data->tenant)
            );
        }

        // 2. Check plan configuration (seat plans need team_members feature)
        if (! $this->checker->isPlanConfiguredForCheckout($data->plan)) {
            throw SeatPricingMisconfiguredException::forPlan(
                $data->plan->id,
                $this->checker->getSeatFeatureCode()
            );
        }

        // 3. Verify no existing active subscription for this plan
        // 4. Get PlanPrice for the requested currency (or throw)
        // 5. Force seat quantity to actual member count for seat plans
        // 6. Create checkout session via gateway
        return $this->gateway->createCheckoutSession($checkoutData);
    }
}

The action returns a CheckoutSession DTO containing the Stripe session ID and the hosted checkout URL.

Frontend: useCheckout Composable

The useCheckout() composable handles the full checkout lifecycle:

frontend/features/core/docs/billing/composables/useCheckout.ts
const {
  isProcessing,       // true during checkout creation
  session,            // CheckoutSession after creation
  error,              // Error if checkout failed
  billingNotReady,    // true if tenant is missing billing fields
  missingFields,      // List of missing fields (e.g., ['billing_email', 'country'])

  // Actions
  startCheckout,      // Creates session, returns CheckoutSession
  redirectToCheckout, // Creates session AND redirects browser to Stripe
} = useCheckout()

// Usage in a pricing page:
await redirectToCheckout({
  planId: selectedPlan.id,
  currency: 'EUR',
  successUrl: 'https://app.example.com/docs/billing/success',
  cancelUrl: 'https://app.example.com/docs/billing/cancel',
})

If the tenant's billing profile is incomplete (missing email, country, etc.), the checkout request returns a billing_not_ready error with the list of missing fields. The composable exposes these as billingNotReady and missingFields so you can prompt the user to complete their profile.

Checkout API Endpoint

POST /api/v1/tenant/{tenantId}/checkout

Request body:

{
  "plan_id": "uuid-of-plan",
  "currency": "EUR",
  "success_url": "https://app.example.com/docs/billing/success",
  "cancel_url": "https://app.example.com/docs/billing/cancel",
  "quantity": 1
}

Response (200):

{
  "data": {
    "session_id": "cs_test_xxx",
    "url": "https://checkout.stripe.com/c/pay/cs_test_xxx"
  }
}

Error (422) — Billing not ready:

{
  "message": "Billing information is incomplete.",
  "code": "billing_not_ready",
  "errors": {
    "missing_fields": ["billing_email", "country"]
  }
}

What Stripe Handles vs. What's Internal

ResponsibilityStripeInternal
Payment processingYes
Invoice generationYes (V1)Shadow validation only
Invoice PDFsYes
Invoice numberingYes (V1)
Subscription lifecycleYes (via webhooks)Local state sync
Tax calculationYes (Stripe Tax)
Customer managementYesID mapping
Checkout UIYes (hosted)
3D Secure / SCAYes
Plan/Price catalogYes (synced)Source of truth for config
Proration calculationYesShadow calculation for preview
Billing readiness checksYes
Plan eligibility checksYes
Seat quota enforcementYes
Usage quota enforcementYes

Price Sync

Your plan prices must exist in both your database and Stripe. The StripePriceMappingStatus enum tracks the sync state:

backend/app/Domain/Billing/Enums/StripePriceMappingStatus.php
enum StripePriceMappingStatus: string
{
    case Linked = 'linked';            // Local price has a matching Stripe Price
    case Missing = 'missing';          // No Stripe Price ID stored locally
    case Mismatch = 'mismatch';        // Local and Stripe prices differ
    case StripeMissing = 'stripe_missing'; // Stripe Price was deleted
    case Inactive = 'inactive';        // Stripe Price exists but is inactive
    case Error = 'error';              // Error checking status
}

Admin endpoints are available to check price sync status and manage Stripe Price mappings:

GET /api/v1/admin/docs/billing/stripe-prices          # List all price mappings with status

When you create a plan price in your database, you need to either:

  1. Create the corresponding Price in Stripe and link them via the external_billing_references table
  2. Use the admin tooling to sync prices automatically

Billing Portal

Stripe's Customer Portal provides a self-service UI where customers can update payment methods, view invoices, and manage their billing information. SaaS4Builders integrates this via a simple redirect.

Backend

The BillingPortalController creates a portal session and returns the URL:

POST /api/v1/tenant/{tenantId}/docs/billing/portal

Response (200):

{
  "data": {
    "url": "https://docs/billing.stripe.com/p/session/xxx"
  }
}

If the tenant doesn't have a Stripe Customer yet, the endpoint returns a 422 error with code customer_not_created.

Frontend: useBillingPortal Composable

frontend/features/core/docs/billing/composables/useBillingPortal.ts
const {
  isProcessing,
  error,
  customerNotCreated,   // true if tenant has no Stripe Customer
  openPortal,           // Creates session and redirects browser
  clearError,
} = useBillingPortal()

// Opens Stripe's billing portal in the current tab
await openPortal('https://app.example.com/docs/billing')

The openPortal() method creates a portal session and redirects the browser to Stripe. The returnUrl parameter determines where the user is sent when they click "Return to app" in the portal.


Other Billing Contracts

Beyond the payment gateway, the billing domain defines additional contracts for specialized concerns:

Tax Provider

backend/app/Domain/Billing/Contracts/TaxProviderInterface.php
interface TaxProviderInterface
{
    public function calculate(Money $amount, TaxContext $context): TaxCalculation;
    public function validateVatNumber(string $vatNumber, string $countryCode): bool;
    public function isReverseCharge(TaxContext $context): bool;
    public function getTaxRate(string $countryCode, ?string $region = null): float;
}

Implemented by StripeTaxProvider — delegates all tax calculation to the Stripe Tax API. See Tax Configuration.

Billing Calculator

backend/app/Domain/Billing/Contracts/BillingCalculatorInterface.php
interface BillingCalculatorInterface
{
    public function calculatePrice(Plan $plan, string $currency, int $quantity = 1): PriceCalculation;
    public function calculateProration(
        Subscription $currentSubscription,
        Plan $newPlan,
        string $currency,
        \DateTimeInterface $changeDate
    ): ProrationCalculation;
    public function getNextBillingAmount(Subscription $subscription): Money;
}

Implemented by InternalBillingCalculator. Used for proration previews and shadow validation. See Subscriptions & Lifecycle for how proration works.


Error Handling

Billing operations throw specific domain exceptions that map to HTTP error responses:

ExceptionHTTP CodeError CodeWhen
BillingNotReadyException422billing_not_readyTenant missing billing email, country, etc.
SeatPricingMisconfiguredException422seat_pricing_misconfiguredSeat plan missing team_members feature
AlreadySubscribedException422already_subscribedTenant already has an active subscription to this plan
PlanNotAvailableInCurrencyException422plan_not_availableNo price defined for the requested currency
CustomerNotCreatedException422customer_not_createdBilling portal requested but no Stripe Customer exists

These exceptions contain structured data (e.g., missingFields, availableCurrencies) that the frontend can use to guide the user.


What's Next