3 · Payments & billing
Objective — choose a payment approach from the gateway matrix (Stripe built-in vs from-scratch, PayPal, Tap for MENA, LemonSqueezy as Merchant of Record), then add the production billing depth — model, plans, verified webhooks, tax, dunning, and lifecycle docs — so billing goes from “a card can charge” to production-ready.
Background
Section titled “Background”Payment configuration is two jobs: (1) create the external account(s) and get API keys, then (2) enter those keys in the admin panel or wire them in code — and then take billing from “a card can charge” to “subscriptions, webhooks, tax, dunning, and lifecycle are production-ready.”
1. Choose a payment approach
Section titled “1. Choose a payment approach”Pick at least one. Stripe + PayPal is the most common combination; add Tap for GCC markets, or LemonSqueezy to offload tax compliance entirely.
-
Pick the approach(es) from the matrix.
Approach Best for Fee Tax handling Stripe (CodeCanyon built-in) Apps that ship a Stripe module — just paste keys ~3% Yours (or Stripe Tax) Stripe (from scratch) Apps with no Stripe module — via Laravel Cashier ~3% Yours PayPal Global coverage, buyer trust 2.9% + $0.30 Yours Tap Payments MENA region (Mada, KNET, Benefit) 2.75% + AED 1.00 Partial (VAT config) LemonSqueezy (MoR) Solo founders wanting zero tax burden 5% + $0.50 They handle everything flowchart TDQ{"App ships a Stripe module?"}Q -- Yes --> CC["Stripe (CodeCanyon)<br/>paste keys, no code"]Q -- No --> S{"Want zero tax burden?"}S -- Yes --> LS["LemonSqueezy (MoR)"]S -- No --> ST["Stripe (Cashier, from scratch)"]CC --> M{"Selling in GCC?"}ST --> MLS --> MM -- "Mada / KNET needed" --> TAP["Add Tap Payments"]M -- No --> PP["Add PayPal for global reach"]- ✅ At least one approach chosen, with regional gateways added where the market demands.
Whichever gateway you pick, the object-creation steps below can be automated rather than clicked.
2. Configure Stripe — CodeCanyon built-in (recommended where available)
Section titled “2. Configure Stripe — CodeCanyon built-in (recommended where available)”No custom code — the app already has the Stripe module wired. Paste keys, test, and go live.
-
Toggle Stripe on and paste the test keys. Log in as Super Admin → Settings → Payment Setting → Stripe → Stripe ON → paste the Stripe Key (
pk_test_…) and Stripe Secret Key (sk_test_…), then Save.- ✅ Stripe is toggled on with the test keys saved.
-
Run a test purchase. Log in as a test user → Pricing → pick a plan → pay with
4242 4242 4242 4242(CVC123, expiry12/34, ZIP12345). Verify the user upgrades and the transaction appears in the Stripe Dashboard.- ✅ The test purchase upgrades the user and shows in the Stripe Dashboard.
-
Run a decline test. Pay with
4000 0000 0000 0002→ error message shown, user not upgraded.- ✅ The declined card errors and does not upgrade the user.
-
Confirm branding. The app logo, brand colors, and business name appear during checkout and on the receipt email.
- ✅ Checkout and the receipt are on-brand.
-
Go live. Swap sandbox keys for live keys (Stripe Dashboard → Live mode → Developers → API keys), make a small real purchase ($1–5), verify in the Live dashboard, then refund it.
- ✅ A small live purchase succeeds and is refunded.
3. Integrate Stripe — from scratch via Cashier (only if no built-in module)
Section titled “3. Integrate Stripe — from scratch via Cashier (only if no built-in module)”Skip this if the CodeCanyon built-in above applies. Otherwise wire Cashier end-to-end.
-
Install Cashier.
Terminal window composer require laravel/cashierphp artisan migrate# Verify: php artisan migrate:status | grep cashier → all "Ran"# Expected: Cashier migrations show "Ran"- ✅ Cashier is installed and its migrations have run.
-
Configure env and publish config. Confirm
STRIPE_KEY,STRIPE_SECRET,STRIPE_WEBHOOK_SECRETare set.Terminal window php artisan vendor:publish --tag="cashier-config"# Expected: cashier.php published to config/- ✅ The Stripe env vars are set and the Cashier config is published.
-
Add the Billable trait to
App\Models\User.use Laravel\Cashier\Billable;class User extends Authenticatable{use Billable;// ...}- ✅
Useruses theBillabletrait.
- ✅
-
Add the checkout route. Add
GET /subscribe/{plan}that maps plan slugs to Stripe price IDs and calls->newSubscription()->checkout().- ✅
/subscribe/{plan}resolves plan slugs to price IDs and starts checkout.
- ✅
-
Add the billing portal. Add
GET /billingcalling->redirectToBillingPortal(); enable the portal at Stripe Dashboard → Settings → Billing → Customer portal.- ✅
/billingredirects to the customer portal.
- ✅
-
Add the webhook handler. Add
POST /stripe/webhook→WebhookController::handleWebhook, excludestripe/*from CSRF (Laravel 10:$exceptinapp/Http/Middleware/VerifyCsrfToken.php; Laravel 11+:$middleware->validateCsrfTokens(except: ['stripe/*'])inbootstrap/app.php), and optionally extend Cashier’sWebhookController.- ✅ The webhook route is wired and excluded from CSRF.
-
Add subscription checks. Use
$user->subscribed('default'),$user->subscribedToPrice(),$user->onTrial(); add anEnsureSubscribedmiddleware for gated routes.- ✅ Subscription gating works via Cashier helpers + middleware.
-
Test and commit. Run
/subscribe/basic-monthlywith4242 4242 4242 4242; test the portal at/billing; runstripe listen --forward-to localhost:8000/stripe/webhook.Terminal window git add composer.json composer.lock database/ app/ routes/ config/git commit -m "feat(payments): integrate Stripe via Laravel Cashier"# Expected: the Cashier integration is committed- ✅ Checkout, portal, and webhook forwarding work, and the integration is committed.
4. Set up PayPal (SHOULD — broad global coverage)
Section titled “4. Set up PayPal (SHOULD — broad global coverage)”Add PayPal for global reach and buyer trust.
-
Create and verify a business account. Sign up at paypal.com/business, complete the business profile (legal name, category Software/Technology, address), add a bank account, and finish identity verification until the profile shows Verified.
- ✅ The PayPal business profile shows Verified.
-
Create the API apps. At developer.paypal.com → Apps & Credentials, create a Sandbox app (
[PROJECT] Development, Merchant) and copy the Client ID (AW…) + Secret (EJ…); switch to Live and create[PROJECT] Production, copy Client ID (AR…) + Secret (ES…).- ✅ Sandbox and Live Client IDs + Secrets are copied.
-
Register webhooks. Add
https://<staging>/paypal/webhook(sandbox app) andhttps://<domain>/paypal/webhook(live app); subscribe to payment-capture and subscription events; copy each Webhook ID.- ✅ Both webhook endpoints are registered and the Webhook IDs copied.
-
Create sandbox test accounts. A Business (Seller) account ($5000) and a Personal (Buyer) account ($1000).
- ✅ Sandbox seller + buyer accounts exist.
-
Create subscription products (if recurring) — Products & Subscriptions → create a Service/SaaS product, add Monthly and Yearly billing plans, note the Plan IDs (
P-…).- ✅ Subscription products and Plan IDs exist (if recurring).
-
Wire env. Add placeholders to
.env.example(safe to commit) and fill real values in.env.Terminal window ## === PAYPAL ===PAYPAL_MODE=sandboxPAYPAL_SANDBOX_CLIENT_ID=PAYPAL_SANDBOX_CLIENT_SECRET=PAYPAL_LIVE_CLIENT_ID=PAYPAL_LIVE_CLIENT_SECRET=PAYPAL_SANDBOX_WEBHOOK_ID=PAYPAL_LIVE_WEBHOOK_ID=# Expected: placeholders in .env.example; real values in .env (uncommitted)- ✅ The PayPal env block is wired with placeholders committed, real values local.
5. Set up Tap Payments (OPTIONAL — MENA only)
Section titled “5. Set up Tap Payments (OPTIONAL — MENA only)”Only needed for Saudi Arabia, Kuwait, Bahrain, or other GCC markets. Approval takes 1–3 business days.
-
Sign up and submit for verification. At tap.company → select country → enter business info (legal name, trade-license number, category) → upload documents (Trade License/CR, Owner ID, bank statement) → submit.
- ✅ The Tap account is submitted for verification.
-
Copy the API keys after approval. dashboard.tap.company → Developers → API Keys; copy test (
sk_test_…,pk_test_…) and live (sk_live_…,pk_live_…).- ✅ Test and live Tap keys are copied.
-
Enable payment methods per target country. Settings → Payment Methods.
Country Essential methods Saudi Arabia Mada (90%+ usage), Visa/MC, Apple Pay Kuwait KNET (80%+ usage), Visa/MC Bahrain Benefit (70%+ usage), Visa/MC UAE Visa/MC, Apple Pay - ✅ Country-essential methods are enabled.
-
Register webhooks. Developers → Webhooks →
https://<domain>/tap/webhook; subscribe toCHARGE.CAPTURED,CHARGE.FAILED,CHARGE.REFUNDED,AUTHORIZE.CAPTURED,AUTHORIZE.VOIDED; copy the secret.- ✅ The Tap webhook is registered with its secret copied.
-
Configure VAT. Settings → Tax Settings: SA 15%, UAE 5%, BH 10%, KW 0%, QA 0%, OM 5%.
- ✅ VAT rates are set per country.
-
Wire env.
Terminal window ## === TAP PAYMENTS ===TAP_SECRET_KEY=TAP_PUBLISHABLE_KEY=TAP_WEBHOOK_SECRET=TAP_CURRENCY=SAR# Expected: the Tap env block is present with placeholders- ✅ The Tap env block is wired.
6. Set up LemonSqueezy — Merchant of Record (ALTERNATIVE)
Section titled “6. Set up LemonSqueezy — Merchant of Record (ALTERNATIVE)”LemonSqueezy is the legal seller: they handle all tax compliance (VAT, GST, sales tax), invoicing, and refunds, and pay you out minus their fee (5% + $0.50). Best for solo founders selling globally; not ideal for low-margin products, enterprise buyers, or MENA (no Mada/KNET).
-
Create the account. lemonsqueezy.com → Get Started Free → onboarding (Software/SaaS, revenue estimate, location) → connect payout (Stripe Connect or PayPal) → verify identity.
- ✅ The LemonSqueezy account is created with payout connected.
-
Configure the store. Settings → General: Store Name, Support Email, Logo, Currency. Optional: add a custom checkout domain.
- ✅ Store details are set.
-
Create products. Store → Products → New Product, Pricing Type Subscription, set Monthly + Yearly, add variants (Basic/Pro/Business), note Product ID + Variant IDs.
- ✅ Subscription products with Variant IDs exist.
-
Create the API key. Settings → API → create (
[PROJECT] Production, Read & Write); copy the key (lmn_…, shown once); note the Store ID.- ✅ The API key (
lmn_…) and Store ID are stored.
- ✅ The API key (
-
Register webhooks. Settings → Webhooks →
https://<domain>/lemonsqueezy/webhook; subscribe toorder_created,order_refunded,subscription_created,subscription_updated,subscription_cancelled,subscription_resumed,subscription_expired,subscription_payment_failed; copy the signing secret.- ✅ The webhook is registered with its signing secret copied.
-
Pick the integration method by effort — simple checkout links (low), Lemon.js overlay (medium), or a Laravel package (high/full API).
- ✅ An integration method is chosen.
-
Wire env.
Terminal window ## === LEMONSQUEEZY ===LEMONSQUEEZY_API_KEY=LEMONSQUEEZY_STORE_ID=LEMONSQUEEZY_SIGNING_SECRET=LEMONSQUEEZY_BASIC_MONTHLY_VARIANT_ID=LEMONSQUEEZY_BASIC_YEARLY_VARIANT_ID=LEMONSQUEEZY_PRO_MONTHLY_VARIANT_ID=LEMONSQUEEZY_PRO_YEARLY_VARIANT_ID=# Expected: the LemonSqueezy env block is present with placeholders- ✅ The LemonSqueezy env block is wired.
-
Test the checkout. Open the checkout link → complete a test purchase → verify the webhook fires and the user gets access.
- ✅ A test purchase fires the webhook and grants access.
7. Assess the billing model (MUST)
Section titled “7. Assess the billing model (MUST)”Answer these before configuring plans anywhere — wrong answers cascade into every later task. Stripe Products/Prices are hard to rename after customers subscribe, and proration/cancellation rules must match your Terms of Service (Phase 6 / Phase 7) or you create legal risk.
-
Fill the billing-model decision table.
Question Your answer Billing model one-time / recurring / usage-based / hybrid Number of tiers single / 2–3 tiers / 4+ tiers Intervals offered monthly / monthly + yearly / + lifetime Free tier? feature-limited / usage-limited / none Trial period none / N days no card / N days card required Annual discount none / X% off / X months free Currency single / multi-currency Tax handling inclusive / exclusive / auto (Stripe Tax) Proration on upgrade immediate charge / next cycle / none Cancellation at period end / immediate + refund / immediate no refund - ✅ You can read this table aloud to a non-technical person and they understand the customer experience.
8. Set up subscription plans (MUST)
Section titled “8. Set up subscription plans (MUST)”Define tiers/prices/features in the admin panel where it exists, otherwise seed them in code — then confirm the provider-side objects exist.
-
Check the admin panel first — look for
Settings → Plans/Pricing,Subscriptions → Plans,Admin → Membership Plans, orFinance → Subscription Plans. If found, configure tiers/prices/features/trial/annual toggle in the UI (~5 min) and skip to substep 3.- ✅ Plans are configured in the admin UI (if it exists).
-
Code fallback — if no admin UI, seed via
database/seeders/PlansSeeder.phpusing Cashier. Each plan needsname,stripe_id(the Price ID),stripe_plan,billing_interval, and afeaturesJSON.Terminal window php artisan make:seeder PlansSeederphp artisan db:seed --class=PlansSeeder# Expected: the plan rows are seeded with non-null stripe_id values- ✅ Plans are seeded with Price IDs and a features JSON.
-
Confirm provider-side Products/Prices (critical). The Products + Prices must also exist in Stripe/Paddle/LemonSqueezy. Your local records only store the
price_id; they don’t create the provider object. A plan that exists in your DB but not in the gateway will 500 at checkout.- ✅ Each DB plan maps to a real provider-side Product/Price.
-
Map feature gating. Map each plan to the features it unlocks (character counts, seats, API calls, branding removal). Most apps store this as JSON on the plan row — document the schema in
Admin-Local/1-Project/1-ProjectInfo/billing-features.md.- ✅ The admin lists all tiers with correct prices;
\App\Models\Plan::all()->pluck('stripe_id')returns non-null Price IDs;/pricingrenders every tier correctly.
- ✅ The admin lists all tiers with correct prices;
9. Set up webhooks (MUST)
Section titled “9. Set up webhooks (MUST)”Register the endpoint, store the signing secret, and confirm signature verification — the security-critical step.
-
Check the admin panel. Many apps expose
Settings → WebhooksorIntegrations → Stripewith the URL + secret pre-wired. If present, configure there.- ✅ The admin webhook surface is used if it exists.
-
Register the endpoint. Stripe Dashboard → Developers → Webhooks → Add endpoint →
https://<domain>/stripe/webhook(confirm withphp artisan route:list | grep stripe). Subscribe to the minimum set:-
checkout.session.completed -
invoice.payment_succeeded -
invoice.payment_failed -
customer.subscription.created -
customer.subscription.updated -
customer.subscription.deleted -
customer.subscription.trial_will_end -
✅ The endpoint is registered and subscribed to the minimum event set.
-
-
Store the signing secret. Fill the
STRIPE_WEBHOOK_SECRET=whsec_…placeholder in the production.envvia your secrets manager.- ✅ The signing secret is stored via the secrets manager.
-
Confirm signature verification (critical security step). Cashier does this automatically via its
WebhookController. A custom handler must call:\Stripe\Stripe::setApiKey(config('cashier.secret'));$event = \Stripe\Webhook::constructEvent($request->getContent(),$request->header('Stripe-Signature'),config('cashier.webhook.secret'));A handler that trusts unverified POST bodies lets any attacker upgrade themselves to enterprise for free.
- ✅ Signature verification runs on every webhook (Cashier or
constructEvent).
- ✅ Signature verification runs on every webhook (Cashier or
-
Test locally.
Terminal window stripe listen --forward-to http://<project>.test/stripe/webhookstripe trigger checkout.session.completed# Expected: handler runs, signature verifies, subscription row created/updated- ✅ The triggered event runs the handler, verifies the signature, and updates a row.
-
Add alerting. Webhook failures are silent (users pay, your DB doesn’t know). Add a Sentry alert for signature failures and 5xx from
/stripe/webhook(see Phase 7B for Sentry setup).- ✅ The Stripe endpoint shows “200 OK” for recent deliveries;
stripe trigger invoice.payment_failedflips a row topast_due; Sentry receives a test error on a deliberately invalid signature.
- ✅ The Stripe endpoint shows “200 OK” for recent deliveries;
10. Configure tax & invoicing (SHOULD)
Section titled “10. Configure tax & invoicing (SHOULD)”Pick one tax approach, then verify it on real test purchases.
-
Pick a tax approach.
Approach Cost Coverage Best for Stripe Tax (auto) 0.5%/txn 50+ countries, auto VAT/GST/sales tax Any SaaS selling internationally Paddle as MoR ~5% + $0.50 All countries, remits for you Indie teams wanting zero tax burden (replaces Stripe) Manual tax tables Free Only what you configure Single-country, single-rate No tax collection Free N/A Pre-revenue MVPs (may be illegal past thresholds) - ✅ A tax approach is chosen.
-
Enable Stripe Tax (recommended default). Enable at Dashboard → Tax → Settings, then in the Cashier checkout:
$user->newSubscription('default', $priceId)->checkout(['automatic_tax' => ['enabled' => true],'customer_update' => ['address' => 'auto'],]);For EU B2B, collect VAT IDs so Stripe applies the reverse charge — add
tax_id_collection: ['enabled' => true](Cashier 14+). For invoices: Stripe-hosted PDFs come free via$user->invoice()->download(); for a branded template,php artisan vendor:publish --tag=cashier-viewsand editreceipt.blade.php; if you legally need sequential gapless numbering, generate it on theinvoice.payment_succeededwebhook.- ✅ A VAT-registered EU test purchase shows 0% VAT with a “reverse charge” note; a US purchase shows correct state tax; the invoice PDF downloads with your branding.
11. Configure dunning & failed payments (SHOULD)
Section titled “11. Configure dunning & failed payments (SHOULD)”Dunning is the retry + notification sequence when a recurring charge fails. Without it, MRR silently leaks.
-
Enable Smart Retries. Stripe Dashboard → Settings → Billing → Subscriptions and emails → “Manage failed payments” → enable Smart Retries; set the max retry period (3 weeks typical, 7 days minimum).
- ✅ Smart Retries are enabled with a retry window set.
-
Set a grace period before cancellation.
Grace period Use case 3 days High-churn, low-ARPU consumer 7 days Standard SaaS default 14 days B2B, ARR > $500 - ✅ A grace period is set for the product’s profile.
-
Enable email notifications. Stripe’s branded dunning emails (Dashboard → Settings → Emails) or handle in Laravel from the
invoice.payment_failedwebhook:$user->notify(new PaymentFailedNotification($invoice));- ✅ Failed-payment emails are wired (Stripe or Laravel).
-
Document the post-failure behavior.
-
Soft downgrade (recommended) — subscription →
past_due, feature gates check$user->subscribed() && !$user->subscription()->pastDue(), user sees a billing banner + limited access. Easier recovery. -
Hard cancel — subscription deleted, user drops to free. Cleaner, harder to win back.
-
✅ The chosen post-failure behavior is documented.
-
-
Test the failed-payment path. In test mode use card
4000 0000 0000 0341(attaches but fails on charge) to walk failed webhook →past_due→ dunning emails → grace period → cancellation.- ✅ A test-mode failed renewal triggers
invoice.payment_failed; the user gets the email; after the grace period the DB status flips tocanceledand access revokes.
- ✅ A test-mode failed renewal triggers
12. Document the payment lifecycle (SHOULD)
Section titled “12. Document the payment lifecycle (SHOULD)”The goal: future-you (or a support agent at 2 AM) can look up any subscription’s state and know what should happen next.
-
Write the lifecycle doc. Create
Admin-Local/1-Project/1-ProjectInfo/billing-lifecycle.mdwith the state machine, a test matrix, alert rules, and a runbook.stateDiagram-v2[*] --> trialing: checkout (trial_days > 0)[*] --> active: checkout (trial_days == 0)trialing --> active: trial end + payment successtrialing --> canceled: trial end + no cardactive --> past_due: invoice.payment_failedpast_due --> active: retry successpast_due --> canceled: grace expiredactive --> canceled: user cancels → period endcanceled --> active: user resubscribes- ✅ The lifecycle doc exists with the state diagram.
-
Record the test matrix — for each transition, how to reproduce it in Stripe test mode.
Transition Reproduce (test mode) new → trialing Checkout with trial_days=7, card4242…4242trialing → active stripe trigger invoice.payment_succeededactive → past_due Card 4000…0341on renewal, thenstripe trigger invoice.payment_failedpast_due → canceled Wait out grace, or stripe trigger customer.subscription.deletedcanceled → active Same user resubscribes - ✅ Every test-matrix transition has been executed once.
-
Add the Sentry alert rules (see Phase 7B) — a
past_duespike (>5 in 1 hour = payment processing broke); anySignatureVerificationException(someone probing, or your secret rotated); a churn threshold (>3% cancel in 24 hours = check for an outage or bad deploy). Append a “what to do when” runbook for the common support cases (“I paid but can’t access”, refund requests, stuck renewals).- ✅ The 3 Sentry rules are active and one has been test-fired.
Checklist
Section titled “Checklist”Do not mark this step done until every box below is checked.
- 🔀 At least one gateway connected — test payment succeeds and a declined card is rejected.
- 👤 Billing model decision record filled in.
- 🔀 Plans exist in both the DB and the provider dashboard —
/pricingrenders correctly. - 🔀 Webhook endpoint returns 200 — signature verification confirmed.
- 🔀 Tax approach chosen — Stripe Tax (if applicable) verified on EU + US test purchases.
- 🔀 Dunning configured + tested — post-failure behavior documented.
- 🤖
billing-lifecycle.mdwritten — state machine, test matrix, and alert rules.