Skip to content

pro: route every Pro check through one case-insensitive getter - #8937

Merged
myleshorton merged 2 commits into
mainfrom
fisk/unify-client-pro-check
Jul 29, 2026
Merged

pro: route every Pro check through one case-insensitive getter#8937
myleshorton merged 2 commits into
mainfrom
fisk/unify-client-pro-check

Conversation

@myleshorton

@myleshorton myleshorton commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Problem

Pro status was re-derived in seven places, and they did not agree.

site comparison case
extensions/user_data.dart isPro userLevel == 'pro' sensitive
extensions/ref.dart isUserProProvider inline duplicate sensitive
extensions/ref.dart isUserExpiredProvider inline == 'expired' sensitive
common/common.dart:158 inline duplicate sensitive
features/account/account.dart:213 inline == 'expired' sensitive
features/account/account.dart:356 inline duplicate sensitive
extensions/plan.dart:53 inline == 'expired' sensitive
services/app_purchase.dart:744 pro OR subscriptionData.status == 'active' insensitive

The post-purchase check lowercased before comparing and accepted an active subscription. Everything else did neither. account.dart had a raw comparison and a call to the isPro getter on adjacent lines:

final isUserExpired = user.legacyUserData.userLevel == 'expired';  // 213
final isUserPro = user.legacyUserData.isPro;                       // 214

Consequence if casing ever changed upstream: the UI shows a paying user the free tier and the data-cap widget (home.dart:172 gates DataUsage() on !isUserPro) while the purchase flow considers them Pro. userLevel comes from a Postgres enum today, so this is latent, not live.

Change

UserDataProX.isPro is now the single derivation, with isExpired alongside it:

/// The one client-side Pro check. `userLevel` is the server's entitlement of
/// record (pro_users.level, via /user-data), so nothing else should re-derive
/// it — duplicate copies of this comparison are how the UI and the purchase
/// flow drifted apart on casing.
bool get isPro => _level == 'pro';

bool get isExpired => _level == 'expired';

/// Folded because the purchase flow has always compared case-insensitively.
/// Matching that means a casing change upstream cannot silently drop someone
/// to the free UI.
String get _level => userLevel.toLowerCase();

The six duplicates now call it. common.dart imports and exports the extension, so the canonical check is the default rather than something each caller re-implements.

The one intentional divergence is kept, and labelled

_userHasActivePurchase stays wider than isPro. It gates post-purchase messaging — narrowing it would start telling paying users their payment failed; widening isPro to match would grant Pro UI to users the server considers free. So it is expressed via isPro and documented, making the divergence visible instead of accidental:

/// Deliberately wider than [UserDataProX.isPro]: a subscription can read as
/// active before the account level flips, and wrongly telling someone their
/// payment failed is worse than being early. Entitlement decisions must still
/// use isPro — this only gates post-purchase messaging.

platinum left alone, deliberately

platinum is a valid server-side level that pro_users treats as paid (users/delete.go:54: level == pro || level == platinum) but the client still excludes. We do not issue it, so widening entitlement here would be speculative. There is a test pinning the current behaviour so it fails loudly the day we do.

This does not fix #180658

Found while investigating Freshdesk #180658 (China, Windows 9.1.17, paid via Shepherd, app kept showing userLevel: expired), but neither divergence could have fired for that user:

  • they paid via Shepherd, so entitlement lives in pro_server.purchases and subscriptionData.status is empty — the subscription fallback is inert
  • the server sends lowercase pro from a Postgres enum — the casing difference is inert

Their stale userLevel=expired remains unexplained. Also ruled out along the way: the type '_Map<String, dynamic>' is not a subtype of type 'String' error at flutter.log:1116, one millisecond before that payload, is the user_failures cast fixed in #8929. It is on availableServersProvider, which homeProvider does not depend on, so it cannot affect userLevel. Same-millisecond co-occurrence, not causation.

Testing

  • 125/125 tests pass (flutter test), including 10 new across test/core/extensions/user_data_test.dart (7) and test/core/extensions/plan_test.dart (3)
  • Negative controls: reverting _level to case-sensitive fails exactly the two casing tests; reverting plan.dart:53 to userLevel == 'expired' fails 2 of the 3 toDate() tests. Both discriminate.
  • toDate()'s expired branch is covered per review feedback — a mixed-case Expired previously fell through and reported the expiration date as though the plan were active. Expected dates are derived the way _formatDate derives them, not hard-coded, since toDate() converts UTC to local.
  • flutter analyze on every touched file: no new issues. The 4 remaining warnings are pre-existing — verified by analyzing the same files on stashed HEAD. Two imports this change made redundant were removed.
  • Zero raw userLevel == '…' comparisons remain outside the extension

🤖 Generated with Claude Code

https://claude.ai/code/session_01RVgb2MDpZ4wpH6fywKC2hE

Summary by CodeRabbit

  • Bug Fixes
    • Improved subscription/entitlement detection using normalized, case-insensitive Pro and expired flags across account and purchase flows.
    • Prevented incorrect Pro/expired classification from raw entitlement string values.
    • Updated expired date formatting logic to rely on the expired flag for more consistent results.
  • Tests
    • Added and expanded Flutter tests for Pro/expired status behavior (including mixed-case and unset values).
    • Added coverage for toDate() expired-branch behavior, including formatting and lastExpiredOn == 0 handling.

Pro status was re-derived in seven places. Six compared userLevel to the
literal 'pro' or 'expired' case-sensitively; the seventh, the post-purchase
check in app_purchase.dart, lowercased first and also accepted
subscriptionData.status == 'active'. account.dart had a raw comparison and a
call to the isPro getter on adjacent lines.

So a change in casing upstream would have shown a paying user the free tier
and the data-cap widget while the purchase flow considered them Pro. userLevel
comes from a Postgres enum today, so this is latent rather than live.

UserDataProX.isPro is now the single derivation, folding case to match what the
purchase flow always did, with isExpired alongside it. The six duplicates call
it. common.dart imports and exports the extension so the canonical check is
the default rather than something each caller re-implements.

_userHasActivePurchase stays deliberately wider and is now documented as such:
a subscription can read as active before the account level flips, and wrongly
telling someone their payment failed is worse than being early. Narrowing it
would create false failures; widening isPro to match would grant Pro UI to
users the server considers free. It is expressed via isPro so the one
intentional divergence is visible instead of accidental.

platinum is a valid server-side level that pro_users treats as paid but the
client still excludes. Left as-is since we do not issue it, with a test
pinning the current behaviour so it fails loudly the day we do.

Found while investigating Freshdesk #180658 and does NOT fix it. That user paid
via Shepherd, so subscriptionData.status is empty and the server sent
lowercase 'pro' -- neither divergence could fire for them. Their stale
userLevel=expired is still unexplained.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RVgb2MDpZ4wpH6fywKC2hE
Copilot AI review requested due to automatic review settings July 29, 2026 21:04
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

No new commits to review since the last review.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e73c4bbc-d1e5-4b93-854b-16c2457260c4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Entitlement handling now exposes normalized Pro and expired flags, and consumers use them instead of comparing raw user-level strings. Expiration formatting, account flows, providers, purchase messaging, exports, and tests were updated.

Changes

Entitlement status normalization

Layer / File(s) Summary
Normalized entitlement extensions
lib/core/extensions/user_data.dart, lib/core/extensions/plan.dart, test/core/extensions/*
User levels are normalized case-insensitively for isPro and isExpired; expiration date handling uses isExpired, with tests covering the new behavior.
Account and provider status consumers
lib/core/extensions/ref.dart, lib/core/common/common.dart, lib/features/account/account.dart, lib/core/utils/pro_utils.dart
Providers, account flows, and account-status checks use boolean entitlement properties, and the common export exposes the user-data extension.
Active purchase messaging gate
lib/core/services/app_purchase.dart
Post-purchase messaging checks isPro or an active subscription status.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: copilot

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: centralizing Pro checks around the case-insensitive getter.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fisk/unify-client-pro-check

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request centralizes Pro/Expired entitlement checks by routing all client-side comparisons through a single, case-insensitive extension on UserDataModel, eliminating several duplicated (and previously inconsistent) userLevel comparisons across the app.

Changes:

  • Updated UserDataModel Pro check to be case-insensitive and introduced a paired isExpired getter.
  • Replaced duplicated userLevel == 'pro'/'expired' comparisons in UI/providers/logic with isPro / isExpired.
  • Added focused unit tests covering casing, non-Pro levels, and the intentional exclusion of platinum.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated no comments.

Show a summary per file
File Description
test/core/extensions/user_data_test.dart Adds unit coverage for isPro/isExpired behavior, including casing and platinum pin.
lib/features/account/account.dart Replaces inline userLevel comparisons with canonical isPro / isExpired.
lib/core/utils/pro_utils.dart Relies on exported extension (via common.dart) instead of importing user_data.dart directly.
lib/core/services/app_purchase.dart Expresses the intentional “active subscription” divergence via isPro plus subscription status, and documents it.
lib/core/extensions/user_data.dart Implements the canonical, case-insensitive isPro and adds isExpired based on a normalized _level.
lib/core/extensions/ref.dart Updates Riverpod providers to use isPro / isExpired and imports the extension.
lib/core/extensions/plan.dart Uses isExpired instead of comparing userLevel directly (extension available via common.dart).
lib/core/common/common.dart Imports/exports user_data.dart so entitlement checks are consistently available via common.dart.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
lib/core/extensions/plan.dart (1)

53-63: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add regression coverage for normalized expiration dates.

toDate() now changes behavior for mixed-case levels such as Expired, but the supplied tests only exercise isExpired; they do not verify the lastExpiredOn formatting path. Add a test with a mixed-case expired level and a valid lastExpiredOn.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/core/extensions/plan.dart` around lines 53 - 63, The expiration-date
formatting path in the plan model lacks regression coverage for mixed-case
expired levels. Add a test using an expired level such as “Expired” with a valid
lastExpiredOn value, and assert that the normalized expiration date is formatted
and returned with the expired translation. Keep the existing isExpired coverage
intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@lib/core/extensions/plan.dart`:
- Around line 53-63: The expiration-date formatting path in the plan model lacks
regression coverage for mixed-case expired levels. Add a test using an expired
level such as “Expired” with a valid lastExpiredOn value, and assert that the
normalized expiration date is formatted and returned with the expired
translation. Keep the existing isExpired coverage intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4a798227-9edf-4687-9b47-2b692d65f5df

📥 Commits

Reviewing files that changed from the base of the PR and between 95b3b3b and 479f501.

📒 Files selected for processing (8)
  • lib/core/common/common.dart
  • lib/core/extensions/plan.dart
  • lib/core/extensions/ref.dart
  • lib/core/extensions/user_data.dart
  • lib/core/services/app_purchase.dart
  • lib/core/utils/pro_utils.dart
  • lib/features/account/account.dart
  • test/core/extensions/user_data_test.dart
💤 Files with no reviewable changes (1)
  • lib/core/utils/pro_utils.dart

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

Review feedback: routing plan.dart's expiry check through isExpired changed
toDate() for mixed-case levels -- 'Expired' used to fall through and report the
expiration date as though the plan were still active -- but the tests only
exercised the isExpired getter, not the formatting path.

lastExpiredOn and expiration are given distinct dates so the assertion proves
which branch ran, and the expected string is derived the same way _formatDate
derives it rather than hard-coded, since toDate() converts UTC to local and a
fixed string would only hold in one timezone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RVgb2MDpZ4wpH6fywKC2hE
Copilot AI review requested due to automatic review settings July 29, 2026 21:21
@myleshorton

Copy link
Copy Markdown
Contributor Author

@coderabbitai the nitpick was valid — fixed in 550ff17.

You were right that toDate() had an untested behavior change: routing line 53 through isExpired means a mixed-case Expired now takes the expired branch, where before it fell through and reported the expiration date as though the plan were still active. My tests only covered the isExpired getter, not the formatting path.

Added test/core/extensions/plan_test.dart with three cases:

  • Expired + valid lastExpiredOn → asserts the output carries the lastExpiredOn date and not the expiration date, so it proves which branch ran
  • lowercase expired → unchanged behavior, guarding against a regression in the casing the server actually sends
  • Expired + lastExpiredOn: 0N/A, covering the guard

Two details worth noting for review:

  • lastExpiredOn and expiration are deliberately given different dates. Asserting only "contains the expected date" wouldn't distinguish the branches if both formatted the same value.
  • The expected string is derived the same way _formatDate derives it rather than hard-coded, because toDate() converts UTC → local. A fixed literal would only pass in one timezone — locally these timestamps render as 12/31/25 and 12/31/26, not the 01/01 you'd naively expect.

Verified the test discriminates: reverting line 53 to userLevel == 'expired' fails 2 of the 3 (mixed-case and the N/A guard) while the lowercase case still passes. Full suite 125/125, flutter analyze clean apart from two pre-existing unused-formatter warnings in plan.dart that predate this PR.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== Commit summary and changed files =='
git show --stat --oneline --decorate --no-renames 550ff17a

echo
echo '== Commit patch =='
git show --format=fuller --no-ext-diff --unified=50 --no-renames 550ff17a -- \
  lib/core/extensions/plan.dart \
  test/core/extensions/plan_test.dart

echo
echo '== Source outline =='
ast-grep outline lib/core/extensions/plan.dart --items all --view expanded

echo
echo '== Test outline =='
ast-grep outline test/core/extensions/plan_test.dart --items all --view expanded

echo
echo '== Current relevant source and test sections =='
nl -ba lib/core/extensions/plan.dart | sed -n '1,130p'
nl -ba test/core/extensions/plan_test.dart | sed -n '1,260p'

Length of output: 798


@myleshorton Thanks for the thorough follow-up—the test cases and deliberately distinct timestamps are exactly the right way to prove the formatting branch, including the mixed-case entitlement behavior and lastExpiredOn: 0 guard.

I couldn’t independently verify commit 550ff17a because repository cloning failed in the review environment. Please retry the verification or request another review pass; I’ll leave the finding unresolved until the commit can be inspected.

🐇🔍


✏️ Learnings added
Learnt from: myleshorton
URL: https://github.com/getlantern/lantern/pull/8937

Timestamp: 2026-07-29T21:22:10.417Z
Learning: In the Flutter extension `UserDataModel.toDate()` in `lib/core/extensions/plan.dart`, expired-plan formatting must use `lastExpiredOn` (or return `N/A` when it is zero) whenever the canonical case-insensitive `UserDataModel.isExpired` getter is true. Tests for this branch must distinguish `lastExpiredOn` from `expiration` and derive expected dates using the same UTC-to-local conversion as `_formatDate`, rather than fixed date literals.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@myleshorton

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.

@myleshorton
myleshorton merged commit 2bf4cc4 into main Jul 29, 2026
11 checks passed
@myleshorton
myleshorton deleted the fisk/unify-client-pro-check branch July 29, 2026 21:24
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.

2 participants