Stripe Integration
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)
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):
- Go to Developers > Webhooks in your Stripe Dashboard
- Add endpoint URL:
https://your-domain.com/api/v1/webhooks/stripe - Select the events listed in the Webhooks section
- 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.
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
FakePaymentGatewayinstead 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:
public function ensureCustomer(Tenant $tenant): string
This method:
- Checks the
external_billing_referencestable for an existing Stripe Customer ID - If found, returns it immediately
- If not, creates a new Stripe Customer with the tenant's billing email and metadata
- Stores the mapping in
external_billing_referencesfor 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:
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:
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:
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:
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
| Responsibility | Stripe | Internal |
|---|---|---|
| Payment processing | Yes | |
| Invoice generation | Yes (V1) | Shadow validation only |
| Invoice PDFs | Yes | |
| Invoice numbering | Yes (V1) | |
| Subscription lifecycle | Yes (via webhooks) | Local state sync |
| Tax calculation | Yes (Stripe Tax) | |
| Customer management | Yes | ID mapping |
| Checkout UI | Yes (hosted) | |
| 3D Secure / SCA | Yes | |
| Plan/Price catalog | Yes (synced) | Source of truth for config |
| Proration calculation | Yes | Shadow calculation for preview |
| Billing readiness checks | Yes | |
| Plan eligibility checks | Yes | |
| Seat quota enforcement | Yes | |
| Usage quota enforcement | Yes |
Price Sync
Your plan prices must exist in both your database and Stripe. The StripePriceMappingStatus enum tracks the sync state:
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:
- Create the corresponding Price in Stripe and link them via the
external_billing_referencestable - 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
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
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
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:
| Exception | HTTP Code | Error Code | When |
|---|---|---|---|
BillingNotReadyException | 422 | billing_not_ready | Tenant missing billing email, country, etc. |
SeatPricingMisconfiguredException | 422 | seat_pricing_misconfigured | Seat plan missing team_members feature |
AlreadySubscribedException | 422 | already_subscribed | Tenant already has an active subscription to this plan |
PlanNotAvailableInCurrencyException | 422 | plan_not_available | No price defined for the requested currency |
CustomerNotCreatedException | 422 | customer_not_created | Billing 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
- Subscriptions & Lifecycle — How subscriptions are created, changed, and canceled
- Webhooks — How Stripe events drive subscription and invoice state
- Tax Configuration — Stripe Tax setup and responsibilities