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.
Steps at a glance:
- 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.
- Configure Stripe — CodeCanyon built-in — No custom code — the app already has the Stripe module wired. Paste keys, test, and go live.
- Integrate Stripe — from scratch via Cashier — Skip this if the CodeCanyon built-in above applies. Otherwise wire Cashier end-to-end.
- Set up PayPal (SHOULD — broad global coverage) — Add PayPal for global reach and buyer trust.
- Set up Tap Payments for MENA — Only needed for Saudi Arabia, Kuwait, Bahrain, or other GCC markets. Approval takes 1–3 business days.
- Set up LemonSqueezy — Merchant of Record — 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).
- Assess the billing model — 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.
- Set up subscription plans — Define tiers/prices/features in the admin panel where it exists, otherwise seed them in code — then confirm the provider-side objects exist.
- Set up webhooks — Register the endpoint, store the signing secret, and confirm signature verification — the security-critical step.
- Configure tax & invoicing — Pick one tax approach, then verify it on real test purchases.
- Configure dunning & failed payments — Dunning is the retry + notification sequence when a recurring charge fails. Without it, MRR silently leaks.
- Document the payment lifecycle — The goal: future-you (or a support agent at 2 AM) can look up any subscription’s state and know what should happen next.
Background
Section titled “Background”Payment configuration is two jobs: (1) establish or authorize the external account(s) and secret material, then (2) wire the non-secret provider objects, keys, webhook endpoint, and app code — and then take billing from “a card can charge” to “subscriptions, webhooks, tax, dunning, and lifecycle are production-ready.”
Read Zaj-PROJECT.md before provider work so the environment URLs, gateway choices, billing model, non-secret provider IDs, webhook endpoint paths, and vault pointers come from one place. Update it after each provider is verified; secrets, signing secrets, and live keys stay only in the vault/provider dashboard.
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://<selected-nonproduction-domain>/paypal/webhook(sandbox app) andhttps://<production-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 for MENA
Section titled “5. Set up Tap Payments for MENA”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.- ✅ Plans are seeded with Price IDs and a features JSON, and a pre-seed table backup exists if the DB already had subscriptions.
-
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-Info/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 7 · Observability 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-Info/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 7 · Observability) — 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. - 🤖 Project state updated —
Zaj-PROJECT.mdrecords the billing model, active gateways, non-secret provider/product/price IDs, webhook endpoint paths, mode per environment, and vault item pointers.