Customer Subscription — Staging Deployment Checklist
Scope: yoyacoo BE (Laravel 10 monorepo:
admin/,customer/,user/, sharedcommon/).
Generated from a graph analysis of the subscription/Stripe code paths in this repo.
Deploy: AWS CodeBuild → S3 → CodeDeploy (seebuildspec_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 plans —
lite / standard / business / free_trial / free_plandriven bySTRIPE_LITE_PLAN_ID,STRIPE_STANDARD_PLAN_ID,STRIPE_BUSINESS_PLAN_IDincustomer/config/subscription_setting.phpandadmin/config/subscription_setting.php. - Per-service subscriptions — created in
customer/app/Domains/Reservation/Usecase/PaymentServiceTypeInteractor/SubscriptionInteractor.php:36againstServiceMenu.stripe_price_id(destination charge viauserPaymentSetting.stripe_connect_id). Order is written withstripe_subscription_idinOrderServiceTypeInteractor/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.adminin 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_WEBHOOKis missing from every.env.exampleAND from the encrypted.dotenv/.env.staging.adminand.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-incustomer/.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-useraccepts 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>peryoyacoo_be/.dotenv/readme.md:1-33 - Placed at
/var/www/yoyacoo-api/{admin,customer,user}/.envon each EC2 host - No
sk_live_keys anywhere in staging; confirmSTRIPE_PAYMENT_KEYstarts withpk_test_andSTRIPE_PAYMENT_SECRETwithsk_test_ -
php artisan config:cacheruns withoutstrpos(): Argument #1 ($haystack) must be of type string(would mean a missing webhook secret broke theenv()chain inservices.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_VERSIONpinned in dashboard to match Cashier^14.14requirement (code handles both pre-2026-04-22.dahliacurrent_period_endand newitems.data[0].current_period_end—admin/.../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:cacheon the admin host after env changes -
STRIPE_WEBHOOK_TOLERANCEset (default 300) so clock-skew replay attempts fail
2.2 Idempotency / event handling
-
customer.subscription.updated→CustomerSubscriptionWebhookController::handleCustomerSubscriptionUpdatedsyncsOrder.status(BILLED / TRIALING / WAITINGCANCEL / CANCEL / NOTPAYMENT). Status is the source of truth here. -
customer.subscription.deleted→handleCustomerSubscriptionDeletedsetsOrder.status=CANCEL,canceled_at,subscription_end_at. Idempotency guard at:62-64skips if already CANCEL withcanceled_at. -
invoice.payment_succeeded→handleInvoicePaymentSucceeded. Idempotency onstripe_invoice_idviaSubscriptionPaymentHistory::firstOrCreate(['stripe_invoice_id' => ...])(DB unique index,:131-143, 154-167). Replay is a no-op. -
invoice.payment_failed→handleInvoicePaymentFailedwrites aPAYMENT_FAILEDhistory row. No status change (status comes from thecustomer.subscription.updatedevent,:177-178). - Cancel-email dedup — cancel mails fire only on the first transition into a cancel-family status. If the cancel originated in-app (
CancelInteractoralready sent mail), the webhook must not re-send. Verified bywasCancelFamilycheck at:69, 264-264, 306-309. Do not change this logic without reviewing the email package. -
current_period_endresolver: code reads top-level,items.data[0].current_period_end,ended_at,cancel_atin 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-userdocumented for devs (thecustomer/user404 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:runreturns the line above - Verify staging admin EC2 host is the one actually running it (not prod by accident):
hostname+cat /etc/environment | grep APP_ENVshould show the staging instance - Verify with
php artisan schedule:listfrom/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=synceverywhere (<app>/.env.example:24) — no Horizon, no Redis worker. Long webhook work runs synchronously inside the request; if you ever enable a queue, refactorSubscriptionPaymentHistory::firstOrCreatefirst. -
php artisan schedule:test(Laravel 10) on the admin host passes
4. Database & Migrations
-
php artisan migrateruns cleanly on staging — theAfterInstallhook 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 (
orderscolumns,subscription_payment_historieswith UNIQUEstripe_invoice_id) included in the migration bundle -
subscription_payment_histories.stripe_invoice_idUNIQUE index is present (it is the idempotency key — drop only after backfilling) - Seed
service_menusfor staging withstripe_price_idvalues pointing at test-mode Stripe prices - Seed a test
userwithstripe_connect_idset 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.lockis committed;composer installsucceeds with the new lock on each host (appspec.yml copiescomposer.json+composer.lockto/var/www/yoyacoo-api/<app>/, thenAfterInstallrunsdeploy/<app>/scripts/setup_laravelwhich doescomposer install— seedeploy/customer/appspec.yml:32-35, 60-63) -
laravel/cashier ^14.14resolved 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\UserandServiceMenuautoload - Storage permissions after deploy:
chown -R ec2-user:ec2-user /var/www/yoyacoo-api/<app>exceptstoragewhich goes towww-data(appspecpermissions:block,deploy/customer/appspec.yml:44-58+Dockerfile:137-152) - Nginx restarted by CodeDeploy
ApplicationStart→deploy/scripts/start_server; stopped byApplicationStop→deploy/scripts/stop_server -
ALLOWED_ORIGINSandSESSION_DOMAINon staging customer app point at the staging frontends (not*.reserve-example01.comunless that is the staging domain) -
LOG_CHANNEL=stderrandLOG_SLACK_WEBHOOK_URLset if Slack alerts are wired
6. CodeDeploy pipeline (buildspec_stage.yaml)
Important: the stage build only ships
userandcustomerartifacts. 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.zipwritable by CodeBuild role - CodeDeploy applications
stage-api-userandstage-api-customerexist with EC2 tag group pointing at the right ASG / instances - Admin changes are deployed separately (out of band) before customer/user if needed
-
db_migratehook will runphp artisan migrateon 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 missingcashier.webhooketc.) - From customer/user host: same command shows
stripe/webhookandstripe/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
litevia the in-app flow (customer/.../PaymentServiceTypeInteractor/SubscriptionInteractor.php:36-87) - Stripe Dashboard → Webhooks → “Send test event” with
customer.subscription.updatedto the/stripe/customer-subscription-userendpoint → admin log showsWebhook[customer.subscription.updated],Order.statusflipped toBILLED - Repeat delivery (replay same event) → admin log shows
skipped — status already BILLED; no duplicatesubscription_payment_historiesrow - Cancel via Stripe Customer Portal → admin log shows
Webhook[customer.subscription.deleted], order →CANCEL,canceled_atset, customer + user email sent exactly once - Cancel via in-app
CancelInteractor(customer-sidecustomer/.../CancelInteractor.php:229-333, user-sideuser/.../CancelInteractor.php:181-285) → cancel mails sent once, thencustomer.subscription.deletedarrives → does NOT re-send (verifywasCancelFamilybranch) - Trigger
invoice.payment_failedvia dashboard →subscription_payment_historiesrow withstatus=PAYMENT_FAILED, noOrder.statuschange - Per-service subscription: subscribe to a
ServiceMenuwithstripe_price_id→ order getsstripe_subscription_id(customer/.../OrderServiceTypeInteractor/SubscriptionInteractor.php:76-111)
7.3 Schedulers
-
php artisan schedule:test(or wait one cycle) →StripeCapturePaymentlogs inadmin/storage/logs/stripe.log(channel driven byCASHIER_LOGGER) -
php artisan tinker --execute="dump((new \App\Console\Commands\StripeCapturePayment)->handle());"for one-off run if needed - No 5xx on
tail -fof nginx/admin logs during the test run
7.4 Mail
-
STRIPE_NOTIFICATION_EMAILset on admin host (else the failing-payment notification inadmin/config/mail.php:124no-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=stackwrites to a centralizable log channel (CloudWatch / Papertrail) -
admin/app/Domains/Subscription/Controllers/CustomerSubscriptionWebhookController.phpcallsLogger::Stripe(...)at every branch — these are your structured webhook log lines; tail them in CI - Slack alert on
LOG_SLACK_WEBHOOK_URLforerror-level (LOG_SLACK_LEVEL=error) - Sentry / error tracker DSN set
- Stripe Dashboard → Webhooks → “Logs” tab watched during the deploy window
-
stripe listenoutput during testing: signatures verify (200not4xx); noSignatureVerificationExceptionstack traces inadmin/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
-
customeranduserhosts return404(not 500) onPOST /stripe/webhook— confirmed viaRoute::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.phpgroup middleware that would require it - No write access to
STRIPE_PAYMENT_SECRETfrom customer app logs (useservices.stripe.connect.secretfor 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_xxxagainst 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_migrateis idempotent only for additive migrations — destructive migrations need a manual revert - Changelog / release notes mention any change to
Order.statusmapping 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 mappingadmin/app/Http/Middleware/VerifyCustomerSubscriptionWebhook.php:21-34— signature verificationadmin/app/Domains/Subscription/Controllers/CustomerSubscriptionWebhookController.php— handlers (full file is the contract)admin/app/Console/Kernel.php:28-55— scheduleradmin/routes/web.php:20-37— webhook routesadmin/.env.example:92-130— required env varscustomer/.env.example:91-124— required env varsuser/.env.example:100-132— required env varscustomer/routes/web.php:24-28,user/routes/web.php:21— webhook 404 guardscustomer/app/Domains/Reservation/Usecase/PaymentServiceTypeInteractor/SubscriptionInteractor.php:36-87— per-service subscription creationcustomer/app/Domains/Reservation/Usecase/OrderServiceTypeInteractor/SubscriptionInteractor.php:76-111— order hydrationcustomer/app/Domains/Reservation/Usecase/CancelInteractor.php:229-333— in-app canceluser/app/Domains/Reservation/Usecase/CancelInteractor.php:181-285— in-app cancelcustomer/config/subscription_setting.php:1-199— plan matrixcommon/src/Models/User.php:55,138,352-359—stripe_customer_idcommon/src/Models/ServiceMenu.php:39—stripe_price_idbuildspec_stage.yaml:1-19— stage build (user + customer only)deploy/customer/appspec.yml:1-71,deploy/user/appspec.yml— CodeDeploydeploy/scripts/db_migrate:1-3— autophp artisan migrateon adminyoyacoo_be/.dotenv/readme.md:1-33— secrets decryptionyoyacoo_be/graphify-out/graph.html— interactive knowledge graph of the subscription code pathsyoyacoo_be/graphify-out/GRAPH_REPORT.md— graph audit report