Fix: clear fair-use enforcement on paid plan upgrade#6300
Conversation
…6298) When a user upgrades from free to paid, stale fair-use enforcement (stage=restrict/throttle/warning from free credit exhaustion) was not cleared. Payment webhooks updated subscription and unlocked content but never touched fair-use state. This left upgraded users restricted. - Add clear_fair_use_on_upgrade(uid) that resets only free_exhausted enforcement (preserves abuse-derived restrictions) - Wire into all 3 webhook paths: checkout.session.completed, customer.subscription.*, subscription_schedule.completed - Add defense-in-depth guard in is_hard_restricted() for missed webhooks - Formalize 'free_exhausted' in UsageType enum - 16 unit tests covering clearing, preservation, guards, and source-level Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR fixes stale fair-use enforcement persisting after a free→paid upgrade by adding
Confidence Score: 4/5Safe to merge with the ordering bug fixed; the defense-in-depth guard prevents users from being incorrectly blocked, but the primary clear silently fails for the most common upgrade webhook path. One P1 defect:
Important Files Changed
Sequence DiagramsequenceDiagram
participant Stripe
participant Webhook as stripe_webhook()
participant FairUse as clear_fair_use_on_upgrade()
participant Firestore
Note over Webhook,Firestore: checkout.session.completed (order OK)
Stripe->>Webhook: checkout.session.completed
Webhook->>Firestore: _update_subscription_from_session() written first
Webhook->>FairUse: clear_fair_use_on_upgrade(uid)
FairUse->>Firestore: get_user_valid_subscription() - paid
FairUse->>Firestore: update_fair_use_state(stage=none)
FairUse->>Firestore: invalidate Redis cache
Note over Webhook,Firestore: customer.subscription.* (order BUG)
Stripe->>Webhook: customer.subscription.updated
Webhook->>FairUse: clear_fair_use_on_upgrade(uid) called first
FairUse->>Firestore: get_user_valid_subscription() - FREE stale
FairUse-->>Webhook: return False no-op silent
Webhook->>Firestore: update_user_subscription() written too late
Note over Webhook,Firestore: is_hard_restricted() fallback guard
Webhook->>Firestore: get_fair_use_state() - stage=restrict
Firestore-->>Webhook: last_classifier_type=free_exhausted
Webhook->>Firestore: get_user_valid_subscription() - paid
Webhook-->>Webhook: return False user not blocked
|
| fair_use_db.update_fair_use_state( | ||
| uid, | ||
| { | ||
| 'stage': 'none', | ||
| 'violation_count_7d': 0, | ||
| 'violation_count_30d': 0, | ||
| 'throttle_until': None, | ||
| 'restrict_until': None, | ||
| 'cleared_by': 'subscription_upgrade', | ||
| 'cleared_at': datetime.utcnow(), | ||
| }, | ||
| ) |
There was a problem hiding this comment.
last_violation_at not reset alongside violation counts
violation_count_7d and violation_count_30d are zeroed, but last_violation_at is left at its previous value. If downstream code uses last_violation_at to infer recency of violations (e.g. display in admin dashboard or in FairUseUserSummary), it will show a stale timestamp even after the enforcement was fully cleared. Resetting it to None alongside the counts would be consistent with treating the enforcement as fully removed.
| fair_use_db.update_fair_use_state( | |
| uid, | |
| { | |
| 'stage': 'none', | |
| 'violation_count_7d': 0, | |
| 'violation_count_30d': 0, | |
| 'throttle_until': None, | |
| 'restrict_until': None, | |
| 'cleared_by': 'subscription_upgrade', | |
| 'cleared_at': datetime.utcnow(), | |
| }, | |
| ) | |
| fair_use_db.update_fair_use_state( | |
| uid, | |
| { | |
| 'stage': 'none', | |
| 'violation_count_7d': 0, | |
| 'violation_count_30d': 0, | |
| 'last_violation_at': None, | |
| 'throttle_until': None, | |
| 'restrict_until': None, | |
| 'cleared_by': 'subscription_upgrade', | |
| 'cleared_at': datetime.utcnow(), | |
| }, | |
| ) |
| TV_MOVIE = "tv_movie" | ||
| COMMERCIAL = "commercial" | ||
| UNKNOWN = "unknown" | ||
| FREE_EXHAUSTED = "free_exhausted" |
There was a problem hiding this comment.
cleared_by / cleared_at audit fields not modeled in FairUseState
clear_fair_use_on_upgrade writes cleared_by and cleared_at directly to Firestore, but these fields are not declared in the FairUseState Pydantic model. They'll be persisted to Firestore but silently dropped if the document is ever round-tripped through the model (e.g. via FairUseState(**state_dict)). Adding the fields to the model also makes the schema self-documenting:
cleared_by: Optional[str] = None
cleared_at: Optional[datetime] = NoneIn customer.subscription.* path, clear_fair_use_on_upgrade was called before users_db.update_user_subscription, so the helper's paid-plan check would read the old (free) subscription and no-op. Move the call after the subscription write so the persisted plan is already paid when the helper reads it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Covers legacy/malformed fair-use state where last_classifier_type is absent from the Firestore document. clear_fair_use_on_upgrade should no-op (not clear) since it can't confirm the restriction origin. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
PR_APPROVED_LGTM No issues found. The new boundary test covers the legacy/malformed case where Test results:
by AI for @beastoin |
CP9 Changed-Path Coverage Checklist
L1 SynthesisAll 6 changed paths (P1-P6) proven at L1. P1 and P2 verified via 13 behavioral unit tests covering happy-path clearing and 4 non-happy-path scenarios (abuse preservation, no-op on free/missing/none). P3-P5 verified via source-level analysis confirming correct import, call presence, and write-before-clear ordering in all 3 webhook paths (R1 ordering fix applied to P4). P6 verified via import check. No paths remain UNTESTED. by AI for @beastoin |
L2 Integrated Test EvidenceBackend build and boot
Integration verification
App integration
L2 SynthesisAll 6 changed paths (P1-P6) proven at L2. Backend boots with full dependency chain and all webhook/fair-use routes registered. Import chain from payment.py → fair_use.py → clear_fair_use_on_upgrade verified with real Google Cloud credentials. Write-before-clear ordering confirmed in all 3 webhook paths. App is unchanged (backend-only PR) — existing sync/transcribe endpoints benefit from the by AI for @beastoin |
Keep is_hard_restricted clean — webhook-based clearing via clear_fair_use_on_upgrade is the fix. Removed the paid+free_exhausted subscription check and associated 4 tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
PR_APPROVED_LGTM No issues found. Re-reviewing the accepted Test results:
by AI for @beastoin |
|
lgtm |
🚀 Deployment Complete — Post-Deploy StatusPR #6300 has been deployed to production (both Cloud Run and Pusher GKE). Deploy Status
Initial Monitoring (T+0)
What's Being Monitored
What to ExpectWhen a user with Abuse-derived restrictions (audiobook, podcast, etc.) will NOT be cleared — those log: cc @mdmohsin7 — as stakeholder, you'll be notified of any issues. Closes #6298. by AI for @beastoin |
Fixes #6298 — upgrading to paid plan doesn't clear free-tier fair-use enforcement restriction.
Bug
When a user upgrades from free to paid plan, stale fair-use enforcement (stage=restrict/throttle/warning from free credit exhaustion) persists. Payment webhooks in
payment.pyupdate subscription state and unlock content but never touchfair_use_state.is_hard_restricted()only checksstage, not subscription status.Root cause
Three webhook paths (
checkout.session.completed,customer.subscription.*,subscription_schedule.completed) all update subscription and callunlock_all_conversations/memories/action_itemsbut none callreset_fair_use_state()orinvalidate_enforcement_cache().Fix
Add
clear_fair_use_on_upgrade(uid)that:last_classifier_type == 'free_exhausted'none, nulls timestamps, invalidates Redis cachecleared_by='subscription_upgrade',cleared_at)Also formalizes
'free_exhausted'inUsageTypeenum (was previously only a string literal).Tests (13)
clear_fair_use_on_upgrade():Source-level webhook verification:
payment.pyimportsclear_fair_use_on_upgradecheckout.session.completedcalls itcustomer.subscription.*calls itsubscription_schedule.completedcalls itReview cycle
2 review rounds via CODEx → PR_APPROVED_LGTM:
Tester: 1 round → TESTS_APPROVED. Added boundary test for missing last_classifier_type.
Manager feedback: removed defense-in-depth guard from
is_hard_restricted()to keep it clean.Files changed
backend/utils/fair_use.pyclear_fair_use_on_upgrade()backend/routers/payment.pyclear_fair_use_on_upgrade(uid)from all 3 webhook pathsbackend/models/fair_use.pyFREE_EXHAUSTEDtoUsageTypeenumbackend/tests/unit/test_fair_use_upgrade.pybackend/test.shRisks
get_enforcement_stage()in transcribe/sync reads raw stage — cleared by webhook, relies on webhook firing correctlyby AI for @beastoin