Subscription History Fix Deployment Runbook

Scope

This deployment updates the BE payment-history flow so that:

  • Each payment is keyed by Stripe invoice_id.
  • Trial subscriptions without an invoice do not create payment-history rows.
  • Unpaid invoices are not recorded as settled payments.
  • Historical paid invoices can be backfilled safely.
  • Existing legacy rows without an invoice ID are reconciled when the match is unambiguous.

BE branch: fix/payment-history-invoice-keyed-upsert

Pre-deployment Checks

  1. Confirm the BE release contains these files and changes:

    • common/database/migrations/2026_07_20_000001_add_stripe_invoice_id_to_user_payments_table.php
    • common/src/Models/UserPayment.php
    • common/src/Packages/Util/Subscription.php
    • admin/app/Console/Commands/BackfillHistoricalPayments.php
    • admin/app/Domains/Subscription/Controllers/PaymentWebhookController.php
    • user/app/Domains/Subscription/Usecase/OrderInteractor.php
  2. Confirm the deployment has a database backup and a Stripe API key with permission to read customers, invoices, and payment methods.

  3. Confirm the application is using PHP 8.2 and the expected Stripe account/environment.

  4. Run the BE tests in CI or on a host with PHP available:

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

cd ../admin
./vendor/bin/phpunit tests/Feature/BackfillHistoricalPaymentsTest.php
  1. Run static analysis:
cd yoyacoo_be
composer phpstan

Deployment Order

Deploy the BE application before running the backfill command.

  1. Deploy the BE release using the normal CI/CD process.

  2. Run the database migration from the admin application:

cd yoyacoo_be/admin
php artisan migrate --force
  1. Verify the schema:
php artisan tinker --execute="dump(Schema::hasColumn('user_payments', 'stripe_invoice_id'));"

Expected result: true.

  1. Confirm the command is registered:
php artisan list | grep payments:backfill-historical

Backfill Procedure

Run the dry run first. This reads paid Stripe invoices but does not write payment rows.

cd yoyacoo_be/admin
php artisan payments:backfill-historical --dry-run

Review the output:

scanned=<users> processed=<invoices> skipped=<items> errors=0

Investigate any errors or plan not resolved log entries before continuing. Plan resolution depends on the Stripe product IDs configured in subscription_setting.subscriptions.

For a single account, use:

php artisan payments:backfill-historical --dry-run --user=<user_id>

After the dry run is approved, execute the backfill:

php artisan payments:backfill-historical

For a single account:

php artisan payments:backfill-historical --user=<user_id>

The command is safe to rerun. Invoice IDs are unique and existing invoice-keyed rows are updated rather than duplicated.

Post-deployment Verification

  1. Confirm the migration index exists:
SHOW INDEX FROM user_payments
WHERE Key_name = 'user_payments_stripe_invoice_id_unique';
  1. Confirm invoice IDs are unique:
SELECT stripe_invoice_id, COUNT(*) AS total
FROM user_payments
WHERE stripe_invoice_id IS NOT NULL
GROUP BY stripe_invoice_id
HAVING COUNT(*) > 1;

Expected result: no rows.

  1. Verify one account with renewal history in /user/accounts/plan?tab=3.

  2. Verify the displayed settled rows correspond to paid Stripe invoices and that the amount matches amount_paid.

  3. Repeat the command for the same account:

php artisan payments:backfill-historical --user=<user_id>

The second run must not increase the number of rows for any existing stripe_invoice_id.

  1. Verify a trial subscription without an invoice does not create a user_payments row.

  2. Verify a failed or unpaid invoice does not appear as a settled payment.

  3. Monitor application and Stripe webhook logs for:

    • plan not resolved
    • ambiguous legacy payment rows
    • failed to fetch invoices
    • errors reported by the backfill command

Rollback

Application rollback can restore the previous BE code, but do not immediately roll back the migration after payment rows have been written with stripe_invoice_id.

If the application must be rolled back:

  1. Stop the backfill command.
  2. Roll back the application release.
  3. Keep the stripe_invoice_id column and unique index until a data migration plan is approved.
  4. Do not run the old code against a partially reverted schema.

The migration rollback is only appropriate before this feature has written data:

php artisan migrate:rollback --step=1

Operational Notes

  • The backfill reads Stripe invoices per user and may take time for accounts with large invoice histories.
  • Run the full backfill during a low-traffic period.
  • Do not log or expose Stripe secret keys.
  • An ambiguous legacy row is intentionally left unchanged and logged for manual investigation.
  • The payment-history API already filters for settled payments; no FE deployment is required.