Implementation Summary: Customer Subscription Order Pipeline
Date: 2026-05-29
Branch: feat/956_service_subscriptions_v0.3 / feat/redmine_956
Status: Complete (with known Stripe Dashboard config dependency)
Scope: Backend (admin + customer + user apps + common package) + Frontend (supplier + customer apps)
Overview
This document summarises the end-to-end implementation of the customer-to-supplier subscription order pipeline, including:
- A new standalone
CustomerSubscriptionWebhookControllerfor Stripe subscription events - A new
TRIALING = 9order status - A dedicated
stripe_subscription_idcolumn on theorderstable - Correct separation of
stripe_subscription_id(sub_xxx) andstripe_payment_id(pi_xxx) - Frontend display of all 5 subscription statuses including
トライアル中
Architecture: Data Flow
Customer Browser
│
├─ POST /payment → customer app: PaymentServiceTypeInteractor\SubscriptionInteractor
│ Creates Stripe subscription → returns { client_secret, subscription_id }
│ FE: PaymentConformForm captures sub_xxx, confirms card payment → gets pi_xxx
│
├─ PUT /order → customer app: OrderServiceTypeInteractor\SubscriptionInteractor
│ Receives: service.stripe_subscription_id (sub_xxx), payment.payment_intent_id (pi_xxx)
│ Writes: orders.stripe_subscription_id = sub_xxx
│ orders.stripe_payment_id = pi_xxx (null for trials)
│ orders.status = NOTPAYMENT (1) initially
│
Stripe Webhooks (POST /stripe/customer-subscription-user)
│
├─ customer.subscription.created → syncSubscriptionStatus() → set initial status
├─ customer.subscription.updated → syncSubscriptionStatus() → update status
├─ customer.subscription.deleted → set CANCEL + canceled_at (safety net)
├─ invoice.payment_succeeded → set payment_time (amount_paid > 0 only)
└─ invoice.payment_failed → log only (status managed by subscription.updated)
Status Mapping
| Stripe Subscription Status | cancel_at_period_end |
Order Status | Value |
|---|---|---|---|
trialing |
any | TRIALING |
9 |
active |
false |
BILLED |
2 |
active |
true |
WAITINGCANCEL |
6 |
past_due or unpaid |
any | NOTPAYMENT |
1 |
canceled |
any | CANCEL |
5 |
Backend Changes
common package
common/src/Enums/Order/Status.php
- Added
case TRIALING = 9;with label'トライアル中' - No DB migration needed —
orders.statuswas already an integer column
common/src/Models/Base/Order.php
- Added
@property string|null $stripe_subscription_idPHPDoc annotation
common/src/Base/Traits/StripeInvoiceHelper.php (new)
- Shared trait:
resolveSubscriptionIdFromInvoice(array $invoice): ?string - Supports both legacy (
invoice.subscription) and Stripe API 2025+ (invoice.parent.subscription_details.subscription) formats - Used by both
ConnectWebhookControllerandCustomerSubscriptionWebhookController
common/database/migrations/2026_05_29_000001_add_stripe_subscription_id_to_orders_table.php (new)
ALTER TABLE orders
ADD COLUMN stripe_subscription_id VARCHAR(255) UNIQUE NULL
COMMENT 'Stripe subscription id (sub_xxx) for subscription-type service orders'
AFTER stripe_payment_id;
admin app
admin/app/Domains/Subscription/Controllers/CustomerSubscriptionWebhookController.php
Standalone controller (extends Laravel\Cashier\Http\Controllers\WebhookController directly — does not extend ConnectWebhookController).
5 event handlers:
| Handler | Action |
|---|---|
handleCustomerSubscriptionCreated |
Delegates to syncSubscriptionStatus() — sets initial status |
handleCustomerSubscriptionUpdated |
Delegates to syncSubscriptionStatus() — authoritative for all transitions |
handleCustomerSubscriptionDeleted |
Sets CANCEL + canceled_at; idempotent if already cancelled |
handleInvoicePaymentSucceeded |
Sets payment_time using status_transitions.paid_at; skips when amount_paid == 0 |
handleInvoicePaymentFailed |
Log only — Stripe fires subscription.updated with past_due first |
Key design decisions:
- Single lookup column:
Order::where('stripe_subscription_id', $subscriptionId)— notstripe_payment_id - Idempotency guards:
syncSubscriptionStatus()skips DB write if status already matches;handleCustomerSubscriptionDeletedskips if alreadyCANCEL + canceled_at - Timestamp authority:
payment_timeusesstatus_transitions.paid_atfrom Stripe, notnow() - Scope guard: Skips silently if order not found or if
!isSubscription() - CSRF exemption:
stripe/*is excluded inVerifyCustomerSubscriptionWebhookmiddleware
admin/app/Http/Middleware/VerifyCustomerSubscriptionWebhook.php (new)
Verifies Stripe webhook signatures using cashier.customer_subscription_webhook.secret.
admin/config/cashier.php
Added customer_subscription_webhook key referencing STRIPE_CUSTOMER_SUBSCRIPTION_WEBHOOK env var.
admin/routes/web.php
POST /stripe/customer-subscription-user → CustomerSubscriptionWebhookController
admin/tests/Feature/Subscription/CustomerSubscriptionWebhookTest.php (new)
18 PHPUnit feature tests using SQLite in-memory with hand-built minimal schema. All pass.
Coverage:
- All 5 Stripe status → Order status transitions
customer.subscription.created+customer.subscription.updatedboth handled- Idempotency on duplicate event delivery (both
updatedanddeleted) invoice.payment_succeededuses Stripe’s authoritativepaid_attimestamp- Trial invoice (
amount_paid == 0) does not setpayment_time - Non-subscription orders silently skipped
- Order not found silently skipped
admin/app/Domains/Subscription/Controllers/ConnectWebhookController.php
- Added
use StripeInvoiceHelpertrait - Removed duplicate private
resolveSubscriptionIdFromInvoice()method
customer app
customer/app/Domains/Reservation/Usecase/PaymentServiceTypeInteractor/SubscriptionInteractor.php
- Returns
{ client_secret, subscription_id }for all subscription payment flows (paid and trial) subscription_idis the Stripesub_xxxvalue
customer/app/Domains/Reservation/Controller/Resource/PaymentResource.php
- Exposes
subscription_idin the payment response
customer/app/Domains/Reservation/Controller/Request/OrderServiceTypeRequest/SubscriptionRequest.php
- Validates
service.stripe_subscription_id: nullable, string, max 255, regex^sub_
customer/app/Domains/Reservation/Usecase/OrderServiceTypeInteractor/SubscriptionInteractor.php
- Reads
$interface->getPayment()to getpayment_intent_id(pi_xxx) - Writes
stripe_subscription_id=service.stripe_subscription_id(sub_xxx) - Writes
stripe_payment_id=payment.payment_intent_id(pi_xxx;nullfor free trials) - The two IDs are now stored in separate, dedicated columns
user app
user/app/Domains/Reservation/Controller/Resource/ShowResource.php
- Added
Status::TRIALING->value => 'trialing'to both$subscriptionStatusandpaymentHistorymatch blocks - Enables the supplier frontend to receive and display the TRIALING status
Frontend Changes
Swagger / OpenAPI (swagger/api/)
swagger/api/common/components/enum.yaml
Added trialing / TRIALING / トライアル中 to both:
SubscriptionContractStatusSubscriptionPaymentHistoryStatus
swagger/api/customer/paths/reservation/_payment.yaml
Added subscription_id (nullable string) to the payment response schema.
swagger/api/customer/components/_reservation.yaml
Added stripe_subscription_id (nullable string) to CustomerReservationServiceDto.
Supplier app
supplier/src/apis/clients/api.ts (regenerated)
SubscriptionContractStatus.TRIALING = 'trialing' and SubscriptionPaymentHistoryStatus.TRIALING = 'trialing' added via codegen.
supplier/src/components/organisms/reservations/show/ReservationInfor.tsx
Both status label switch statements handle SubscriptionContractStatus.TRIALING → 'トライアル中' and SubscriptionPaymentHistoryStatus.TRIALING → 'トライアル中'.
Customer app
customer/src/states/service-form/types.ts
FormPayment type gained subscriptionId?: string — stores sub_xxx captured at payment time.
customer/src/components/organisms/reservations/payment/PaymentConformForm.tsx
After Stripe confirmCardPayment (or in the no-immediate-payment path for free trials), captures subscription_id from the /payment API response and attaches it to the FormPayment object passed to onSuccess.
customer/src/states/service-form/selectors.ts (putReservationOrderRequestAtom)
For subscription-type services, injects stripe_subscription_id = formUiState.payment.subscriptionId into the order service payload before calling PUT /order.
Column Separation: Why Two Stripe ID Columns
| Column | Content | Who writes it | Who reads it |
|---|---|---|---|
orders.stripe_subscription_id |
sub_xxx |
OrderServiceTypeInteractor\SubscriptionInteractor (customer) |
CustomerSubscriptionWebhookController (admin) |
orders.stripe_payment_id |
pi_xxx |
OrderServiceTypeInteractor\SubscriptionInteractor (customer) |
General payment reconciliation; null for free trials |
Before this change, stripe_payment_id was overloaded to store sub_xxx for subscription orders. This made webhook lookups work but broke the semantic meaning of the column and lost the payment intent ID entirely for subscription orders.
Verification
PHPStan
admin/CustomerSubscriptionWebhookController.php→[OK] No errorscustomer/OrderServiceTypeInteractor/SubscriptionInteractor.php→[OK] No errors
Tests
Tests\Feature\Subscription\CustomerSubscriptionWebhookTest 20 passed 0.18s
Pre-existing issue (not introduced by this work)
PHPStan reports one error on ConnectWebhookController::sendMailWhenSystemCancel — this existed before this implementation.
Environment Configuration
# .env (admin)
STRIPE_CUSTOMER_SUBSCRIPTION_WEBHOOK=whsec_2c79cv4CR6AYMwAB8fyzfPP4Fmi7firT
// admin/config/cashier.php
'customer_subscription_webhook' => [
'secret' => env('STRIPE_CUSTOMER_SUBSCRIPTION_WEBHOOK'),
],
Key Files Reference
| File | Role |
|---|---|
common/src/Enums/Order/Status.php |
TRIALING = 9 |
common/src/Models/Base/Order.php |
stripe_subscription_id property annotation |
common/src/Base/Traits/StripeInvoiceHelper.php |
Shared invoice subscription ID resolver |
common/database/migrations/2026_05_29_000001_add_stripe_subscription_id_to_orders_table.php |
DB column |
admin/app/Domains/Subscription/Controllers/CustomerSubscriptionWebhookController.php |
5 Stripe event handlers |
admin/app/Http/Middleware/VerifyCustomerSubscriptionWebhook.php |
Webhook signature verification |
admin/tests/Feature/Subscription/CustomerSubscriptionWebhookTest.php |
20 PHPUnit tests (incl. incomplete handling) |
customer/app/Domains/Reservation/Usecase/PaymentServiceTypeInteractor/SubscriptionInteractor.php |
Returns subscription_id from Stripe |
customer/app/Domains/Reservation/Controller/Resource/PaymentResource.php |
Exposes subscription_id |
customer/app/Domains/Reservation/Controller/Request/OrderServiceTypeRequest/SubscriptionRequest.php |
Validates stripe_subscription_id |
customer/app/Domains/Reservation/Usecase/OrderServiceTypeInteractor/SubscriptionInteractor.php |
Writes both stripe columns |
user/app/Domains/Reservation/Controller/Resource/ShowResource.php |
TRIALING in status map |
swagger/api/common/components/enum.yaml |
TRIALING in both subscription enums |
swagger/api/customer/paths/reservation/_payment.yaml |
subscription_id in payment response |
swagger/api/customer/components/_reservation.yaml |
stripe_subscription_id in order DTO |
supplier/src/apis/clients/api.ts |
Generated client with TRIALING |
supplier/src/components/organisms/reservations/show/ReservationInfor.tsx |
TRIALING display |
customer/src/states/service-form/types.ts |
FormPayment.subscriptionId |
customer/src/components/organisms/reservations/payment/PaymentConformForm.tsx |
Captures subscription_id |
customer/src/states/service-form/selectors.ts |
Injects stripe_subscription_id into order payload |
Race Condition Fix: Synchronous Initial Status
Problem Discovered (2026-05-29)
When order 617 was created, the webhook did not update its status. Investigation revealed three compounding issues:
Race condition: Stripe fires
customer.subscription.createdduringPOST /payment(subscription creation at Stripe), but the order is created later byPUT /order. The webhook arrives before the order exists, the controller returns 200 (“order not found”), and Stripe never retries.incompletestatus: When the webhook does arrive after the order exists, the Stripe subscription is inincompletestatus (an interim state before payment confirmation finalizes it totrialing/active).syncSubscriptionStatus()didn’t handleincomplete— it logged “unhandled stripe status” and skipped.Missing webhook event type: The Stripe Dashboard webhook endpoint for
/stripe/customer-subscription-userwas only configured to sendcustomer.subscription.createdevents — NOTcustomer.subscription.updated. The entire status transition design depended oncustomer.subscription.updated, but it was never received.
Solution
Initial status is now set synchronously in PUT /order, eliminating the webhook dependency:
// OrderServiceTypeInteractor\SubscriptionInteractor
// Uses the same menu fields that determine Stripe's trial behavior
$orderModel->status = !empty($menuModel->trial_period_flag) && $menuModel->trial_period_days > 0
? Status::TRIALING->value
: Status::BILLED->value;
The $menuModel is already loaded in the interactor and its trial_period_flag/trial_period_days fields are the same ones used by PaymentServiceTypeInteractor\SubscriptionInteractor to set trial_end on the Stripe subscription — making this deterministic.
incomplete status is now handled explicitly in syncSubscriptionStatus(): it returns __skip__ which logs “skipped interim incomplete status” and returns 200 without modifying the order. The initial status was already set correctly by PUT /order.
Required: Stripe Dashboard Configuration
Critical: The /stripe/customer-subscription-user webhook endpoint in Stripe Dashboard must be configured to send customer.subscription.updated events in addition to customer.subscription.created. Without this, subsequent status transitions (trial expiration → BILLED, payment failure → NOTPAYMENT, cancellation → CANCEL) will not be synced.
Required Stripe webhook event types:
customer.subscription.createdcustomer.subscription.updated⬅️ must be addedcustomer.subscription.deletedinvoice.payment_succeededinvoice.payment_failed