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.php

  • Step 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.php

  • Step 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.php

  • Step 1: Add use StripeInvoiceHelper and 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.php

  • Step 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 test once 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:

  1. handleCustomerSubscriptionUpdatedtrialingTRIALING (9)
  2. handleCustomerSubscriptionUpdatedactive + cancel_at_period_end=falseBILLED (2)
  3. handleCustomerSubscriptionUpdatedactive + cancel_at_period_end=trueWAITINGCANCEL (6)
  4. handleCustomerSubscriptionUpdatedpast_dueNOTPAYMENT (1)
  5. handleCustomerSubscriptionUpdatedcanceledCANCEL (5) + canceled_at set
  6. handleCustomerSubscriptionUpdated — unknown status (paused) → no status change
  7. handleCustomerSubscriptionUpdated — non-subscription order → no status change
  8. handleCustomerSubscriptionDeleted — sets CANCEL (5) + canceled_at
  9. handleCustomerSubscriptionDeleted — non-subscription order → no change
  10. handleInvoicePaymentSucceededamount_paid > 0payment_time set
  11. handleInvoicePaymentSucceededamount_paid == 0 (trial) → payment_time NOT set
  12. handleInvoicePaymentFailed — 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 the customerPurchaseHistory->service->isSubscription() relationship correctly. If no factory state exists, you will need to create one or use Mockery to 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.yaml

  • Regenerate: yoyacoo_fe/supplier/src/apis/clients/api.ts

  • Step 1: Add trialing to SubscriptionContractStatus in enum.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.ts

  • Modify: yoyacoo_fe/supplier/src/components/atoms/budge/SubscriptionOrderStatusBadge.tsx

  • Step 1: Add TRIALING string to subscriptionOrderStatusToString

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 TRIALING color to SubscriptionOrderStatusBadge

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

  • CustomerSubscriptionWebhookController does NOT extend ConnectWebhookController — it extends Laravel\Cashier\Http\Controllers\WebhookController directly.
  • handleInvoicePaymentSucceeded in CustomerSubscriptionWebhookController does not write order->status — only payment_time.
  • handleInvoicePaymentFailed in CustomerSubscriptionWebhookController does not write any DB fields — log only.
  • ConnectWebhookController.resolveSubscriptionIdFromInvoice private method is removed (replaced by trait).
  • TRIALING = 9 is the only new integer value added to the DB orders.status column — no migration needed.
  • All handlers return HTTP 200 (via successMethod()) to prevent Stripe retries.