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 CustomerSubscriptionWebhookController for Stripe subscription events
  • A new TRIALING = 9 order status
  • A dedicated stripe_subscription_id column on the orders table
  • Correct separation of stripe_subscription_id (sub_xxx) and stripe_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.createdsyncSubscriptionStatus() → set initial status
  ├─ customer.subscription.updatedsyncSubscriptionStatus() → update status
  ├─ customer.subscription.deletedset CANCEL + canceled_at (safety net)
  ├─ invoice.payment_succeededset 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.status was already an integer column

common/src/Models/Base/Order.php

  • Added @property string|null $stripe_subscription_id PHPDoc 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 ConnectWebhookController and CustomerSubscriptionWebhookController

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) — not stripe_payment_id
  • Idempotency guards: syncSubscriptionStatus() skips DB write if status already matches; handleCustomerSubscriptionDeleted skips if already CANCEL + canceled_at
  • Timestamp authority: payment_time uses status_transitions.paid_at from Stripe, not now()
  • Scope guard: Skips silently if order not found or if !isSubscription()
  • CSRF exemption: stripe/* is excluded in VerifyCustomerSubscriptionWebhook middleware

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.updated both handled
  • Idempotency on duplicate event delivery (both updated and deleted)
  • invoice.payment_succeeded uses Stripe’s authoritative paid_at timestamp
  • Trial invoice (amount_paid == 0) does not set payment_time
  • Non-subscription orders silently skipped
  • Order not found silently skipped

admin/app/Domains/Subscription/Controllers/ConnectWebhookController.php

  • Added use StripeInvoiceHelper trait
  • 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_id is the Stripe sub_xxx value

customer/app/Domains/Reservation/Controller/Resource/PaymentResource.php

  • Exposes subscription_id in 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 get payment_intent_id (pi_xxx)
  • Writes stripe_subscription_id = service.stripe_subscription_id (sub_xxx)
  • Writes stripe_payment_id = payment.payment_intent_id (pi_xxx; null for 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 $subscriptionStatus and paymentHistory match 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:

  • SubscriptionContractStatus
  • SubscriptionPaymentHistoryStatus

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 errors
  • customer/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:

  1. Race condition: Stripe fires customer.subscription.created during POST /payment (subscription creation at Stripe), but the order is created later by PUT /order. The webhook arrives before the order exists, the controller returns 200 (“order not found”), and Stripe never retries.

  2. incomplete status: When the webhook does arrive after the order exists, the Stripe subscription is in incomplete status (an interim state before payment confirmation finalizes it to trialing/active). syncSubscriptionStatus() didn’t handle incomplete — it logged “unhandled stripe status” and skipped.

  3. Missing webhook event type: The Stripe Dashboard webhook endpoint for /stripe/customer-subscription-user was only configured to send customer.subscription.created events — NOT customer.subscription.updated. The entire status transition design depended on customer.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.created
  • customer.subscription.updated ⬅️ must be added
  • customer.subscription.deleted
  • invoice.payment_succeeded
  • invoice.payment_failed