Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion lib/core/common/common.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -155,7 +157,7 @@ Future<bool> 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
Expand Down
2 changes: 1 addition & 1 deletion lib/core/extensions/plan.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down
7 changes: 3 additions & 4 deletions lib/core/extensions/ref.dart
Original file line number Diff line number Diff line change
@@ -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<bool>((ref) {
return ref.watch(
homeProvider.select(
(value) => value.value?.legacyUserData.userLevel == 'pro',
),
homeProvider.select((value) => value.value?.legacyUserData.isPro ?? false),
);
});

final isUserExpiredProvider = Provider<bool>((ref) {
return ref.watch(
homeProvider.select(
(value) => value.value?.legacyUserData.userLevel == 'expired',
(value) => value.value?.legacyUserData.isExpired ?? false,
),
);
});
Expand Down
13 changes: 12 additions & 1 deletion lib/core/extensions/user_data.dart
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 7 additions & 4 deletions lib/core/services/app_purchase.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 0 additions & 1 deletion lib/core/utils/pro_utils.dart
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
5 changes: 2 additions & 3 deletions lib/features/account/account.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ||
Expand Down
59 changes: 59 additions & 0 deletions test/core/extensions/plan_test.dart
Original file line number Diff line number Diff line change
@@ -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');
});
});
}
52 changes: 52 additions & 0 deletions test/core/extensions/user_data_test.dart
Original file line number Diff line number Diff line change
@@ -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');
}
});
}
Loading