Skip to content

Fix catalog migration review findings for #1155 - #1203

Merged
think-back merged 9 commits into
feat/staging-legacy-renewal-catalog-migrationfrom
fix/pr1155-catalog-migration-review
Sep 17, 2026
Merged

think-back merged 9 commits into
feat/staging-legacy-renewal-catalog-migrationfrom
fix/pr1155-catalog-migration-review

Conversation

@think-back

Copy link
Copy Markdown
Collaborator

Summary

Review fixes for #1155 (legacy → new catalog cutover at paid renewal), stacked on feat/staging-legacy-renewal-catalog-migration. Every finding of the 2026-09-14 review is addressed with a failing test first, one commit per finding group. External API and the two-phase design are unchanged.

Plan and finding map: docs/superpowers/plans/2026-09-14-pr1155-catalog-migration-review-fixes.md.

Blocking defects fixed

# Finding Fix
1 Snapshot validator rejected every live Stripe Price while the production gate required live facts → production could never preview or schedule Validator no longer pins Stripe mode; mode consistency stays in ValidateCatalogMigrationStripeSandbox
7, 6, 10, 12 Exact facts.Amount equality on renewal (tax / credit balance / coupons / rounding → permanent non-retryable failure of genuinely paid invoices); released reservation blocked the grant; v1 discount snapshots rejected; re-prepare failed on recomputed subtotal All Stripe amount comparisons removed (identity = subscription/item/customer/binding/contract + price id + currency + quantity); released reservation → grant proceeds, no ledger commit; v1 honored; the two tests the PR had inverted are restored to their baseline semantics
2, 3, 4, sweeper Wallet sweeper aborted the whole batch (and skipped expiry/reset) on the first error; three independent triggers: applied intent next cycle, flag off, balance pause + expiry Sweeper logs and skips a failing contract; non-scheduled intents mean "renew current plan"; flag/allowlist gate admin ops only, scheduled cutovers are committed facts; runtime drift (service/env/mode) is a retryable error, never a permanent ack; paused-for-balance contracts accept an expired entitlement so the top-up retry and cutover still happen
8, 9 Stale typed snapshot after upgrade/downgrade bricked every future renewal Snapshot whose plan id ≠ binding plan is treated as absent; downgrade renewals clear it; ordinary typed renewals no longer require the current catalog row to match the frozen snapshot
11 Cancel not terminal; re-POST apply re-scheduled contracts that had no intent row status=cancelled persisted and sticky; Apply refuses a cancelled batch
13 AddDate(0,1,0) month overflow (Jan 31 → Mar 3) → ownership re-check never matched catalogMigrationNextMonthlyPeriodEnd clamps to month end, time of day preserved
14, 15 Cancel released the Stripe schedule before checking the client's ExpectedChangeVersion, then overwrote it; attention marker relabelled applied intents; busy checks ignored expiry/tombstones; wallet contracts never superseded catalog intents Version enforced inside the locked tx before any side effect; status-guarded marker; shared SubscriptionProviderLifecycleReservationIsActive; wallet supersede path
5 + rules type:longtext (Rule 2), encoding/json in business code (Rule 1), duplicated helpers, dead func, unindexed post-tx lookup on every paid invoice Dialect-neutral columns (longtext on MySQL, text on PG), common.* JSON, common.GetEnvOrDefaultBool / common.StringsContains, batch id carried out of the tx on the result
workflow Empty allowlist filtered out of --update-env-vars (merge-only) → a hand-set production allowlist could never be cleared by a deploy Written explicitly in both production jobs

Deliberately kept

  • context.Background() for the user-action supersede (a multi-step provider mutation should not be cancelled by a client disconnect).
  • Hard-fail of the user's cancel/plan change when the supersede fails (fail-closed is correct; the marker is now CAS-guarded).
  • Duplicate Stripe adapters / N+1 preview lookups: pure refactors, out of scope here.

Verification

  • go vet ./model ./controller ./router ./service clean.
  • go test ./service/ on every touched suite (catalog migration, plan snapshot, invoice, discount, wallet renewal, renewal lifecycle, price authority) passes; ./router/ and ./controller/ -run CatalogMigration pass; model catalog-migration/lifecycle suites pass.
  • Pre-existing failures unchanged and reproduced on the untouched PR head 39b32eed6: TestChangeSubscriptionPlanGateDisabledPreservesLegacyBehavior, TestStripeUpgradeReplay* (2), TestSQLiteQuotaLifecycleMigrationWidensLegacyCycleAndSourceColumns, and the 4 controller subscription tests already noted in Renew legacy subscriptions into the new catalog at paid renewal #1155.

Deployment

Same footprint as #1155: router and console both serve renewal paths (paid-invoice webhook, wallet sweeper, AutoMigrate); staging via the staging branch; both GitHub Actions workflows. No frontend, Terraform or Cloudflare changes.

…pe test mode

The snapshot validator rejected every live-mode Stripe Price while the
production sandbox gate requires live-mode facts, so production preview
and scheduling could never succeed. Mode consistency stays enforced by
ValidateCatalogMigrationStripeSandbox.

Also checks in the review-fix implementation plan.
… paid invoices after discount release

Renewal validation now proves ownership (subscription, item, customer,
binding, contract), price id, currency and quantity only. Amount equality
against local prices was removed everywhere: tax, customer credit
balance, coupons and rounding legitimately change what Stripe collects and
turned genuinely paid invoices into permanent, non-retryable failures.

- A discount reservation that was already released no longer blocks the
  paid renewal grant; only the ledger commit is skipped.
- Version 1 discount snapshots (reserved before typed renewal ownership
  existed) stay honored on bindings that gained a typed plan snapshot.
- Re-preparing an already adjusted draft invoice no longer fails on the
  recomputed subtotal.
- Discount fingerprints fall back to plan identity for renewals without a
  frozen snapshot (reached scheduled downgrades).
…nges

A typed plan snapshot frozen for one plan can be left on a binding that
later moves to another plan through an upgrade or downgrade. Treat a
snapshot whose plan id no longer matches the binding as absent instead
of failing every future renewal permanently, and clear it when a reached
scheduled downgrade moves the binding to another catalog plan.
…ary and keep the wallet sweeper alive

Renewal-boundary semantics are now explicit: the feature flag and contract
allowlist gate admin preview/apply/cancel only. A scheduled cutover is a
committed fact and still applies after the flag is closed. Only runtime
drift (batch prepared on another service, environment or Stripe mode) blocks
it, and that error is retryable rather than a permanent webhook ack.

- Wallet loader treats every non-scheduled catalog intent as "no cutover"
  instead of erroring forever once an intent is applied.
- Stripe loader applies compensation_required/needs_attention intents only
  when the paid invoice already bills the target price; otherwise the
  invoice is an ordinary legacy renewal.
- RunWalletSubscriptionRenewalOnce logs and skips a failing contract instead
  of aborting the batch (which also skipped term advance, expiry and reset).
- A contract paused for insufficient balance may have its entitlement
  expired by the expiry task; that state is accepted so the top-up retry
  and the cutover can still happen.
- Ordinary typed-snapshot renewals no longer require the current catalog
  row to match the frozen snapshot.
…per-contract apply errors

- Cancel persists status=cancelled on the batch. Get/refreshBatchSummary
  keep it sticky, and Apply refuses a cancelled batch, so a replayed apply
  can no longer schedule a contract whose first prepare failed before an
  intent row existed.
- The summary counts syncing intents as in-flight (batch stays applying)
  instead of as failures.
- processCatalogMigrationPreview logs each per-contract apply/resume error
  with batch and contract ids instead of discarding it.
…erflow

Go's AddDate normalizes Jan 31 + 1 month to Mar 3. The schedule wrote a
longer-than-monthly target phase for subscribers anchored on the 29th to
31st, and the ownership re-check (same expression) then never matched what
Stripe stored, parking the intent in needs_attention. Clamp to the last
day of the following month, preserving the time of day.
…g and respect reservation expiry

- supersedeCatalogMigrationForUserAction takes the client's
  ExpectedChangeVersion and enforces it inside the locked transaction,
  before the Stripe schedule release and the intent supersede. A stale
  cancel now fails the precondition with no side effect instead of
  releasing the schedule and then overwriting the precondition to pass.
- Wallet contracts (no provider binding) supersede a scheduled local
  cutover on user cancel/plan change, so the next renewal tick cannot
  silently undo the user's action.
- The user-action attention marker is status-guarded: an intent that a
  concurrent paid renewal applied (or a cancel superseded) is never
  relabelled needs_attention and its contract is not frozen.
- Busy checks (preview and user action) use the shared active-reservation
  predicate: consumed tombstones and expired leases no longer read as
  provider_lifecycle_busy. Database time is read on the transaction handle
  to avoid a second connection inside a locked transaction.
- containsString is replaced by common.StringsContains.
… rules

- Drop gorm type:longtext from the four snapshot columns; the dialector
  maps an unsized string to longtext on MySQL and text on PostgreSQL, so
  AutoMigrate no longer emits a MySQL-only type on PostgreSQL (Rule 2).
- Route all JSON encoding through common.* instead of encoding/json in
  business code (Rule 1); the digest inputs are byte-identical.
- Replace parseCatalogMigrationBool with common.GetEnvOrDefaultBool and
  delete the unused isCatalogMigrationTerminalProviderStatus.
- Carry the applied cutover's batch id out of the paid-invoice transaction
  on the result instead of an unindexed intent lookup after every paid
  invoice.
The env export loop drops empty values and --update-env-vars is
merge-only, so an allowlist set by hand on the running service could never
be cleared by a deploy. Write the (possibly empty) allowlist explicitly in
both production jobs.
@think-back
think-back merged commit e19548a into feat/staging-legacy-renewal-catalog-migration Sep 17, 2026
1 check passed
think-back added a commit that referenced this pull request Sep 17, 2026
…ng-20260916

Staging: validate reviewed legacy renewal catalog migration (#1155 + #1203)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant