Customer Subscription — Staging Deployment Checklist

Scope: yoyacoo BE (Laravel 10 monorepo: admin/, customer/, user/, shared common/).
Generated from a graph analysis of the subscription/Stripe code paths in this repo.
Deploy: AWS CodeBuild → S3 → CodeDeploy (see buildspec_stage.yaml).

0. Pre-flight: what “subscription” actually means here

The repo implements customer→user subscription billing via two parallel surfaces, both backed by Stripe:

  • Platform-tier planslite / standard / business / free_trial / free_plan driven by STRIPE_LITE_PLAN_ID, STRIPE_STANDARD_PLAN_ID, STRIPE_BUSINESS_PLAN_ID in customer/config/subscription_setting.php and admin/config/subscription_setting.php.
  • Per-service subscriptions — created in customer/app/Domains/Reservation/Usecase/PaymentServiceTypeInteractor/SubscriptionInteractor.php:36 against ServiceMenu.stripe_price_id (destination charge via userPaymentSetting.stripe_connect_id). Order is written with stripe_subscription_id in OrderServiceTypeInteractor/SubscriptionInteractor.php:76.

All real Stripe webhooks land in the admin host only. The customer and user apps explicitly 404 Cashier’s default webhook routes (customer/routes/web.php:24-28, user/routes/web.php:21). Do not point Stripe dashboard webhooks at customer/user domains.


1. Environment & Secrets (per app, all 3 hosts)

1.0 The one thing that is actually missing

Audit of the encrypted .dotenv/.env.staging.* files (key names are visible even when values are encrypted) confirms only STRIPE_CUSTOMER_SUBSCRIPTION_WEBHOOK is missing on the admin host. All other Stripe vars (STRIPE_PAYMENT_*, STRIPE_CONNECT_*, STRIPE_ACCOUNT_WEBHOOK, STRIPE_*_PLAN_ID, STRIPE_NOTIFICATION_EMAIL, etc.) are already present.

So the staging subscription deploy is effectively a one-variable add:

# 1. Create the endpoint in Stripe Dashboard (Test mode):
#    URL: https://<stg-admin-host>/stripe/customer-subscription-user
#    Events: customer.subscription.{created,updated,deleted},
#            invoice.payment_succeeded, invoice.payment_failed
# 2. Copy the resulting whsec_... into:
dotenvx set STRIPE_CUSTOMER_SUBSCRIPTION_WEBHOOK "whsec_..." -f .dotenv/.env.staging.admin
# 3. Re-decrypt and place on the admin host:
dotenvx get -f .dotenv/.env.staging.admin > /var/www/yoyacoo-api/admin/.env
# 4. Clear config cache on the admin host:
cd /var/www/yoyacoo-api/admin && php artisan config:cache

Do the same for .dotenv/.env.production.admin in a follow-up PR. The same gap exists in prod today and the endpoint is currently accepting unsigned requests.

1.1 Variables per app

Variable Where it lives admin customer user
APP_ENV=staging, APP_DEBUG=false, APP_URL .env
CASHIER_CURRENCY=jpy all .env.example:92-93
CASHIER_LOGGER=stack all .env.example
CONNECT_CLIENT_ID all .env.example
STRIPE_PAYMENT_KEY / STRIPE_PAYMENT_SECRET / STRIPE_PAYMENT_WEBHOOK all .env.example:97-99
STRIPE_CONNECT_KEY / STRIPE_CONNECT_SECRET / STRIPE_CONNECT_WEBHOOK all .env.example:100-102
STRIPE_ACCOUNT_WEBHOOK all .env.example:103
STRIPE_KEY / STRIPE_SECRET / STRIPE_WEBHOOK_SECRET (= aliases) all .env.example:105-107
STRIPE_LITE_PLAN_ID / STRIPE_STANDARD_PLAN_ID / STRIPE_BUSINESS_PLAN_ID all .env.example:119-121
STRIPE_WEBHOOK_TOLERANCE (default 300) consumed in admin/config/cashier.php:6,10,14
STRIPE_CUSTOMER_SUBSCRIPTION_WEBHOOK ⚠️ consumed in admin/config/cashier.php:13
STRIPE_NOTIFICATION_EMAIL admin/config/mail.php:124 + .env.example:129-130
PAYPAL_MODE / PAYPAL_*_CLIENT_ID / PAYPAL_*_SECRET / PAYPAL_BN_CODE / PAYPAL_WEBHOOK_ID admin/.env.example:108-123

⚠️ STRIPE_CUSTOMER_SUBSCRIPTION_WEBHOOK is missing from every .env.example AND from the encrypted .dotenv/.env.staging.admin and .dotenv/.env.production.admin (verified by grep on the encrypted files — key names are visible even when values are encrypted). It only appears in the checked-in customer/.env (local dev).

When this var is missing, config('cashier.customer_subscription_webhook.secret') is null and the signature middleware is skipped (admin/app/Domains/Subscription/Controllers/CustomerSubscriptionWebhookController.php:26-28) — meaning /stripe/customer-subscription-user accepts unsigned requests in production today. This is a security gap, not just a staging one. Fix in both staging and production.

1.2 Where the secrets actually come from

  • Decrypted with dotenvx get -f .dotenv/.env.staging.<app> per yoyacoo_be/.dotenv/readme.md:1-33
  • Placed at /var/www/yoyacoo-api/{admin,customer,user}/.env on each EC2 host
  • No sk_live_ keys anywhere in staging; confirm STRIPE_PAYMENT_KEY starts with pk_test_ and STRIPE_PAYMENT_SECRET with sk_test_
  • php artisan config:cache runs without strpos(): Argument #1 ($haystack) must be of type string (would mean a missing webhook secret broke the env() chain in services.php)

1.3 Stripe dashboard (test mode)

  • Logged into Stripe Test mode for all staging work
  • Test products + prices created for lite, standard, business; price IDs copied into env (config/subscription_setting.php:86,127,165)
  • Restricted API key for staging with scope: customers, subscriptions, payment_intents, checkout.sessions, invoices, customer_portal.sessions, connect
  • STRIPE_API_VERSION pinned in dashboard to match Cashier ^14.14 requirement (code handles both pre-2026-04-22.dahlia current_period_end and new items.data[0].current_period_endadmin/.../CustomerSubscriptionWebhookController.php:393-417)
  • Billing Portal enabled in test mode
  • Currency: JPY

2. Webhook Configuration (all four go to the admin host)

Configure in Stripe Dashboard → Developers → Webhooks → Test mode, pointed at https://<stg-admin-host>:

Dashboard endpoint URL Consumed by Secret env var Signature middleware Events to enable
/stripe/webhook admin/.../PaymentWebhookController@handleWebhook (admin/routes/web.php:26) STRIPE_PAYMENT_WEBHOOK Cashier default (STRIPE_WEBHOOK_SECRET) payment_intent.*, invoice.*, customer.*, charge.*
/stripe/connect admin/.../ConnectWebhookController@handleWebhook (admin/routes/web.php:29) STRIPE_CONNECT_WEBHOOK VerifyConnectWebhook (admin/app/Http/Middleware/VerifyConnectWebhook.php) account.*, payout.*, charge.dispute.*
/stripe/account admin/.../AccountWebhookController@handleWebhook (admin/routes/web.php:32) STRIPE_ACCOUNT_WEBHOOK VerifyAccountWebhook (admin/app/Http/Middleware/VerifyAccountWebhook.php) account.updated and connected-account events
/stripe/customer-subscription-user ⚠️ new admin/.../CustomerSubscriptionWebhookController@handleWebhook (admin/routes/web.php:35) STRIPE_CUSTOMER_SUBSCRIPTION_WEBHOOK VerifyCustomerSubscriptionWebhook (admin/app/Http/Middleware/VerifyCustomerSubscriptionWebhook.php:21-34) customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.payment_succeeded, invoice.payment_failed

2.1 Webhook secret placement

  • Each endpoint’s signing secret copied into the matching env var on the admin host
  • Re-run php artisan config:cache on the admin host after env changes
  • STRIPE_WEBHOOK_TOLERANCE set (default 300) so clock-skew replay attempts fail

2.2 Idempotency / event handling

  • customer.subscription.updatedCustomerSubscriptionWebhookController::handleCustomerSubscriptionUpdated syncs Order.status (BILLED / TRIALING / WAITINGCANCEL / CANCEL / NOTPAYMENT). Status is the source of truth here.
  • customer.subscription.deletedhandleCustomerSubscriptionDeleted sets Order.status=CANCEL, canceled_at, subscription_end_at. Idempotency guard at :62-64 skips if already CANCEL with canceled_at.
  • invoice.payment_succeededhandleInvoicePaymentSucceeded. Idempotency on stripe_invoice_id via SubscriptionPaymentHistory::firstOrCreate(['stripe_invoice_id' => ...]) (DB unique index, :131-143, 154-167). Replay is a no-op.
  • invoice.payment_failedhandleInvoicePaymentFailed writes a PAYMENT_FAILED history row. No status change (status comes from the customer.subscription.updated event, :177-178).
  • Cancel-email dedup — cancel mails fire only on the first transition into a cancel-family status. If the cancel originated in-app (CancelInteractor already sent mail), the webhook must not re-send. Verified by wasCancelFamily check at :69, 264-264, 306-309. Do not change this logic without reviewing the email package.
  • current_period_end resolver: code reads top-level, items.data[0].current_period_end, ended_at, cancel_at in that order (:393-417). Don’t strip these branches.

2.3 Local dev fallback

  • stripe listen --forward-to https://<stg-admin-host>/stripe/customer-subscription-user documented for devs (the customer/user 404 Cashier defaults — don’t use them)

3. Scheduler & Queue (admin host only)

The customer and user Console Kernels are empty (customer/app/Console/Kernel.php:15-18, user/app/Console/Kernel.php:15-18). All scheduled work lives on the admin host (admin/app/Console/Kernel.php:28-55).

  • EC2 cron on admin host is already: * * * * * cd /var/www/yoyacoo-api/admin && php artisan schedule:run ✓ confirmed
  • Verify on the staging admin EC2: crontab -l | grep schedule:run returns the line above
  • Verify staging admin EC2 host is the one actually running it (not prod by accident): hostname + cat /etc/environment | grep APP_ENV should show the staging instance
  • Verify with php artisan schedule:list from /var/www/yoyacoo-api/admin:
Command Cron Source Effect on staging
StripeCapturePayment * * * * * admin/app/Console/Kernel.php:37 Captures authorized manual-capture payments. ⚠️ Runs every env — keep test data out of staging cron.
StripeCreatePayout 0 0 17 * * (17:00 JST) :43 Creates daily payouts to connected accounts. Skip on staging by adding ->environments(['production']) if undesired.
DowngradePlanTrialToFree 0 0 * * * :47 Auto-downgrades expired trials. Will downgrade any staging trial that ages out — verify before/after.
SendPaymentRemindMail hourly :51 Fires payment-due reminders. OK to run in staging.
SendRemindMail every 5 min :33 Already env-gated to local, staging, local_backend, production. ✓
  • QUEUE_CONNECTION=sync everywhere (<app>/.env.example:24) — no Horizon, no Redis worker. Long webhook work runs synchronously inside the request; if you ever enable a queue, refactor SubscriptionPaymentHistory::firstOrCreate first.
  • php artisan schedule:test (Laravel 10) on the admin host passes

4. Database & Migrations

  • php artisan migrate runs cleanly on staging — the AfterInstall hook on every CodeDeploy already does this against the admin app (deploy/scripts/db_migrate:1-3):
    cd /var/www/yoyacoo-api/admin
    php artisan migrate
    
  • New subscription-related tables (orders columns, subscription_payment_histories with UNIQUE stripe_invoice_id) included in the migration bundle
  • subscription_payment_histories.stripe_invoice_id UNIQUE index is present (it is the idempotency key — drop only after backfilling)
  • Seed service_menus for staging with stripe_price_id values pointing at test-mode Stripe prices
  • Seed a test user with stripe_connect_id set so per-service subscriptions can be tested end-to-end
  • DB host/port/user are staging-specific, not prod (per admin/.env.example:14-19 — must be overridden in .env.staging.*)

5. Code & Configuration

  • composer.lock is committed; composer install succeeds with the new lock on each host (appspec.yml copies composer.json + composer.lock to /var/www/yoyacoo-api/<app>/, then AfterInstall runs deploy/<app>/scripts/setup_laravel which does composer install — see deploy/customer/appspec.yml:32-35, 60-63)
  • laravel/cashier ^14.14 resolved on all 3 apps (customer/composer.json:19, user/composer.json:16, admin/composer.json:17)
  • packages/common (path repo → ../common, customer/composer.json:94-101) symlink works on EC2; ReserveApp\Common\Models\User and ServiceMenu autoload
  • Storage permissions after deploy: chown -R ec2-user:ec2-user /var/www/yoyacoo-api/<app> except storage which goes to www-data (appspec permissions: block, deploy/customer/appspec.yml:44-58 + Dockerfile:137-152)
  • Nginx restarted by CodeDeploy ApplicationStartdeploy/scripts/start_server; stopped by ApplicationStopdeploy/scripts/stop_server
  • ALLOWED_ORIGINS and SESSION_DOMAIN on staging customer app point at the staging frontends (not *.reserve-example01.com unless that is the staging domain)
  • LOG_CHANNEL=stderr and LOG_SLACK_WEBHOOK_URL set if Slack alerts are wired

6. CodeDeploy pipeline (buildspec_stage.yaml)

Important: the stage build only ships user and customer artifacts. Admin is not redeployed in stage (lines 9-10 are commented out, buildspec_stage.yaml:9-19).

  • S3 bucket s3://code-artifact-stage-yoyacoo/api/{user,customer}/artifact.zip writable by CodeBuild role
  • CodeDeploy applications stage-api-user and stage-api-customer exist with EC2 tag group pointing at the right ASG / instances
  • Admin changes are deployed separately (out of band) before customer/user if needed
  • db_migrate hook will run php artisan migrate on the admin host after every customer/user deploy — confirm this is intended

7. Smoke Tests (post-deploy)

7.1 Routes

  • From admin host: php artisan route:list | grep -E 'stripe|webhook-paypal' shows the four POST routes above (no missing cashier.webhook etc.)
  • From customer/user host: same command shows stripe/webhook and stripe/payment/{id} blocked with 404 — confirms customer/user are NOT receiving Stripe webhooks

7.2 End-to-end on staging

  • Create a test customer → subscribe to lite via the in-app flow (customer/.../PaymentServiceTypeInteractor/SubscriptionInteractor.php:36-87)
  • Stripe Dashboard → Webhooks → “Send test event” with customer.subscription.updated to the /stripe/customer-subscription-user endpoint → admin log shows Webhook[customer.subscription.updated], Order.status flipped to BILLED
  • Repeat delivery (replay same event) → admin log shows skipped — status already BILLED; no duplicate subscription_payment_histories row
  • Cancel via Stripe Customer Portal → admin log shows Webhook[customer.subscription.deleted], order → CANCEL, canceled_at set, customer + user email sent exactly once
  • Cancel via in-app CancelInteractor (customer-side customer/.../CancelInteractor.php:229-333, user-side user/.../CancelInteractor.php:181-285) → cancel mails sent once, then customer.subscription.deleted arrives → does NOT re-send (verify wasCancelFamily branch)
  • Trigger invoice.payment_failed via dashboard → subscription_payment_histories row with status=PAYMENT_FAILED, no Order.status change
  • Per-service subscription: subscribe to a ServiceMenu with stripe_price_id → order gets stripe_subscription_id (customer/.../OrderServiceTypeInteractor/SubscriptionInteractor.php:76-111)

7.3 Schedulers

  • php artisan schedule:test (or wait one cycle) → StripeCapturePayment logs in admin/storage/logs/stripe.log (channel driven by CASHIER_LOGGER)
  • php artisan tinker --execute="dump((new \App\Console\Commands\StripeCapturePayment)->handle());" for one-off run if needed
  • No 5xx on tail -f of nginx/admin logs during the test run

7.4 Mail

  • STRIPE_NOTIFICATION_EMAIL set on admin host (else the failing-payment notification in admin/config/mail.php:124 no-ops or 500s)
  • MAIL_* configured for a staging-safe driver (e.g. Mailtrap or a low-volume SES sandbox) — staging should not send to real customers

8. Observability & Logging

  • CASHIER_LOGGER=stack writes to a centralizable log channel (CloudWatch / Papertrail)
  • admin/app/Domains/Subscription/Controllers/CustomerSubscriptionWebhookController.php calls Logger::Stripe(...) at every branch — these are your structured webhook log lines; tail them in CI
  • Slack alert on LOG_SLACK_WEBHOOK_URL for error-level (LOG_SLACK_LEVEL=error)
  • Sentry / error tracker DSN set
  • Stripe Dashboard → Webhooks → “Logs” tab watched during the deploy window
  • stripe listen output during testing: signatures verify (200 not 4xx); no SignatureVerificationException stack traces in admin/storage/logs/laravel.log

9. Security & Compliance

  • No sk_live_* keys anywhere in any .env.staging.* or in the encrypted .dotenv/
  • No real customer PII in staging DB (synthetic data only)
  • All four Stripe webhook URLs reachable only over HTTPS; HTTP redirects to HTTPS
  • customer and user hosts return 404 (not 500) on POST /stripe/webhook — confirmed via Route::any('stripe/webhook', fn() => abort(404)) (customer/routes/web.php:24, user/routes/web.php:21)
  • CSRF: webhook routes bypass CSRF via the signature middleware; nothing else accidentally added under web.php group middleware that would require it
  • No write access to STRIPE_PAYMENT_SECRET from customer app logs (use services.stripe.connect.secret for Connect operations, not payment)

10. Documentation & Handoff

  • Staging URLs and test card numbers (4242 4242 4242 4242, 3DS-required cards) posted in team channel
  • Runbook for replaying a webhook: stripe trigger customer.subscription.updated --add subscription:id=sub_xxx against the admin host URL
  • Runbook for rotating STRIPE_CUSTOMER_SUBSCRIPTION_WEBHOOK (and the other three): update Stripe endpoint, update env, php artisan config:cache, no code change
  • Owner assigned for monitoring the first 24h post-deploy
  • Rollback plan: previous CodeDeploy deploy revision + DB snapshot; re-pushing the previous artifact is enough because db_migrate is idempotent only for additive migrations — destructive migrations need a manual revert
  • Changelog / release notes mention any change to Order.status mapping or cancel-email logic — those are the riskiest areas to change

11. Go / No-Go

  • All boxes above checked
  • PM/QA sign-off in deploy channel
  • Deploy window announced
  • Rollback owner on standby
  • Admin host cron confirmed active on the staging instance (not prod) — crontab -l + hostname

Appendix A — Files this checklist was synthesized from

(Use these file:line references when reviewing the checklist against future changes.)

  • admin/config/cashier.php:1-16 — webhook secret env mapping
  • admin/app/Http/Middleware/VerifyCustomerSubscriptionWebhook.php:21-34 — signature verification
  • admin/app/Domains/Subscription/Controllers/CustomerSubscriptionWebhookController.php — handlers (full file is the contract)
  • admin/app/Console/Kernel.php:28-55 — scheduler
  • admin/routes/web.php:20-37 — webhook routes
  • admin/.env.example:92-130 — required env vars
  • customer/.env.example:91-124 — required env vars
  • user/.env.example:100-132 — required env vars
  • customer/routes/web.php:24-28, user/routes/web.php:21 — webhook 404 guards
  • customer/app/Domains/Reservation/Usecase/PaymentServiceTypeInteractor/SubscriptionInteractor.php:36-87 — per-service subscription creation
  • customer/app/Domains/Reservation/Usecase/OrderServiceTypeInteractor/SubscriptionInteractor.php:76-111 — order hydration
  • customer/app/Domains/Reservation/Usecase/CancelInteractor.php:229-333 — in-app cancel
  • user/app/Domains/Reservation/Usecase/CancelInteractor.php:181-285 — in-app cancel
  • customer/config/subscription_setting.php:1-199 — plan matrix
  • common/src/Models/User.php:55,138,352-359stripe_customer_id
  • common/src/Models/ServiceMenu.php:39stripe_price_id
  • buildspec_stage.yaml:1-19 — stage build (user + customer only)
  • deploy/customer/appspec.yml:1-71, deploy/user/appspec.yml — CodeDeploy
  • deploy/scripts/db_migrate:1-3 — auto php artisan migrate on admin
  • yoyacoo_be/.dotenv/readme.md:1-33 — secrets decryption
  • yoyacoo_be/graphify-out/graph.html — interactive knowledge graph of the subscription code paths
  • yoyacoo_be/graphify-out/GRAPH_REPORT.md — graph audit report