Renew legacy subscriptions into the new catalog at paid renewal - #1155
Open
think-back wants to merge 14 commits into
Open
think-back wants to merge 14 commits into
think-back wants to merge 14 commits into
Conversation
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
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
force-pushed
the
feat/staging-legacy-renewal-catalog-migration
branch
from
September 13, 2026 14:20
4c2aa29 to
39b32ee
Compare
…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.
This was referenced Sep 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Safety
FLATKEY_DEPLOYMENT_ENV=staging,K_SERVICE=newapi-staging, test keys, and an explicit contract allowlist.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.SandboxOnly/Livemode) are immutable and revalidated at renewal, preventing cross-environment or cross-mode cutovers.Verification
go vet ./model ./controller ./router ./servicepassed.