fix(billing): catch partial-truth subscription rows that misrender as Explorer#3812
Merged
fix(billing): catch partial-truth subscription rows that misrender as Explorer#3812
Conversation
… Explorer Founding-member orgs (e.g. Adzymic) sat in a state where subscription_status='active' but stripe_subscription_id, subscription_price_lookup_key, and subscription_amount were all NULL. Tier resolution returned null and the dashboard fell back to "Explorer / Upgrade to Professional" — an active insult to a paying corporate member. Both existing Stripe-side invariants and lazy-reconcile treated active status as proof of full sync. - New `every-entitled-org-has-resolvable-tier` invariant tests the resolver directly - Tighten `stripe-sub-reflected-in-org-row` to require populated product fields - Tighten `lazy-reconcile` guard + UPDATE WHERE clause for partial-truth heals - Dashboard renders neutral "Active membership" instead of Explorer fallback - One-shot reconciliation script under scripts/incidents/ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Script: encodeURIComponent the org id, treat HTTP 2xx as success regardless of body shape, distinguish "healed" from "no Stripe data to write" - Invariant message: reflects the new neutral fallback (not "Explorer") - Dashboard pending state: drop misleading warning→success color fallback - Lazy-reconcile test: match lookup_key in params via toContain, not by index Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 tasks
bokelley
added a commit
that referenced
this pull request
May 2, 2026
…product metadata Follow-up to #3812. Three gaps the Adzymic incident exposed: - /sync UPDATE wrote 6 fields and dropped stripe_subscription_id, lookup_key, product_id/name, and membership_tier — every successful sync left the row in the partial-truth state. Now delegates to buildSubscriptionUpdate so /sync, webhook handlers, and lazy-reconcile all write identical state. - Founding-era Stripe prices have no aao_membership_* lookup_key — they use product metadata.category=membership instead. Without recognizing it, pickMembershipSub filtered them out and stripe-sub-reflected-in-org-row's orphan-customer detection never saw paying customers (Adzymic/Advertible/ Bidcliq/Equativ). isMembershipSub now falls back to product metadata; /sync and the invariant expand price.product so it's available. - detectEnvMismatch() classified prod as "not prod" because the allowlist had *.fly.dev but not Fly's actual private patterns (*.flycast, *.internal) or FLY_APP_NAME. The integrity runner refused in production. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley
added a commit
that referenced
this pull request
May 2, 2026
…product metadata (#3829) * fix(billing): canonical /sync write + recognize founding-era subs by product metadata Follow-up to #3812. Three gaps the Adzymic incident exposed: - /sync UPDATE wrote 6 fields and dropped stripe_subscription_id, lookup_key, product_id/name, and membership_tier — every successful sync left the row in the partial-truth state. Now delegates to buildSubscriptionUpdate so /sync, webhook handlers, and lazy-reconcile all write identical state. - Founding-era Stripe prices have no aao_membership_* lookup_key — they use product metadata.category=membership instead. Without recognizing it, pickMembershipSub filtered them out and stripe-sub-reflected-in-org-row's orphan-customer detection never saw paying customers (Adzymic/Advertible/ Bidcliq/Equativ). isMembershipSub now falls back to product metadata; /sync and the invariant expand price.product so it's available. - detectEnvMismatch() classified prod as "not prod" because the allowlist had *.fly.dev but not Fly's actual private patterns (*.flycast, *.internal) or FLY_APP_NAME. The integrity runner refused in production. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(billing): address review nits — revert index.json, configurable prod app, $10K corporate test - Revert speculative published_version downgrade in static/schemas/source/index.json: the build:schemas regen during pre-push silently rolled adcp_version 3.0.4 -> 3.0.3 to match package.json. Reviewer correctly flagged this as out-of-scope and incorrect (would mis-label the published schema version). Restored to main's state. The pre-push hook is currently broken against main on a separate concern (forward-merge release process desyncs package.json from the registry intentionally). - Configurable prod-app allowlist: AAO_PROD_FLY_APPS env var, defaults to "adcp-docs" (server/src/routes/admin/integrity.ts). - Use 'deleted' in product instead of double-cast in isMembershipProductByMetadata (server/src/billing/membership-prices.ts). - Add Equativ-shape regression: founding Corporate sub at $10K with metadata-only classification (server/tests/unit/integrity/invariants.test.ts), plus a load-bearing comment on the substring assertion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
Founding-member orgs (e.g. Adzymic, escalation #300) sat in a state where
subscription_status='active'butstripe_subscription_id,subscription_price_lookup_key, andsubscription_amountwere all NULL. The tier resolver returned null, anddashboard-organization.htmlfell back toDEFAULT_TIER(label "Explorer") and rendered "Upgrade to Professional — $250/yr" to a paying corporate member.Both existing Stripe-side invariants (
stripe-sub-reflected-in-org-row,org-row-matches-live-stripe-sub) and thelazy-reconcileheal path treatedstatus='active'as proof of full sync, so neither flagged or repaired the rows. Five+ founding-era corporate orgs were in the same state.What changed
every-entitled-org-has-resolvable-tier(critical, DB-only). Walks every org with entitledsubscription_statusand assertsresolveMembershipTier()returns non-null. Backstop on the function the dashboard and Addie's prompt rules consume — catches the Adzymic shape and any future schema drift.stripe-sub-reflected-in-org-rowhealthy predicate: status entitled ANDstripe_subscription_idnon-null AND tier-resolving product field populated (lookup_keynon-null ORamount > 0).lazy-reconcileread-side guard with the same predicate, plus broadened the UPDATE WHERE clause so heals can write when the row is partial-truth.server/public/dashboard-organization.html): whenmembership_tieris null but the user is a member, render a neutral "Active membership" state and skip the upgrade teaser. Never silently default toindividual_academic.scripts/incidents/2026-05-heal-partial-truth-tier-rows.ts— calls the new invariant, lists violations, POSTs/api/admin/accounts/:orgId/syncfor each. Defaults to dry-run.stripe-sub-reflected-in-org-row, andattemptStripeReconciliation.Test plan
npx vitest run tests/unit/integrity/ tests/unit/billing/— 87 tests pass, including new Adzymic-shape regressionstsc --noEmitclean--executeto heal the founding cohortGET /api/admin/integrity/check/every-entitled-org-has-resolvable-tierpost-heal to confirm zero violations🤖 Generated with Claude Code