Skip to content

Renew legacy subscriptions into the new catalog at paid renewal - #1155

Open
think-back wants to merge 14 commits into
mainfrom
feat/staging-legacy-renewal-catalog-migration
Open

think-back wants to merge 14 commits into
mainfrom
feat/staging-legacy-renewal-catalog-migration

Conversation

@think-back

@think-back think-back commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add immutable root-only preview/apply/status/cancel flow for legacy-to-new same-tier renewal cutover.
  • Preserve legacy plan, entitlement usage/windows, and historical orders until a successful paid renewal boundary.
  • On the renewal boundary, apply the versioned target snapshot exactly once; duplicate webhook delivery is idempotent.
  • Support both staging Stripe test mode and explicitly gated production Stripe live mode.
  • No frontend changes.

Safety

  • Staging requires FLATKEY_DEPLOYMENT_ENV=staging, K_SERVICE=newapi-staging, test keys, and an explicit contract allowlist.
  • Production requires FLATKEY_DEPLOYMENT_ENV=production, K_SERVICE=newapi-console/newapi-router, SUBSCRIPTION_CATALOG_MIGRATION_ENABLED=true, SUBSCRIPTION_CATALOG_MIGRATION_PRODUCTION_ENABLED=true, live keys, and an explicit contract allowlist.
  • Batch mode facts (SandboxOnly/Livemode) are immutable and revalidated at renewal, preventing cross-environment or cross-mode cutovers.
  • Existing users are not rewritten immediately: old entitlements and limits remain until Stripe confirms the paid renewal.

Verification

  • Focused migration, invoice renewal, wallet lifecycle, model/controller/router suites pass.
  • Production/live validator and batch-mode coverage included.
  • go vet ./model ./controller ./router ./service passed.
  • Full service package retains unrelated pre-existing asset-binding/environment failures; race build is unavailable because gcc is not installed.

Add an immutable, root-only catalog migration path that schedules new same-tier benefits only at a successful renewal boundary while retaining legacy orders and entitlements. Keep the first rollout sandbox-only and default staging deployment disabled.

Constraint: Existing users, orders, used quota, and legacy 5h/7d limits must remain unchanged until paid renewal; production must not be enabled.

Rejected: In-place plan edits or a permanent successor rule | they would rewrite legacy meaning or broaden the cohort beyond the explicit allowlist.

Confidence: high

Scope-risk: moderate

Directive: Keep SUBSCRIPTION_CATALOG_MIGRATION_ENABLED false and the staging allowlist empty unless an isolated staging test is actively running.

Tested: Focused model/controller/router/service migration suites, wallet concurrency x10, lock-order tests x10, go vet changed packages, git diff --check.

Not-tested: Full service package remains affected by unrelated asset-binding/environment failures; race build lacks gcc.
Persist provider schedule ownership proofs as fixed-width SHA-256 values so staging MySQL can schedule an eligible contract without truncation or a false zero-row update.\n\nConstraint: preserve the existing prefixed key format for IDs and idempotency tokens\nRejected: widen the production column | would require a broader schema rollout for a fixed-width digest\nConfidence: high\nScope-risk: narrow\nDirective: keep provider schedule fingerprints exactly 64 hexadecimal characters\nTested: go test ./service -run 'CatalogMigration' -count=1 -timeout=600s; git diff --check\nNot-tested: full repository suite and race detector on this host
Create subscription schedules from existing subscriptions without forbidden metadata, then configure ownership and renewal phases in a separate idempotent update. Recognize Stripe's initial current phase and fail closed on ambiguous schedule recovery.\n\nConstraint: Stripe rejects metadata when from_subscription is supplied, and legacy entitlements must remain untouched until the scheduled renewal.\nRejected: Reusing an unmarked attached schedule by phase shape alone | it could take ownership of an unrelated Stripe schedule.\nConfidence: high\nScope-risk: narrow\nDirective: Keep staging migration allowlisted and disabled by default; do not broaden provider recovery without an ownership proof.\nTested: go test ./service -run 'StripeCatalogMigration|CatalogMigrationUserAction|CancelCurrentSubscriptionRenewalPreemptsCatalogMigration' -count=1 -timeout=600s; go test ./service -run 'CatalogMigration|SubscriptionDiscount' -count=1 -timeout=600s; go vet ./service; git diff --check\nNot-tested: Full end-to-end renewal at the natural Stripe billing boundary
Keep the migration branch mergeable while preserving both purchased-price authority and pending-plan target validation.\n\nConstraint: The open main PR must include current main without weakening renewal ownership checks or changing production deployment behavior.\nRejected: Drop the upstream payment-validation changes | that would reintroduce price-authority regressions.\nConfidence: high\nScope-risk: moderate\nDirective: Review renewal price authority and catalog migration together whenever payment validation changes.\nTested: go test ./service -run 'CatalogMigration|RenewalPriceAuthority|PaidRenewal|SubscriptionDiscount' -count=1 -timeout=600s; git diff --cached --check\nNot-tested: Full repository suite after merge
@think-back think-back changed the title Sandbox-only legacy subscription renewal catalog migration Renew legacy subscriptions into the new catalog at paid renewal Sep 13, 2026
Keep legacy entitlements untouched until a paid renewal and persist immutable deployment-mode facts for replay safety.\n\nConstraint: Production Stripe renewals must be opt-in and mode-isolated.\nRejected: Enabling live migration by default | would risk unintended cohort changes.\nConfidence: high\nScope-risk: moderate\nDirective: Enable production flags only after reviewing the immutable preview cohort.\nTested: Focused service/model migration suites; go vet ./model ./controller ./router ./service; git diff --check\nNot-tested: Full service suite retains unrelated environment failures.
@think-back
think-back force-pushed the feat/staging-legacy-renewal-catalog-migration branch from 4c2aa29 to 39b32ee Compare September 13, 2026 14:20
…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 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