Design: Customer Subscription Webhook Handler
Date: 2026-05-29
Status: Approved
Scope: Backend (admin app + common package) + Frontend (supplier app)
Problem Statement
The stripe/customer-subscription-user webhook endpoint handles Stripe events for customer-to-user subscriptions. Currently:
CustomerSubscriptionWebhookControllerextendsConnectWebhookController, inheriting PaymentIntent and dispute handlers that touch non-subscription orders — violating the requirement to only update subscription service orders.customer.subscription.updatedis not handled, meaning status transitions (trial → active, payment failed, cancel requested) are never synced.- There is no
TRIALINGorder status, so free-trial subscriptions have no distinguishable state. - The subscription ID lookup used
Invoice.subscription(top-level), which Stripe moved toInvoice.parent.subscription_details.subscriptionin API 2025+ — already patched but the helper remains a private method onConnectWebhookController.
Goals
- Make
CustomerSubscriptionWebhookControllera fully standalone controller handling only subscription-service orders. - Use
customer.subscription.updatedas the single source of truth for all 5 subscription statuses. - Add
TRIALING = 9to theOrder.statusenum. - Move the Stripe invoice subscription ID resolver to a shared trait usable by both webhook controllers.
- Update the supplier frontend to display the new トライアル中 status.
Out of Scope
- Customer-facing subscription status display (supplier view only).
- Refund logic or stock restoration on subscription cancellation (handled by
CancelInteractorin the customer app). - Email notifications on subscription status change (not required by this spec).
- Changes to the subscription creation flow (
OrderServiceTypeInteractor,PaymentServiceTypeInteractor).
Architecture
Approach: Standalone Controller (Approach B)
CustomerSubscriptionWebhookController extends Cashier’s WebhookController directly (not ConnectWebhookController). It handles only the 4 Stripe events relevant to subscription order lifecycle. ConnectWebhookController remains unchanged in responsibility.
Cashier WebhookController (base)
├── ConnectWebhookController ← Connect PaymentIntent + dispute orders
│ uses StripeInvoiceHelper ← shared trait (moved from private method)
│
└── CustomerSubscriptionWebhookController ← NEW: standalone, subscription only
uses StripeInvoiceHelper
VerifyCustomerSubscriptionWebhook middleware
Status Enum
New value: TRIALING = 9
Added to common/src/Enums/Order/Status.php:
case TRIALING = 9; // トライアル中
Label: 'トライアル中'
No DB migration required — orders.status is already an integer column.
Full subscription status mapping
Order.status |
Enum | Display (JP) | Stripe trigger |
|---|---|---|---|
| 9 | TRIALING |
トライアル中 | stripe_status = trialing |
| 2 | BILLED |
契約継続中 | stripe_status = active AND cancel_at_period_end = false |
| 1 | NOTPAYMENT |
支払失敗あり | stripe_status = past_due OR unpaid |
| 6 | WAITINGCANCEL |
解約受付済み | stripe_status = active AND cancel_at_period_end = true |
| 5 | CANCEL |
解約済み | stripe_status = canceled |
Shared Trait: StripeInvoiceHelper
Location: common/src/Base/Traits/StripeInvoiceHelper.php
Provides resolveSubscriptionIdFromInvoice(array $invoice): ?string.
Supports both Stripe API formats:
- Legacy (pre-2025):
$invoice['subscription']— top-level field - New (2025+):
$invoice['parent']['subscription_details']['subscription']— nested field
private function resolveSubscriptionIdFromInvoice(array $invoice): ?string
{
if (!empty($invoice['subscription'])) {
return $invoice['subscription'];
}
return $invoice['parent']['subscription_details']['subscription'] ?? null;
}
Both ConnectWebhookController and CustomerSubscriptionWebhookController use this trait.
Backend: CustomerSubscriptionWebhookController
Location: admin/app/Domains/Subscription/Controllers/CustomerSubscriptionWebhookController.php
Extends: Laravel\Cashier\Http\Controllers\WebhookController
Middleware: VerifyCustomerSubscriptionWebhook (applied when cashier.customer_subscription_webhook.secret is set)
Traits: StripeInvoiceHelper
Order lookup contract
All handlers share this lookup pattern:
- Resolve the Stripe subscription ID from the payload.
- Find
Orderwherestripe_payment_id = subscriptionId. - Verify
$order->customerPurchaseHistory->service->isSubscription(). - If either check fails → log and return
successMethod()(safe no-op, returns 200).
Handler 1: handleCustomerSubscriptionUpdated — single source of truth
Fires on: customer.subscription.updated
subscription = payload['data']['object']
subscriptionId = subscription['id']
resolve order (see lookup contract above)
stripeStatus = subscription['status']
cancelAtPeriodEnd = subscription['cancel_at_period_end']
newStatus = match:
stripeStatus = 'trialing' → TRIALING (9)
stripeStatus = 'active', cancelAtPeriodEnd = true → WAITINGCANCEL (6)
stripeStatus = 'active' → BILLED (2)
stripeStatus = 'past_due' | 'unpaid' → NOTPAYMENT (1)
stripeStatus = 'canceled' → CANCEL (5)
anything else (incomplete, paused, etc.) → log + skip (no update)
if newStatus = CANCEL and order->canceled_at is null:
order->canceled_at = now()
order->status = newStatus
order->save()
Handler 2: handleCustomerSubscriptionDeleted — safety net
Fires on: customer.subscription.deleted
Stripe fires customer.subscription.deleted after customer.subscription.updated (status = canceled). This handler guarantees the final CANCEL state even if the updated event was missed.
subscriptionId = payload['data']['object']['id']
resolve order
order->status = CANCEL
if order->canceled_at is null: order->canceled_at = now()
order->save()
Handler 3: handleInvoicePaymentSucceeded — payment_time only
Fires on: invoice.payment_succeeded
No status change. Status is already managed by handleCustomerSubscriptionUpdated. This handler only records when a real payment was collected.
invoice = payload['data']['object']
if invoice['amount_paid'] == 0: skip (trial invoice, no real payment)
subscriptionId = resolveSubscriptionIdFromInvoice(invoice)
resolve order
order->payment_time = now()
order->save()
Handler 4: handleInvoicePaymentFailed — log only
Fires on: invoice.payment_failed
Status is already handled by customer.subscription.updated (Stripe sets past_due before firing invoice.payment_failed). This handler only logs the event for observability.
Logger::Stripe('Webhook[invoice.payment_failed]', payload)
return successMethod()
Full Stripe Event Sequence
Customer creates subscription (free trial)
└─ customer.subscription.updated → order status: TRIALING
Customer creates subscription (no trial, payment confirmed by FE)
├─ invoice.payment_succeeded → order.payment_time = now()
└─ customer.subscription.updated → order status: BILLED
Recurring charge succeeds
├─ invoice.payment_succeeded → order.payment_time = now()
└─ customer.subscription.updated → order status: BILLED (idempotent)
Recurring charge fails
├─ invoice.payment_failed → log only
└─ customer.subscription.updated → order status: NOTPAYMENT
Trial ends, first charge succeeds
├─ invoice.payment_succeeded → order.payment_time = now()
└─ customer.subscription.updated → order status: BILLED
Trial ends, first charge fails
├─ invoice.payment_failed → log only
└─ customer.subscription.updated → order status: NOTPAYMENT
Customer requests cancel (via Yoyacoo FE → CancelInteractor)
└─ customer.subscription.updated → order status: WAITINGCANCEL
(Stripe sets cancel_at_period_end = true)
Subscription period ends after cancel
├─ customer.subscription.updated → order status: CANCEL
└─ customer.subscription.deleted → order status: CANCEL (safety net + canceled_at)
Frontend Trial Flow (Customer App — no changes)
When the BE returns no client_secret (free trial subscription), PaymentConformForm.tsx already handles this:
// PaymentConformForm.tsx lines 91-98
} else {
// No immediate payment required (e.g., free trial)
const payment = {
paymentIntentId: "pi_none",
billingAmount: 0,
settlementMethod: ServicePaymentMethod.CREDIT,
} as FormPayment;
onSuccess && onSuccess(payment);
}
No changes required in the customer app.
Frontend: Supplier App Changes
1. OpenAPI spec — swagger/api/supplier/openapi.yaml
Add trialing to SubscriptionContractStatus enum:
SubscriptionContractStatus:
type: string
enum:
- active
- payment_failed
- cancellation_accepted
- cancelled
- trialing
description: |
- active: 契約継続中
- payment_failed: 支払失敗あり
- cancellation_accepted: 解約受付済み
- cancelled: 解約済み
- trialing: トライアル中
After updating the spec, regenerate the client:
./crage codegen:supplier
2. supplier/src/libs/enums/enumStrings.ts
Add TRIALING case to subscriptionOrderStatusToString:
case SubscriptionContractStatus.TRIALING:
return "トライアル中";
3. supplier/src/components/atoms/budge/SubscriptionOrderStatusBadge.tsx
Add TRIALING color class to colorToClasses:
case SubscriptionContractStatus.TRIALING:
return "bg-blue-100 border-0 text-blue-700";
Complete File Change List
| File | App/Package | Action |
|---|---|---|
common/src/Enums/Order/Status.php |
common | Modify — add TRIALING = 9 with label |
common/src/Base/Traits/StripeInvoiceHelper.php |
common | Create — shared trait |
admin/app/Domains/Subscription/Controllers/ConnectWebhookController.php |
admin | Modify — add use StripeInvoiceHelper, remove private method |
admin/app/Domains/Subscription/Controllers/CustomerSubscriptionWebhookController.php |
admin | Rewrite — standalone, 4 handlers |
swagger/api/supplier/openapi.yaml |
FE shared | Modify — add trialing to enum |
supplier/src/apis/clients/api.ts |
supplier FE | Regenerate via ./crage codegen:supplier |
supplier/src/libs/enums/enumStrings.ts |
supplier FE | Modify — add TRIALING label |
supplier/src/components/atoms/budge/SubscriptionOrderStatusBadge.tsx |
supplier FE | Modify — add TRIALING color |
Key Invariants
CustomerSubscriptionWebhookControllernever updates orders for non-subscription services.customer.subscription.updatedis the only handler that writesorder->statusfor subscription orders.handleCustomerSubscriptionDeletedonly writesCANCEL+canceled_atas a final safety net.handleInvoicePaymentSucceededonly writespayment_timewhenamount_paid > 0.- All handlers return HTTP 200 (Cashier
successMethod()) regardless of outcome to prevent Stripe retries on expected no-ops. - The
resolveSubscriptionIdFromInvoicehelper supports both pre-2025 and post-2025 Stripe API invoice formats.