Skip to content

fix(billing): catch partial-truth subscription rows that misrender as Explorer#3812

Merged
bokelley merged 2 commits intomainfrom
bokelley/founding-tier-mapping
May 2, 2026
Merged

fix(billing): catch partial-truth subscription rows that misrender as Explorer#3812
bokelley merged 2 commits intomainfrom
bokelley/founding-tier-mapping

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented May 2, 2026

Summary

Founding-member orgs (e.g. Adzymic, escalation #300) sat in a state where subscription_status='active' but stripe_subscription_id, subscription_price_lookup_key, and subscription_amount were all NULL. The tier resolver returned null, and dashboard-organization.html fell back to DEFAULT_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 the lazy-reconcile heal path treated status='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

  • New invariant every-entitled-org-has-resolvable-tier (critical, DB-only). Walks every org with entitled subscription_status and asserts resolveMembershipTier() returns non-null. Backstop on the function the dashboard and Addie's prompt rules consume — catches the Adzymic shape and any future schema drift.
  • Tightened stripe-sub-reflected-in-org-row healthy predicate: status entitled AND stripe_subscription_id non-null AND tier-resolving product field populated (lookup_key non-null OR amount > 0).
  • Tightened lazy-reconcile read-side guard with the same predicate, plus broadened the UPDATE WHERE clause so heals can write when the row is partial-truth.
  • Dashboard fallback fix (server/public/dashboard-organization.html): when membership_tier is null but the user is a member, render a neutral "Active membership" state and skip the upgrade teaser. Never silently default to individual_academic.
  • One-shot reconciliation script scripts/incidents/2026-05-heal-partial-truth-tier-rows.ts — calls the new invariant, lists violations, POSTs /api/admin/accounts/:orgId/sync for each. Defaults to dry-run.
  • Tests: Adzymic-shape regression cases for the new invariant, the tightened stripe-sub-reflected-in-org-row, and attemptStripeReconciliation.

Test plan

  • npx vitest run tests/unit/integrity/ tests/unit/billing/ — 87 tests pass, including new Adzymic-shape regressions
  • tsc --noEmit clean
  • Precommit hook (full unit suite + typecheck + dynamic imports) passed
  • After merge: run the one-shot script in dry-run mode against prod to confirm the violation list, then --execute to heal the founding cohort
  • After merge: hit GET /api/admin/integrity/check/every-entitled-org-has-resolvable-tier post-heal to confirm zero violations

🤖 Generated with Claude Code

bokelley and others added 2 commits May 2, 2026 06:56
… 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>
@bokelley bokelley merged commit 2419699 into main May 2, 2026
13 checks passed
@bokelley bokelley deleted the bokelley/founding-tier-mapping branch May 2, 2026 11:07
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>
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