Customer Subscription Webhook Handler Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make CustomerSubscriptionWebhookController a standalone controller that uses customer.subscription.updated as the single source of truth for 5 subscription statuses (including a new TRIALING = 9 status), and display the new status in the supplier frontend.
Architecture: CustomerSubscriptionWebhookController extends Cashier’s WebhookController directly (not ConnectWebhookController), handling only 4 Stripe events. The resolveSubscriptionIdFromInvoice() helper is extracted into a shared StripeInvoiceHelper trait in the common package, used by both controllers. The supplier frontend OpenAPI enum and badge component are updated to include trialing.
Tech Stack: PHP 8.2, Laravel 10, Laravel Cashier, PHPUnit, TypeScript, Next.js 12, Tailwind CSS, OpenAPI codegen via ./crage.
File Map
| File | Action | Purpose |
|---|---|---|
common/src/Enums/Order/Status.php |
Modify | Add TRIALING = 9 case and label |
common/src/Base/Traits/StripeInvoiceHelper.php |
Create | Shared trait: resolveSubscriptionIdFromInvoice() |
admin/app/Domains/Subscription/Controllers/ConnectWebhookController.php |
Modify | Add use StripeInvoiceHelper, remove private method |
admin/app/Domains/Subscription/Controllers/CustomerSubscriptionWebhookController.php |
Rewrite | Standalone controller with 4 handlers |
admin/tests/Feature/Subscription/CustomerSubscriptionWebhookTest.php |
Create | PHPUnit feature tests for all 4 handlers |
swagger/api/common/components/enum.yaml |
Modify | Add trialing to SubscriptionContractStatus |
supplier/src/apis/clients/api.ts |
Regenerate | ./crage codegen:supplier — adds TRIALING to enum |
supplier/src/libs/enums/enumStrings.ts |
Modify | Add TRIALING → "トライアル中" |
supplier/src/components/atoms/budge/SubscriptionOrderStatusBadge.tsx |
Modify | Add TRIALING color class |
Task 1: Add TRIALING = 9 to the Order Status enum
Files:
Modify:
yoyacoo_be/common/src/Enums/Order/Status.phpStep 1: Add the enum case and label
Open common/src/Enums/Order/Status.php. After case FREE_FINISHED = 8;, add:
case TRIALING = 9;
Then in the label() match, after self::FREE_FINISHED => '申し込み完了(決済なし)',, add:
self::TRIALING => 'トライアル中',
The full file should look like:
<?php declare(strict_types=1);
namespace ReserveApp\Common\Enums\Order;
enum Status: int
{
case NOTPAYMENT = 1;
case BILLED = 2;
case DEPOSITED = 3;
case ENDOFPURCHASE = 4;
case CANCEL = 5;
case WAITINGCANCEL = 6;
case FINISHED = 7;
case FREE_FINISHED = 8;
case TRIALING = 9;
/**
* @return string
*/
public function label(): string
{
return match ($this) {
self::NOTPAYMENT => '未入金(未処理)',
self::BILLED => '決済済み',
self::DEPOSITED => '入金済',
self::ENDOFPURCHASE => '購入・受講終了',
self::CANCEL => 'キャンセル',
self::WAITINGCANCEL => 'キャンセル待ち',
self::FINISHED => '現地決済',
self::FREE_FINISHED => '申し込み完了(決済なし)',
self::TRIALING => 'トライアル中',
};
}
}
- Step 2: Update the common package in admin
cd /var/www/yoyacoo_be/admin && composer update reserveapp/common
Expected: Lock file operations: ... or Nothing to install, update or remove (common is a path repo — any change is picked up automatically once the autoloader is reloaded; the composer update refreshes the autoload map).
- Step 3: Run php-cs-fixer on the changed file
cd /var/www/yoyacoo_be && ./vendor/bin/php-cs-fixer fix common/src/Enums/Order/Status.php
- Step 4: Commit
cd /var/www/yoyacoo_be
git add common/src/Enums/Order/Status.php
git commit -m "feat(common): add TRIALING = 9 to Order\\Status enum"
Task 2: Create StripeInvoiceHelper trait
Files:
Create:
yoyacoo_be/common/src/Base/Traits/StripeInvoiceHelper.phpStep 1: Create the trait file
<?php declare(strict_types=1);
namespace ReserveApp\Common\Base\Traits;
trait StripeInvoiceHelper
{
/**
* Resolve the Stripe subscription ID from an invoice payload.
*
* Stripe moved Invoice.subscription to Invoice.parent.subscription_details.subscription
* in newer API versions (2025+). This method supports both formats.
*
* @param array $invoice
* @return string|null
*/
private function resolveSubscriptionIdFromInvoice(array $invoice): ?string
{
// Legacy format: top-level subscription field
if (!empty($invoice['subscription'])) {
return $invoice['subscription'];
}
// New format (Stripe API 2025+): nested under parent.subscription_details
return $invoice['parent']['subscription_details']['subscription'] ?? null;
}
}
- Step 2: Run php-cs-fixer
cd /var/www/yoyacoo_be && ./vendor/bin/php-cs-fixer fix common/src/Base/Traits/StripeInvoiceHelper.php
- Step 3: Commit
cd /var/www/yoyacoo_be
git add common/src/Base/Traits/StripeInvoiceHelper.php
git commit -m "feat(common): extract StripeInvoiceHelper trait from ConnectWebhookController"
Task 3: Update ConnectWebhookController to use the shared trait
Files:
Modify:
yoyacoo_be/admin/app/Domains/Subscription/Controllers/ConnectWebhookController.phpStep 1: Add
use StripeInvoiceHelperand remove the private method
Add the import after the existing use statements at the top:
use ReserveApp\Common\Base\Traits\StripeInvoiceHelper;
In the class body, add StripeInvoiceHelper to the trait use list:
use Reservation, CalendarReservation;
use StripeConnectPayment;
use StripeInvoiceHelper;
use GoogleCalendarService;
Then delete the entire private resolveSubscriptionIdFromInvoice() method (lines 248–257 in the original file):
/**
* Resolve the Stripe subscription ID from an invoice payload.
*
* Stripe moved Invoice.subscription to Invoice.parent.subscription_details.subscription
* in newer API versions. This method supports both formats for backwards compatibility.
*
* @param array $invoice
* @return string|null
*/
private function resolveSubscriptionIdFromInvoice(array $invoice): ?string
{
// Legacy format: top-level subscription field
if (!empty($invoice['subscription'])) {
return $invoice['subscription'];
}
// New format (Stripe API 2025+): nested under parent.subscription_details
return $invoice['parent']['subscription_details']['subscription'] ?? null;
}
The final class signature section (top of class body) should look like:
class ConnectWebhookController extends WebhookController
{
use Reservation, CalendarReservation;
use StripeConnectPayment;
use StripeInvoiceHelper;
use GoogleCalendarService;
- Step 2: Run php-cs-fixer
cd /var/www/yoyacoo_be && ./vendor/bin/php-cs-fixer fix admin/app/Domains/Subscription/Controllers/ConnectWebhookController.php
- Step 3: Run PHPStan to confirm no regressions
cd /var/www/yoyacoo_be/admin && ./vendor/bin/phpstan analyse app/Domains/Subscription/Controllers/ConnectWebhookController.php --no-progress
Expected: [OK] No errors
- Step 4: Commit
cd /var/www/yoyacoo_be
git add admin/app/Domains/Subscription/Controllers/ConnectWebhookController.php
git commit -m "refactor(admin): use StripeInvoiceHelper trait in ConnectWebhookController"
Task 4: Rewrite CustomerSubscriptionWebhookController
Files:
Rewrite:
yoyacoo_be/admin/app/Domains/Subscription/Controllers/CustomerSubscriptionWebhookController.phpStep 1: Write the new controller
Replace the entire file content with:
<?php declare(strict_types=1);
namespace App\Domains\Subscription\Controllers;
use App\Http\Middleware\VerifyCustomerSubscriptionWebhook;
use Laravel\Cashier\Http\Controllers\WebhookController;
use ReserveApp\Common\Base\Traits\StripeInvoiceHelper;
use ReserveApp\Common\Enums\Order\Status as OrderStatus;
use ReserveApp\Common\Models\Order;
use ReserveApp\Common\Packages\Util\Logger;
class CustomerSubscriptionWebhookController extends WebhookController
{
use StripeInvoiceHelper;
/**
* Create a new WebhookController instance.
*/
public function __construct()
{
if (config('cashier.customer_subscription_webhook.secret')) {
$this->middleware(VerifyCustomerSubscriptionWebhook::class);
}
}
/**
* Handle customer.subscription.updated
* Single source of truth for all subscription order status transitions.
*
* @param array $payload
* @return \Symfony\Component\HttpFoundation\Response
*/
protected function handleCustomerSubscriptionUpdated(array $payload): \Symfony\Component\HttpFoundation\Response
{
$subscription = $payload['data']['object'];
$subscriptionId = $subscription['id'] ?? null;
if (!$subscriptionId) {
Logger::Stripe(__('Webhook[customer.subscription.updated] missing subscription id'), $payload);
return $this->successMethod();
}
$order = Order::where('stripe_payment_id', $subscriptionId)->first();
if (!$order || !$order->customerPurchaseHistory->service->isSubscription()) {
Logger::Stripe(__('Webhook[customer.subscription.updated] order not found or not subscription'), ['subscription_id' => $subscriptionId]);
return $this->successMethod();
}
$stripeStatus = $subscription['status'] ?? '';
$cancelAtPeriodEnd = (bool) ($subscription['cancel_at_period_end'] ?? false);
$newStatus = match (true) {
$stripeStatus === 'trialing' => OrderStatus::TRIALING,
$stripeStatus === 'active' && $cancelAtPeriodEnd => OrderStatus::WAITINGCANCEL,
$stripeStatus === 'active' => OrderStatus::BILLED,
in_array($stripeStatus, ['past_due', 'unpaid'], true) => OrderStatus::NOTPAYMENT,
$stripeStatus === 'canceled' => OrderStatus::CANCEL,
default => null,
};
if ($newStatus === null) {
Logger::Stripe(__('Webhook[customer.subscription.updated] unhandled stripe status'), ['stripe_status' => $stripeStatus]);
return $this->successMethod();
}
if ($newStatus === OrderStatus::CANCEL && $order->canceled_at === null) {
$order->canceled_at = now();
}
$order->status = $newStatus->value;
$order->save();
Logger::Stripe(__('Webhook[customer.subscription.updated]'), $subscription);
return $this->successMethod();
}
/**
* Handle customer.subscription.deleted
* Safety net: guarantees final CANCEL + canceled_at even if updated event was missed.
*
* @param array $payload
* @return \Symfony\Component\HttpFoundation\Response
*/
protected function handleCustomerSubscriptionDeleted(array $payload): \Symfony\Component\HttpFoundation\Response
{
$subscription = $payload['data']['object'];
$subscriptionId = $subscription['id'] ?? null;
if (!$subscriptionId) {
Logger::Stripe(__('Webhook[customer.subscription.deleted] missing subscription id'), $payload);
return $this->successMethod();
}
$order = Order::where('stripe_payment_id', $subscriptionId)->first();
if (!$order || !$order->customerPurchaseHistory->service->isSubscription()) {
Logger::Stripe(__('Webhook[customer.subscription.deleted] order not found or not subscription'), ['subscription_id' => $subscriptionId]);
return $this->successMethod();
}
$order->status = OrderStatus::CANCEL->value;
if ($order->canceled_at === null) {
$order->canceled_at = now();
}
$order->save();
Logger::Stripe(__('Webhook[customer.subscription.deleted]'), $subscription);
return $this->successMethod();
}
/**
* Handle invoice.payment_succeeded
* Records payment_time only when a real charge was collected (amount_paid > 0).
* Status is managed exclusively by handleCustomerSubscriptionUpdated.
*
* @param array $payload
* @return \Symfony\Component\HttpFoundation\Response
*/
protected function handleInvoicePaymentSucceeded(array $payload): \Symfony\Component\HttpFoundation\Response
{
$invoice = $payload['data']['object'];
// Skip trial invoices (no real payment collected)
if (($invoice['amount_paid'] ?? 0) === 0) {
Logger::Stripe(__('Webhook[invoice.payment_succeeded] skipped trial invoice'), $invoice);
return $this->successMethod();
}
$subscriptionId = $this->resolveSubscriptionIdFromInvoice($invoice);
if (!$subscriptionId) {
Logger::Stripe(__('Webhook[invoice.payment_succeeded] missing subscription id'), $invoice);
return $this->successMethod();
}
$order = Order::where('stripe_payment_id', $subscriptionId)->first();
if (!$order || !$order->customerPurchaseHistory->service->isSubscription()) {
Logger::Stripe(__('Webhook[invoice.payment_succeeded] order not found or not subscription'), ['subscription_id' => $subscriptionId]);
return $this->successMethod();
}
$order->payment_time = now();
$order->save();
Logger::Stripe(__('Webhook[invoice.payment_succeeded]'), $invoice);
return $this->successMethod();
}
/**
* Handle invoice.payment_failed
* Status is managed by customer.subscription.updated (Stripe sets past_due before this fires).
* This handler logs only for observability.
*
* @param array $payload
* @return \Symfony\Component\HttpFoundation\Response
*/
protected function handleInvoicePaymentFailed(array $payload): \Symfony\Component\HttpFoundation\Response
{
Logger::Stripe(__('Webhook[invoice.payment_failed]'), $payload);
return $this->successMethod();
}
}
- Step 2: Run php-cs-fixer
cd /var/www/yoyacoo_be && ./vendor/bin/php-cs-fixer fix admin/app/Domains/Subscription/Controllers/CustomerSubscriptionWebhookController.php
- Step 3: Run PHPStan
cd /var/www/yoyacoo_be/admin && ./vendor/bin/phpstan analyse app/Domains/Subscription/Controllers/CustomerSubscriptionWebhookController.php --no-progress
Expected: [OK] No errors
- Step 4: Commit
cd /var/www/yoyacoo_be
git add admin/app/Domains/Subscription/Controllers/CustomerSubscriptionWebhookController.php
git commit -m "feat(admin): rewrite CustomerSubscriptionWebhookController as standalone subscription handler"
Task 5: Write PHPUnit feature tests
Files:
- Create:
yoyacoo_be/admin/tests/Feature/Subscription/CustomerSubscriptionWebhookTest.php
These tests verify each handler in isolation using factory-created models with mocked relationships.
Setup note: Run
php artisan testonce first to confirm the base test suite is green before adding new tests.
- Step 1: Confirm existing tests pass
cd /var/www/yoyacoo_be/admin && php artisan test
Expected: all green (or confirm the count before and after).
- Step 2: Discover the Order factory and how
customerPurchaseHistory->service->isSubscription()is constructed
Check what factories exist:
ls /var/www/yoyacoo_be/common/src/database/factories/
ls /var/www/yoyacoo_be/admin/database/factories/ 2>/dev/null || true
Then check the Order model’s customerPurchaseHistory relationship:
grep -n "customerPurchaseHistory\|isSubscription" /var/www/yoyacoo_be/common/src/Models/Order.php | head -20
grep -n "isSubscription" /var/www/yoyacoo_be/common/src/Models/Service.php 2>/dev/null | head -5
Use these findings to configure factories in the test. The test below uses a partial mock approach via Order::shouldReceive (Mockery) or uses database factories with a SQLite in-memory DB (typical for Laravel feature tests).
- Step 3: Create the test file
After completing Step 2, create admin/tests/Feature/Subscription/CustomerSubscriptionWebhookTest.php.
The test class must cover:
handleCustomerSubscriptionUpdated—trialing→TRIALING (9)handleCustomerSubscriptionUpdated—active + cancel_at_period_end=false→BILLED (2)handleCustomerSubscriptionUpdated—active + cancel_at_period_end=true→WAITINGCANCEL (6)handleCustomerSubscriptionUpdated—past_due→NOTPAYMENT (1)handleCustomerSubscriptionUpdated—canceled→CANCEL (5)+canceled_atsethandleCustomerSubscriptionUpdated— unknown status (paused) → no status changehandleCustomerSubscriptionUpdated— non-subscription order → no status changehandleCustomerSubscriptionDeleted— setsCANCEL (5)+canceled_athandleCustomerSubscriptionDeleted— non-subscription order → no changehandleInvoicePaymentSucceeded—amount_paid > 0→payment_timesethandleInvoicePaymentSucceeded—amount_paid == 0(trial) →payment_timeNOT sethandleInvoicePaymentFailed— returns 200, no DB change
The test file structure below is a working template — adapt factory calls to match the actual factories found in Step 2:
<?php declare(strict_types=1);
namespace Tests\Feature\Subscription;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use ReserveApp\Common\Enums\Order\Status as OrderStatus;
use ReserveApp\Common\Models\Order;
use Tests\TestCase;
class CustomerSubscriptionWebhookTest extends TestCase
{
use RefreshDatabase;
private const ENDPOINT = '/stripe/customer-subscription-user/webhook';
/** Build a minimal subscription order in the DB and return it */
private function createSubscriptionOrder(string $subscriptionId, int $status = 2): Order
{
// Adjust factory calls to match actual factories discovered in Step 2
$order = Order::factory()
->forSubscriptionService() // state that sets up isSubscription() = true
->create([
'stripe_payment_id' => $subscriptionId,
'status' => $status,
'canceled_at' => null,
'payment_time' => null,
]);
return $order;
}
private function subscriptionUpdatedPayload(string $subscriptionId, string $stripeStatus, bool $cancelAtPeriodEnd = false): array
{
return [
'type' => 'customer.subscription.updated',
'data' => [
'object' => [
'id' => $subscriptionId,
'status' => $stripeStatus,
'cancel_at_period_end' => $cancelAtPeriodEnd,
],
],
];
}
private function subscriptionDeletedPayload(string $subscriptionId): array
{
return [
'type' => 'customer.subscription.deleted',
'data' => [
'object' => ['id' => $subscriptionId],
],
];
}
private function invoicePayload(string $subscriptionId, int $amountPaid): array
{
return [
'type' => 'invoice.payment_succeeded',
'data' => [
'object' => [
'amount_paid' => $amountPaid,
'subscription' => $subscriptionId, // legacy format
'payment_intent' => null,
],
],
];
}
private function postWebhook(array $payload): \Illuminate\Testing\TestResponse
{
// Disable webhook signature verification by leaving secret unset in test env
return $this->postJson(self::ENDPOINT, $payload);
}
// --- handleCustomerSubscriptionUpdated ---
public function test_subscription_updated_trialing_sets_trialing_status(): void
{
$order = $this->createSubscriptionOrder('sub_trial_001');
$this->postWebhook($this->subscriptionUpdatedPayload('sub_trial_001', 'trialing'))
->assertStatus(200);
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'status' => OrderStatus::TRIALING->value,
]);
}
public function test_subscription_updated_active_sets_billed_status(): void
{
$order = $this->createSubscriptionOrder('sub_active_001');
$this->postWebhook($this->subscriptionUpdatedPayload('sub_active_001', 'active', false))
->assertStatus(200);
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'status' => OrderStatus::BILLED->value,
]);
}
public function test_subscription_updated_active_cancel_at_period_end_sets_waitingcancel(): void
{
$order = $this->createSubscriptionOrder('sub_waitcancel_001');
$this->postWebhook($this->subscriptionUpdatedPayload('sub_waitcancel_001', 'active', true))
->assertStatus(200);
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'status' => OrderStatus::WAITINGCANCEL->value,
]);
}
public function test_subscription_updated_past_due_sets_notpayment(): void
{
$order = $this->createSubscriptionOrder('sub_pastdue_001');
$this->postWebhook($this->subscriptionUpdatedPayload('sub_pastdue_001', 'past_due'))
->assertStatus(200);
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'status' => OrderStatus::NOTPAYMENT->value,
]);
}
public function test_subscription_updated_canceled_sets_cancel_and_canceled_at(): void
{
Carbon::setTestNow('2026-01-15 10:00:00');
$order = $this->createSubscriptionOrder('sub_canceled_001');
$this->postWebhook($this->subscriptionUpdatedPayload('sub_canceled_001', 'canceled'))
->assertStatus(200);
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'status' => OrderStatus::CANCEL->value,
]);
$this->assertNotNull($order->fresh()->canceled_at);
Carbon::setTestNow();
}
public function test_subscription_updated_unknown_status_does_not_change_order(): void
{
$order = $this->createSubscriptionOrder('sub_unknown_001', OrderStatus::BILLED->value);
$this->postWebhook($this->subscriptionUpdatedPayload('sub_unknown_001', 'paused'))
->assertStatus(200);
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'status' => OrderStatus::BILLED->value,
]);
}
public function test_subscription_updated_non_subscription_order_is_skipped(): void
{
// Create a non-subscription order (e.g., seminar) with the same stripe_payment_id
$order = Order::factory()
->forSeminarService() // state that sets up isSubscription() = false
->create([
'stripe_payment_id' => 'sub_nonsub_001',
'status' => OrderStatus::BILLED->value,
]);
$this->postWebhook($this->subscriptionUpdatedPayload('sub_nonsub_001', 'trialing'))
->assertStatus(200);
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'status' => OrderStatus::BILLED->value,
]);
}
// --- handleCustomerSubscriptionDeleted ---
public function test_subscription_deleted_sets_cancel_and_canceled_at(): void
{
Carbon::setTestNow('2026-01-20 12:00:00');
$order = $this->createSubscriptionOrder('sub_del_001', OrderStatus::WAITINGCANCEL->value);
$this->postWebhook($this->subscriptionDeletedPayload('sub_del_001'))
->assertStatus(200);
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'status' => OrderStatus::CANCEL->value,
]);
$this->assertNotNull($order->fresh()->canceled_at);
Carbon::setTestNow();
}
public function test_subscription_deleted_non_subscription_order_is_skipped(): void
{
$order = Order::factory()
->forSeminarService()
->create([
'stripe_payment_id' => 'sub_del_nonsub_001',
'status' => OrderStatus::BILLED->value,
]);
$this->postWebhook($this->subscriptionDeletedPayload('sub_del_nonsub_001'))
->assertStatus(200);
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'status' => OrderStatus::BILLED->value,
]);
}
// --- handleInvoicePaymentSucceeded ---
public function test_invoice_payment_succeeded_records_payment_time(): void
{
Carbon::setTestNow('2026-02-01 08:00:00');
$order = $this->createSubscriptionOrder('sub_invoice_001', OrderStatus::BILLED->value);
$this->postWebhook($this->invoicePayload('sub_invoice_001', 5000))
->assertStatus(200);
$this->assertNotNull($order->fresh()->payment_time);
Carbon::setTestNow();
}
public function test_invoice_payment_succeeded_skips_trial_invoice(): void
{
$order = $this->createSubscriptionOrder('sub_trial_invoice_001', OrderStatus::TRIALING->value);
$this->postWebhook($this->invoicePayload('sub_trial_invoice_001', 0))
->assertStatus(200);
$this->assertNull($order->fresh()->payment_time);
}
// --- handleInvoicePaymentFailed ---
public function test_invoice_payment_failed_returns_200_with_no_db_change(): void
{
$order = $this->createSubscriptionOrder('sub_failinv_001', OrderStatus::BILLED->value);
$this->postJson(self::ENDPOINT, [
'type' => 'invoice.payment_failed',
'data' => ['object' => ['subscription' => 'sub_failinv_001', 'amount_paid' => 0]],
])->assertStatus(200);
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'status' => OrderStatus::BILLED->value,
]);
}
}
Important: After discovering the actual factory states in Step 2, replace
->forSubscriptionService()and->forSeminarService()with the real factory method names that set up thecustomerPurchaseHistory->service->isSubscription()relationship correctly. If no factory state exists, you will need to create one or useMockeryto mock the relationship on the model.
- Step 4: Run the tests (expect some failures until factories are confirmed)
cd /var/www/yoyacoo_be/admin && php artisan test tests/Feature/Subscription/CustomerSubscriptionWebhookTest.php --verbose
Fix any factory-related issues found in this step. All 12 tests must pass.
- Step 5: Commit
cd /var/www/yoyacoo_be
git add admin/tests/Feature/Subscription/CustomerSubscriptionWebhookTest.php
git commit -m "test(admin): add PHPUnit tests for CustomerSubscriptionWebhookController"
Task 6: Update supplier OpenAPI enum and regenerate client
Files:
Modify:
yoyacoo_fe/swagger/api/common/components/enum.yamlRegenerate:
yoyacoo_fe/supplier/src/apis/clients/api.tsStep 1: Add
trialingtoSubscriptionContractStatusinenum.yaml
In swagger/api/common/components/enum.yaml, find the SubscriptionContractStatus block (currently at line ~435) and update it to:
SubscriptionContractStatus:
type: string
description: サブスクリプション契約ステータス
example: active
enum:
- active
- payment_failed
- cancellation_accepted
- cancelled
- trialing
x-enum-varnames:
- ACTIVE
- PAYMENT_FAILED
- CANCELLATION_ACCEPTED
- CANCELLED
- TRIALING
x-enum-descriptions:
- 契約継続中
- 支払失敗あり
- 解約受付済み
- 解約済み
- トライアル中
- Step 2: Regenerate the supplier API client
cd /var/www/yoyacoo_fe && ./crage codegen:supplier
Expected output: codegen runs and regenerates supplier/src/apis/clients/api.ts. Verify TRIALING: 'trialing' appears in the generated file:
grep -n "TRIALING" /var/www/yoyacoo_fe/supplier/src/apis/clients/api.ts
Expected: a line like TRIALING: 'trialing',
- Step 3: Commit
cd /var/www/yoyacoo_fe
git add swagger/api/common/components/enum.yaml supplier/src/apis/clients/api.ts
git commit -m "feat(supplier): add trialing to SubscriptionContractStatus enum and regenerate client"
Task 7: Update supplier frontend display
Files:
Modify:
yoyacoo_fe/supplier/src/libs/enums/enumStrings.tsModify:
yoyacoo_fe/supplier/src/components/atoms/budge/SubscriptionOrderStatusBadge.tsxStep 1: Add
TRIALINGstring tosubscriptionOrderStatusToString
In supplier/src/libs/enums/enumStrings.ts, find subscriptionOrderStatusToString (line 255) and add the new case before the default:
export const subscriptionOrderStatusToString = (
value: SubscriptionContractStatus,
) => {
switch (value) {
case SubscriptionContractStatus.ACTIVE:
return "契約継続中";
case SubscriptionContractStatus.PAYMENT_FAILED:
return "支払失敗あり";
case SubscriptionContractStatus.CANCELLATION_ACCEPTED:
return "解約受付済み";
case SubscriptionContractStatus.CANCELLED:
return "解約済み";
case SubscriptionContractStatus.TRIALING:
return "トライアル中";
default:
return "";
}
};
- Step 2: Add
TRIALINGcolor toSubscriptionOrderStatusBadge
In supplier/src/components/atoms/budge/SubscriptionOrderStatusBadge.tsx, update colorToClasses to add the TRIALING case before default:
function colorToClasses(s?: SubscriptionContractStatus) {
switch (s) {
case SubscriptionContractStatus.ACTIVE:
return "bg-[#45BEAB]/[0.2] border-0 text-[#07917B]";
case SubscriptionContractStatus.PAYMENT_FAILED:
return "bg-[#FFF3CD] text-[#856404]";
case SubscriptionContractStatus.CANCELLATION_ACCEPTED:
return "bg-[#FFDEE0] text-color-red_error";
case SubscriptionContractStatus.CANCELLED:
return "bg-gray-200 text-[#787878]";
case SubscriptionContractStatus.TRIALING:
return "bg-blue-100 border-0 text-blue-700";
default:
return "bg-primary-hover text-primary-dark";
}
}
- Step 3: Run lint and format check
cd /var/www/yoyacoo_fe/supplier && yarn analyse
Expected: no errors. Fix any lint issues before committing.
- Step 4: Commit
cd /var/www/yoyacoo_fe
git add supplier/src/libs/enums/enumStrings.ts supplier/src/components/atoms/budge/SubscriptionOrderStatusBadge.tsx
git commit -m "feat(supplier): display トライアル中 badge for TRIALING subscription status"
Task 8: Final verification
- Step 1: Run full BE test suite
cd /var/www/yoyacoo_be/admin && php artisan test
Expected: all tests pass, including the 12 new webhook tests.
- Step 2: Run PHPStan on both controllers
cd /var/www/yoyacoo_be/admin && ./vendor/bin/phpstan analyse \
app/Domains/Subscription/Controllers/ConnectWebhookController.php \
app/Domains/Subscription/Controllers/CustomerSubscriptionWebhookController.php \
--no-progress
Expected: [OK] No errors
- Step 3: Run supplier FE type check
cd /var/www/yoyacoo_fe/supplier && yarn typecheck
Expected: no TypeScript errors.
- Step 4: Run full supplier lint
cd /var/www/yoyacoo_fe/supplier && yarn analyse
Expected: no errors.
Key Invariants to Verify
CustomerSubscriptionWebhookControllerdoes NOT extendConnectWebhookController— it extendsLaravel\Cashier\Http\Controllers\WebhookControllerdirectly.handleInvoicePaymentSucceededinCustomerSubscriptionWebhookControllerdoes not writeorder->status— onlypayment_time.handleInvoicePaymentFailedinCustomerSubscriptionWebhookControllerdoes not write any DB fields — log only.ConnectWebhookController.resolveSubscriptionIdFromInvoiceprivate method is removed (replaced by trait).TRIALING = 9is the only new integer value added to the DBorders.statuscolumn — no migration needed.- All handlers return HTTP 200 (via
successMethod()) to prevent Stripe retries.