Skip to content
Closed
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
208 changes: 200 additions & 8 deletions lib/features/subscription/controllers/subscription_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,21 @@ import 'package:fluffychat/features/subscription/models/mobile_subscription_info
import 'package:fluffychat/features/subscription/models/subscription_details.dart';
import 'package:fluffychat/features/subscription/models/subscription_info_manager.dart';
import 'package:fluffychat/features/subscription/models/subscription_state.dart';
import 'package:fluffychat/features/subscription/models/subscription_status_v2.dart';
import 'package:fluffychat/features/subscription/models/web_subscription_info_manager.dart';
import 'package:fluffychat/features/subscription/repo/all_products_repo.dart';
import 'package:fluffychat/features/subscription/repo/cancel_v2_repo.dart';
import 'package:fluffychat/features/subscription/repo/products_v2_repo.dart';
import 'package:fluffychat/features/subscription/repo/status_v2_repo.dart';
import 'package:fluffychat/features/subscription/repo/subscription_app_ids_repo.dart';
import 'package:fluffychat/features/subscription/repo/subscription_management_repo.dart';
import 'package:fluffychat/features/subscription/repo/subscription_repo.dart';
import 'package:fluffychat/features/subscription/subscription_constants.dart';
import 'package:fluffychat/features/subscription/utils/single_flight_guard.dart';
import 'package:fluffychat/features/subscription/utils/subscription_app_id.dart';
import 'package:fluffychat/features/subscription/utils/subscription_status_enum.dart';
import 'package:fluffychat/features/subscription/utils/v2_subscription_catalog.dart';
import 'package:fluffychat/features/subscription/utils/v2_ui_gating.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/common/utils/firebase_analytics.dart';
Expand All @@ -32,11 +40,36 @@ class SubscriptionController with ChangeNotifier {
List<SubscriptionDetails> _allSubscriptions = [];
List<SubscriptionDetails> _availableSubscriptions = [];

/// The `/subscription/status` snapshot fetched once at v2-web init and reused
/// for BOTH the initial state AND the trial-card decision in
/// `_setAvailableSubscriptions` (finding #3). Null on the RC/mobile path.
SubscriptionStatusV2? _statusV2;

Completer<void>? _initCompleter;

/// Single-flight guard for the buy path (finding #1): a purchase submit is
/// re-entrant-safe at the CONTROLLER level, so ANY caller (paywall or
/// settings) is protected from a double-tap firing concurrent checkouts —
/// without redesigning the paywall widget. Drives [isSubmitting] for the UI.
final SingleFlightGuard _submitGuard = SingleFlightGuard();

/// Whether a purchase submit is in flight. The buy button should stay
/// disabled while true (see the frontend contract's client-rules note).
bool get isSubmitting => _submitGuard.inFlight;

final ValueNotifier<bool> subscriptionNotifier = ValueNotifier<bool>(false);

SubscriptionController();
/// Test-only override of the platform-selected manager, injected ONCE at
/// construction into a `final` field so it cannot be mutated post-construction
/// (unlike a public settable field, this cannot change real submit/refresh
/// behavior from any production reference). Production constructs the
/// controller WITHOUT this param, so it is null and [_manager] resolves the
/// real web/mobile manager — byte-for-byte unchanged.
final SubscriptionInfoManager? _managerOverride;

SubscriptionController({
@visibleForTesting SubscriptionInfoManager? managerOverride,
}) : _managerOverride = managerOverride;

SubscriptionState get state => _state;

Expand Down Expand Up @@ -74,7 +107,12 @@ class SubscriptionController with ChangeNotifier {
};

bool get hasPromotionalSubscription => switch (_state) {
SubscriptionActive(subscriptionId: final id) => id.startsWith("rc_promo"),
// I11: on the v2 web path `isPromotional` is set (`!isV2PaidType(...)`, so
// paid/individual are NOT promotional); on the RC/mobile path it is null, so
// this falls back to today's exact `id.startsWith("rc_promo")` check —
// behavior unchanged off the flag.
SubscriptionActive(subscriptionId: final id, isPromotional: final promo) =>
promo ?? id.startsWith("rc_promo"),
_ => false,
};

Expand All @@ -83,12 +121,51 @@ class SubscriptionController with ChangeNotifier {
_ => null,
};

/// Whether a v2 trial can be STARTED (D3). Drives trial-card enablement on the
/// v2 web path in place of the RC-only `inTrialWindow`. False off the flag and
/// on mobile, so those paths keep using `inTrialWindow` unchanged.
bool get v2TrialOfferable =>
Environment.subsV2WebEnabled && kIsWeb && v2TrialOfferableFor(_statusV2);

SubscriptionDetails? get subscription {
final id = _subscriptionId;
if (id == null) return null;
return _allSubscriptions.firstWhereOrNull(
final resolved = _allSubscriptions.firstWhereOrNull(
(SubscriptionDetails sub) => sub.id.contains(id) || id.contains(sub.id),
);
if (resolved != null) return resolved;

// v2 (finding #4b): a PAID entitlement whose plan is not in the catalog
// (e.g. a grandfathered price with a null planId) still bills the user —
// render a generic tile with working management instead of an empty/broken
// one (which would also skip the account-delete warning). comp/seat/trial
// legitimately have no sellable plan and keep returning null here. Off the
// flag / on mobile this fallback is never reached, so behavior is unchanged.
final state = _state;
if (Environment.subsV2WebEnabled &&
kIsWeb &&
state is SubscriptionActive &&
state.isPromotional == false &&
!state.isTrial) {
return _genericV2PaidSubscription();
}
return null;
}

/// A generic tile for a paid v2 entitlement missing from the catalog (#4b):
/// `appId == stripeId` so management resolves, `duration == null` so the name
/// is the default-subscription copy, and a blank price (we do not know the
/// grandfathered amount) rather than the misleading "free trial" that a
/// `price <= 0` would render.
SubscriptionDetails _genericV2PaidSubscription() {
final stripeId = _appIds?.stripeId ?? kStripeAppIdFallback;
return SubscriptionDetails(
id: _subscriptionId ?? stripeId,
appId: stripeId,
price: 1,
localizedPrice: "",
duration: null,
);
}

String? get defaultManagementURL {
Expand All @@ -99,7 +176,8 @@ class SubscriptionController with ChangeNotifier {
}

SubscriptionInfoManager get _manager =>
kIsWeb ? WebSubscriptionInfoManager() : MobileSubscriptionInfoManager();
_managerOverride ??
(kIsWeb ? WebSubscriptionInfoManager() : MobileSubscriptionInfoManager());

void _onSubscribe() {
subscriptionNotifier.value = true;
Expand Down Expand Up @@ -140,10 +218,31 @@ class SubscriptionController with ChangeNotifier {
await configurePurchases(userID);

await _setAppIds();
await _setAvailableSubscriptions();
await updateCurrentSubscription();

if (_subscriptionId == null && inTrialWindow) {
if (Environment.subsV2WebEnabled && kIsWeb) {
// v2 web: compute state, catalog, and _statusV2 from ONE fresh /status
// snapshot (finding #3). A /products fetch failure throws here and is
// caught below into SubscriptionError (finding #2) rather than degrading
// to an empty catalog.
await _refreshV2State();
notifyListeners();
} else {
await _setAvailableSubscriptions();
await updateCurrentSubscription();
}

// Auto-activate a trial for a brand-new user. On the v2 web path this
// uses the SERVER signal (v2TrialOfferable) rather than the RC-only local
// `inTrialWindow`, so a server-eligible user outside the local window
// still gets their trial; off the flag / on mobile it stays on
// `inTrialWindow`, byte-for-byte. `_statusV2` is already fetched above on
// the v2 path, so `v2TrialOfferable` is populated here.
final bool trialOfferable = isTrialOfferable(
v2Path: Environment.subsV2WebEnabled && kIsWeb,
v2TrialOfferable: v2TrialOfferable,
inTrialWindow: inTrialWindow,
);
if (_subscriptionId == null && trialOfferable) {
await activateNewUserTrial();
}

Expand Down Expand Up @@ -180,6 +279,25 @@ class SubscriptionController with ChangeNotifier {
}

Future<void> _setAvailableSubscriptions() async {
if (Environment.subsV2WebEnabled && kIsWeb) {
// v2 web: build the catalog from /products + the /status snapshot. No
// appId/isVisible filter is needed (these are already the sellable web
// plans, all appId==stripeId), and the trial card is synthesized off the
// status snapshot (D3). Pure logic lives in buildV2SubscriptionCatalog.
// A /products FETCH failure THROWS out of here (finding #2) — the caller
// (_refreshV2State / _initialize) turns it into a retryable error state
// rather than a silently-empty catalog. A real empty 200 stays empty.
final productsResponse = await ProductsV2Repo.get();
final catalog = buildV2SubscriptionCatalog(
productsResponse.plans,
_statusV2,
stripeAppId: _appIds?.stripeId ?? kStripeAppIdFallback,
);
_allSubscriptions = catalog.all;
_availableSubscriptions = catalog.available;
return;
}

final allProductsResult = await AllProductsRepo.get();
final allProducts = allProductsResult.result;
if (allProducts != null) {
Expand Down Expand Up @@ -251,6 +369,12 @@ class SubscriptionController with ChangeNotifier {
SubscriptionDetails selectedSubscription,
BuildContext context,
) async {
// Single-flight at the controller (finding #1): a concurrent submit — e.g.
// a paywall double-tap — is a no-op, so no caller can fire two checkouts or
// two redirects. Real failures still propagate so the caller's
// showFutureLoadingDialog renders them (see the contract client-rules note).
if (!_submitGuard.tryEnter()) return;
notifyListeners(); // isSubmitting -> true
try {
if (selectedSubscription.isTrial) {
await activateNewUserTrial();
Expand All @@ -272,6 +396,9 @@ class SubscriptionController with ChangeNotifier {
);

rethrow;
} finally {
_submitGuard.exit();
notifyListeners(); // isSubmitting -> false
}
}

Expand All @@ -282,7 +409,72 @@ class SubscriptionController with ChangeNotifier {
}

Future<void> updateCurrentSubscription() async {
_state = await _manager.getCurrentSubscriptionInfo();
// v2 web: refresh through the single source of truth so _statusV2 (and the
// catalog / v2TrialOfferable) can never go stale after a status-changing
// action such as trial activation or cancel (finding #3). A fetch failure
// sets a retryable error state instead of leaving the caller with an
// unhandled throw (safe for the paywall's fire-and-forget trial tap).
if (Environment.subsV2WebEnabled && kIsWeb) {
try {
await _refreshV2State();
} catch (e, s) {
ErrorHandler.logError(e: e, s: s, data: {});
_state = SubscriptionError(error: e);
}
notifyListeners();
return;
}

// stripeAppId is web-v2 only; the mobile/RC manager ignores it.
_state = await _manager.getCurrentSubscriptionInfo(
stripeAppId: _appIds?.stripeId,
);
notifyListeners();
}

/// The single source of truth for the v2 web state: re-fetch `/status`, store
/// it in [_statusV2], rebuild the sellable catalog from that SAME snapshot,
/// and map the state — so [_statusV2], the catalog ([v2TrialOfferable]), and
/// [_state] can never drift (finding #3). Throws on a fetch failure (e.g. a
/// `/products` 503) so the caller sets an error state instead of an empty
/// sellable catalog (finding #2). Does NOT notify — the caller decides when.
Future<void> _refreshV2State() async {
_statusV2 = await StatusV2Repo.get();
final status = _statusV2!;
if (isPaidWithoutPlan(status)) {
// #4b: a paid entitlement should always map to a catalog plan; log the
// anomaly (the generic-tile fallback in `subscription` keeps management
// working) rather than stranding a paying user on a broken tile.
ErrorHandler.logError(
m: "v2 paid entitlement missing a catalog planId",
s: StackTrace.current,
data: {},
);
}
await _setAvailableSubscriptions();
_state = mapStatusV2ToState(
status,
stripeAppId: _appIds?.stripeId ?? kStripeAppIdFallback,
);
}

/// In-app v2 cancel (S4/D4): POST /cancel for the user-owned entitlement, then
/// refresh from /status so the UI reflects the server-confirmed
/// cancel-at-period-end. [entitlementRef] MUST come from `/status` (I5) — the
/// caller sources it from the active state's `entitlementRef`. Errors are
/// logged and rethrown (matching submitSubscriptionChange) so the UI can react.
Future<void> cancelSubscription(String entitlementRef) async {
try {
await CancelV2Repo.cancel(entitlementRef);
await updateCurrentSubscription();
notifyListeners();
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {"entitlementRef": entitlementRef},
);
rethrow;
}
}
Comment on lines 411 to +479

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

_refreshV2State() has no reentrancy guard, unlike every other new v2 mutation path.

updateCurrentSubscription() and initialize()/_initialize() both call _refreshV2State(), which mutates _statusV2, _allSubscriptions/_availableSubscriptions (via _setAvailableSubscriptions()), and _state across multiple await boundaries. Unlike submitSubscriptionChange, cancelSubscription's caller, and the billing-portal flow — all of which got a SingleFlightGuard — this path has no protection against concurrent invocation.

SettingsSubscriptionController.initState() (in settings_subscription.dart) triggers exactly this: it calls subscriptionController.initialize(...) via .then(...) without awaiting, then immediately calls subscriptionController.updateCurrentSubscription() synchronously after. Both can run _refreshV2State() concurrently, so the catalog built in one call can be paired with a _statusV2/_state snapshot from the other interleaved call — the exact "never drift" invariant the docstring on _refreshV2State claims to guarantee.

🔒 Suggested fix: collapse concurrent refreshes into one in-flight future (same pattern already used in `BillingPortalRepo._inflight`)
+  Future<void>? _refreshV2InFlight;
+
   Future<void> _refreshV2State() async {
+    final inflight = _refreshV2InFlight;
+    if (inflight != null) return inflight;
+    final future = _doRefreshV2State();
+    _refreshV2InFlight = future;
+    try {
+      await future;
+    } finally {
+      _refreshV2InFlight = null;
+    }
+  }
+
+  Future<void> _doRefreshV2State() async {
     _statusV2 = await StatusV2Repo.get();
     final status = _statusV2!;
     if (isPaidWithoutPlan(status)) {
       ErrorHandler.logError(
         m: "v2 paid entitlement missing a catalog planId",
         s: StackTrace.current,
         data: {},
       );
     }
     await _setAvailableSubscriptions();
     _state = mapStatusV2ToState(
       status,
       stripeAppId: _appIds?.stripeId ?? kStripeAppIdFallback,
     );
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Future<void> updateCurrentSubscription() async {
_state = await _manager.getCurrentSubscriptionInfo();
// v2 web: refresh through the single source of truth so _statusV2 (and the
// catalog / v2TrialOfferable) can never go stale after a status-changing
// action such as trial activation or cancel (finding #3). A fetch failure
// sets a retryable error state instead of leaving the caller with an
// unhandled throw (safe for the paywall's fire-and-forget trial tap).
if (Environment.subsV2WebEnabled && kIsWeb) {
try {
await _refreshV2State();
} catch (e, s) {
ErrorHandler.logError(e: e, s: s, data: {});
_state = SubscriptionError(error: e);
}
notifyListeners();
return;
}
// stripeAppId is web-v2 only; the mobile/RC manager ignores it.
_state = await _manager.getCurrentSubscriptionInfo(
stripeAppId: _appIds?.stripeId,
);
notifyListeners();
}
/// The single source of truth for the v2 web state: re-fetch `/status`, store
/// it in [_statusV2], rebuild the sellable catalog from that SAME snapshot,
/// and map the state — so [_statusV2], the catalog ([v2TrialOfferable]), and
/// [_state] can never drift (finding #3). Throws on a fetch failure (e.g. a
/// `/products` 503) so the caller sets an error state instead of an empty
/// sellable catalog (finding #2). Does NOT notify — the caller decides when.
Future<void> _refreshV2State() async {
_statusV2 = await StatusV2Repo.get();
final status = _statusV2!;
if (isPaidWithoutPlan(status)) {
// #4b: a paid entitlement should always map to a catalog plan; log the
// anomaly (the generic-tile fallback in `subscription` keeps management
// working) rather than stranding a paying user on a broken tile.
ErrorHandler.logError(
m: "v2 paid entitlement missing a catalog planId",
s: StackTrace.current,
data: {},
);
}
await _setAvailableSubscriptions();
_state = mapStatusV2ToState(
status,
stripeAppId: _appIds?.stripeId ?? kStripeAppIdFallback,
);
}
/// In-app v2 cancel (S4/D4): POST /cancel for the user-owned entitlement, then
/// refresh from /status so the UI reflects the server-confirmed
/// cancel-at-period-end. [entitlementRef] MUST come from `/status` (I5) — the
/// caller sources it from the active state's `entitlementRef`. Errors are
/// logged and rethrown (matching submitSubscriptionChange) so the UI can react.
Future<void> cancelSubscription(String entitlementRef) async {
try {
await CancelV2Repo.cancel(entitlementRef);
await updateCurrentSubscription();
notifyListeners();
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {"entitlementRef": entitlementRef},
);
rethrow;
}
}
Future<void>? _refreshV2InFlight;
Future<void> updateCurrentSubscription() async {
// v2 web: refresh through the single source of truth so _statusV2 (and the
// catalog / v2TrialOfferable) can never go stale after a status-changing
// action such as trial activation or cancel (finding `#3`). A fetch failure
// sets a retryable error state instead of leaving the caller with an
// unhandled throw (safe for the paywall's fire-and-forget trial tap).
if (Environment.subsV2WebEnabled && kIsWeb) {
try {
await _refreshV2State();
} catch (e, s) {
ErrorHandler.logError(e: e, s: s, data: {});
_state = SubscriptionError(error: e);
}
notifyListeners();
return;
}
// stripeAppId is web-v2 only; the mobile/RC manager ignores it.
_state = await _manager.getCurrentSubscriptionInfo(
stripeAppId: _appIds?.stripeId,
);
notifyListeners();
}
/// The single source of truth for the v2 web state: re-fetch `/status`, store
/// it in [_statusV2], rebuild the sellable catalog from that SAME snapshot,
/// and map the state — so [_statusV2], the catalog ([v2TrialOfferable]), and
/// [_state] can never drift (finding `#3`). Throws on a fetch failure (e.g. a
/// `/products` 503) so the caller sets an error state instead of an empty
/// sellable catalog (finding `#2`). Does NOT notify — the caller decides when.
Future<void> _refreshV2State() async {
final inflight = _refreshV2InFlight;
if (inflight != null) return inflight;
final future = _doRefreshV2State();
_refreshV2InFlight = future;
try {
await future;
} finally {
_refreshV2InFlight = null;
}
}
Future<void> _doRefreshV2State() async {
_statusV2 = await StatusV2Repo.get();
final status = _statusV2!;
if (isPaidWithoutPlan(status)) {
// `#4b`: a paid entitlement should always map to a catalog plan; log the
// anomaly (the generic-tile fallback in `subscription` keeps management
// working) rather than stranding a paying user on a broken tile.
ErrorHandler.logError(
m: "v2 paid entitlement missing a catalog planId",
s: StackTrace.current,
data: {},
);
}
await _setAvailableSubscriptions();
_state = mapStatusV2ToState(
status,
stripeAppId: _appIds?.stripeId ?? kStripeAppIdFallback,
);
}
/// In-app v2 cancel (S4/D4): POST /cancel for the user-owned entitlement, then
/// refresh from /status so the UI reflects the server-confirmed
/// cancel-at-period-end. [entitlementRef] MUST come from `/status` (I5) — the
/// caller sources it from the active state's `entitlementRef`. Errors are
/// logged and rethrown (matching submitSubscriptionChange) so the UI can react.
Future<void> cancelSubscription(String entitlementRef) async {
try {
await CancelV2Repo.cancel(entitlementRef);
await updateCurrentSubscription();
notifyListeners();
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {"entitlementRef": entitlementRef},
);
rethrow;
}
}
🤖 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/features/subscription/controllers/subscription_controller.dart` around
lines 411 - 479, Protect _refreshV2State with a single in-flight Future so
concurrent calls from updateCurrentSubscription and initialize/_initialize share
the same refresh operation instead of interleaving mutations. Add and reuse an
in-flight field around the full _statusV2, _setAvailableSubscriptions, and
_state update sequence, clearing it when the operation completes or fails while
preserving error propagation and existing caller notification behavior.

}
17 changes: 17 additions & 0 deletions lib/features/subscription/models/cancel_response.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/// Client model for the Subscriptions-v2 `/subscription/cancel` response
/// (choreo `CancelSubscriptionResponse`, status_v2_schema.py). The subscription
/// is set to cancel at the end of the current paid period; access continues
/// until then. The client refetches `/subscription/status` to observe
/// `cancel_at_period_end` once the webhook reflects it.
class CancelResponse {
/// Always "cancel_at_period_end" on success.
final String status;
final String entitlementRef;

const CancelResponse({required this.status, required this.entitlementRef});

factory CancelResponse.fromJson(Map<String, dynamic> json) => CancelResponse(
status: json['status'] as String? ?? "",
entitlementRef: json['entitlementRef'] as String? ?? "",
);
}
40 changes: 40 additions & 0 deletions lib/features/subscription/models/checkout_response.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/// Client model for the Subscriptions-v2 `/subscription/checkout` response
/// (choreo `CheckoutResponse`, checkout_v2_schema.py).
///
/// - `created` -> a fresh Stripe Checkout session (200 + sessionUrl)
/// - `reused` -> the caller's already-open session (200 + sessionUrl); the
/// anti-double-tap path, never a second session
/// - `creating` -> another worker is mid-creation (202); poll after
/// [retryAfterSeconds]
class CheckoutResponse {
final String status;
final String? sessionUrl;
final int? retryAfterSeconds;

/// The promo code ACTUALLY on the returned session (the STORED code the
/// server echoes back), or null when no discount is applied. On a `reused`
/// open session the request's `promoCode` is IGNORED, so this — not the
/// requested code — is the discount state the UI should reflect.
final String? appliedPromoCode;

const CheckoutResponse({
required this.status,
this.sessionUrl,
this.retryAfterSeconds,
this.appliedPromoCode,
});

/// A terminal, session-bearing response (`created` or `reused`).
bool get isResolved => status == "created" || status == "reused";

/// The in-progress state that drives the bounded poll (I3).
bool get isCreating => status == "creating";

factory CheckoutResponse.fromJson(Map<String, dynamic> json) =>
CheckoutResponse(
status: json['status'] as String,
sessionUrl: json['sessionUrl'] as String?,
retryAfterSeconds: (json['retryAfterSeconds'] as num?)?.toInt(),
appliedPromoCode: json['appliedPromoCode'] as String?,
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import 'package:fluffychat/pangea/common/utils/error_handler.dart';

class MobileSubscriptionInfoManager implements SubscriptionInfoManager {
@override
Future<SubscriptionState> getCurrentSubscriptionInfo() async {
Future<SubscriptionState> getCurrentSubscriptionInfo({
String? stripeAppId,
}) async {
// [stripeAppId] is web-v2 only; the RC SDK path ignores it (behavior
// unchanged).
try {
await Purchases.invalidateCustomerInfoCache();
final info = await Purchases.getCustomerInfo();
Expand Down
Loading
Loading