Subscription History Fix Plan

Goal: Fix the three payment-history bugs without expanding support beyond Stripe card payments.

Scope

  1. Sort 支払履歴 by 請求日 newest first.
  2. Recover card details when a backfilled invoice does not expose a direct payment method.
  3. Show the plan paid for by each payment, not the user’s current plan.

Non-goal: support PayPal, Link, bank transfer, or other non-card Stripe payment methods.

Current Root Causes

  • PlanPurchaseHistoryInteractor calls paginate() without orderBy, so billing order is database-dependent.
  • BackfillHistoricalPayments::insertFromInvoice() only reads $invoice->payment->payment_method. Some invoices expose the card through payment_intent->payment_method instead.
  • PlanPurchaseHistoryCollection reads $item->userplan, and UserPayment::userplan resolves the current user_plans row by subscription ID. Subscription upgrades overwrite that row, so old payment rows display the current plan.
  • UserPayment already stores the historical plan enum in plan, but it does not store the historical monthly/yearly type.

Simplified Design

1. Billing-date ordering

Modify user/app/Domains/Subscription/Usecase/PlanPurchaseHistoryInteractor.php:

return UserPayment::where('user_id', auth()->user()->user_id)
    ->where('status', Status::SETTLED->value)
    ->orderByDesc('payment_date')
    ->orderByDesc('id')
    ->paginate($interface->getPerPage() ?? 15);

The secondary ID sort makes equal billing timestamps deterministic across pages.

2. Card-only backfill recovery

Modify admin/app/Console/Commands/BackfillHistoricalPayments.php:

  1. Read the direct invoice payment method first.
  2. If absent, read the invoice payment intent ID.
  3. Retrieve the PaymentIntent and read its payment_method ID.
  4. Retrieve that PaymentMethod.
  5. Populate the existing card fields: last4, exp_month_card, exp_year_card, display_brand, and billing_detail_name.
  6. Only treat type === 'card' as a supported payment method. If the resolved method is not a card, log it and leave card fields null.

Do not add a generic Stripe payment-method column or change the existing integer enum.

3. Historical plan snapshot

Add one nullable plan_type column to user_payments:

  • 1: monthly
  • 2: yearly

Modify these files:

  • common/database/migrations/2026_08_02_000001_add_plan_type_to_user_payments.php
  • common/src/Models/Base/UserPayment.php
  • common/src/Models/UserPayment.php
  • common/src/Packages/Util/Subscription.php
  • admin/app/Console/Commands/BackfillHistoricalPayments.php
  • user/app/Domains/Subscription/Controller/Resource/PlanPurchaseHistoryCollection.php

Live subscription writes already calculate the plan type from plan.interval; persist that value as plan_type.

Backfill derives plan_type from the invoice line price recurring interval. If the invoice does not contain a recognizable interval, leave it null and log the invoice rather than using the current plan.

Update the resource:

  • content comes from the payment row’s plan.
  • type comes from the payment row’s plan_type.
  • Do not access $item->userplan for history output.

Rows created before the new column will have a null historical type. They must not fall back to the current subscription type.

Tests

Modify user/tests/Feature/PlanPurchaseHistoryActionTest.php:

  • Assert newest payment_date is returned first.
  • Assert equal dates use descending ID order.
  • Create a current UserPlan with a different plan and assert history returns the payment row’s plan.
  • Assert history returns the payment row’s plan_type.

Modify admin/tests/Feature/BackfillHistoricalPaymentsTest.php:

  • Add an invoice with no direct payment method and a payment intent.
  • Mock the PaymentIntent to return a card PaymentMethod.
  • Assert card details are persisted.
  • Assert a non-card PaymentMethod is not displayed as a card.
  • Assert plan_type is persisted from the invoice interval.

Modify user/tests/Unit/SubscriptionNewOrUpdateUserSubscriptionTest.php:

  • Assert live subscription writes persist plan_type.

Verification

cd yoyacoo_be/user
./vendor/bin/phpunit tests/Feature/PlanPurchaseHistoryActionTest.php
./vendor/bin/phpunit tests/Unit/SubscriptionNewOrUpdateUserSubscriptionTest.php

cd ../admin
./vendor/bin/phpunit tests/Feature/BackfillHistoricalPaymentsTest.php

cd ..
composer phpstan

After deployment:

php artisan migrate --force
php artisan payments:backfill-historical --dry-run

Confirm card records have last4 or display_brand, history is newest first, and old payments do not change when the current plan changes. Run the real backfill only after the dry run has no unexpected errors.