diff --git a/lib/core/common/common.dart b/lib/core/common/common.dart index ad943365f7..bbe8c00826 100644 --- a/lib/core/common/common.dart +++ b/lib/core/common/common.dart @@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lantern/core/common/app_build_info.dart'; import 'package:lantern/core/common/app_eum.dart'; import 'package:lantern/core/common/app_urls.dart'; +import 'package:lantern/core/extensions/user_data.dart'; import 'package:lantern/core/localization/i18n.dart'; import 'package:lantern/core/models/private_server.dart'; import 'package:lantern/core/models/server_location.dart'; @@ -49,6 +50,7 @@ export 'package:lantern/core/extensions/pointer.dart'; export 'package:lantern/core/extensions/ref.dart'; // Extensions export 'package:lantern/core/extensions/string.dart'; +export 'package:lantern/core/extensions/user_data.dart'; export 'package:lantern/core/localization/i18n.dart'; // Routes export 'package:lantern/core/router/router.gr.dart'; @@ -155,7 +157,7 @@ Future checkUserAccountStatus(WidgetRef ref, BuildContext context) async { return false; }, (newUser) { - final isPro = newUser.legacyUserData.userLevel == 'pro'; + final isPro = newUser.legacyUserData.isPro; if (isPro) { // User has bought a plan // update user data diff --git a/lib/core/extensions/plan.dart b/lib/core/extensions/plan.dart index b608941e67..f56ba152fc 100644 --- a/lib/core/extensions/plan.dart +++ b/lib/core/extensions/plan.dart @@ -50,7 +50,7 @@ extension PlanExtension on Plan { extension IsoDateFormatter on UserDataModel { String toDate() { try { - if (userLevel == 'expired') { + if (isExpired) { if (lastExpiredOn <= 0) { return "N/A"; } diff --git a/lib/core/extensions/ref.dart b/lib/core/extensions/ref.dart index 3825c7a22c..f962473196 100644 --- a/lib/core/extensions/ref.dart +++ b/lib/core/extensions/ref.dart @@ -1,19 +1,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lantern/core/extensions/user_data.dart'; import 'package:lantern/features/home/provider/home_notifier.dart'; import 'package:lantern/features/vpn/provider/available_servers_notifier.dart'; final isUserProProvider = Provider((ref) { return ref.watch( - homeProvider.select( - (value) => value.value?.legacyUserData.userLevel == 'pro', - ), + homeProvider.select((value) => value.value?.legacyUserData.isPro ?? false), ); }); final isUserExpiredProvider = Provider((ref) { return ref.watch( homeProvider.select( - (value) => value.value?.legacyUserData.userLevel == 'expired', + (value) => value.value?.legacyUserData.isExpired ?? false, ), ); }); diff --git a/lib/core/extensions/user_data.dart b/lib/core/extensions/user_data.dart index 0ba0f3ae51..9c69927b54 100644 --- a/lib/core/extensions/user_data.dart +++ b/lib/core/extensions/user_data.dart @@ -1,7 +1,18 @@ import 'package:lantern/core/models/user.dart'; extension UserDataProX on UserDataModel { - bool get isPro => userLevel == 'pro'; + /// 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(); /// Total bonus days earned from converted referrals. int get referralBonusDays => referrals diff --git a/lib/core/services/app_purchase.dart b/lib/core/services/app_purchase.dart index 825673496f..308f72fbf2 100644 --- a/lib/core/services/app_purchase.dart +++ b/lib/core/services/app_purchase.dart @@ -737,11 +737,14 @@ class AppPurchase { return _userHasActivePurchase(user); } + /// 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. bool _userHasActivePurchase(UserResponseModel user) { - final userLevel = user.legacyUserData.userLevel.toLowerCase(); - final subscriptionStatus = user.legacyUserData.subscriptionData.status - .toLowerCase(); - return userLevel == 'pro' || subscriptionStatus == 'active'; + if (user.legacyUserData.isPro) return true; + return user.legacyUserData.subscriptionData.status.toLowerCase() == + 'active'; } /// Determines the plan id to send to the backend for acknowledgment. diff --git a/lib/core/utils/pro_utils.dart b/lib/core/utils/pro_utils.dart index 0405a5f97e..6de2adfc3c 100644 --- a/lib/core/utils/pro_utils.dart +++ b/lib/core/utils/pro_utils.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:lantern/core/common/common.dart'; -import 'package:lantern/core/extensions/user_data.dart'; import 'package:lantern/core/models/user.dart'; bool hasRegisteredProAccount(UserResponseModel? user) { diff --git a/lib/features/account/account.dart b/lib/features/account/account.dart index 468b62c9a1..05c1c6a130 100644 --- a/lib/features/account/account.dart +++ b/lib/features/account/account.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/extensions/plan.dart'; -import 'package:lantern/core/extensions/user_data.dart'; import 'package:lantern/core/keys/app_keys.dart'; import 'package:lantern/core/models/user.dart'; import 'package:lantern/core/widgets/info_row.dart'; @@ -210,7 +209,7 @@ class Account extends HookConsumerWidget { WidgetRef ref, ) { final autoRenew = user.legacyUserData.subscriptionData.autoRenew; - final isUserExpired = user.legacyUserData.userLevel == 'expired'; + final isUserExpired = user.legacyUserData.isExpired; final isUserPro = user.legacyUserData.isPro; ///User has an active subscription with auto-renew enabled @@ -353,7 +352,7 @@ class Account extends HookConsumerWidget { (newUser) { final oldPlanId = oldUser.legacyUserData.subscriptionData.planID; final newPlanId = newUser.legacyUserData.subscriptionData.planID; - final isPro = newUser.legacyUserData.userLevel == 'pro'; + final isPro = newUser.legacyUserData.isPro; final isPlanChanged = isPro && oldPlanId != newPlanId; final isCancelled = !isPro || diff --git a/test/core/extensions/plan_test.dart b/test/core/extensions/plan_test.dart new file mode 100644 index 0000000000..b80b27590a --- /dev/null +++ b/test/core/extensions/plan_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lantern/core/extensions/plan.dart'; +import 'package:lantern/core/models/user.dart'; + +void main() { + // Mirrors IsoDateFormatter._formatDate rather than hard-coding a date, so the + // expectation holds in any timezone — toDate() converts from UTC to local. + String formattedLocal(int epochSeconds) { + final d = DateTime.fromMillisecondsSinceEpoch( + epochSeconds * 1000, + isUtc: true, + ).toLocal(); + final mm = d.month.toString().padLeft(2, '0'); + final dd = d.day.toString().padLeft(2, '0'); + final yy = (d.year % 100).toString().padLeft(2, '0'); + return '$mm/$dd/$yy'; + } + + // Distinct dates so the assertion proves *which* branch ran: the expired + // branch formats lastExpiredOn, the fall-through formats expiration. + const lastExpiredOn = 1767225600; // 2026-01-01 UTC + const expiration = 1798761600; // 2027-01-01 UTC + + group('UserDataModel.toDate() expired branch', () { + // toDate() used to compare userLevel to 'expired' case-sensitively, so a + // mixed-case level fell through and reported the expiration date as though + // the plan were still active. + test('takes the expired branch for a mixed-case level', () { + final date = UserDataModel( + userLevel: 'Expired', + lastExpiredOn: lastExpiredOn, + expiration: expiration, + ).toDate(); + + expect(date, contains(formattedLocal(lastExpiredOn))); + expect(date, isNot(contains(formattedLocal(expiration)))); + }); + + test('still takes it for the level the server actually sends', () { + final date = UserDataModel( + userLevel: 'expired', + lastExpiredOn: lastExpiredOn, + expiration: expiration, + ).toDate(); + + expect(date, contains(formattedLocal(lastExpiredOn))); + }); + + test('reports N/A when there is no expiry timestamp to format', () { + final date = UserDataModel( + userLevel: 'Expired', + lastExpiredOn: 0, + expiration: expiration, + ).toDate(); + + expect(date, 'N/A'); + }); + }); +} diff --git a/test/core/extensions/user_data_test.dart b/test/core/extensions/user_data_test.dart new file mode 100644 index 0000000000..d50090c6fa --- /dev/null +++ b/test/core/extensions/user_data_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lantern/core/extensions/user_data.dart'; +import 'package:lantern/core/models/user.dart'; + +void main() { + UserDataModel userAt(String level) => UserDataModel(userLevel: level); + + group('UserDataModel.isPro', () { + test('true for the level the server sends', () { + expect(userAt('pro').isPro, isTrue); + }); + + // The purchase flow has always lowercased before comparing while the UI did + // not, so a change in casing upstream would have shown a paying user the + // free tier while the purchase flow considered them Pro. + test('is case-insensitive', () { + expect(userAt('Pro').isPro, isTrue); + expect(userAt('PRO').isPro, isTrue); + }); + + test('false for every other level', () { + expect(userAt('expired').isPro, isFalse); + expect(userAt('initial').isPro, isFalse); + expect(userAt('').isPro, isFalse); + }); + + // platinum is a valid server-side level that we do not issue yet. Recorded + // so the day it is issued, this failing expectation is the reminder. + test('does not yet treat platinum as Pro', () { + expect(userAt('platinum').isPro, isFalse); + }); + }); + + group('UserDataModel.isExpired', () { + test('true for expired, case-insensitively', () { + expect(userAt('expired').isExpired, isTrue); + expect(userAt('Expired').isExpired, isTrue); + }); + + test('false for pro and for unset', () { + expect(userAt('pro').isExpired, isFalse); + expect(userAt('').isExpired, isFalse); + }); + }); + + test('isPro and isExpired are never both true', () { + for (final level in ['pro', 'expired', 'initial', 'platinum', '']) { + final user = userAt(level); + expect(user.isPro && user.isExpired, isFalse, reason: 'level=$level'); + } + }); +}