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:

  • CustomerSubscriptionWebhookController extends ConnectWebhookController, inheriting PaymentIntent and dispute handlers that touch non-subscription orders — violating the requirement to only update subscription service orders.
  • customer.subscription.updated is not handled, meaning status transitions (trial → active, payment failed, cancel requested) are never synced.
  • There is no TRIALING order status, so free-trial subscriptions have no distinguishable state.
  • The subscription ID lookup used Invoice.subscription (top-level), which Stripe moved to Invoice.parent.subscription_details.subscription in API 2025+ — already patched but the helper remains a private method on ConnectWebhookController.

Goals

  1. Make CustomerSubscriptionWebhookController a fully standalone controller handling only subscription-service orders.
  2. Use customer.subscription.updated as the single source of truth for all 5 subscription statuses.
  3. Add TRIALING = 9 to the Order.status enum.
  4. Move the Stripe invoice subscription ID resolver to a shared trait usable by both webhook controllers.
  5. 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 CancelInteractor in 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:

  1. Resolve the Stripe subscription ID from the payload.
  2. Find Order where stripe_payment_id = subscriptionId.
  3. Verify $order->customerPurchaseHistory->service->isSubscription().
  4. 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 = trueWAITINGCANCEL (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: handleInvoicePaymentSucceededpayment_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

  • CustomerSubscriptionWebhookController never updates orders for non-subscription services.
  • customer.subscription.updated is the only handler that writes order->status for subscription orders.
  • handleCustomerSubscriptionDeleted only writes CANCEL + canceled_at as a final safety net.
  • handleInvoicePaymentSucceeded only writes payment_time when amount_paid > 0.
  • All handlers return HTTP 200 (Cashier successMethod()) regardless of outcome to prevent Stripe retries on expected no-ops.
  • The resolveSubscriptionIdFromInvoice helper supports both pre-2025 and post-2025 Stripe API invoice formats.