Skip to content
prod e051e98
Browse

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.

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.”

Pick at least one. Stripe + PayPal is the most common combination; add Tap for GCC markets, or LemonSqueezy to offload tax compliance entirely.

  1. Pick the approach(es) from the matrix.

    ApproachBest forFeeTax 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
    PayPalGlobal coverage, buyer trust2.9% + $0.30Yours
    Tap PaymentsMENA region (Mada, KNET, Benefit)2.75% + AED 1.00Partial (VAT config)
    LemonSqueezy (MoR)Solo founders wanting zero tax burden5% + $0.50They handle everything
    flowchart TD
    Q{"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 --> M
    LS --> M
    M -- "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.

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.

  1. 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.
  2. Run a test purchase. Log in as a test user → Pricing → pick a plan → pay with 4242 4242 4242 4242 (CVC 123, expiry 12/34, ZIP 12345). Verify the user upgrades and the transaction appears in the Stripe Dashboard.

    • ✅ The test purchase upgrades the user and shows in the Stripe Dashboard.
  3. 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.
  4. 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.
  5. 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.

  1. Install Cashier.

    Terminal window
    composer require laravel/cashier
    php artisan migrate
    # Verify: php artisan migrate:status | grep cashier → all "Ran"
    # Expected: Cashier migrations show "Ran"
    • ✅ Cashier is installed and its migrations have run.
  2. Configure env and publish config. Confirm STRIPE_KEY, STRIPE_SECRET, STRIPE_WEBHOOK_SECRET are 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.
  3. Add the Billable trait to App\Models\User.

    use Laravel\Cashier\Billable;
    class User extends Authenticatable
    {
    use Billable;
    // ...
    }
    • User uses the Billable trait.
  4. 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.
  5. Add the billing portal. Add GET /billing calling ->redirectToBillingPortal(); enable the portal at Stripe Dashboard → Settings → Billing → Customer portal.

    • /billing redirects to the customer portal.
  6. Add the webhook handler. Add POST /stripe/webhookWebhookController::handleWebhook, exclude stripe/* from CSRF (Laravel 10: $except in app/Http/Middleware/VerifyCsrfToken.php; Laravel 11+: $middleware->validateCsrfTokens(except: ['stripe/*']) in bootstrap/app.php), and optionally extend Cashier’s WebhookController.

    • ✅ The webhook route is wired and excluded from CSRF.
  7. Add subscription checks. Use $user->subscribed('default'), $user->subscribedToPrice(), $user->onTrial(); add an EnsureSubscribed middleware for gated routes.

    • ✅ Subscription gating works via Cashier helpers + middleware.
  8. Test and commit. Run /subscribe/basic-monthly with 4242 4242 4242 4242; test the portal at /billing; run stripe 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.

  1. 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.
  2. 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.
  3. Register webhooks. Add https://<staging>/paypal/webhook (sandbox app) and https://<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.
  4. Create sandbox test accounts. A Business (Seller) account ($5000) and a Personal (Buyer) account ($1000).

    • ✅ Sandbox seller + buyer accounts exist.
  5. 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).
  6. Wire env. Add placeholders to .env.example (safe to commit) and fill real values in .env.

    Terminal window
    ## === PAYPAL ===
    PAYPAL_MODE=sandbox
    PAYPAL_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.

  1. 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.
  2. 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.
  3. Enable payment methods per target country. Settings → Payment Methods.

    CountryEssential methods
    Saudi ArabiaMada (90%+ usage), Visa/MC, Apple Pay
    KuwaitKNET (80%+ usage), Visa/MC
    BahrainBenefit (70%+ usage), Visa/MC
    UAEVisa/MC, Apple Pay
    • ✅ Country-essential methods are enabled.
  4. Register webhooks. Developers → Webhooks → https://<domain>/tap/webhook; subscribe to CHARGE.CAPTURED, CHARGE.FAILED, CHARGE.REFUNDED, AUTHORIZE.CAPTURED, AUTHORIZE.VOIDED; copy the secret.

    • ✅ The Tap webhook is registered with its secret copied.
  5. Configure VAT. Settings → Tax Settings: SA 15%, UAE 5%, BH 10%, KW 0%, QA 0%, OM 5%.

    • ✅ VAT rates are set per country.
  6. 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).

  1. 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.
  2. Configure the store. Settings → General: Store Name, Support Email, Logo, Currency. Optional: add a custom checkout domain.

    • ✅ Store details are set.
  3. 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.
  4. 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.
  5. Register webhooks. Settings → Webhooks → https://<domain>/lemonsqueezy/webhook; subscribe to order_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.
  6. 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.
  7. 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.
  8. 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.

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.

  1. Fill the billing-model decision table.

    QuestionYour answer
    Billing modelone-time / recurring / usage-based / hybrid
    Number of tierssingle / 2–3 tiers / 4+ tiers
    Intervals offeredmonthly / monthly + yearly / + lifetime
    Free tier?feature-limited / usage-limited / none
    Trial periodnone / N days no card / N days card required
    Annual discountnone / X% off / X months free
    Currencysingle / multi-currency
    Tax handlinginclusive / exclusive / auto (Stripe Tax)
    Proration on upgradeimmediate charge / next cycle / none
    Cancellationat period end / immediate + refund / immediate no refund
    • ✅ You can read this table aloud to a non-technical person and they understand the customer experience.

Define tiers/prices/features in the admin panel where it exists, otherwise seed them in code — then confirm the provider-side objects exist.

  1. Check the admin panel first — look for Settings → Plans/Pricing, Subscriptions → Plans, Admin → Membership Plans, or Finance → 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).
  2. Code fallback — if no admin UI, seed via database/seeders/PlansSeeder.php using Cashier. Each plan needs name, stripe_id (the Price ID), stripe_plan, billing_interval, and a features JSON.

    Terminal window
    php artisan make:seeder PlansSeeder
    php 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.
  3. 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.
  4. 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; /pricing renders every tier correctly.

Register the endpoint, store the signing secret, and confirm signature verification — the security-critical step.

  1. Check the admin panel. Many apps expose Settings → Webhooks or Integrations → Stripe with the URL + secret pre-wired. If present, configure there.

    • ✅ The admin webhook surface is used if it exists.
  2. Register the endpoint. Stripe Dashboard → Developers → Webhooks → Add endpoint → https://<domain>/stripe/webhook (confirm with php 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.

  3. Store the signing secret. Fill the STRIPE_WEBHOOK_SECRET=whsec_… placeholder in the production .env via your secrets manager.

    • ✅ The signing secret is stored via the secrets manager.
  4. 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).
  5. Test locally.

    Terminal window
    stripe listen --forward-to http://<project>.test/stripe/webhook
    stripe 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.
  6. 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_failed flips a row to past_due; Sentry receives a test error on a deliberately invalid signature.

Pick one tax approach, then verify it on real test purchases.

  1. Pick a tax approach.

    ApproachCostCoverageBest for
    Stripe Tax (auto)0.5%/txn50+ countries, auto VAT/GST/sales taxAny SaaS selling internationally
    Paddle as MoR~5% + $0.50All countries, remits for youIndie teams wanting zero tax burden (replaces Stripe)
    Manual tax tablesFreeOnly what you configureSingle-country, single-rate
    No tax collectionFreeN/APre-revenue MVPs (may be illegal past thresholds)
    • ✅ A tax approach is chosen.
  2. 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-views and edit receipt.blade.php; if you legally need sequential gapless numbering, generate it on the invoice.payment_succeeded webhook.

    • ✅ 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.

  1. 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.
  2. Set a grace period before cancellation.

    Grace periodUse case
    3 daysHigh-churn, low-ARPU consumer
    7 daysStandard SaaS default
    14 daysB2B, ARR > $500
    • ✅ A grace period is set for the product’s profile.
  3. Enable email notifications. Stripe’s branded dunning emails (Dashboard → Settings → Emails) or handle in Laravel from the invoice.payment_failed webhook: $user->notify(new PaymentFailedNotification($invoice));

    • ✅ Failed-payment emails are wired (Stripe or Laravel).
  4. 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.

  5. 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 to canceled and access revokes.

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.

  1. Write the lifecycle doc. Create Admin-Local/1-Project/1-ProjectInfo/billing-lifecycle.md with 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 success
    trialing --> canceled: trial end + no card
    active --> past_due: invoice.payment_failed
    past_due --> active: retry success
    past_due --> canceled: grace expired
    active --> canceled: user cancels → period end
    canceled --> active: user resubscribes
    • ✅ The lifecycle doc exists with the state diagram.
  2. Record the test matrix — for each transition, how to reproduce it in Stripe test mode.

    TransitionReproduce (test mode)
    new → trialingCheckout with trial_days=7, card 4242…4242
    trialing → activestripe trigger invoice.payment_succeeded
    active → past_dueCard 4000…0341 on renewal, then stripe trigger invoice.payment_failed
    past_due → canceledWait out grace, or stripe trigger customer.subscription.deleted
    canceled → activeSame user resubscribes
    • ✅ Every test-matrix transition has been executed once.
  3. Add the Sentry alert rules (see Phase 7B) — a past_due spike (>5 in 1 hour = payment processing broke); any SignatureVerificationException (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.

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/pricing renders 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.md written — state machine, test matrix, and alert rules.