Skip to content
prod 352bb92
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.

Steps at a glance:

  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.
  2. Configure Stripe — CodeCanyon built-in — No custom code — the app already has the Stripe module wired. Paste keys, test, and go live.
  3. Integrate Stripe — from scratch via Cashier — Skip this if the CodeCanyon built-in above applies. Otherwise wire Cashier end-to-end.
  4. Set up PayPal (SHOULD — broad global coverage) — Add PayPal for global reach and buyer trust.
  5. Set up Tap Payments for MENA — Only needed for Saudi Arabia, Kuwait, Bahrain, or other GCC markets. Approval takes 1–3 business days.
  6. 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).
  7. 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.
  8. 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.
  9. Set up webhooks — Register the endpoint, store the signing secret, and confirm signature verification — the security-critical step.
  10. Configure tax & invoicing — Pick one tax approach, then verify it on real test purchases.
  11. Configure dunning & failed payments — Dunning is the retry + notification sequence when a recurring charge fails. Without it, MRR silently leaks.
  12. 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.

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.

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://<selected-nonproduction-domain>/paypal/webhook (sandbox app) and https://<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.
  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.

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.

    • ✅ Plans are seeded with Price IDs and a features JSON, and a pre-seed table backup exists if the DB already had subscriptions.
  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-Info/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 7 · Observability 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-Info/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 7 · Observability) — 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.
  • 🤖 Project state updatedZaj-PROJECT.md records the billing model, active gateways, non-secret provider/product/price IDs, webhook endpoint paths, mode per environment, and vault item pointers.