diff --git a/lib/features/subscription/controllers/subscription_controller.dart b/lib/features/subscription/controllers/subscription_controller.dart index 98405eb279..3e211e09c0 100644 --- a/lib/features/subscription/controllers/subscription_controller.dart +++ b/lib/features/subscription/controllers/subscription_controller.dart @@ -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'; @@ -32,11 +40,36 @@ class SubscriptionController with ChangeNotifier { List _allSubscriptions = []; List _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? _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 subscriptionNotifier = ValueNotifier(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; @@ -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, }; @@ -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 { @@ -99,7 +176,8 @@ class SubscriptionController with ChangeNotifier { } SubscriptionInfoManager get _manager => - kIsWeb ? WebSubscriptionInfoManager() : MobileSubscriptionInfoManager(); + _managerOverride ?? + (kIsWeb ? WebSubscriptionInfoManager() : MobileSubscriptionInfoManager()); void _onSubscribe() { subscriptionNotifier.value = true; @@ -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(); } @@ -180,6 +279,25 @@ class SubscriptionController with ChangeNotifier { } Future _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) { @@ -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(); @@ -272,6 +396,9 @@ class SubscriptionController with ChangeNotifier { ); rethrow; + } finally { + _submitGuard.exit(); + notifyListeners(); // isSubmitting -> false } } @@ -282,7 +409,72 @@ class SubscriptionController with ChangeNotifier { } Future 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 _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 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; + } + } } diff --git a/lib/features/subscription/models/cancel_response.dart b/lib/features/subscription/models/cancel_response.dart new file mode 100644 index 0000000000..425e0156e0 --- /dev/null +++ b/lib/features/subscription/models/cancel_response.dart @@ -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 json) => CancelResponse( + status: json['status'] as String? ?? "", + entitlementRef: json['entitlementRef'] as String? ?? "", + ); +} diff --git a/lib/features/subscription/models/checkout_response.dart b/lib/features/subscription/models/checkout_response.dart new file mode 100644 index 0000000000..1235744164 --- /dev/null +++ b/lib/features/subscription/models/checkout_response.dart @@ -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 json) => + CheckoutResponse( + status: json['status'] as String, + sessionUrl: json['sessionUrl'] as String?, + retryAfterSeconds: (json['retryAfterSeconds'] as num?)?.toInt(), + appliedPromoCode: json['appliedPromoCode'] as String?, + ); +} diff --git a/lib/features/subscription/models/mobile_subscription_info_manager.dart b/lib/features/subscription/models/mobile_subscription_info_manager.dart index a04153a1e1..5ddb5f092a 100644 --- a/lib/features/subscription/models/mobile_subscription_info_manager.dart +++ b/lib/features/subscription/models/mobile_subscription_info_manager.dart @@ -8,7 +8,11 @@ import 'package:fluffychat/pangea/common/utils/error_handler.dart'; class MobileSubscriptionInfoManager implements SubscriptionInfoManager { @override - Future getCurrentSubscriptionInfo() async { + Future 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(); diff --git a/lib/features/subscription/models/products_v2_response.dart b/lib/features/subscription/models/products_v2_response.dart new file mode 100644 index 0000000000..e62fb3d06f --- /dev/null +++ b/lib/features/subscription/models/products_v2_response.dart @@ -0,0 +1,120 @@ +import 'package:fluffychat/features/subscription/models/subscription_details.dart'; +import 'package:fluffychat/features/subscription/utils/subscription_duration_enum.dart'; + +/// A single plan from the Subscriptions-v2 `/subscription/products` storefront +/// (choreo `ProductPlanV2`). `amount` is the SERVER-OWNED base price in the +/// currency's minor units (999 == 9.99); `currency` is the lowercase ISO code +/// (Stripe convention). The Stripe `priceId` is deliberately never exposed — +/// the client subscribes by [planId]. +class ProductV2 { + final String planId; + final int amount; + final String currency; + final String interval; + final int intervalCount; + + const ProductV2({ + required this.planId, + required this.amount, + required this.currency, + required this.interval, + this.intervalCount = 1, + }); + + factory ProductV2.fromJson(Map json) => ProductV2( + planId: json['planId'] as String, + amount: (json['amount'] as num).toInt(), + currency: json['currency'] as String, + interval: json['interval'] as String, + intervalCount: (json['interval_count'] as num?)?.toInt() ?? 1, + ); +} + +/// The `/subscription/products` response envelope (choreo `ProductsV2Response`). +class ProductsV2Response { + final List plans; + + /// The displayed `amount`/`currency` are the server-owned BASE price; Stripe + /// Adaptive Pricing localizes the buyer's actual charge at checkout. This + /// flag (default true) tells the client the shown price is the base. + final bool pricesLocalizedAtCheckout; + + /// Display-only echo of the `X-Storefront-Country` hint; never selects a + /// price. + final String? country; + + const ProductsV2Response({ + required this.plans, + this.pricesLocalizedAtCheckout = true, + this.country, + }); + + factory ProductsV2Response.fromJson(Map json) { + // A REAL empty catalog is `{"plans": []}`; a body with no `plans` LIST + // (absent / null / non-list) is contract-malformed and must THROW so the + // repo's getWith propagates it as a fetch failure — NEVER default it into + // an empty catalog that would render "no plans" and strand a buyer. + final rawPlans = json['plans']; + if (rawPlans is! List) { + throw const FormatException( + 'ProductsV2Response: "plans" must be a list — malformed /products body', + ); + } + return ProductsV2Response( + plans: rawPlans + .map((e) => ProductV2.fromJson(Map.from(e as Map))) + .toList(), + pricesLocalizedAtCheckout: + json['prices_localized_at_checkout'] as bool? ?? true, + country: json['country'] as String?, + ); + } +} + +/// Explicit `planId -> duration` whitelist (I4). Unknown ids THROW rather than +/// silently degrade, so a new/renamed backend plan can never map to a null +/// duration that would strand the web checkout (which needs a duration to +/// resolve a planId). +const Map kPlanIdToDuration = + { + "month": SubscriptionDuration.month, + "year": SubscriptionDuration.year, + }; + +/// Thrown when a v2 plan id is outside the [kPlanIdToDuration] whitelist (I4). +class UnknownPlanIdException implements Exception { + final String planId; + const UnknownPlanIdException(this.planId); + + @override + String toString() => + 'UnknownPlanIdException: "$planId" is not a known v2 plan id ' + '(expected one of ${kPlanIdToDuration.keys.toList()})'; +} + +/// Pure mapper: a v2 [ProductV2] -> the existing [SubscriptionDetails] shape so +/// a Stripe web plan renders through the same RC-coupled getters (D7). +/// +/// - `id = planId` (so the `subscription` getter resolves by id containment) +/// - `price = amount / 100.0` +/// - `currency` carried through for currency-aware display (D5) +/// - `duration` via the [kPlanIdToDuration] whitelist; unknown id THROWS (I4) +/// - `appId = stripeAppId` (so management getters treat it as a web purchase) +/// - `isVisible = true` +SubscriptionDetails productV2ToSubscriptionDetails( + ProductV2 product, { + required String stripeAppId, +}) { + final duration = kPlanIdToDuration[product.planId]; + if (duration == null) { + throw UnknownPlanIdException(product.planId); + } + return SubscriptionDetails( + id: product.planId, + price: product.amount / 100.0, + currency: product.currency, + duration: duration, + appId: stripeAppId, + isVisible: true, + ); +} diff --git a/lib/features/subscription/models/subscription_details.dart b/lib/features/subscription/models/subscription_details.dart index 011e661b73..52bd0e1eb1 100644 --- a/lib/features/subscription/models/subscription_details.dart +++ b/lib/features/subscription/models/subscription_details.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; +import 'package:intl/intl.dart'; import 'package:purchases_flutter/purchases_flutter.dart'; import 'package:fluffychat/features/subscription/utils/subscription_duration_enum.dart'; @@ -15,6 +16,10 @@ class SubscriptionDetails { final Package? package; final String? localizedPrice; + /// ISO currency code (lowercase, e.g. "usd") for the v2 web path (D5). Null on + /// the mobile/RC path, where display stays on `localizedPrice`/`$`. + final String? currency; + SubscriptionDetails({ required this.price, required this.id, @@ -23,13 +28,33 @@ class SubscriptionDetails { this.appId, this.isVisible = true, this.localizedPrice, + this.currency, }); bool get isTrial => appId == "trial"; - String displayPrice(BuildContext context) => isTrial || price <= 0 - ? L10n.of(context).freeTrial - : localizedPrice ?? "\$${price.toStringAsFixed(2)}"; + /// Context-free price string for a non-trial, priced product. Kept separate + /// from [displayPrice] so the currency logic is unit-testable without a + /// BuildContext/L10n. Precedence: an RC store-localized string wins; then a + /// currency-aware format (v2 web, D5); then today's plain `$` fallback. + /// + /// When [currency] is null (mobile/RC path) this collapses to the exact + /// prior expression `localizedPrice ?? "$price"`, so mobile output is + /// byte-for-byte unchanged. + String formattedPrice() { + final localized = localizedPrice; + if (localized != null) return localized; + final currencyCode = currency; + if (currencyCode != null) { + return NumberFormat.simpleCurrency( + name: currencyCode.toUpperCase(), + ).format(price); + } + return "\$${price.toStringAsFixed(2)}"; + } + + String displayPrice(BuildContext context) => + isTrial || price <= 0 ? L10n.of(context).freeTrial : formattedPrice(); String displayName(BuildContext context) { if (isTrial) { @@ -53,6 +78,7 @@ class SubscriptionDetails { bool? isVisible, Package? package, String? localizedPrice, + String? currency, }) => SubscriptionDetails( price: price ?? this.price, duration: duration ?? this.duration, @@ -61,6 +87,7 @@ class SubscriptionDetails { isVisible: isVisible ?? this.isVisible, package: package ?? this.package, localizedPrice: localizedPrice ?? this.localizedPrice, + currency: currency ?? this.currency, ); Map toJson() { @@ -70,6 +97,12 @@ class SubscriptionDetails { data['duration'] = duration?.name; data['appId'] = appId; data['is_visible'] = isVisible; + // Omit `currency` entirely when null so the mobile/RC serialized JSON is + // byte-for-byte identical to today; only the v2 web path (currency != null) + // adds the key. + if (currency != null) { + data['currency'] = currency; + } return data; } @@ -82,6 +115,7 @@ class SubscriptionDetails { id: json['id'], appId: json['appId'], isVisible: json['is_visible'] ?? true, + currency: json['currency'], ); } } diff --git a/lib/features/subscription/models/subscription_info_manager.dart b/lib/features/subscription/models/subscription_info_manager.dart index 5014ce86ea..7fd0bc8221 100644 --- a/lib/features/subscription/models/subscription_info_manager.dart +++ b/lib/features/subscription/models/subscription_info_manager.dart @@ -2,7 +2,10 @@ import 'package:fluffychat/features/subscription/models/subscription_details.dar import 'package:fluffychat/features/subscription/models/subscription_state.dart'; abstract class SubscriptionInfoManager { - Future getCurrentSubscriptionInfo(); + /// [stripeAppId] is the resolved `/subscription_app_ids` `stripeId`, threaded + /// through only for the web v2 status mapper (D7). Additive + defaulted: the + /// mobile/RC implementation ignores it, so its behavior is unchanged. + Future getCurrentSubscriptionInfo({String? stripeAppId}); Future submitSubscriptionChange(SubscriptionDetails subscription); } diff --git a/lib/features/subscription/models/subscription_state.dart b/lib/features/subscription/models/subscription_state.dart index 445713542f..1d1bb4a200 100644 --- a/lib/features/subscription/models/subscription_state.dart +++ b/lib/features/subscription/models/subscription_state.dart @@ -7,10 +7,42 @@ class SubscriptionActive extends SubscriptionState { final DateTime? expirationDate; final DateTime? unsubscribeDetectedAt; + /// Subscriptions-v2 additive fields (I6). All null/false on the mobile/RC + /// path, which never sets them — so mobile behavior is unchanged. They are + /// populated only by `mapStatusV2ToState` on the web v2 path. + + /// The `/status` entitlement ref to pass to `/cancel`; null unless a + /// user-owned, cancelable entitlement was selected (I5/I10). NEVER a Stripe + /// id. + final String? entitlementRef; + + /// Whether an in-app cancel is available for the selected entitlement. + final bool? cancelable; + + /// Whether the winning subscription is already set to end at the period end + /// (mirrors the winning summary's `cancel_at_period_end`). + final bool? cancelAtPeriodEnd; + + /// v2 promo signal: `!isV2PaidType(winning.type)` (I11) — true for granted + /// (seat/comp) or trial access, false for billable paid/individual. Nullable + /// so the RC-path getter can fall back to `id.startsWith("rc_promo")` when + /// this is null. + final bool? isPromotional; + + /// v2 trial signal: `winning.type == "trial"`. Lets the wiring layer render + /// the trial tile (`appId == "trial"`, no cancel) and distinguish a trial + /// from a comp/seat (both of which are also promotional). False on mobile/RC. + final bool isTrial; + SubscriptionActive({ required this.subscriptionId, this.expirationDate, this.unsubscribeDetectedAt, + this.entitlementRef, + this.cancelable, + this.cancelAtPeriodEnd, + this.isPromotional, + this.isTrial = false, }); } diff --git a/lib/features/subscription/models/subscription_status_v2.dart b/lib/features/subscription/models/subscription_status_v2.dart new file mode 100644 index 0000000000..ea0a9dea27 --- /dev/null +++ b/lib/features/subscription/models/subscription_status_v2.dart @@ -0,0 +1,343 @@ +import 'package:fluffychat/features/subscription/models/subscription_state.dart'; +import 'package:fluffychat/features/subscription/subscription_constants.dart'; + +/// The v2 winning `type` values that represent a BILLABLE, user-paid +/// subscription — as opposed to a granted one (`seat`/`comp`) or a `trial`. +/// `individual` is the RC-era paid type; treating only `"paid"` as billable +/// would misclassify an individual-plan payer as promotional, skipping their +/// account-delete warning and paid tile. Centralized so every paid/promotional +/// decision agrees. Lives here (with the mapper) rather than in `v2_ui_gating` +/// to avoid a circular import. +const Set kV2PaidTypes = {"paid", "individual"}; + +/// Whether a v2 winning `type` is a billable, user-paid subscription (see +/// [kV2PaidTypes]). +bool isV2PaidType(String? type) => type != null && kV2PaidTypes.contains(type); + +/// Client model for the Subscriptions-v2 `/subscription/status` response +/// (choreo `SubscriptionStatusV2Response`, status_v2_schema.py). The client +/// ALWAYS consumes this shape — never raw RC JSON — in both the RC (pre-flip) +/// and CMS (post-flip) phases; the source is transparent behind +/// [entitlementSource]. JSON keys mirror the Pydantic field names verbatim +/// (a mix of snake_case and camelCase), so they are read exactly as the server +/// emits them. All dates parse via `DateTime.tryParse` and tolerate nulls. +class SubscriptionStatusV2 { + /// "full" | "none". + final String accessLevel; + + /// "rc" | "cms". + final String entitlementSource; + + final WinningSummaryV2? winning; + final BillingIssueV2? billingIssue; + final List entitlements; + final bool manageEligible; + final bool trialEligible; + final bool trialClaimed; + final DateTime? trialEndsAt; + + const SubscriptionStatusV2({ + required this.accessLevel, + required this.entitlementSource, + this.winning, + this.billingIssue, + this.entitlements = const [], + this.manageEligible = false, + this.trialEligible = false, + this.trialClaimed = false, + this.trialEndsAt, + }); + + factory SubscriptionStatusV2.fromJson(Map json) { + final rawEntitlements = + (json['entitlements'] as List?) ?? const []; + return SubscriptionStatusV2( + accessLevel: json['access_level'] as String? ?? "none", + entitlementSource: json['entitlement_source'] as String? ?? "cms", + winning: json['winning'] == null + ? null + : WinningSummaryV2.fromJson( + Map.from(json['winning'] as Map), + ), + billingIssue: json['billing_issue'] == null + ? null + : BillingIssueV2.fromJson( + Map.from(json['billing_issue'] as Map), + ), + entitlements: rawEntitlements + .map( + (e) => EntitlementV2.fromJson(Map.from(e as Map)), + ) + .toList(), + manageEligible: json['manage_eligible'] as bool? ?? false, + trialEligible: json['trial_eligible'] as bool? ?? false, + trialClaimed: json['trial_claimed'] as bool? ?? false, + trialEndsAt: _tryParseDate(json['trial_ends_at']), + ); + } +} + +/// The single entitlement that defines the user's access (display precedence). +class WinningSummaryV2 { + /// paid | seat | comp | trial | individual (RC always emits "paid"). + final String type; + final String status; + final DateTime? endsAt; + final DateTime? paidThroughAt; + final DateTime? graceEndsAt; + final bool cancelAtPeriodEnd; + final String? provider; + + /// The catalog plan id ("month" | "year"), added to the backend by S-BE + /// (reverse-mapped from the entitlement's stored priceId). Null on the RC + /// path and for entitlements with no known plan. + final String? planId; + + /// The owner-scoped Stripe subscription id, when the backend exposes it on + /// the winning summary. Kept nullable so the cancel entitlement selection can + /// disambiguate a matching row when [entitlementRef] is absent (I10). + final String? sourceSubscriptionId; + + /// The ref of the entitlement that IS the winning one, added to the backend + /// by S-BE. When present it identifies THE cancel target unambiguously — + /// preferred over scanning the entitlements list (I10). Null on older + /// backends / the RC path. + final String? entitlementRef; + + const WinningSummaryV2({ + required this.type, + required this.status, + this.endsAt, + this.paidThroughAt, + this.graceEndsAt, + this.cancelAtPeriodEnd = false, + this.provider, + this.planId, + this.sourceSubscriptionId, + this.entitlementRef, + }); + + factory WinningSummaryV2.fromJson(Map json) => + WinningSummaryV2( + type: json['type'] as String? ?? "", + status: json['status'] as String? ?? "", + endsAt: _tryParseDate(json['ends_at']), + paidThroughAt: _tryParseDate(json['paid_through_at']), + graceEndsAt: _tryParseDate(json['grace_ends_at']), + cancelAtPeriodEnd: json['cancel_at_period_end'] as bool? ?? false, + provider: json['provider'] as String?, + planId: json['planId'] as String? ?? json['plan_id'] as String?, + sourceSubscriptionId: json['sourceSubscriptionId'] as String?, + entitlementRef: + json['entitlementRef'] as String? ?? + json['entitlement_ref'] as String?, + ); +} + +class BillingIssueV2 { + final bool present; + final String? reason; + final ActionDescriptorV2? action; + + const BillingIssueV2({required this.present, this.reason, this.action}); + + factory BillingIssueV2.fromJson(Map json) => BillingIssueV2( + present: json['present'] as bool? ?? false, + reason: json['reason'] as String?, + action: json['action'] == null + ? null + : ActionDescriptorV2.fromJson( + Map.from(json['action'] as Map), + ), + ); +} + +/// A first-party action descriptor — resolved to a live provider URL only by +/// the dedicated portal endpoint, NEVER a live URL here. +class ActionDescriptorV2 { + /// "portal" | "update_payment". + final String kind; + final String entitlementRef; + + const ActionDescriptorV2({required this.kind, required this.entitlementRef}); + + factory ActionDescriptorV2.fromJson(Map json) => + ActionDescriptorV2( + kind: json['kind'] as String? ?? "", + entitlementRef: json['entitlementRef'] as String? ?? "", + ); +} + +class EntitlementV2 { + final String entitlementRef; + final String type; + final String? provider; + final String? sourceSubscriptionId; + final bool cancelable; + + /// Whether THIS entitlement is already set to cancel at period end. The + /// backend does not currently emit a per-entitlement flag (only the winning + /// summary carries `cancel_at_period_end`), so this defaults to false and the + /// effective guard is the winning value carried on the state. Kept for I10's + /// selection predicate and forward-compatibility if the backend adds it. + final bool cancelAtPeriodEnd; + + final String status; + final DateTime? endsAt; + final ActionDescriptorV2? manageAction; + final String? planId; + + const EntitlementV2({ + required this.entitlementRef, + required this.type, + this.provider, + this.sourceSubscriptionId, + this.cancelable = false, + this.cancelAtPeriodEnd = false, + required this.status, + this.endsAt, + this.manageAction, + this.planId, + }); + + factory EntitlementV2.fromJson(Map json) => EntitlementV2( + entitlementRef: json['entitlementRef'] as String? ?? "", + type: json['type'] as String? ?? "", + provider: json['provider'] as String?, + sourceSubscriptionId: json['sourceSubscriptionId'] as String?, + cancelable: json['cancelable'] as bool? ?? false, + cancelAtPeriodEnd: json['cancel_at_period_end'] as bool? ?? false, + status: json['status'] as String? ?? "", + endsAt: _tryParseDate(json['ends_at']), + manageAction: json['manage_action'] == null + ? null + : ActionDescriptorV2.fromJson( + Map.from(json['manage_action'] as Map), + ), + planId: json['planId'] as String? ?? json['plan_id'] as String?, + ); +} + +DateTime? _tryParseDate(dynamic value) { + if (value is! String || value.isEmpty) return null; + return DateTime.tryParse(value); +} + +/// The selected cancel entitlement for a winning subscription — its ref and +/// whether an in-app cancel is offered (I10). Internal to the mapper. +class _CancelSelection { + final String? entitlementRef; + final bool cancelable; + const _CancelSelection(this.entitlementRef, this.cancelable); + + static const _CancelSelection none = _CancelSelection(null, false); +} + +/// PURE adapter: a v2 `/status` payload -> the existing [SubscriptionState] +/// shape (D7/I7/I10/I11). Deterministic, no I/O, no clock. +/// +/// - `access_level == "none"` OR no winning -> [SubscriptionInactive]. +/// - `access_level == "full"` with a winning -> [SubscriptionActive] where: +/// - `subscriptionId`: for an ACTIVE TRIAL (`winning.type == "trial"`) this is +/// the [kV2TrialId] sentinel so the controller `subscription` getter +/// resolves the synthesized trial tile (which the wiring layer keys off to +/// render trial copy). Otherwise `winning.planId ?? stripeAppId` (stable +/// non-null id; the stripeAppId fallback resolves no product tile, which is +/// correct for a comp/seat that has no catalog plan). +/// - `expirationDate = winning.endsAt`. +/// - `unsubscribeDetectedAt = winning.cancelAtPeriodEnd ? winning.endsAt : null` +/// (so the RC-coupled `subscriptionEndDate` getter keeps working, D7/#6). +/// - `cancelAtPeriodEnd = winning.cancelAtPeriodEnd`. +/// - `isPromotional = !isV2PaidType(winning.type)` (I11) — paid/individual +/// are billable; seat/comp/trial are promotional. +/// - `isTrial = winning.type == "trial"` (D7 trial clause). +/// - the cancel `entitlementRef`/`cancelable` from [_selectCancelEntitlement] +/// (I10) — never a Stripe id. +SubscriptionState mapStatusV2ToState( + SubscriptionStatusV2 status, { + required String stripeAppId, +}) { + final winning = status.winning; + if (status.accessLevel != "full" || winning == null) { + return SubscriptionInactive(); + } + + final cancel = _selectCancelEntitlement(status.entitlements, winning); + final bool isTrial = winning.type == "trial"; + + return SubscriptionActive( + subscriptionId: isTrial ? kV2TrialId : (winning.planId ?? stripeAppId), + expirationDate: winning.endsAt, + unsubscribeDetectedAt: winning.cancelAtPeriodEnd ? winning.endsAt : null, + cancelAtPeriodEnd: winning.cancelAtPeriodEnd, + isPromotional: !isV2PaidType(winning.type), + isTrial: isTrial, + entitlementRef: cancel.entitlementRef, + cancelable: cancel.cancelable, + ); +} + +/// Deterministic, safe cancel-entitlement selection (I10). Correctness beats +/// offering a cancel button: it must NEVER target the wrong subscription. +/// +/// 1. When the backend names the winning entitlement (`winning.entitlementRef`), +/// that ref IS the cancel target — subject to its row being user-cancelable +/// and the winning not already set to cancel at period end. If the named row +/// is absent or not cancelable, offer NO cancel. +/// 2. Otherwise (older backend / RC path) scan the entitlements for rows that +/// are `cancelable && !cancelAtPeriodEnd && entitlementRef != ""`: +/// - exactly one -> that row; +/// - more than one -> disambiguate ONLY by a `sourceSubscriptionId` that +/// matches the winning's and is unique; if there is no such discriminator, +/// offer NO cancel rather than guess (defensive: never cancel the wrong +/// sub); +/// - none -> no cancel. +_CancelSelection _selectCancelEntitlement( + List entitlements, + WinningSummaryV2 winning, +) { + // The winning subscription is already ending; there is nothing to cancel. + if (winning.cancelAtPeriodEnd) return _CancelSelection.none; + + bool isCancelable(EntitlementV2 e) => + e.cancelable && !e.cancelAtPeriodEnd && e.entitlementRef.isNotEmpty; + + // 1. Preferred: the backend tells us exactly which entitlement is winning. + final winningRef = winning.entitlementRef; + if (winningRef != null && winningRef.isNotEmpty) { + final row = _firstOrNull( + entitlements.where((e) => e.entitlementRef == winningRef), + ); + if (row != null && isCancelable(row)) { + return _CancelSelection(row.entitlementRef, true); + } + // Named winner is absent or not user-cancelable -> no cancel affordance. + return _CancelSelection.none; + } + + // 2. Fallback scan for backends that do not name the winner. + final candidates = entitlements.where(isCancelable).toList(); + if (candidates.isEmpty) return _CancelSelection.none; + if (candidates.length == 1) { + return _CancelSelection(candidates.single.entitlementRef, true); + } + + // Ambiguous: more than one cancelable row. The only trustworthy discriminator + // is a unique sourceSubscriptionId match to the winning's; anything else would + // be a guess. + final winningSourceId = winning.sourceSubscriptionId; + if (winningSourceId != null && winningSourceId.isNotEmpty) { + final matched = candidates + .where((e) => e.sourceSubscriptionId == winningSourceId) + .toList(); + if (matched.length == 1) { + return _CancelSelection(matched.single.entitlementRef, true); + } + } + return _CancelSelection.none; +} + +EntitlementV2? _firstOrNull(Iterable items) { + final it = items.iterator; + return it.moveNext() ? it.current : null; +} diff --git a/lib/features/subscription/models/web_subscription_info_manager.dart b/lib/features/subscription/models/web_subscription_info_manager.dart index 2836d4ad79..e8494b035d 100644 --- a/lib/features/subscription/models/web_subscription_info_manager.dart +++ b/lib/features/subscription/models/web_subscription_info_manager.dart @@ -1,18 +1,56 @@ +import 'package:flutter/foundation.dart'; + import 'package:url_launcher/url_launcher_string.dart'; +import 'package:fluffychat/features/subscription/models/products_v2_response.dart'; 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/repo/checkout_v2_repo.dart'; import 'package:fluffychat/features/subscription/repo/payment_link_repo.dart'; import 'package:fluffychat/features/subscription/repo/payment_link_request.dart'; +import 'package:fluffychat/features/subscription/repo/status_v2_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/v2_ui_gating.dart'; +import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; class WebSubscriptionInfoManager implements SubscriptionInfoManager { @override - Future getCurrentSubscriptionInfo() async { + Future getCurrentSubscriptionInfo({ + String? stripeAppId, + }) async { + // v2 web path (flag-on): fetch /status and adapt into the existing state + // shape (D7/I7). Keeps the same SubscriptionError-on-failure contract as + // the RC branch below. + if (Environment.subsV2WebEnabled && kIsWeb) { + try { + final status = await StatusV2Repo.get(); + if (isPaidWithoutPlan(status)) { + // #4b: a paid entitlement should always map to a catalog plan. If it + // does not, log it (this shouldn't happen) — the controller renders a + // generic tile so the paying user still sees management + the + // account-delete warning, rather than a broken/empty tile. + ErrorHandler.logError( + m: "v2 paid entitlement missing a catalog planId", + s: StackTrace.current, + data: {}, + ); + } + return mapStatusV2ToState( + status, + stripeAppId: stripeAppId ?? kStripeAppIdFallback, + ); + } catch (e, s) { + ErrorHandler.logError(e: e, s: s, data: {}); + return SubscriptionError(error: e); + } + } + try { final res = await SubscriptionRepo.getCurrentSubscriptionInfo(null); final subscriptionId = res.currentSubscriptionId; @@ -57,6 +95,37 @@ class WebSubscriptionInfoManager implements SubscriptionInfoManager { ); return; } + + // v2 web path (flag-on): resolve a planId from the duration (I4 whitelist, + // throws on unknown), run /checkout (which POSTs `{planId}` and resolves the + // session URL via the bounded poll, I1/I3), mark the pending web payment, + // then redirect. The client-side prefilled_email append is dropped (I2 — + // v2 prefills server-side). + if (Environment.subsV2WebEnabled && kIsWeb) { + final planId = kPlanIdToDuration.entries + .firstWhere( + (entry) => entry.value == duration, + orElse: () => throw UnknownPlanIdException(duration.name), + ) + .key; + + // The repo now returns the resolved CheckoutResponse (sessionUrl + + // appliedPromoCode). A discount code, when Gabby's UI collects one, is + // passed via `promoCode:`; appliedPromoCode reflects what actually landed + // on the session (an open session ignores a late code). checkoutWith + // guarantees a non-null sessionUrl on a resolved response — the guard is + // defensive. + final checkout = await CheckoutV2Repo.checkout(planId); + final sessionUrl = checkout.sessionUrl; + if (sessionUrl == null) { + throw const CheckoutException("Checkout returned no session URL"); + } + + await SubscriptionManagementRepo.setBeganWebPayment(); + launchUrlString(sessionUrl, webOnlyWindowName: "_self"); + return; + } + final request = PaymentLinkRequest(duration: duration, isPromo: false); final result = await PaymentLinkRepo.get(request); diff --git a/lib/features/subscription/repo/billing_portal_repo.dart b/lib/features/subscription/repo/billing_portal_repo.dart index ee1fb8aca5..7a314d238e 100644 --- a/lib/features/subscription/repo/billing_portal_repo.dart +++ b/lib/features/subscription/repo/billing_portal_repo.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; + import 'package:async/async.dart'; import 'package:http/http.dart'; @@ -9,60 +11,84 @@ import 'package:fluffychat/pangea/common/network/urls.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/widgets/matrix.dart'; -class BillingPortalCacheEntry { - final Future> future; - final DateTime timestamp; - - const BillingPortalCacheEntry({ - required this.future, - required this.timestamp, - }); +/// Returned (as the `Result.error`) when `/subscription/billing_portal` answers +/// 404 `{"detail": "No billing account"}` — the user has no canonical Stripe +/// customer (e.g. never purchased on web). Typed so the UI can HIDE the manage +/// entry rather than surfacing a transient-error retry. +class NoBillingAccountException implements Exception { + const NoBillingAccountException(); - static const Duration _cacheDuration = Duration(minutes: 10); - - bool get isExpired => - timestamp.isBefore(DateTime.now().subtract(_cacheDuration)); + @override + String toString() => "NoBillingAccountException"; } +/// Mints a short-lived Stripe billing-portal URL for the user's CANONICAL +/// customer (choreo `BillingPortalSessionResponse`, field `url`). +/// +/// Portal session URLs are SHORT-LIVED by design: mint on click, open +/// immediately, and NEVER cache the minted URL — every call performs a fresh +/// request. The only sharing is in-flight dedupe (two overlapping taps ride +/// the same request), and the in-flight entry is evicted as soon as the +/// request completes, success OR failure — a transient error is never cached, +/// so the next tap retries cleanly. A user with no canonical customer -> +/// [NoBillingAccountException] (hide the manage entry). class BillingPortalRepo { - static final Map _cache = {}; + static final Map>> _inflight = + {}; - static Future> get(String userID) async { - final cached = _cache[userID]; - if (cached != null) { - if (cached.isExpired) { - _cache.remove(userID); - } else { - return cached.future; - } - } - - final future = _fetch(); - _cache[userID] = BillingPortalCacheEntry( - future: future, - timestamp: DateTime.now(), + static Future> get(String userID) { + final Requests req = Requests( + accessToken: MatrixState.pangeaController.userController.accessToken, ); - final result = await future; - return result; + return getWith(req, dedupeKey: userID); } - static Future> _fetch() async { - try { - final Requests req = Requests( - accessToken: MatrixState.pangeaController.userController.accessToken, - ); - final Response res = await req.get(url: PApiUrls.billingPortal); + /// The transport core, decoupled from `MatrixState` so the dedupe/no-cache + /// behavior and the typed-404 path are unit-testable with an injected + /// [Requests] (MockClient). + @visibleForTesting + static Future> getWith( + Requests req, { + String? url, + String dedupeKey = "portal", + }) { + final inflight = _inflight[dedupeKey]; + if (inflight != null) return inflight; - if (res.statusCode != 200) { - throw res; - } + // _fetch never throws (failures come back as Result.error), so + // whenComplete is the `finally` that guarantees eviction either way. + // NOTE: the callback MUST have a block body — `Map.remove` returns the + // evicted value (this very future), and whenComplete AWAITS a returned + // future, which would deadlock the future on itself. + final future = _fetch(req, url: url).whenComplete(() { + _inflight.remove(dedupeKey); + }); + _inflight[dedupeKey] = future; + return future; + } + static Future> _fetch( + Requests req, { + String? url, + }) async { + try { + final Response res = await req.get(url: url ?? PApiUrls.billingPortal); + // `req.get` throws on >= 400; only a 2xx body reaches here. final Map json = jsonDecode( utf8.decode(res.bodyBytes).toString(), ); - - final response = BillingPortalResponse.fromJson(json); - return Result.value(response); + return Result.value(BillingPortalResponse.fromJson(json)); + } on ChoreoException catch (e) { + // ONLY a 404 whose detail is exactly "No billing account" is the + // no-portal signal (no canonical Stripe customer). Any OTHER 404 + // (route/deploy mismatch, FastAPI "Not Found") is a real integration + // failure and must surface as the ChoreoException — never masked as + // "management unavailable" (finding #4). + if (e.response.statusCode == 404 && e.message == "No billing account") { + return Result.error(const NoBillingAccountException()); + } + ErrorHandler.logError(e: e.errorMessage, data: {}); + return Result.error(e); } catch (e, s) { ErrorHandler.logError(e: e, s: s, data: {}); return Result.error(e); diff --git a/lib/features/subscription/repo/cancel_v2_repo.dart b/lib/features/subscription/repo/cancel_v2_repo.dart new file mode 100644 index 0000000000..5e80786c79 --- /dev/null +++ b/lib/features/subscription/repo/cancel_v2_repo.dart @@ -0,0 +1,35 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'package:fluffychat/features/subscription/models/cancel_response.dart'; +import 'package:fluffychat/pangea/common/network/requests.dart'; +import 'package:fluffychat/pangea/common/network/urls.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +/// POSTs `/subscription/cancel` to set the user-owned subscription to cancel at +/// period end. +/// +/// Body is EXACTLY `{"entitlementRef": ref}` sent with +/// `injectUserContext: false` (I1) — the server resolves the OWNED entitlement +/// from `(authenticated user, entitlementRef)`, so a client can never cancel a +/// subscription it does not own, and the ref is NEVER a Stripe id (I5). +class CancelV2Repo { + static Future cancel( + String entitlementRef, { + http.Client? client, + }) async { + final Requests req = Requests( + accessToken: MatrixState.pangeaController.userController.accessToken, + client: client, + ); + final http.Response res = await req.post( + url: PApiUrls.subscriptionCancel, + body: {"entitlementRef": entitlementRef}, + injectUserContext: false, + ); + return CancelResponse.fromJson( + jsonDecode(res.body) as Map, + ); + } +} diff --git a/lib/features/subscription/repo/checkout_v2_repo.dart b/lib/features/subscription/repo/checkout_v2_repo.dart new file mode 100644 index 0000000000..3ef38a03a7 --- /dev/null +++ b/lib/features/subscription/repo/checkout_v2_repo.dart @@ -0,0 +1,243 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'package:fluffychat/features/subscription/models/checkout_response.dart'; +import 'package:fluffychat/pangea/common/network/requests.dart'; +import 'package:fluffychat/pangea/common/network/urls.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +/// Raised when a `/subscription/checkout` flow cannot be resolved to a session +/// URL — a malformed terminal response, an unexpected status, the bounded poll +/// giving up, or a schema-shaped 422 (a client bug, distinct from the business +/// promo rejection below). User-visible (I3). +class CheckoutException implements Exception { + final String message; + const CheckoutException(this.message); + + @override + String toString() => "CheckoutException: $message"; +} + +/// The nine server-side reasons `/subscription/checkout` rejects a REQUESTED +/// promo code (the `promo_not_applicable` 422). The first eight come from the +/// choreo promo resolver; [rejectedByStripe] is emitted when Stripe re-validates +/// and rejects the code at Session.create (defense-in-depth). [unknown] is a +/// forward-compatible fallback for a reason string this client does not model +/// yet — surface it as a generic "code could not be applied" message. +enum CheckoutPromoRejectionReason { + notFoundOrInactive, + wrongCustomer, + expired, + maxRedeemed, + couponInvalid, + wrongPlan, + belowMinimum, + currencyMismatch, + rejectedByStripe, + unknown; + + /// Maps the server `reason` wire string to a typed value; an unrecognized or + /// null string maps to [unknown] (never throws). + static CheckoutPromoRejectionReason fromWire(String? wire) { + switch (wire) { + case 'not_found_or_inactive': + return CheckoutPromoRejectionReason.notFoundOrInactive; + case 'wrong_customer': + return CheckoutPromoRejectionReason.wrongCustomer; + case 'expired': + return CheckoutPromoRejectionReason.expired; + case 'max_redeemed': + return CheckoutPromoRejectionReason.maxRedeemed; + case 'coupon_invalid': + return CheckoutPromoRejectionReason.couponInvalid; + case 'wrong_plan': + return CheckoutPromoRejectionReason.wrongPlan; + case 'below_minimum': + return CheckoutPromoRejectionReason.belowMinimum; + case 'currency_mismatch': + return CheckoutPromoRejectionReason.currencyMismatch; + case 'rejected_by_stripe': + return CheckoutPromoRejectionReason.rejectedByStripe; + default: + return CheckoutPromoRejectionReason.unknown; + } + } +} + +/// Raised when `/subscription/checkout` rejects the REQUESTED promo code with +/// the business-rejection 422 shape +/// `{"detail": {"code": "promo_not_applicable", "reason": ""}}`. +/// +/// This is DISTINCT from a FastAPI schema-validation 422 (where `detail` is a +/// LIST of `extra_forbidden`/length errors) — that is a client bug and is +/// surfaced as a [CheckoutException] instead. Carries the typed [reason] so the +/// UI can show a specific message; [rawReason] preserves the exact wire string +/// for logging / forward-compat. +class PromoNotApplicableException implements Exception { + final CheckoutPromoRejectionReason reason; + final String? rawReason; + + const PromoNotApplicableException(this.reason, {this.rawReason}); + + @override + String toString() => + "PromoNotApplicableException: ${rawReason ?? reason.name}"; +} + +/// POSTs `/subscription/checkout` and resolves the Stripe Checkout session, +/// running the bounded "creating" poll (I3) and carrying the optional discount +/// code. +/// +/// - Body is EXACTLY `{"planId": planId}` or `{"planId": planId, "promoCode": +/// promoCode}` sent with `injectUserContext: false` (I1) — the choreo +/// `CheckoutRequest` is `extra="forbid"`, so any injected/extra field is a +/// schema 422 (money-safety). The promo **code string** is the only discount +/// input; the client never sends amounts or coupon ids. +/// - Returns the resolved [CheckoutResponse]: `.sessionUrl` (the redirect +/// target) plus `.appliedPromoCode` — the code ACTUALLY on the session. On a +/// `reused` open session the request's `promoCode` is IGNORED server-side, so +/// `appliedPromoCode` (not the requested code) is the truth the UI reflects. +/// - On a `creating` (202) response, re-POSTs the SAME body after +/// `retryAfterSeconds` (fallback 2s), capped at 5 attempts / ~15s of waiting; +/// on exhaustion it throws a [CheckoutException]. +/// - An invalid/inapplicable requested promo -> 422 `promo_not_applicable` -> +/// [PromoNotApplicableException] (typed [CheckoutPromoRejectionReason]). A +/// schema-shaped 422 (extra field / >64-char code) -> [CheckoutException] +/// (a client bug — a different promo code will not fix it). +/// - The delay is injected ([delay]) so tests run without real waits. +class CheckoutV2Repo { + static const int maxAttempts = 5; + static const Duration fallbackDelay = Duration(seconds: 2); + static const Duration maxTotalWait = Duration(seconds: 15); + + static Future checkout( + String planId, { + String? promoCode, + http.Client? client, + Future Function(Duration)? delay, + }) { + final Requests req = Requests( + accessToken: MatrixState.pangeaController.userController.accessToken, + client: client, + ); + return checkoutWith(req, planId, promoCode: promoCode, delay: delay); + } + + /// Pollable core, decoupled from `MatrixState` so the bounded-poll and + /// promo-rejection behavior is unit-testable with an injected [Requests] + /// (MockClient) + [delay] seam. Production callers use [checkout]; this is the + /// same logic with the request transport supplied by the caller. + static Future checkoutWith( + Requests req, + String planId, { + String? promoCode, + String? url, + Future Function(Duration)? delay, + }) async { + // Resolve lazily: only fall back to the Environment-backed URL when the + // caller did not supply one, so unit tests can pass a literal URL and avoid + // touching `Environment` (GetStorage/dotenv are not initialized under + // `flutter test` — see endpoint_test_env.dart). + final String target = url ?? PApiUrls.subscriptionCheckout; + final Future Function(Duration) delayFn = + delay ?? (d) => Future.delayed(d); + + // EXACTLY {planId[, promoCode]} — extra="forbid". Trim first, then omit + // promoCode entirely when blank (an empty OR whitespace-only code must + // never reach the server — it would 422 as not_found_or_inactive). + final String? normalizedPromoCode = promoCode?.trim(); + final Map body = { + "planId": planId, + if (normalizedPromoCode != null && normalizedPromoCode.isNotEmpty) + "promoCode": normalizedPromoCode, + }; + + var accumulatedWait = Duration.zero; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) { + final http.Response res; + try { + res = await req.post(url: target, body: body, injectUserContext: false); + } on http.Response catch (errRes) { + // `Requests.handleError` throws the raw response for a non-string + // `detail` — the two 422 shapes. Map them to typed outcomes. (A + // string-detail error — 401/404/409/502/503 — surfaces as the usual + // `ChoreoException` and is not caught here.) + _throwForErrorResponse(errRes); + } + + final parsed = CheckoutResponse.fromJson( + jsonDecode(res.body) as Map, + ); + + if (parsed.isResolved) { + final sessionUrl = parsed.sessionUrl; + if (sessionUrl == null || sessionUrl.isEmpty) { + throw CheckoutException( + 'Checkout resolved as "${parsed.status}" but returned no sessionUrl', + ); + } + return parsed; + } + + if (!parsed.isCreating) { + throw CheckoutException( + 'Unexpected checkout status "${parsed.status}"', + ); + } + + // "creating": decide whether to poll again. Stop on the last attempt or + // when the cumulative wait would exceed the total budget (I3). + final Duration wait = parsed.retryAfterSeconds != null + ? Duration(seconds: parsed.retryAfterSeconds!) + : fallbackDelay; + final bool isLastAttempt = attempt >= maxAttempts; + final bool wouldExceedBudget = accumulatedWait + wait > maxTotalWait; + if (isLastAttempt || wouldExceedBudget) break; + + accumulatedWait += wait; + await delayFn(wait); + } + + throw const CheckoutException( + "Checkout session is still being created; please try again", + ); + } + + /// Maps a thrown error [http.Response] (raised by `Requests.handleError` for a + /// non-string `detail`) to a typed checkout failure. Never returns normally. + static Never _throwForErrorResponse(http.Response res) { + if (res.statusCode == 422) { + Object? detail; + try { + final decoded = jsonDecode(res.body); + if (decoded is Map) { + detail = decoded['detail']; + } + } catch (_) { + // Non-JSON body — fall through to the generic schema error. + } + + // Business rejection: detail is an OBJECT {code, reason}. + if (detail is Map && detail['code'] == 'promo_not_applicable') { + final rawReason = detail['reason'] as String?; + throw PromoNotApplicableException( + CheckoutPromoRejectionReason.fromWire(rawReason), + rawReason: rawReason, + ); + } + + // Schema rejection: detail is a LIST (extra_forbidden / too-long code) — + // a client bug (an unexpected body field or an over-length code). The UI + // cannot fix it with a different promo code, so surface it as an error. + throw CheckoutException( + "Checkout request rejected by schema validation (HTTP 422): ${res.body}", + ); + } + + // Any other non-string-detail failure — rethrow the raw response so the + // caller's generic error handling takes over (mirrors prior behavior). + throw res; + } +} diff --git a/lib/features/subscription/repo/payment_history_response.dart b/lib/features/subscription/repo/payment_history_response.dart index 2d3d8d3ed7..c9d98f5757 100644 --- a/lib/features/subscription/repo/payment_history_response.dart +++ b/lib/features/subscription/repo/payment_history_response.dart @@ -50,9 +50,11 @@ class InvoiceSummary { id: json['id'] as String, number: json['number'] as String?, created: json['created'] as String, - subtotal: json['subtotal'] as int, - total: json['total'] as int, - amountPaid: json['amount_paid'] as int, + // Minor-unit amounts arrive as JSON numbers; a serializer may emit + // 999.0 for 999, so parse via num (consistent with products_v2). + subtotal: (json['subtotal'] as num).toInt(), + total: (json['total'] as num).toInt(), + amountPaid: (json['amount_paid'] as num).toInt(), currency: json['currency'] as String, status: json['status'] as String, hostedInvoiceUrl: json['hosted_invoice_url'] as String?, diff --git a/lib/features/subscription/repo/products_v2_repo.dart b/lib/features/subscription/repo/products_v2_repo.dart new file mode 100644 index 0000000000..9702c0c11b --- /dev/null +++ b/lib/features/subscription/repo/products_v2_repo.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +import 'package:http/http.dart' as http; + +import 'package:fluffychat/features/subscription/models/products_v2_response.dart'; +import 'package:fluffychat/pangea/common/network/requests.dart'; +import 'package:fluffychat/pangea/common/network/urls.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +/// Fetches the Subscriptions-v2 `/subscription/products` storefront. +/// +/// I9 (finding #8): this is a SEPARATE repo from `AllProductsRepo` with NO +/// shared cache — the RC and v2 catalogs must never poison each other across +/// the flag flip. It fetches fresh every call (the catalog is small and read at +/// init only), so a flip cannot serve a stale cross-schema payload. +/// +/// Failure semantics (finding #2): the repo distinguishes a REAL empty catalog +/// (a 200 with `plans: []`) from a FETCH FAILURE (transport error, 5xx / 503 +/// `products_unavailable`, or a malformed body). On success it returns the +/// parsed response — possibly with an empty `plans` list. On a fetch failure it +/// THROWS, so the controller sets a retryable error state instead of silently +/// building an empty sellable catalog that would strand a buyer on a 503. +class ProductsV2Repo { + static Future get({http.Client? client}) { + final Requests req = Requests( + accessToken: MatrixState.pangeaController.userController.accessToken, + client: client, + ); + return getWith(req); + } + + /// The transport core, decoupled from `MatrixState` so the throw-on-failure + /// vs empty-200 distinction is unit-testable with an injected [Requests] + /// (MockClient). `req.get` throws (ChoreoException) on >= 400; a malformed + /// body throws while decoding — both propagate as the fetch-failure signal. + @visibleForTesting + static Future getWith(Requests req, {String? url}) async { + final http.Response res = await req.get( + url: url ?? PApiUrls.subscriptionProducts, + ); + final Map json = + jsonDecode(res.body) as Map; + return ProductsV2Response.fromJson(json); + } +} diff --git a/lib/features/subscription/repo/status_v2_repo.dart b/lib/features/subscription/repo/status_v2_repo.dart new file mode 100644 index 0000000000..d22d5d395e --- /dev/null +++ b/lib/features/subscription/repo/status_v2_repo.dart @@ -0,0 +1,26 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'package:fluffychat/features/subscription/models/subscription_status_v2.dart'; +import 'package:fluffychat/pangea/common/network/requests.dart'; +import 'package:fluffychat/pangea/common/network/urls.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +/// Fetches the Subscriptions-v2 `/subscription/status` response and parses it +/// into [SubscriptionStatusV2]. Throws on a network/parse failure so the caller +/// (the web manager) can surface a `SubscriptionError`, mirroring the RC +/// `SubscriptionRepo.getCurrentSubscriptionInfo` contract. NEVER runs the RC +/// parser on this payload (I7). +class StatusV2Repo { + static Future get({http.Client? client}) async { + final Requests req = Requests( + accessToken: MatrixState.pangeaController.userController.accessToken, + client: client, + ); + final http.Response res = await req.get(url: PApiUrls.subscriptionStatus); + final Map json = + jsonDecode(res.body) as Map; + return SubscriptionStatusV2.fromJson(json); + } +} diff --git a/lib/features/subscription/repo/validate_promo_code_repo.dart b/lib/features/subscription/repo/validate_promo_code_repo.dart index 11adad59ae..f21e4473ba 100644 --- a/lib/features/subscription/repo/validate_promo_code_repo.dart +++ b/lib/features/subscription/repo/validate_promo_code_repo.dart @@ -26,6 +26,21 @@ class ValidatePromoCodeCacheEntry { timestamp.isBefore(DateTime.now().subtract(_cacheDuration)); } +/// GETs `/subscription/validate_promo_code?code=&duration=` for a DISPLAY-ONLY +/// discount preview (choreo `PromoCodeValidationResponse`). +/// +/// The returned terms (`percent_off` / `amount_off` / `discounted_price` / +/// `expires_at` / `restrictions`) and `discounted_price` in particular are an +/// ESTIMATE for the UI only — the server independently re-validates the code at +/// `/checkout` and Stripe finalizes the charged total, so a green preview is +/// NEVER authorization. A `valid:false` body carries a `reason` +/// (`not_found_or_inactive | expired | max_redeemed | below_minimum`; for +/// `below_minimum` the `restrictions` object is also present) — show an inline +/// message but do NOT block a checkout retry. An upstream Stripe outage / +/// unresolvable coupon surfaces as an HTTP 400 (`Promo validation failed`), +/// returned here as a `Result.error` (distinct from a `valid:false` value). +/// Results are cached (10 min) and in-flight requests de-duplicated by code + +/// duration. class ValidatePromoCodeRepo { static final Map>> _inflightCache = {}; diff --git a/lib/features/subscription/repo/validate_promo_code_response.dart b/lib/features/subscription/repo/validate_promo_code_response.dart index c9dacd22b7..9bd7af33f1 100644 --- a/lib/features/subscription/repo/validate_promo_code_response.dart +++ b/lib/features/subscription/repo/validate_promo_code_response.dart @@ -31,7 +31,9 @@ class ValidatePromoCodeResponse { code: json['code'] as String?, discountType: json['discount_type'] as String?, percentOff: (json['percent_off'] as num?)?.toDouble(), - amountOff: json['amount_off'] as int?, + // Minor-unit amounts arrive as JSON numbers; a serializer may emit + // 500.0 for 500, so parse via num (consistent with products_v2). + amountOff: (json['amount_off'] as num?)?.toInt(), currency: json['currency'] as String?, couponDuration: json['coupon_duration'] as String?, restrictions: json['restrictions'] != null @@ -44,7 +46,7 @@ class ValidatePromoCodeResponse { json['discounted_price'] as Map, ) : null, - expiresAt: json['expires_at'] as int?, + expiresAt: (json['expires_at'] as num?)?.toInt(), reason: json['reason'] as String?, ); } @@ -79,7 +81,7 @@ class PromoRestrictions { factory PromoRestrictions.fromJson(Map json) { return PromoRestrictions( - minimumAmount: json['minimum_amount'] as int?, + minimumAmount: (json['minimum_amount'] as num?)?.toInt(), minimumAmountCurrency: json['minimum_amount_currency'] as String?, firstTimeTransaction: json['first_time_transaction'] as bool? ?? false, ); @@ -102,7 +104,7 @@ class DiscountedPrice { factory DiscountedPrice.fromJson(Map json) { return DiscountedPrice( - amount: json['amount'] as int, + amount: (json['amount'] as num).toInt(), currency: json['currency'] as String, ); } diff --git a/lib/features/subscription/subscription_constants.dart b/lib/features/subscription/subscription_constants.dart index f42bd3633b..9b145fca22 100644 --- a/lib/features/subscription/subscription_constants.dart +++ b/lib/features/subscription/subscription_constants.dart @@ -1,3 +1,17 @@ class SubscriptionConstants { static const String starBackground = "Background+Star+with+characters.png"; } + +/// The synthesized catalog id + appId sentinel for the Subscriptions-v2 web +/// trial (D3/D7). A trial has no Stripe catalog plan, so the client uses this +/// stable value both as the synthesized trial [SubscriptionDetails] id AND as +/// the active-trial `subscriptionId` produced by `mapStatusV2ToState`, so the +/// controller `subscription` getter resolves the trial tile (which keys off +/// `appId == "trial"`). Matches the RC catalog's existing trial appId. +const String kV2TrialId = "trial"; + +/// Fallback Stripe app-id for the v2 web path used when +/// `/subscription_app_ids` has not yet resolved a `stripeId`. Keeps the +/// status/products mappers producing a stable, non-null appId so management +/// degrades gracefully rather than stranding on a null id. +const String kStripeAppIdFallback = "stripe"; diff --git a/lib/features/subscription/utils/cancel_eligibility.dart b/lib/features/subscription/utils/cancel_eligibility.dart new file mode 100644 index 0000000000..dc63c8d892 --- /dev/null +++ b/lib/features/subscription/utils/cancel_eligibility.dart @@ -0,0 +1,16 @@ +import 'package:fluffychat/features/subscription/models/subscription_state.dart'; + +/// Pure predicate for whether the in-app v2 cancel affordance should be shown +/// for the current subscription (I5). True iff a user-owned, cancelable +/// entitlement was selected AND the subscription is not already set to cancel at +/// period end AND we hold its `/status`-sourced `entitlementRef` (never a Stripe +/// id). +/// +/// The nullable comparisons are deliberate: `cancelable`/`cancelAtPeriodEnd` are +/// null on the mobile/RC path, so a bare truthiness check would misfire — +/// `cancelable == true` is false when null, and `cancelAtPeriodEnd != true` +/// treats null as "not cancelling". +bool shouldShowV2Cancel(SubscriptionActive state) => + state.cancelable == true && + state.cancelAtPeriodEnd != true && + state.entitlementRef != null; diff --git a/lib/features/subscription/utils/single_flight_guard.dart b/lib/features/subscription/utils/single_flight_guard.dart new file mode 100644 index 0000000000..c3e7b195d8 --- /dev/null +++ b/lib/features/subscription/utils/single_flight_guard.dart @@ -0,0 +1,26 @@ +/// A minimal, synchronous re-entry guard for tap handlers that must never run +/// twice concurrently — a double-tap on the checkout / cancel / manage-billing +/// controls must not fire concurrent requests or multiple redirects. +/// +/// Usage: +/// ```dart +/// if (!_guard.tryEnter()) return; // a run is already in flight +/// try { ...await work... } finally { _guard.exit(); } +/// ``` +class SingleFlightGuard { + bool _inFlight = false; + + /// Whether a run is currently in flight (drives disabled/loading UI). + bool get inFlight => _inFlight; + + /// Locks and returns true when idle; returns false when a run is already in + /// flight (the caller must early-return). + bool tryEnter() { + if (_inFlight) return false; + _inFlight = true; + return true; + } + + /// Unlocks. Safe to call when already idle (idempotent); call from `finally`. + void exit() => _inFlight = false; +} diff --git a/lib/features/subscription/utils/v2_subscription_catalog.dart b/lib/features/subscription/utils/v2_subscription_catalog.dart new file mode 100644 index 0000000000..4c8bb6db18 --- /dev/null +++ b/lib/features/subscription/utils/v2_subscription_catalog.dart @@ -0,0 +1,80 @@ +import 'package:collection/collection.dart'; + +import 'package:fluffychat/features/subscription/models/products_v2_response.dart'; +import 'package:fluffychat/features/subscription/models/subscription_details.dart'; +import 'package:fluffychat/features/subscription/models/subscription_status_v2.dart'; +import 'package:fluffychat/features/subscription/subscription_constants.dart'; + +/// The two catalog lists the controller exposes on the v2 web path, derived +/// PURELY (no I/O, no clock, no singleton) from the `/products` plans and the +/// `/status` snapshot — so the trial-card + trial-resolution logic is +/// unit-testable in isolation of `MatrixState`. +class V2SubscriptionCatalog { + /// Every subscription the controller can resolve `subscription` against — + /// the sellable plans plus (when relevant) the trial, so an ACTIVE trial's + /// `subscriptionId == kV2TrialId` resolves to its tile. + final List all; + + /// The plans (plus a synthesized trial card when the user can start one) to + /// render on the paywall, sorted by price. + final List available; + + const V2SubscriptionCatalog({required this.all, required this.available}); +} + +/// The synthesized trial card / active-trial tile (D3/D7): a $0, appId=="trial" +/// [SubscriptionDetails] with the [kV2TrialId] id, so `isTrial` is true and the +/// controller `subscription` getter resolves an active trial by id. +SubscriptionDetails v2TrialSubscription() => + SubscriptionDetails(id: kV2TrialId, appId: "trial", price: 0); + +/// Builds the v2 web catalog from the storefront [plans] and the [status] +/// snapshot (D3, finding #11). +/// +/// - Plans map through [productV2ToSubscriptionDetails] (`appId = stripeAppId`, +/// `id = planId`); an unknown plan id THROWS [UnknownPlanIdException] (I4) — +/// propagated to fail closed rather than silently degrade. +/// - `available` = plans + a synthesized trial card iff the user can START a +/// trial (`trialEligible && !trialClaimed`), sorted by price. +/// - `all` = plans + a trial [SubscriptionDetails] whenever a trial is +/// startable (`trialEligible`) OR one is currently ACTIVE, so the +/// current-subscription getter resolves for an active trial. +V2SubscriptionCatalog buildV2SubscriptionCatalog( + List plans, + SubscriptionStatusV2? status, { + required String stripeAppId, +}) { + final mapped = plans + .map((p) => productV2ToSubscriptionDetails(p, stripeAppId: stripeAppId)) + .sorted((a, b) => a.price.compareTo(b.price)) + .toList(); + + final bool trialEligible = status?.trialEligible ?? false; + final bool trialClaimed = status?.trialClaimed ?? false; + final bool trialActive = + status != null && + status.accessLevel == "full" && + status.winning?.type == "trial"; + + // Reuse ONE synthesized trial object across both lists (finding #3), so an + // active trial is identified by the same stable instance/id in `all` and + // `available` — never two divergent objects. + final SubscriptionDetails? trial = (trialEligible || trialActive) + ? v2TrialSubscription() + : null; + + final all = List.from(mapped); + if (trial != null) { + all.add(trial); + } + + final available = List.from(mapped); + if (trial != null && trialEligible && !trialClaimed) { + available.add(trial); + } + + return V2SubscriptionCatalog( + all: all, + available: available.sorted((a, b) => a.price.compareTo(b.price)).toList(), + ); +} diff --git a/lib/features/subscription/utils/v2_ui_gating.dart b/lib/features/subscription/utils/v2_ui_gating.dart new file mode 100644 index 0000000000..a461465f96 --- /dev/null +++ b/lib/features/subscription/utils/v2_ui_gating.dart @@ -0,0 +1,97 @@ +import 'package:fluffychat/features/subscription/models/subscription_state.dart'; +import 'package:fluffychat/features/subscription/models/subscription_status_v2.dart'; +import 'package:fluffychat/features/subscription/utils/cancel_eligibility.dart'; + +/// PURE decision helpers for the v2 web UI (no I/O, no clock, no singleton), so +/// the flag-gated wiring in the widgets/controller stays testable in isolation. +/// Each is a plain function of already-resolved inputs; the `subsV2WebEnabled && +/// kIsWeb` gate lives at the CALL sites so the flag-off + mobile paths never +/// reach these decisions. + +/// Whether a v2 trial can be STARTED given the [status] snapshot (D3): +/// server-eligible and not already claimed. This DRIVES trial-card enablement +/// on the v2 web path (paywall / change-subscription), replacing the RC-only +/// `inTrialWindow()` heuristic — a server-eligible trial must render enabled +/// even when the local trial window has lapsed (finding: v2 trial not +/// activatable). +bool v2TrialOfferableFor(SubscriptionStatusV2? status) => + status?.trialEligible == true && status?.trialClaimed != true; + +/// A billable v2 entitlement (paid/individual — see [isV2PaidType]) whose plan +/// is missing from the catalog (null `planId`) — an anomaly the catalog should +/// prevent (current subs should always map to a sellable plan). Used to LOG the +/// anomaly and to justify the generic-tile fallback so a paying, grandfathered +/// user never sees a broken tile or silently loses the account-delete warning +/// (finding: paid Stripe access without planId). +bool isPaidWithoutPlan(SubscriptionStatusV2 status) { + final winning = status.winning; + return status.accessLevel == "full" && + winning != null && + isV2PaidType(winning.type) && + winning.planId == null; +} + +/// Path-aware "can a trial be offered / auto-activated". On the v2 web path the +/// SERVER signal ([v2TrialOfferable], = `trialEligible && !trialClaimed`) decides +/// — a server-eligible trial must be offered even when the local trial window +/// has lapsed. Off the flag / on mobile it stays on the RC [inTrialWindow] +/// heuristic, so those paths are byte-for-byte unchanged. Drives BOTH the +/// controller auto-activation and the paywall top-level trial branch. +bool isTrialOfferable({ + required bool v2Path, + required bool v2TrialOfferable, + required bool inTrialWindow, +}) => v2Path ? v2TrialOfferable : inTrialWindow; + +/// What a cancel-tile tap should do. The v2 path is SELF-CONTAINED: it either +/// runs the in-app cancel or no-ops — it MUST NEVER fall through to the legacy +/// external-portal + clicked-cancel polling shim (finding: v2 cancel handler +/// not self-gated). Only the non-v2 path is [legacy]. +enum CancelClickAction { v2Cancel, v2NoOp, legacy } + +CancelClickAction classifyCancelClick({ + required bool v2CancelPath, + required SubscriptionState state, +}) { + if (v2CancelPath) { + return (state is SubscriptionActive && shouldShowV2Cancel(state)) + ? CancelClickAction.v2Cancel + : CancelClickAction.v2NoOp; + } + return CancelClickAction.legacy; +} + +/// Where the management actions (payment method / payment history) route. +/// DECISION (subs-v2 wiring): on the v2 web path BOTH tiles mint a fresh +/// Stripe billing-portal session — the portal surfaces the payment method AND +/// the full invoice history, which is the minimal correct wiring onto the +/// canonical v2 APIs without building new client UI (Gabby's history page will +/// consume `PaymentHistoryRepo` directly). The legacy static +/// `stripeManagementUrl` is NEVER launched on the v2 path. Off the flag / on +/// mobile the legacy behavior is byte-for-byte unchanged. +enum ManagementLaunchRoute { v2BillingPortal, legacy } + +ManagementLaunchRoute classifyManagementLaunch({required bool v2Path}) => v2Path + ? ManagementLaunchRoute.v2BillingPortal + : ManagementLaunchRoute.legacy; + +/// Whether the promotional-access warning should use the UNDATED copy (no +/// expiration date). True for a lifetime/comp/seat/manual grant whose expiration +/// is null — force-unwrapping the date there would crash (finding: settings NPE +/// for comp/seat). When false, the caller has a non-null expiration to format. +bool showUndatedPromoWarning({ + required bool isLifetime, + required DateTime? expiration, +}) => isLifetime || expiration == null; + +/// Whether to warn a user before deleting their account. On the v2 path we warn +/// for ANY active paid access — even when no management URL resolves (a paid +/// entitlement whose plan is not in the catalog still bills the user), so a +/// paying user can never delete their account with no warning (finding: paid +/// Stripe access without planId — safety). Off the v2 path the behavior is +/// exactly today's: warn only when a paid sub AND a management URL resolve. +bool shouldWarnBeforeAccountDelete({ + required bool hasPaidSubscription, + required bool hasManagementUrl, + required bool v2Path, +}) => hasPaidSubscription && (hasManagementUrl || v2Path); diff --git a/lib/features/subscription/widgets/subscription_paywall.dart b/lib/features/subscription/widgets/subscription_paywall.dart index e1c5c951bb..6199d8c2ce 100644 --- a/lib/features/subscription/widgets/subscription_paywall.dart +++ b/lib/features/subscription/widgets/subscription_paywall.dart @@ -1,9 +1,12 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/features/subscription/models/subscription_details.dart'; import 'package:fluffychat/features/subscription/repo/subscription_management_repo.dart'; +import 'package:fluffychat/features/subscription/utils/v2_ui_gating.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; @@ -82,8 +85,18 @@ class SubscriptionPaywall extends StatelessWidget { direction: Axis.horizontal, spacing: 10, children: - MatrixState.pangeaController.userController - .inTrialWindow() + // #2: path-aware trial branch. v2 web uses the server + // signal (v2TrialOfferable); off the flag / mobile keeps + // the RC `inTrialWindow()` heuristic, so this is + // byte-for-byte unchanged off the flag. + isTrialOfferable( + v2Path: Environment.subsV2WebEnabled && kIsWeb, + v2TrialOfferable: sub.v2TrialOfferable, + inTrialWindow: MatrixState + .pangeaController + .userController + .inTrialWindow(), + ) ? [ SubscriptionCard( onTap: sub.activateNewUserTrial, @@ -104,8 +117,17 @@ class SubscriptionPaywall extends StatelessWidget { ), ), title: subscription.displayName(context), - enabled: !subscription.isTrial, - description: subscription.isTrial + // #1: on the v2 path a server-eligible trial card + // is enabled (driven off v2 status, not the RC + // `inTrialWindow`). Off the flag `v2TrialOfferable` + // is false, so this collapses to today's exact + // `!isTrial` / "trial expired" behavior. + enabled: subscription.isTrial + ? sub.v2TrialOfferable + : true, + description: + subscription.isTrial && + !sub.v2TrialOfferable ? L10n.of(context).trialPeriodExpired : null, ), diff --git a/lib/pangea/common/config/environment.dart b/lib/pangea/common/config/environment.dart index ecaf14936f..cdb13d3949 100644 --- a/lib/pangea/common/config/environment.dart +++ b/lib/pangea/common/config/environment.dart @@ -25,6 +25,13 @@ class Environment { /// production. See `playwright-testing.instructions.md`. static bool get enableSemantics => dotenv.env["ENABLE_SEMANTICS"] == "true"; + /// Subscriptions v2 web path (D1). When unset/false, the web subscription + /// flow keeps today's RevenueCat-shaped choreo endpoints byte-for-byte; when + /// "true", the client uses the four v2 endpoints (`/products`, `/checkout`, + /// `/status`, `/cancel`). Ships dark; flipped on only in lockstep with the + /// backend Plan-7 gate flip. Mirrors `enableSemantics`. + static bool get subsV2WebEnabled => dotenv.env["SUBS_V2_WEB"] == "true"; + static String get frontendURL { return appConfigOverride?.frontendURL ?? dotenv.env["FRONTEND_URL"] ?? diff --git a/lib/pangea/common/network/requests.dart b/lib/pangea/common/network/requests.dart index 877942fe54..333cf50558 100644 --- a/lib/pangea/common/network/requests.dart +++ b/lib/pangea/common/network/requests.dart @@ -20,18 +20,41 @@ class ChoreoException implements Exception { class Requests { late String? accessToken; - Requests({this.accessToken}); + /// Optional injected HTTP client. Production callers leave this null, so the + /// package's top-level `http.post`/`http.get` are used exactly as before + /// (behavior unchanged). Tests pass a `MockClient` to capture the outbound + /// request — the repo's established seam (see PayloadClient). + final http.Client? client; + + /// Optional override for the user-context injector, defaulting to + /// [BaseRequestModel.injectUserContext]. Production leaves this null. It + /// exists ONLY so tests can prove the routing honestly: the real injector + /// reads `MatrixState`, which is unavailable under `flutter test`, so without + /// this seam the `injectUserContext: true` path is indistinguishable from a + /// verbatim copy for a bare body. A test spy makes the routing observable. + final Map Function(Map)? contextInjector; + + Requests({this.accessToken, this.client, this.contextInjector}); Future post({ required String url, required Map body, + bool injectUserContext = true, }) async { - final enrichedBody = BaseRequestModel.injectUserContext(body); + // I8 (finding #7): whether user context (cefr_level / user_gender) is added + // is controlled ONLY by this param, NEVER by any feature flag. Callers that + // hit an `extra="forbid"` choreo schema (v2 `/checkout`, `/cancel`) pass + // `injectUserContext: false` so the body is sent verbatim; every existing + // caller keeps the default `true`. + final inject = contextInjector ?? BaseRequestModel.injectUserContext; + final Map enrichedBody = injectUserContext + ? inject(body) + : Map.from(body); dynamic encoded; encoded = jsonEncode(enrichedBody); - final http.Response response = await http.post( + final http.Response response = await (client?.post ?? http.post)( Uri.parse(url), body: encoded, headers: _headers, @@ -42,7 +65,7 @@ class Requests { } Future get({required String url}) async { - final http.Response response = await http.get( + final http.Response response = await (client?.get ?? http.get)( Uri.parse(url), headers: _headers, ); diff --git a/lib/pangea/common/network/urls.dart b/lib/pangea/common/network/urls.dart index f9e3866e0a..11816ccb4a 100644 --- a/lib/pangea/common/network/urls.dart +++ b/lib/pangea/common/network/urls.dart @@ -98,6 +98,17 @@ class PApiUrls { static String rcSubscription = PApiUrls._subscriptionEndpoint; + ///-------------------------------- subscriptions v2 -------------------------- + /// The four Subscriptions-v2 endpoints (web-only, flag-gated by + /// `Environment.subsV2WebEnabled`). The RC constants above stay for the + /// flag-off / mobile path. + static String subscriptionProducts = + "${PApiUrls._subscriptionEndpoint}/products"; + static String subscriptionCheckout = + "${PApiUrls._subscriptionEndpoint}/checkout"; + static String subscriptionStatus = "${PApiUrls._subscriptionEndpoint}/status"; + static String subscriptionCancel = "${PApiUrls._subscriptionEndpoint}/cancel"; + static String validatePromoCode = "${PApiUrls._subscriptionEndpoint}/validate_promo_code"; diff --git a/lib/pangea/common/utils/firebase_analytics.dart b/lib/pangea/common/utils/firebase_analytics.dart index 77553760df..f0c2e7849e 100644 --- a/lib/pangea/common/utils/firebase_analytics.dart +++ b/lib/pangea/common/utils/firebase_analytics.dart @@ -217,7 +217,9 @@ class GoogleAnalytics { logEvent( 'begin_checkout', parameters: { - "currency": "USD", + // Use the plan's own ISO currency when present (v2 web path, D5); + // otherwise keep the historical USD default (mobile/RC, currency null). + "currency": details.currency?.toUpperCase() ?? "USD", 'value': details.price, 'transaction_id': details.id, if (details.package != null) 'item_id': details.package!.identifier, diff --git a/lib/routes/settings/settings_security/settings_security.dart b/lib/routes/settings/settings_security/settings_security.dart index 9518023ebd..c070d2f354 100644 --- a/lib/routes/settings/settings_security/settings_security.dart +++ b/lib/routes/settings/settings_security/settings_security.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; @@ -5,7 +6,9 @@ import 'package:url_launcher/url_launcher_string.dart'; import 'package:fluffychat/config/setting_keys.dart'; import 'package:fluffychat/features/authentication/delete_account_extension.dart'; +import 'package:fluffychat/features/subscription/utils/v2_ui_gating.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_text_input_dialog.dart'; import 'package:fluffychat/widgets/app_lock.dart'; @@ -53,17 +56,39 @@ class SettingsSecurityController extends State { final subscriptionController = MatrixState.pangeaController.subscriptionController; final managementURL = subscriptionController.defaultManagementURL; - if (subscriptionController.hasPaidSubscription && managementURL != null) { + final bool v2Path = Environment.subsV2WebEnabled && kIsWeb; + // #4a: warn a paying user before deletion. On the v2 path warn for ANY + // active paid access even when no management URL resolves (a paid + // entitlement whose plan is not in the catalog still bills the user), so a + // paying user can never delete with no warning. Off the flag this is + // byte-for-byte today's gate: `hasPaidSubscription && managementURL != null`. + if (shouldWarnBeforeAccountDelete( + hasPaidSubscription: subscriptionController.hasPaidSubscription, + hasManagementUrl: managementURL != null, + v2Path: v2Path, + )) { + final bool canManage = managementURL != null; final resp = await showOkCancelAlertDialog( useRootNavigator: false, context: context, title: L10n.of(context).deleteSubscriptionWarningTitle, message: L10n.of(context).deleteSubscriptionWarningBody, - okLabel: L10n.of(context).manageSubscription, - cancelLabel: L10n.of(context).continueText, + okLabel: canManage + ? L10n.of(context).manageSubscription + : L10n.of(context).continueText, + cancelLabel: canManage + ? L10n.of(context).continueText + : L10n.of(context).cancel, + isDestructive: !canManage, ); - if (resp == OkCancelResult.ok) { - launchUrlString(managementURL, mode: LaunchMode.externalApplication); + if (managementURL != null) { + if (resp == OkCancelResult.ok) { + launchUrlString(managementURL, mode: LaunchMode.externalApplication); + return; + } + } else if (resp != OkCancelResult.ok) { + // No management URL to offer: OK = acknowledge + continue to the delete + // confirmation; Cancel = abort deletion entirely. return; } } diff --git a/lib/routes/settings/settings_subscription/change_subscription.dart b/lib/routes/settings/settings_subscription/change_subscription.dart index 31e63935f5..e28a04097f 100644 --- a/lib/routes/settings/settings_subscription/change_subscription.dart +++ b/lib/routes/settings/settings_subscription/change_subscription.dart @@ -6,7 +6,9 @@ import 'package:intl/intl.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/features/subscription/controllers/subscription_controller.dart'; import 'package:fluffychat/features/subscription/models/subscription_details.dart'; +import 'package:fluffychat/features/subscription/utils/single_flight_guard.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; class ChangeSubscription extends StatefulWidget { @@ -18,7 +20,12 @@ class ChangeSubscription extends StatefulWidget { class ChangeSubscriptionState extends State { SubscriptionDetails? _selectedSubscription; - bool _loading = false; + + /// Re-entry guard for the checkout button (unit-tested SingleFlightGuard): + /// a double-tap must never fire concurrent checkouts or multiple redirects. + final SingleFlightGuard _submitGuard = SingleFlightGuard(); + + bool get _loading => _submitGuard.inFlight; SubscriptionController get _subscriptionController => MatrixState.pangeaController.subscriptionController; @@ -35,7 +42,12 @@ class ChangeSubscriptionState extends State { _subscriptionController.subscription == subscription; bool _isEnabled(SubscriptionDetails subscription) => - (!subscription.isTrial || _subscriptionController.inTrialWindow) && + // #1: a trial is enabled when the local RC trial window is open OR (v2 + // web) the server says the trial is offerable. Off the flag + // `v2TrialOfferable` is false, so this is byte-for-byte today's behavior. + (!subscription.isTrial || + _subscriptionController.inTrialWindow || + _subscriptionController.v2TrialOfferable) && !_isCurrentSubscription(subscription); void _selectSubscription(SubscriptionDetails? subscription) { @@ -47,14 +59,25 @@ class ChangeSubscriptionState extends State { } Future _submitChange(SubscriptionDetails subscription) async { - setState(() => _loading = true); + // Early-return while a submit is already in flight (the button is ALSO + // disabled below — belt and braces against a queued double-tap). + if (!_submitGuard.tryEnter()) return; + setState(() {}); try { - await _subscriptionController.submitSubscriptionChange( - subscription, - context, + // The paywall idiom: showFutureLoadingDialog surfaces a checkout + // failure (network error, promo rejection, poll give-up) in a + // dismissible error dialog instead of letting it escape the tap + // handler unrendered. + await showFutureLoadingDialog( + context: context, + future: () => _subscriptionController.submitSubscriptionChange( + subscription, + context, + ), ); } finally { - if (mounted) setState(() => _loading = false); + _submitGuard.exit(); + if (mounted) setState(() {}); } } @@ -169,8 +192,9 @@ class ChangeSubscriptionState extends State { ), const SizedBox(height: 20.0), ElevatedButton( - onPressed: () => - _submitChange(subscription), + onPressed: _loading + ? null + : () => _submitChange(subscription), child: _loading ? const LinearProgressIndicator() : Row( diff --git a/lib/routes/settings/settings_subscription/settings_subscription.dart b/lib/routes/settings/settings_subscription/settings_subscription.dart index 8b5408343b..62d46f4701 100644 --- a/lib/routes/settings/settings_subscription/settings_subscription.dart +++ b/lib/routes/settings/settings_subscription/settings_subscription.dart @@ -8,12 +8,18 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/features/subscription/controllers/subscription_controller.dart'; import 'package:fluffychat/features/subscription/models/subscription_state.dart'; +import 'package:fluffychat/features/subscription/repo/billing_portal_repo.dart'; import 'package:fluffychat/features/subscription/repo/subscription_management_repo.dart'; +import 'package:fluffychat/features/subscription/utils/cancel_eligibility.dart'; +import 'package:fluffychat/features/subscription/utils/single_flight_guard.dart'; +import 'package:fluffychat/features/subscription/utils/v2_ui_gating.dart'; import 'package:fluffychat/features/subscription/widgets/subscription_snackbar.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/routes/settings/settings_subscription/settings_subscription_view.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:fluffychat/widgets/announcing_snackbar.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; class SubscriptionManagement extends StatefulWidget { @@ -136,6 +142,27 @@ class SubscriptionManagementController extends State DateTime? get subscriptionEndDate => _unsubscribeDetectedAt == null ? null : expirationDate; + /// True on the v2 web path (flag-on, web) only. Off the flag and on mobile + /// this is false and every v2 branch below is inert. + bool get _v2CancelPath => Environment.subsV2WebEnabled && kIsWeb; + + /// Re-entry guards (unit-tested SingleFlightGuard): a double-tap on the + /// cancel tile or a manage tile must not fire concurrent requests or open + /// duplicate confirm dialogs / portal tabs. + final SingleFlightGuard _cancelGuard = SingleFlightGuard(); + final SingleFlightGuard _portalGuard = SingleFlightGuard(); + + /// Gates the cancel / re-enable-renewal tile in the view. Off the v2 path + /// this is always true (RC behavior byte-for-byte unchanged — the tile always + /// renders in that block). On the v2 path the in-app cancel tile shows ONLY + /// when a user-owned, cancelable, not-already-cancelling entitlement exists + /// (shouldShowV2Cancel, I5); D4 keeps re-enable-renewal out of scope. + bool get showCancelRenewalTile { + if (!_v2CancelPath) return true; + final state = subscriptionController.state; + return state is SubscriptionActive && shouldShowV2Cancel(state); + } + void _onSubscriptionUpdate() => setState(() {}); void _onSubscribe() => showSubscribedSnackbar(context); @@ -170,6 +197,49 @@ class SubscriptionManagementController extends State } Future onClickCancelSubscription() async { + final state = subscriptionController.state; + // #3: the v2 path is SELF-CONTAINED — it either runs the in-app cancel or + // no-ops, and NEVER falls through to the legacy external-portal + + // clicked-cancel polling shim. Only the non-v2 path reaches the legacy code + // below (byte-for-byte unchanged off the flag). classifyCancelClick encodes + // this so the routing is unit-tested. + switch (classifyCancelClick(v2CancelPath: _v2CancelPath, state: state)) { + case CancelClickAction.v2Cancel: + // Cancel is synchronous + server-confirmed, so we do NOT set the + // clicked-cancel/end-date polling shim — we refresh via + // updateCurrentSubscription inside the controller. + if (!_cancelGuard.tryEnter()) return; // no concurrent cancels + try { + final result = await showOkCancelAlertDialog( + context: context, + title: L10n.of(context).cancelSubscription, + message: L10n.of(context).areYouSure, + okLabel: L10n.of(context).yes, + cancelLabel: L10n.of(context).cancel, + isDestructive: true, + ); + if (result != OkCancelResult.ok) return; + + // The repo idiom for exactly this: a blocking loading dialog while + // the cancel runs, and a dismissible error surface if it throws — + // the tile stays available, so the user can retry. + await showFutureLoadingDialog( + context: context, + future: () => subscriptionController.cancelSubscription( + (state as SubscriptionActive).entitlementRef!, + ), + ); + if (mounted) setState(() {}); + } finally { + _cancelGuard.exit(); + } + return; + case CancelClickAction.v2NoOp: + return; + case CancelClickAction.legacy: + break; + } + final uri = await launchMangementUrl(ManagementOption.cancel); if (uri != null) { ScaffoldMessenger.of(context).showSnackBarAnnounced( @@ -204,6 +274,21 @@ class SubscriptionManagementController extends State } Future launchMangementUrl(ManagementOption option) async { + // v2 web path: BOTH the payment-method and payment-history tiles mint a + // fresh Stripe billing-portal session — the portal surfaces the payment + // method AND the full invoice history, which is the minimal correct + // wiring onto the canonical v2 APIs without building new client UI + // (Gabby's history page will consume PaymentHistoryRepo directly). The + // legacy static stripeManagementUrl is NEVER launched on this path; off + // the flag / on mobile the legacy code below is byte-for-byte unchanged. + // The routing decision is the unit-tested classifyManagementLaunch. + switch (classifyManagementLaunch(v2Path: _v2CancelPath)) { + case ManagementLaunchRoute.v2BillingPortal: + return _launchV2BillingPortal(); + case ManagementLaunchRoute.legacy: + break; + } + String managementUrl = Environment.stripeManagementUrl; if (_userEmail != null) { managementUrl += "?prefilled_email=${Uri.encodeComponent(_userEmail!)}"; @@ -239,6 +324,53 @@ class SubscriptionManagementController extends State } } + /// Mints a fresh Stripe billing-portal session and opens it (v2 web path). + /// Portal URLs are SHORT-LIVED — always minted on click, never cached (the + /// repo only dedupes an in-flight request). The typed no-portal outcome + /// ([NoBillingAccountException] — the user has no canonical Stripe customer) + /// is NOT an error: it renders the management-unavailable message instead of + /// an error dialog. Transient failures surface in the loading dialog's + /// dismissible error state, so the user can retry from the same tile. + Future _launchV2BillingPortal() async { + if (!_portalGuard.tryEnter()) return null; // no duplicate portal mints + try { + final userID = Matrix.of(context).client.userID; + if (userID == null) return null; + + final result = await showFutureLoadingDialog( + context: context, + future: () async { + final res = await BillingPortalRepo.get(userID); + final error = res.asError?.error; + if (error != null) throw error; + return res.asValue!.value; + }, + // The typed no-portal outcome is handled below, not as an error UI. + showError: (e) => e is! NoBillingAccountException, + ); + if (!mounted) return null; + + if (result.error is NoBillingAccountException) { + ScaffoldMessenger.of(context).showSnackBarAnnounced( + SnackBar( + content: Text(L10n.of(context).subscriptionManagementUnavailable), + ), + announcement: L10n.of(context).subscriptionManagementUnavailable, + ); + return null; + } + + final portal = result.result; + if (portal == null) return null; + + final uri = Uri.parse(portal.url); + launchUrl(uri, mode: LaunchMode.externalApplication); + return uri; + } finally { + _portalGuard.exit(); + } + } + @override Widget build(BuildContext context) => SettingsSubscriptionView(this); } diff --git a/lib/routes/settings/settings_subscription/settings_subscription_view.dart b/lib/routes/settings/settings_subscription/settings_subscription_view.dart index 94db7035fa..22033d8bb0 100644 --- a/lib/routes/settings/settings_subscription/settings_subscription_view.dart +++ b/lib/routes/settings/settings_subscription/settings_subscription_view.dart @@ -6,6 +6,7 @@ import 'package:intl/intl.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/features/subscription/repo/subscription_management_repo.dart'; import 'package:fluffychat/features/subscription/subscription_constants.dart'; +import 'package:fluffychat/features/subscription/utils/v2_ui_gating.dart'; import 'package:fluffychat/features/subscription/widgets/pro_features_card.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/routes/settings/settings_subscription/change_subscription.dart'; @@ -90,24 +91,31 @@ class SettingsSubscriptionView extends StatelessWidget { ), Column( children: [ - ListTile( - title: Text( - controller.subscriptionEndDate == null - ? L10n.of( - context, - ).cancelSubscription - : L10n.of(context).enabledRenewal, - ), - enabled: controller.showManagementOptions, - onTap: - controller.onClickCancelSubscription, - trailing: Icon( - controller.subscriptionEndDate == null - ? Icons.cancel_outlined - : Icons.refresh_outlined, + // Off the v2 path this always renders (RC + // behavior unchanged); on the v2 path the + // in-app cancel tile shows only when an + // eligible entitlement exists (I5/D4). + if (controller.showCancelRenewalTile) ...[ + ListTile( + title: Text( + controller.subscriptionEndDate == null + ? L10n.of( + context, + ).cancelSubscription + : L10n.of(context).enabledRenewal, + ), + enabled: + controller.showManagementOptions, + onTap: controller + .onClickCancelSubscription, + trailing: Icon( + controller.subscriptionEndDate == null + ? Icons.cancel_outlined + : Icons.refresh_outlined, + ), ), - ), - const Divider(height: 1), + const Divider(height: 1), + ], ListTile( title: Text( L10n.of(context).paymentMethod, @@ -209,14 +217,20 @@ class ManagementNotAvailableWarning extends StatelessWidget { String getWarningText(BuildContext context) { if (controller.currentSubscriptionIsPromotional) { - if (controller.isLifetimeSubscription) { + // #2: a comp/seat/manual promotional grant has no expiration date, so + // force-unwrapping it crashed. Render the undated promotional copy for + // lifetime OR any null-expiration promotional access; only format a date + // when one actually exists. + final expiration = controller.expirationDate; + if (showUndatedPromoWarning( + isLifetime: controller.isLifetimeSubscription, + expiration: expiration, + )) { return L10n.of(context).promotionalSubscriptionDesc; } final DateFormat formatter = DateFormat('yyyy-MM-dd'); - return L10n.of( - context, - ).trialExpiration(formatter.format(controller.expirationDate!)); + return L10n.of(context).trialExpiration(formatter.format(expiration!)); } if (controller.currentSubscriptionAvailable) { String warningText = L10n.of(context).subsciptionPlatformTooltip; diff --git a/test/features/subscription/billing_portal_repo_test.dart b/test/features/subscription/billing_portal_repo_test.dart new file mode 100644 index 0000000000..f5b0bcdd05 --- /dev/null +++ b/test/features/subscription/billing_portal_repo_test.dart @@ -0,0 +1,226 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:fluffychat/features/subscription/repo/billing_portal_repo.dart'; +import 'package:fluffychat/features/subscription/repo/billing_portal_response.dart'; +import 'package:fluffychat/pangea/common/network/requests.dart'; + +void main() { + const portalUrl = "https://example.test/subscription/billing_portal"; + + group('BillingPortalResponse.fromJson', () { + test('parses the {url} envelope', () { + final res = BillingPortalResponse.fromJson({ + "url": "https://billing.stripe.com/p/session_123", + }); + expect(res.url, "https://billing.stripe.com/p/session_123"); + expect(res.toJson(), {"url": "https://billing.stripe.com/p/session_123"}); + }); + }); + + group('BillingPortalRepo.getWith', () { + Requests requestsWith(int statusCode, Object body) { + final client = MockClient((request) async { + return http.Response(jsonEncode(body), statusCode); + }); + return Requests(accessToken: "token", client: client); + } + + test('200 -> Result.value carrying the portal url', () async { + final req = requestsWith(200, { + "url": "https://billing.stripe.com/p/session_ok", + }); + + final result = await BillingPortalRepo.getWith( + req, + url: portalUrl, + dedupeKey: "t-ok", + ); + + expect(result.isError, false); + expect( + result.asValue!.value.url, + "https://billing.stripe.com/p/session_ok", + ); + }); + + test( + '404 "No billing account" -> typed NoBillingAccountException (hide manage)', + () async { + final req = requestsWith(404, {"detail": "No billing account"}); + + final result = await BillingPortalRepo.getWith( + req, + url: portalUrl, + dedupeKey: "t-404", + ); + + expect(result.isError, true); + expect(result.asError!.error, isA()); + }, + ); + + test('a non-404 error is NOT a NoBillingAccountException', () async { + final req = requestsWith(500, {"detail": "internal error"}); + + final result = await BillingPortalRepo.getWith( + req, + url: portalUrl, + dedupeKey: "t-500", + ); + + expect(result.isError, true); + expect(result.asError!.error, isNot(isA())); + }); + + // finding #4: only a 404 whose detail is exactly "No billing account" is the + // no-portal signal. Any other 404 (route/deploy mismatch, FastAPI + // "Not Found") is an integration failure and must NOT be masked as + // "management unavailable". + test('404 "No billing account" -> NoBillingAccountException', () async { + final req = requestsWith(404, {"detail": "No billing account"}); + + final result = await BillingPortalRepo.getWith( + req, + url: portalUrl, + dedupeKey: "t-404-nba", + ); + + expect(result.asError!.error, isA()); + }); + + test( + 'a DIFFERENT 404 body (route mismatch) -> real ChoreoException', + () async { + final req = requestsWith(404, {"detail": "Not Found"}); + + final result = await BillingPortalRepo.getWith( + req, + url: portalUrl, + dedupeKey: "t-404-notfound", + ); + + expect(result.isError, true); + expect(result.asError!.error, isNot(isA())); + expect(result.asError!.error, isA()); + }, + ); + + // Portal session URLs are SHORT-LIVED and minted on click — the repo must + // never cache them, and must NEVER cache a transient error (a single 5xx + // must not poison manage-billing). + test( + 'an error is never cached — the next call retries and succeeds', + () async { + var calls = 0; + final client = MockClient((request) async { + calls++; + if (calls == 1) { + return http.Response(jsonEncode({"detail": "upstream down"}), 502); + } + return http.Response( + jsonEncode({"url": "https://billing.stripe.com/p/session_retry"}), + 200, + ); + }); + final req = Requests(accessToken: "token", client: client); + + final first = await BillingPortalRepo.getWith( + req, + url: portalUrl, + dedupeKey: "t-retry", + ); + expect(first.isError, true); + + final second = await BillingPortalRepo.getWith( + req, + url: portalUrl, + dedupeKey: "t-retry", + ); + expect(second.isError, false); + expect( + second.asValue!.value.url, + "https://billing.stripe.com/p/session_retry", + ); + expect(calls, 2); + }, + ); + + test('two CONCURRENT calls dedupe to a single HTTP request', () async { + var calls = 0; + final gate = Completer(); + final client = MockClient((request) async { + calls++; + await gate.future; + return http.Response( + jsonEncode({"url": "https://billing.stripe.com/p/session_once"}), + 200, + ); + }); + final req = Requests(accessToken: "token", client: client); + + final f1 = BillingPortalRepo.getWith( + req, + url: portalUrl, + dedupeKey: "t-dedupe", + ); + final f2 = BillingPortalRepo.getWith( + req, + url: portalUrl, + dedupeKey: "t-dedupe", + ); + gate.complete(); + + final results = await Future.wait([f1, f2]); + expect(calls, 1); + expect( + results[0].asValue!.value.url, + "https://billing.stripe.com/p/session_once", + ); + expect( + results[1].asValue!.value.url, + "https://billing.stripe.com/p/session_once", + ); + }); + + test( + 'SEQUENTIAL calls mint a FRESH session each time (no URL cache)', + () async { + var calls = 0; + final client = MockClient((request) async { + calls++; + return http.Response( + jsonEncode({"url": "https://billing.stripe.com/p/session_$calls"}), + 200, + ); + }); + final req = Requests(accessToken: "token", client: client); + + final first = await BillingPortalRepo.getWith( + req, + url: portalUrl, + dedupeKey: "t-fresh", + ); + final second = await BillingPortalRepo.getWith( + req, + url: portalUrl, + dedupeKey: "t-fresh", + ); + + expect(calls, 2); + expect( + first.asValue!.value.url, + "https://billing.stripe.com/p/session_1", + ); + expect( + second.asValue!.value.url, + "https://billing.stripe.com/p/session_2", + ); + }, + ); + }); +} diff --git a/test/features/subscription/cancel_eligibility_test.dart b/test/features/subscription/cancel_eligibility_test.dart new file mode 100644 index 0000000000..79a6953443 --- /dev/null +++ b/test/features/subscription/cancel_eligibility_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fluffychat/features/subscription/models/subscription_state.dart'; +import 'package:fluffychat/features/subscription/utils/cancel_eligibility.dart'; + +void main() { + SubscriptionActive active({ + bool? cancelable, + bool? cancelAtPeriodEnd, + String? entitlementRef, + }) => SubscriptionActive( + subscriptionId: "month", + cancelable: cancelable, + cancelAtPeriodEnd: cancelAtPeriodEnd, + entitlementRef: entitlementRef, + ); + + group('shouldShowV2Cancel truth table (I5)', () { + test('cancelable + not cancelling + ref -> show', () { + expect( + shouldShowV2Cancel( + active( + cancelable: true, + cancelAtPeriodEnd: false, + entitlementRef: "ent_1", + ), + ), + true, + ); + }); + + test('already cancel-at-period-end -> hide', () { + expect( + shouldShowV2Cancel( + active( + cancelable: true, + cancelAtPeriodEnd: true, + entitlementRef: "ent_1", + ), + ), + false, + ); + }); + + test('not cancelable -> hide', () { + expect( + shouldShowV2Cancel( + active( + cancelable: false, + cancelAtPeriodEnd: false, + entitlementRef: "ent_1", + ), + ), + false, + ); + }); + + test('null ref -> hide', () { + expect( + shouldShowV2Cancel(active(cancelable: true, cancelAtPeriodEnd: false)), + false, + ); + }); + + test('mobile/RC (all null) -> hide', () { + expect(shouldShowV2Cancel(active()), false); + }); + + test('null cancelAtPeriodEnd is treated as not cancelling', () { + expect( + shouldShowV2Cancel(active(cancelable: true, entitlementRef: "ent_1")), + true, + ); + }); + }); +} diff --git a/test/features/subscription/cancel_response_test.dart b/test/features/subscription/cancel_response_test.dart new file mode 100644 index 0000000000..966bf3ec1f --- /dev/null +++ b/test/features/subscription/cancel_response_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fluffychat/features/subscription/models/cancel_response.dart'; + +void main() { + group('CancelResponse.fromJson', () { + test('parses the cancel_at_period_end success shape', () { + final res = CancelResponse.fromJson({ + "status": "cancel_at_period_end", + "entitlementRef": "ent_123", + }); + expect(res.status, "cancel_at_period_end"); + expect(res.entitlementRef, "ent_123"); + }); + + test('tolerates a missing entitlementRef', () { + final res = CancelResponse.fromJson({"status": "cancel_at_period_end"}); + expect(res.status, "cancel_at_period_end"); + expect(res.entitlementRef, ""); + }); + }); +} diff --git a/test/features/subscription/checkout_response_test.dart b/test/features/subscription/checkout_response_test.dart new file mode 100644 index 0000000000..281caf5208 --- /dev/null +++ b/test/features/subscription/checkout_response_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fluffychat/features/subscription/models/checkout_response.dart'; + +void main() { + group('CheckoutResponse.fromJson', () { + test('created carries a sessionUrl and is resolved', () { + final res = CheckoutResponse.fromJson({ + "status": "created", + "sessionUrl": "https://checkout.stripe.com/c/pay/cs_test_123", + "retryAfterSeconds": null, + }); + expect(res.status, "created"); + expect(res.sessionUrl, "https://checkout.stripe.com/c/pay/cs_test_123"); + expect(res.isResolved, true); + expect(res.isCreating, false); + }); + + test('parses appliedPromoCode when the session carries a discount', () { + final res = CheckoutResponse.fromJson({ + "status": "created", + "sessionUrl": "https://checkout.stripe.com/c/pay/cs_test_disc", + "appliedPromoCode": "WELCOME50", + }); + expect(res.appliedPromoCode, "WELCOME50"); + expect(res.isResolved, true); + }); + + test('appliedPromoCode is null when absent or explicitly null', () { + final absent = CheckoutResponse.fromJson({ + "status": "created", + "sessionUrl": "https://checkout.stripe.com/c/pay/cs_test_nodisc", + }); + expect(absent.appliedPromoCode, isNull); + + final explicitNull = CheckoutResponse.fromJson({ + "status": "reused", + "sessionUrl": "https://checkout.stripe.com/c/pay/cs_test_reuse", + "appliedPromoCode": null, + }); + expect(explicitNull.appliedPromoCode, isNull); + }); + + test('reused carries a sessionUrl and is resolved', () { + final res = CheckoutResponse.fromJson({ + "status": "reused", + "sessionUrl": "https://checkout.stripe.com/c/pay/cs_test_456", + }); + expect(res.status, "reused"); + expect(res.isResolved, true); + expect(res.isCreating, false); + }); + + test('creating carries retryAfterSeconds and no sessionUrl', () { + final res = CheckoutResponse.fromJson({ + "status": "creating", + "sessionUrl": null, + "retryAfterSeconds": 2, + }); + expect(res.status, "creating"); + expect(res.sessionUrl, isNull); + expect(res.retryAfterSeconds, 2); + expect(res.isCreating, true); + expect(res.isResolved, false); + }); + }); +} diff --git a/test/features/subscription/checkout_v2_repo_poll_test.dart b/test/features/subscription/checkout_v2_repo_poll_test.dart new file mode 100644 index 0000000000..e249294b63 --- /dev/null +++ b/test/features/subscription/checkout_v2_repo_poll_test.dart @@ -0,0 +1,394 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:fluffychat/features/subscription/repo/checkout_v2_repo.dart'; +import 'package:fluffychat/pangea/common/network/requests.dart'; + +void main() { + // A literal checkout URL is passed to `checkoutWith` so the pollable core is + // exercised without touching `Environment` (GetStorage/dotenv are not + // initialized under `flutter test`). + const checkoutUrl = "https://example.test/subscription/checkout"; + + // Bounded-poll behavior (I3), driven through the testable `checkoutWith` core + // with a MockClient transport + a fake delay seam (no real waits). Each POST + // body is captured to assert the money-safety contract: EXACTLY {"planId"} + // (plus an optional "promoCode"), no injected user context (I1). + group('CheckoutV2Repo.checkoutWith poll', () { + late List> sentBodies; + late List observedDelays; + + setUp(() { + sentBodies = []; + observedDelays = []; + }); + + Future fakeDelay(Duration d) async => observedDelays.add(d); + + Requests requestsReturning(List> responses) { + var call = 0; + final client = MockClient((request) async { + sentBodies.add(jsonDecode(request.body) as Map); + // creating -> 202, resolved -> 200 (mirrors the choreo status codes). + final body = + responses[call < responses.length ? call : responses.length - 1]; + call++; + final statusCode = body["status"] == "creating" ? 202 : 200; + return http.Response(jsonEncode(body), statusCode); + }); + return Requests(accessToken: "token", client: client); + } + + test( + 'creating -> created loops, honors retryAfterSeconds, returns response', + () async { + final req = requestsReturning([ + {"status": "creating", "sessionUrl": null, "retryAfterSeconds": 2}, + { + "status": "created", + "sessionUrl": "https://checkout.stripe.com/c/pay/cs_test_ok", + }, + ]); + + final res = await CheckoutV2Repo.checkoutWith( + req, + "month", + url: checkoutUrl, + delay: fakeDelay, + ); + + expect(res.sessionUrl, "https://checkout.stripe.com/c/pay/cs_test_ok"); + // One wait, of exactly the server-supplied retryAfterSeconds. + expect(observedDelays, [const Duration(seconds: 2)]); + expect(sentBodies.length, 2); + }, + ); + + test( + 'every POST body is exactly {planId} — no injected context (I1)', + () async { + final req = requestsReturning([ + {"status": "creating", "retryAfterSeconds": 2}, + {"status": "reused", "sessionUrl": "https://s/cs_test_reuse"}, + ]); + + await CheckoutV2Repo.checkoutWith( + req, + "year", + url: checkoutUrl, + delay: fakeDelay, + ); + + for (final body in sentBodies) { + expect(body, {"planId": "year"}); + expect(body.containsKey("user_cefr"), false); + expect(body.containsKey("user_gender"), false); + } + }, + ); + + test('falls back to a 2s delay when retryAfterSeconds is absent', () async { + final req = requestsReturning([ + {"status": "creating"}, + {"status": "created", "sessionUrl": "https://s/cs_test_fallback"}, + ]); + + await CheckoutV2Repo.checkoutWith( + req, + "month", + url: checkoutUrl, + delay: fakeDelay, + ); + expect(observedDelays, [CheckoutV2Repo.fallbackDelay]); + }); + + test('creating forever -> throws after the attempt cap', () async { + final req = requestsReturning([ + {"status": "creating", "retryAfterSeconds": 2}, + ]); + + await expectLater( + CheckoutV2Repo.checkoutWith( + req, + "month", + url: checkoutUrl, + delay: fakeDelay, + ), + throwsA(isA()), + ); + // 5 attempts total, 4 waits between them (the 5th attempt does not wait). + expect(sentBodies.length, CheckoutV2Repo.maxAttempts); + expect(observedDelays.length, CheckoutV2Repo.maxAttempts - 1); + }); + + test('resolved without a sessionUrl throws (malformed terminal)', () async { + final req = requestsReturning([ + {"status": "created", "sessionUrl": null}, + ]); + + await expectLater( + CheckoutV2Repo.checkoutWith( + req, + "month", + url: checkoutUrl, + delay: fakeDelay, + ), + throwsA(isA()), + ); + }); + + test('stops polling once the cumulative wait budget is exhausted', () async { + // A large retryAfterSeconds trips the ~15s total-wait cap before the + // attempt cap: first wait 10s (<=15 issued), second would push to 20s and + // is refused, so it throws after 2 attempts / 1 wait. + final req = requestsReturning([ + {"status": "creating", "retryAfterSeconds": 10}, + ]); + + await expectLater( + CheckoutV2Repo.checkoutWith( + req, + "month", + url: checkoutUrl, + delay: fakeDelay, + ), + throwsA(isA()), + ); + expect(observedDelays, [const Duration(seconds: 10)]); + expect(sentBodies.length, 2); + }); + }); + + // Discount-checkout behavior: the optional promoCode in the body, the echoed + // appliedPromoCode, and the typed promo-rejection surface Gabby's UI consumes. + group('CheckoutV2Repo.checkoutWith discount', () { + const checkoutUrl = "https://example.test/subscription/checkout"; + late List> sentBodies; + + setUp(() => sentBodies = []); + + Requests requestsReturning({ + required Map body, + int statusCode = 200, + }) { + final client = MockClient((request) async { + sentBodies.add(jsonDecode(request.body) as Map); + return http.Response(jsonEncode(body), statusCode); + }); + return Requests(accessToken: "token", client: client); + } + + // Emits a raw error response with the given `detail` (object or list) so + // `Requests.handleError` rethrows the raw response — the 422 shapes. + Requests requestsErroring({required Object detail, int statusCode = 422}) { + final client = MockClient((request) async { + sentBodies.add(jsonDecode(request.body) as Map); + return http.Response(jsonEncode({"detail": detail}), statusCode); + }); + return Requests(accessToken: "token", client: client); + } + + test('promoCode is sent as exactly {planId, promoCode}', () async { + final req = requestsReturning( + body: { + "status": "created", + "sessionUrl": "https://s/cs_test_disc", + "appliedPromoCode": "WELCOME50", + }, + ); + + await CheckoutV2Repo.checkoutWith( + req, + "month", + promoCode: "WELCOME50", + url: checkoutUrl, + ); + + expect(sentBodies.single, {"planId": "month", "promoCode": "WELCOME50"}); + }); + + test('an empty promoCode is omitted from the body', () async { + final req = requestsReturning( + body: {"status": "created", "sessionUrl": "https://s/cs_test_ok"}, + ); + + await CheckoutV2Repo.checkoutWith( + req, + "month", + promoCode: "", + url: checkoutUrl, + ); + + expect(sentBodies.single, {"planId": "month"}); + }); + + test('a whitespace-only promoCode is omitted from the body', () async { + final req = requestsReturning( + body: {"status": "created", "sessionUrl": "https://s/cs_test_ok"}, + ); + + await CheckoutV2Repo.checkoutWith( + req, + "month", + promoCode: " ", + url: checkoutUrl, + ); + + expect(sentBodies.single, {"planId": "month"}); + }); + + test('a promoCode with surrounding whitespace is sent trimmed', () async { + final req = requestsReturning( + body: { + "status": "created", + "sessionUrl": "https://s/cs_test_disc", + "appliedPromoCode": "WELCOME50", + }, + ); + + await CheckoutV2Repo.checkoutWith( + req, + "month", + promoCode: " WELCOME50 ", + url: checkoutUrl, + ); + + expect(sentBodies.single, {"planId": "month", "promoCode": "WELCOME50"}); + }); + + test('exposes appliedPromoCode from the resolved response', () async { + final req = requestsReturning( + body: { + "status": "reused", + "sessionUrl": "https://s/cs_test_reuse", + // Reuse ignores the requested code; the STORED code echoes back. + "appliedPromoCode": "EXISTINGCODE", + }, + ); + + final res = await CheckoutV2Repo.checkoutWith( + req, + "month", + promoCode: "WELCOME50", + url: checkoutUrl, + ); + + expect(res.appliedPromoCode, "EXISTINGCODE"); + expect(res.sessionUrl, "https://s/cs_test_reuse"); + }); + + test( + 'promo_not_applicable 422 -> PromoNotApplicableException, each reason typed', + () async { + const wireToReason = { + "not_found_or_inactive": + CheckoutPromoRejectionReason.notFoundOrInactive, + "wrong_customer": CheckoutPromoRejectionReason.wrongCustomer, + "expired": CheckoutPromoRejectionReason.expired, + "max_redeemed": CheckoutPromoRejectionReason.maxRedeemed, + "coupon_invalid": CheckoutPromoRejectionReason.couponInvalid, + "wrong_plan": CheckoutPromoRejectionReason.wrongPlan, + "below_minimum": CheckoutPromoRejectionReason.belowMinimum, + "currency_mismatch": CheckoutPromoRejectionReason.currencyMismatch, + "rejected_by_stripe": CheckoutPromoRejectionReason.rejectedByStripe, + }; + + for (final entry in wireToReason.entries) { + final req = requestsErroring( + detail: {"code": "promo_not_applicable", "reason": entry.key}, + ); + + await expectLater( + CheckoutV2Repo.checkoutWith( + req, + "month", + promoCode: "BADCODE", + url: checkoutUrl, + ), + throwsA( + isA() + .having((e) => e.reason, 'reason', entry.value) + .having((e) => e.rawReason, 'rawReason', entry.key), + ), + ); + } + }, + ); + + test( + 'an unmodeled promo reason maps to unknown (never throws on map)', + () async { + final req = requestsErroring( + detail: { + "code": "promo_not_applicable", + "reason": "brand_new_reason", + }, + ); + + await expectLater( + CheckoutV2Repo.checkoutWith( + req, + "month", + promoCode: "BADCODE", + url: checkoutUrl, + ), + throwsA( + isA().having( + (e) => e.reason, + 'reason', + CheckoutPromoRejectionReason.unknown, + ), + ), + ); + }, + ); + + test( + 'schema-shaped 422 (list detail) -> CheckoutException, NOT promo rejection', + () async { + final req = requestsErroring( + detail: [ + { + "type": "extra_forbidden", + "loc": ["body", "surprise"], + "msg": "Extra inputs are not permitted", + }, + ], + ); + + await expectLater( + CheckoutV2Repo.checkoutWith( + req, + "month", + promoCode: "WELCOME50", + url: checkoutUrl, + ), + throwsA( + allOf( + isA(), + isNot(isA()), + ), + ), + ); + }, + ); + + test( + 'a string-detail error (e.g. 409) surfaces as ChoreoException', + () async { + final req = requestsErroring( + detail: "already_subscribed", + statusCode: 409, + ); + + await expectLater( + CheckoutV2Repo.checkoutWith(req, "month", url: checkoutUrl), + throwsA(isA()), + ); + }, + ); + }); +} diff --git a/test/features/subscription/payment_history_response_test.dart b/test/features/subscription/payment_history_response_test.dart new file mode 100644 index 0000000000..9e435e4fd6 --- /dev/null +++ b/test/features/subscription/payment_history_response_test.dart @@ -0,0 +1,137 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fluffychat/features/subscription/repo/payment_history_response.dart'; + +// Verifies the client InvoiceSummary matches the REAL v2 `/subscription/history` +// shape (choreo InvoiceHistoryResponse): total/subtotal/amount_paid in minor +// units, `created` as a Zulu ISO-8601 STRING (not a unix int), the nullable +// url/number/promo fields, and NO single `amount` field. +void main() { + group('InvoiceSummary.fromJson', () { + test('parses a full paid invoice (money in minor units)', () { + final inv = InvoiceSummary.fromJson({ + "id": "in_123", + "number": "ABCD-0001", + "created": "2026-07-01T12:00:00Z", + "subtotal": 999, + "total": 999, + "amount_paid": 999, + "currency": "usd", + "status": "paid", + "hosted_invoice_url": "https://pay.stripe.com/invoice/abc", + "invoice_pdf": "https://pay.stripe.com/invoice/abc/pdf", + "promo_code": null, + }); + + expect(inv.id, "in_123"); + expect(inv.number, "ABCD-0001"); + // Zulu ISO string, not an int — and re-parseable as a DateTime. + expect(inv.created, "2026-07-01T12:00:00Z"); + expect(DateTime.parse(inv.created).isUtc, true); + expect(inv.subtotal, 999); + expect(inv.total, 999); + expect(inv.amountPaid, 999); + expect(inv.currency, "usd"); + expect(inv.status, "paid"); + expect(inv.hostedInvoiceUrl, "https://pay.stripe.com/invoice/abc"); + expect(inv.invoicePdf, "https://pay.stripe.com/invoice/abc/pdf"); + expect(inv.promoCode, isNull); + }); + + test('tolerates null number / urls / promo_code', () { + final inv = InvoiceSummary.fromJson({ + "id": "in_456", + "number": null, + "created": "2026-06-15T08:30:00Z", + "subtotal": 8999, + "total": 8999, + "amount_paid": 0, + "currency": "eur", + "status": "open", + "hosted_invoice_url": null, + "invoice_pdf": null, + "promo_code": null, + }); + + expect(inv.number, isNull); + expect(inv.hostedInvoiceUrl, isNull); + expect(inv.invoicePdf, isNull); + expect(inv.promoCode, isNull); + expect(inv.amountPaid, 0); + expect(inv.status, "open"); + }); + + test('there is NO single `amount` field on the invoice model', () { + // Guards against a regression to the pre-v2 shape (which had `amount`). + final json = InvoiceSummary.fromJson({ + "id": "in_789", + "created": "2026-05-01T00:00:00Z", + "subtotal": 100, + "total": 100, + "amount_paid": 100, + "currency": "usd", + "status": "paid", + }).toJson(); + + expect(json.containsKey("amount"), false); + expect(json.keys, containsAll(["subtotal", "total", "amount_paid"])); + }); + }); + + group('numeric leniency (serializer may emit 999.0 for 999)', () { + test('double-valued minor-unit amounts parse to ints', () { + final inv = InvoiceSummary.fromJson({ + "id": "in_dbl", + "created": "2026-07-01T12:00:00Z", + "subtotal": 999.0, + "total": 999.0, + "amount_paid": 999.0, + "currency": "usd", + "status": "paid", + }); + + expect(inv.subtotal, 999); + expect(inv.total, 999); + expect(inv.amountPaid, 999); + }); + }); + + group('PaymentHistoryResponse.fromJson', () { + test('parses a list of invoices', () { + final res = PaymentHistoryResponse.fromJson({ + "invoices": [ + { + "id": "in_1", + "created": "2026-07-01T12:00:00Z", + "subtotal": 999, + "total": 999, + "amount_paid": 999, + "currency": "usd", + "status": "paid", + }, + { + "id": "in_2", + "created": "2026-06-01T12:00:00Z", + "subtotal": 999, + "total": 999, + "amount_paid": 999, + "currency": "usd", + "status": "paid", + }, + ], + }); + + expect(res.invoices, hasLength(2)); + expect(res.invoices.first.id, "in_1"); + }); + + test('no customers -> an empty invoices list', () { + expect( + PaymentHistoryResponse.fromJson({"invoices": []}).invoices, + isEmpty, + ); + // Missing key also degrades to empty (defensive). + expect(PaymentHistoryResponse.fromJson({}).invoices, isEmpty); + }); + }); +} diff --git a/test/features/subscription/products_v2_repo_test.dart b/test/features/subscription/products_v2_repo_test.dart new file mode 100644 index 0000000000..efe89b00ef --- /dev/null +++ b/test/features/subscription/products_v2_repo_test.dart @@ -0,0 +1,111 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:fluffychat/features/subscription/repo/products_v2_repo.dart'; +import 'package:fluffychat/pangea/common/network/requests.dart'; + +// A /products FETCH FAILURE must NOT be swallowed into an empty catalog (which +// would strand a buyer on a 503 with "no plans"). getWith distinguishes a real +// empty 200 (return empty) from a transport/5xx/malformed failure (throw), so +// the controller can set a retryable error state instead (finding #2). +void main() { + const productsUrl = "https://example.test/subscription/products"; + + Requests requestsWith(int statusCode, String body) { + final client = MockClient( + (request) async => http.Response(body, statusCode), + ); + return Requests(accessToken: "token", client: client); + } + + test('200 with plans -> parsed catalog (non-empty)', () async { + final req = requestsWith( + 200, + jsonEncode({ + "plans": [ + { + "planId": "month", + "amount": 999, + "currency": "usd", + "interval": "month", + "interval_count": 1, + }, + ], + }), + ); + + final res = await ProductsV2Repo.getWith(req, url: productsUrl); + expect(res.plans, hasLength(1)); + expect(res.plans.single.planId, "month"); + }); + + test( + '200 with an EMPTY plans list -> empty catalog (NOT an error)', + () async { + final req = requestsWith(200, jsonEncode({"plans": []})); + + final res = await ProductsV2Repo.getWith(req, url: productsUrl); + expect(res.plans, isEmpty); + }, + ); + + test( + '503 products_unavailable -> THROWS (fetch failure, not empty)', + () async { + final req = requestsWith( + 503, + jsonEncode({"detail": "products_unavailable"}), + ); + + await expectLater( + ProductsV2Repo.getWith(req, url: productsUrl), + throwsA(anything), + ); + }, + ); + + test('a malformed 200 body -> THROWS (fetch failure, not empty)', () async { + final req = requestsWith(200, "not json"); + + await expectLater( + ProductsV2Repo.getWith(req, url: productsUrl), + throwsA(anything), + ); + }); + + test( + 'a 200 with NO plans key -> THROWS (contract-malformed, not empty)', + () async { + final req = requestsWith(200, jsonEncode({})); + + await expectLater( + ProductsV2Repo.getWith(req, url: productsUrl), + throwsA(isA()), + ); + }, + ); + + test( + 'a 200 with plans:null -> THROWS (contract-malformed, not empty)', + () async { + final req = requestsWith(200, jsonEncode({"plans": null})); + + await expectLater( + ProductsV2Repo.getWith(req, url: productsUrl), + throwsA(isA()), + ); + }, + ); + + test('a 200 with plans as a non-list -> THROWS', () async { + final req = requestsWith(200, jsonEncode({"plans": "oops"})); + + await expectLater( + ProductsV2Repo.getWith(req, url: productsUrl), + throwsA(isA()), + ); + }); +} diff --git a/test/features/subscription/products_v2_response_test.dart b/test/features/subscription/products_v2_response_test.dart new file mode 100644 index 0000000000..f2dee7e0ae --- /dev/null +++ b/test/features/subscription/products_v2_response_test.dart @@ -0,0 +1,131 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fluffychat/features/subscription/models/products_v2_response.dart'; +import 'package:fluffychat/features/subscription/utils/subscription_duration_enum.dart'; + +void main() { + // Real-shaped fixture matching choreo `ProductsV2Response` / `ProductPlanV2` + // (products_v2_schema.py): amounts are minor units (999 == $9.99), currency + // is lowercase ISO, planId is the catalog id the client sends to /checkout. + Map productsFixture() => { + "plans": [ + { + "planId": "month", + "amount": 999, + "currency": "usd", + "interval": "month", + "interval_count": 1, + }, + { + "planId": "year", + "amount": 9999, + "currency": "usd", + "interval": "year", + "interval_count": 1, + }, + ], + "prices_localized_at_checkout": true, + "country": null, + }; + + group('ProductsV2Response.fromJson', () { + test('parses both plans with amount/currency/interval', () { + final res = ProductsV2Response.fromJson(productsFixture()); + expect(res.plans.length, 2); + expect(res.pricesLocalizedAtCheckout, true); + expect(res.country, isNull); + + final month = res.plans[0]; + expect(month.planId, "month"); + expect(month.amount, 999); + expect(month.currency, "usd"); + expect(month.interval, "month"); + expect(month.intervalCount, 1); + + final year = res.plans[1]; + expect(year.planId, "year"); + expect(year.amount, 9999); + }); + + test('interval_count defaults to 1 when absent', () { + final res = ProductsV2Response.fromJson({ + "plans": [ + { + "planId": "month", + "amount": 999, + "currency": "usd", + "interval": "month", + }, + ], + }); + expect(res.plans.single.intervalCount, 1); + }); + + test('empty catalog yields empty plans (not an error)', () { + final res = ProductsV2Response.fromJson({"plans": []}); + expect(res.plans, isEmpty); + }); + + test('a MISSING plans key throws (malformed, not an empty catalog)', () { + expect( + () => ProductsV2Response.fromJson({}), + throwsA(isA()), + ); + }); + + test('a null / non-list plans throws (malformed)', () { + expect( + () => ProductsV2Response.fromJson({"plans": null}), + throwsA(isA()), + ); + expect( + () => ProductsV2Response.fromJson({"plans": "oops"}), + throwsA(isA()), + ); + }); + + test('echoes country when present', () { + final json = productsFixture()..["country"] = "US"; + expect(ProductsV2Response.fromJson(json).country, "US"); + }); + }); + + group('productV2ToSubscriptionDetails (D7 mapper)', () { + test('month 999/usd -> price 9.99, duration month, currency usd', () { + final res = ProductsV2Response.fromJson(productsFixture()); + final details = productV2ToSubscriptionDetails( + res.plans[0], + stripeAppId: "stripe_app_xyz", + ); + expect(details.id, "month"); + expect(details.price, closeTo(9.99, 1e-9)); + expect(details.currency, "usd"); + expect(details.duration, SubscriptionDuration.month); + expect(details.appId, "stripe_app_xyz"); + expect(details.isVisible, true); + }); + + test('year 9999/usd -> price 99.99, duration year', () { + final res = ProductsV2Response.fromJson(productsFixture()); + final details = productV2ToSubscriptionDetails( + res.plans[1], + stripeAppId: "stripe_app_xyz", + ); + expect(details.price, closeTo(99.99, 1e-9)); + expect(details.duration, SubscriptionDuration.year); + }); + + test('unknown planId throws (I4) — never a null duration', () { + const rogue = ProductV2( + planId: "lifetime", + amount: 19999, + currency: "usd", + interval: "year", + ); + expect( + () => productV2ToSubscriptionDetails(rogue, stripeAppId: "stripe"), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/features/subscription/requests_inject_context_test.dart b/test/features/subscription/requests_inject_context_test.dart new file mode 100644 index 0000000000..6db49dadcc --- /dev/null +++ b/test/features/subscription/requests_inject_context_test.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:fluffychat/features/user/user_constants.dart'; +import 'package:fluffychat/pangea/common/models/base_request_model.dart'; +import 'package:fluffychat/pangea/common/network/requests.dart'; + +void main() { + // I8 (finding #7): `Requests.post` injection is controlled ONLY by its + // `injectUserContext` param, never by SUBS_V2_WEB. The `false` path is the + // money-safety contract for `/checkout` + `/cancel`, whose choreo schemas are + // `extra="forbid"` — any injected field (cefr_level / user_gender) would be a + // 422. This test pins that opt-out sends the body verbatim. + // + // NOTE: the `true` path's MatrixState-fed injection (adding cefr_level / + // user_gender from the signed-in user's settings) cannot be exercised in a + // bare `flutter test` — bringing up a fake `MatrixState` singleton is out of + // scope here (same limitation the repo notes in stt_transcript_tokens_test). + // `BaseRequestModel.injectUserContext` tolerates the missing singleton, so we + // instead pin: (a) the branch is invoked on the default/true path, (b) it + // never strips caller-supplied context, and (c) the helper's no-overwrite + + // singleton-absent contract directly. + group('Requests.post injectUserContext', () { + late List> capturedBodies; + late http.Client mockClient; + + setUp(() { + capturedBodies = []; + mockClient = MockClient((request) async { + capturedBodies.add(jsonDecode(request.body) as Map); + return http.Response('{}', 200); + }); + }); + + test('injectUserContext:false sends the body verbatim', () async { + final req = Requests(accessToken: 'token', client: mockClient); + await req.post( + url: 'https://example.test/checkout', + body: {'planId': 'month'}, + injectUserContext: false, + ); + + expect(capturedBodies.single, {'planId': 'month'}); + expect(capturedBodies.single.containsKey(UserConstants.cefrLevel), false); + expect( + capturedBodies.single.containsKey(UserConstants.userGender), + false, + ); + }); + + test( + 'injectUserContext:false never adds context even for a bare body', + () async { + final req = Requests(accessToken: 'token', client: mockClient); + await req.post( + url: 'https://example.test/cancel', + body: {'entitlementRef': 'ent_abc'}, + injectUserContext: false, + ); + + expect(capturedBodies.single, {'entitlementRef': 'ent_abc'}); + }, + ); + + test( + 'default (no arg) routes through the injector; false bypasses it', + () async { + // A spy injector stands in for BaseRequestModel.injectUserContext (whose + // real MatrixState read is unavailable under flutter test) and stamps a + // marker. This is a NON-VACUOUS proof: the marker appears ONLY when the + // injection branch runs, so it distinguishes default==true from false. + var injectorCalls = 0; + Map spyInjector(Map body) { + injectorCalls++; + return {...Map.from(body), 'injected_marker': true}; + } + + final req = Requests( + accessToken: 'token', + client: mockClient, + contextInjector: spyInjector, + ); + + // No arg -> default true -> injector runs, marker added. + await req.post( + url: 'https://example.test/grammar', + body: {'t': 'hola'}, + ); + expect(injectorCalls, 1); + expect(capturedBodies.last['injected_marker'], true); + expect(capturedBodies.last['t'], 'hola'); + + // Explicit false -> injector NOT called, verbatim body. + await req.post( + url: 'https://example.test/checkout', + body: {'planId': 'month'}, + injectUserContext: false, + ); + expect(injectorCalls, 1); + expect(capturedBodies.last, {'planId': 'month'}); + expect(capturedBodies.last.containsKey('injected_marker'), false); + }, + ); + }); + + group('BaseRequestModel.injectUserContext contract', () { + test('returns a String-keyed copy and tolerates missing MatrixState', () { + final result = BaseRequestModel.injectUserContext({'planId': 'year'}); + expect(result, isA>()); + expect(result['planId'], 'year'); + }); + + test('does not overwrite existing cefr_level / user_gender', () { + final result = BaseRequestModel.injectUserContext({ + UserConstants.cefrLevel: 'c1', + UserConstants.userGender: 'male', + }); + expect(result[UserConstants.cefrLevel], 'c1'); + expect(result[UserConstants.userGender], 'male'); + }); + + test('produces a new map, not an alias of the input', () { + final input = {'planId': 'month'}; + final result = BaseRequestModel.injectUserContext(input); + result['planId'] = 'year'; + expect(input['planId'], 'month'); + }); + }); +} diff --git a/test/features/subscription/single_flight_guard_test.dart b/test/features/subscription/single_flight_guard_test.dart new file mode 100644 index 0000000000..a9147170c9 --- /dev/null +++ b/test/features/subscription/single_flight_guard_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fluffychat/features/subscription/utils/single_flight_guard.dart'; + +// The pure re-entry guard used by the checkout / cancel / portal tap handlers: +// a double-tap must never fire concurrent checkouts, cancels, or redirects. +void main() { + group('SingleFlightGuard', () { + test('starts idle', () { + expect(SingleFlightGuard().inFlight, isFalse); + }); + + test('tryEnter locks; a second tryEnter is refused while in flight', () { + final guard = SingleFlightGuard(); + expect(guard.tryEnter(), isTrue); + expect(guard.inFlight, isTrue); + expect(guard.tryEnter(), isFalse); // the double-tap path + expect(guard.inFlight, isTrue); + }); + + test('exit unlocks; the next tryEnter succeeds', () { + final guard = SingleFlightGuard(); + expect(guard.tryEnter(), isTrue); + guard.exit(); + expect(guard.inFlight, isFalse); + expect(guard.tryEnter(), isTrue); + }); + + test('exit is idempotent', () { + final guard = SingleFlightGuard(); + guard.exit(); + guard.exit(); + expect(guard.inFlight, isFalse); + expect(guard.tryEnter(), isTrue); + }); + }); +} diff --git a/test/features/subscription/status_v2_to_state_test.dart b/test/features/subscription/status_v2_to_state_test.dart new file mode 100644 index 0000000000..adf29e6d21 --- /dev/null +++ b/test/features/subscription/status_v2_to_state_test.dart @@ -0,0 +1,500 @@ +import 'package:flutter_test/flutter_test.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/subscription_constants.dart'; + +void main() { + const stripeAppId = "stripe_app_1"; + final endsAt = DateTime.utc(2026, 8, 13); + + SubscriptionStatusV2 status({ + String accessLevel = "full", + WinningSummaryV2? winning, + List entitlements = const [], + }) => SubscriptionStatusV2( + accessLevel: accessLevel, + entitlementSource: "cms", + winning: winning, + entitlements: entitlements, + ); + + group('mapStatusV2ToState — inactive branch (I7)', () { + test('access_level none -> Inactive', () { + final state = mapStatusV2ToState( + status(accessLevel: "none"), + stripeAppId: stripeAppId, + ); + expect(state, isA()); + }); + + test('full but no winning -> Inactive (fail-safe)', () { + final state = mapStatusV2ToState( + status(accessLevel: "full", winning: null), + stripeAppId: stripeAppId, + ); + expect(state, isA()); + }); + }); + + group('mapStatusV2ToState — paid active', () { + test('maps id=planId, expiration, not promotional, not trial', () { + final state = + mapStatusV2ToState( + status( + winning: WinningSummaryV2( + type: "paid", + status: "active", + endsAt: endsAt, + planId: "month", + ), + entitlements: [ + EntitlementV2( + entitlementRef: "ent_123", + type: "paid", + cancelable: true, + status: "active", + sourceSubscriptionId: "sub_abc", + ), + ], + ), + stripeAppId: stripeAppId, + ) + as SubscriptionActive; + + expect(state.subscriptionId, "month"); + expect(state.expirationDate, endsAt); + expect(state.unsubscribeDetectedAt, isNull); + expect(state.cancelAtPeriodEnd, false); + expect(state.isPromotional, false); + expect(state.isTrial, false); + expect(state.cancelable, true); + expect(state.entitlementRef, "ent_123"); + }); + + test('falls back to stripeAppId when winning has no planId', () { + final state = + mapStatusV2ToState( + status( + winning: const WinningSummaryV2( + type: "paid", + status: "active", + ), + ), + stripeAppId: stripeAppId, + ) + as SubscriptionActive; + expect(state.subscriptionId, stripeAppId); + // #4b: a PAID entitlement with no catalog planId stays classified as paid + // (not promotional, not trial), so the controller renders a generic paid + // tile with working management rather than a comp/seat "no plan" tile. + expect(state.isPromotional, false); + expect(state.isTrial, false); + }); + }); + + group('mapStatusV2ToState — cancel_at_period_end mirroring (D7/#6)', () { + test( + 'unsubscribeDetectedAt mirrors cancelAtPeriodEnd (true -> endsAt)', + () { + final state = + mapStatusV2ToState( + status( + winning: WinningSummaryV2( + type: "paid", + status: "active", + endsAt: endsAt, + cancelAtPeriodEnd: true, + planId: "year", + ), + ), + stripeAppId: stripeAppId, + ) + as SubscriptionActive; + expect(state.cancelAtPeriodEnd, true); + expect(state.unsubscribeDetectedAt, endsAt); + }, + ); + + test('unsubscribeDetectedAt is null when not cancelling', () { + final state = + mapStatusV2ToState( + status( + winning: WinningSummaryV2( + type: "paid", + status: "active", + endsAt: endsAt, + cancelAtPeriodEnd: false, + planId: "year", + ), + ), + stripeAppId: stripeAppId, + ) + as SubscriptionActive; + expect(state.unsubscribeDetectedAt, isNull); + }); + }); + + group('mapStatusV2ToState — billable individual type (finding #1)', () { + test('individual -> isPromotional false, isTrial false (billable)', () { + final state = + mapStatusV2ToState( + status( + winning: const WinningSummaryV2( + type: "individual", + status: "active", + planId: "month", + ), + entitlements: const [ + EntitlementV2( + entitlementRef: "ent_ind", + type: "individual", + cancelable: true, + status: "active", + ), + ], + ), + stripeAppId: stripeAppId, + ) + as SubscriptionActive; + // A paying individual-plan user must NOT be classified as promotional + // (that would skip their account-delete warning + paid tile). + expect(state.isPromotional, false); + expect(state.isTrial, false); + expect(state.subscriptionId, "month"); + expect(state.cancelable, true); + }); + + test('individual with null planId still not promotional (paid tile)', () { + final state = + mapStatusV2ToState( + status( + winning: const WinningSummaryV2( + type: "individual", + status: "active", + ), + ), + stripeAppId: stripeAppId, + ) + as SubscriptionActive; + expect(state.isPromotional, false); + expect(state.isTrial, false); + expect(state.subscriptionId, stripeAppId); + }); + }); + + group('mapStatusV2ToState — promotional shapes (I11)', () { + test('comp -> isPromotional true, isTrial false, not cancelable', () { + final state = + mapStatusV2ToState( + status( + winning: const WinningSummaryV2( + type: "comp", + status: "active", + ), + entitlements: const [ + EntitlementV2( + entitlementRef: "ent_comp", + type: "comp", + cancelable: false, + status: "active", + ), + ], + ), + stripeAppId: stripeAppId, + ) + as SubscriptionActive; + expect(state.isPromotional, true); + expect(state.isTrial, false); + expect(state.cancelable, false); + expect(state.entitlementRef, isNull); + }); + + test('seat -> isPromotional true, not cancelable', () { + final state = + mapStatusV2ToState( + status( + winning: const WinningSummaryV2( + type: "seat", + status: "active", + ), + entitlements: const [ + EntitlementV2( + entitlementRef: "ent_seat", + type: "seat", + cancelable: false, + status: "active", + ), + ], + ), + stripeAppId: stripeAppId, + ) + as SubscriptionActive; + expect(state.isPromotional, true); + expect(state.cancelable, false); + }); + + test('trial -> isTrial true, isPromotional true, not cancelable', () { + final state = + mapStatusV2ToState( + status( + winning: WinningSummaryV2( + type: "trial", + status: "active", + endsAt: endsAt, + ), + ), + stripeAppId: stripeAppId, + ) + as SubscriptionActive; + expect(state.isTrial, true); + expect(state.isPromotional, true); + expect(state.cancelable, false); + expect(state.entitlementRef, isNull); + // An active trial has no catalog plan, so the mapper resolves the + // subscriptionId to the [kV2TrialId] sentinel (NOT stripeAppId) so the + // controller `subscription` getter resolves the synthesized trial tile + // (B2 wiring) and the trial copy / hasFreeTrial render. + expect(state.subscriptionId, kV2TrialId); + }); + + test( + 'active trial resolves subscriptionId to the trial sentinel even when a ' + 'planId is present', + () { + // Defensive: whatever the winning carries, a trial must never resolve + // to a paid product tile. + final state = + mapStatusV2ToState( + status( + winning: WinningSummaryV2( + type: "trial", + status: "active", + endsAt: endsAt, + planId: "month", + ), + ), + stripeAppId: stripeAppId, + ) + as SubscriptionActive; + expect(state.isTrial, true); + expect(state.subscriptionId, kV2TrialId); + }, + ); + }); + + group('mapStatusV2ToState — cancel entitlement selection (I10)', () { + test('skips a non-cancelable row', () { + final state = + mapStatusV2ToState( + status( + winning: const WinningSummaryV2( + type: "paid", + status: "active", + planId: "month", + ), + entitlements: const [ + EntitlementV2( + entitlementRef: "ent_x", + type: "paid", + cancelable: false, + status: "active", + ), + ], + ), + stripeAppId: stripeAppId, + ) + as SubscriptionActive; + expect(state.cancelable, false); + expect(state.entitlementRef, isNull); + }); + + test('skips a row already set to cancel at period end', () { + final state = + mapStatusV2ToState( + status( + winning: const WinningSummaryV2( + type: "paid", + status: "active", + planId: "month", + ), + entitlements: const [ + EntitlementV2( + entitlementRef: "ent_x", + type: "paid", + cancelable: true, + cancelAtPeriodEnd: true, + status: "active", + ), + ], + ), + stripeAppId: stripeAppId, + ) + as SubscriptionActive; + expect(state.cancelable, false); + expect(state.entitlementRef, isNull); + }); + + test('single cancelable row is selected (fallback, no winning ref)', () { + final state = + mapStatusV2ToState( + status( + winning: const WinningSummaryV2( + type: "paid", + status: "active", + planId: "month", + ), + entitlements: const [ + EntitlementV2( + entitlementRef: "ent_only", + type: "paid", + cancelable: true, + status: "active", + ), + ], + ), + stripeAppId: stripeAppId, + ) + as SubscriptionActive; + expect(state.cancelable, true); + expect(state.entitlementRef, "ent_only"); + }); + + test('winning.entitlementRef is THE cancel target when present', () { + final state = + mapStatusV2ToState( + status( + winning: const WinningSummaryV2( + type: "paid", + status: "active", + planId: "month", + entitlementRef: "ent_win", + ), + entitlements: const [ + // Another cancelable row exists; the named winner still wins. + EntitlementV2( + entitlementRef: "ent_aaa", + type: "paid", + cancelable: true, + status: "active", + ), + EntitlementV2( + entitlementRef: "ent_win", + type: "paid", + cancelable: true, + status: "active", + ), + ], + ), + stripeAppId: stripeAppId, + ) + as SubscriptionActive; + expect(state.entitlementRef, "ent_win"); + expect(state.cancelable, true); + }); + + test('winning.entitlementRef naming a non-cancelable row -> no cancel', () { + final state = + mapStatusV2ToState( + status( + winning: const WinningSummaryV2( + type: "paid", + status: "active", + planId: "month", + entitlementRef: "ent_win", + ), + entitlements: const [ + EntitlementV2( + entitlementRef: "ent_win", + type: "paid", + cancelable: false, + status: "active", + ), + // A different cancelable row must NOT be substituted. + EntitlementV2( + entitlementRef: "ent_other", + type: "paid", + cancelable: true, + status: "active", + ), + ], + ), + stripeAppId: stripeAppId, + ) + as SubscriptionActive; + expect(state.cancelable, false); + expect(state.entitlementRef, isNull); + }); + + test( + 'ambiguous (>1 cancelable, no winning ref, no discriminator) -> no cancel', + () { + // Never risk canceling the wrong subscription: with two indistinguishable + // cancelable rows and nothing to pick by, offer NO cancel target. + final entitlements = [ + const EntitlementV2( + entitlementRef: "ent_zzz", + type: "paid", + cancelable: true, + status: "active", + ), + const EntitlementV2( + entitlementRef: "ent_aaa", + type: "paid", + cancelable: true, + status: "active", + ), + ]; + final state = + mapStatusV2ToState( + status( + winning: const WinningSummaryV2( + type: "paid", + status: "active", + planId: "month", + ), + entitlements: entitlements, + ), + stripeAppId: stripeAppId, + ) + as SubscriptionActive; + expect(state.cancelable, false); + expect(state.entitlementRef, isNull); + }, + ); + + test('prefers the row matching the winning sourceSubscriptionId', () { + final state = + mapStatusV2ToState( + status( + winning: const WinningSummaryV2( + type: "paid", + status: "active", + planId: "month", + sourceSubscriptionId: "sub_win", + ), + entitlements: const [ + EntitlementV2( + entitlementRef: "ent_aaa", + type: "paid", + cancelable: true, + status: "active", + sourceSubscriptionId: "sub_other", + ), + EntitlementV2( + entitlementRef: "ent_zzz", + type: "paid", + cancelable: true, + status: "active", + sourceSubscriptionId: "sub_win", + ), + ], + ), + stripeAppId: stripeAppId, + ) + as SubscriptionActive; + // ent_zzz wins on the source-id preference despite a higher ref. + expect(state.entitlementRef, "ent_zzz"); + }); + }); +} diff --git a/test/features/subscription/subscription_controller_submit_test.dart b/test/features/subscription/subscription_controller_submit_test.dart new file mode 100644 index 0000000000..9f4369f1f5 --- /dev/null +++ b/test/features/subscription/subscription_controller_submit_test.dart @@ -0,0 +1,161 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fluffychat/features/subscription/controllers/subscription_controller.dart'; +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/utils/subscription_duration_enum.dart'; +import 'package:fluffychat/l10n/l10n.dart'; + +// Exercises the REAL SubscriptionController.submitSubscriptionChange (not just +// the SingleFlightGuard primitive) via the managerOverride seam + an L10n +// BuildContext. GoogleAnalytics.logEvent is a no-op under `flutter test` +// (analytics is null), so the non-trial buy path reaches the injected manager +// without RevenueCat/MatrixState. +class _FakeManager implements SubscriptionInfoManager { + int submitCalls = 0; + Completer? block; + Object? throwError; + + @override + Future getCurrentSubscriptionInfo({ + String? stripeAppId, + }) async => SubscriptionInactive(); + + @override + Future submitSubscriptionChange( + SubscriptionDetails subscription, + ) async { + submitCalls++; + if (block != null) await block!.future; + if (throwError != null) throw throwError!; + } +} + +// A non-trial plan so submit takes the analytics + manager path (not the +// MatrixState-bound trial branch). +final _monthly = SubscriptionDetails( + id: "month", + price: 9.99, + currency: "usd", + appId: "stripe", + duration: SubscriptionDuration.month, +); + +void main() { + testWidgets('a concurrent second submit while one is in-flight is a NO-OP ' + '(manager invoked once; isSubmitting stays true until release)', ( + tester, + ) async { + final manager = _FakeManager()..block = Completer(); + final controller = SubscriptionController(managerOverride: manager); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: L10n.localizationsDelegates, + supportedLocales: L10n.supportedLocales, + home: Builder( + builder: (context) { + // First submit enters the guard and blocks inside the manager. + final first = controller.submitSubscriptionChange( + _monthly, + context, + ); + // Second submit, while the first is in flight, must no-op. + final second = controller.submitSubscriptionChange( + _monthly, + context, + ); + + scheduleMicrotask(() async { + await second; // returns immediately (guard rejected) + expect(manager.submitCalls, 1); // never reached the manager + expect(controller.isSubmitting, isTrue); // first still in flight + + manager.block!.complete(); + await first; + expect(manager.submitCalls, 1); + expect(controller.isSubmitting, isFalse); // released + }); + return const SizedBox(); + }, + ), + ), + ); + await tester.pumpAndSettle(); + expect(manager.submitCalls, 1); + expect(controller.isSubmitting, isFalse); + }); + + testWidgets('the guard RELEASES on success — a later submit proceeds', ( + tester, + ) async { + final manager = _FakeManager(); + final controller = SubscriptionController(managerOverride: manager); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: L10n.localizationsDelegates, + supportedLocales: L10n.supportedLocales, + home: Builder( + builder: (context) { + scheduleMicrotask(() async { + expect(controller.isSubmitting, isFalse); + await controller.submitSubscriptionChange(_monthly, context); + expect(manager.submitCalls, 1); + expect(controller.isSubmitting, isFalse); + + // A later call proceeds (the guard was released). + await controller.submitSubscriptionChange(_monthly, context); + expect(manager.submitCalls, 2); + }); + return const SizedBox(); + }, + ), + ), + ); + await tester.pumpAndSettle(); + expect(manager.submitCalls, 2); + }); + + testWidgets('the guard RELEASES on throw — exit() is in finally', ( + tester, + ) async { + final manager = _FakeManager()..throwError = Exception("boom"); + final controller = SubscriptionController(managerOverride: manager); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: L10n.localizationsDelegates, + supportedLocales: L10n.supportedLocales, + home: Builder( + builder: (context) { + scheduleMicrotask(() async { + await expectLater( + controller.submitSubscriptionChange(_monthly, context), + throwsA(isA()), + ); + expect( + controller.isSubmitting, + isFalse, + ); // released despite throw + + // A subsequent submit still proceeds. + manager.throwError = null; + await controller.submitSubscriptionChange(_monthly, context); + expect(manager.submitCalls, 2); + }); + return const SizedBox(); + }, + ), + ), + ); + await tester.pumpAndSettle(); + expect(manager.submitCalls, 2); + expect(controller.isSubmitting, isFalse); + }); +} diff --git a/test/features/subscription/subscription_details_display_price_test.dart b/test/features/subscription/subscription_details_display_price_test.dart new file mode 100644 index 0000000000..d64a2754c0 --- /dev/null +++ b/test/features/subscription/subscription_details_display_price_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fluffychat/features/subscription/models/subscription_details.dart'; +import 'package:fluffychat/l10n/l10n.dart'; + +void main() { + // The currency logic lives in the context-free `formattedPrice()` so it is + // unit-testable without an L10n BuildContext; `displayPrice` layers the trial + // copy on top (covered by the widget group below). + group('SubscriptionDetails.formattedPrice (D5, currency-aware)', () { + test('usd formats with a "\$" symbol', () { + final details = SubscriptionDetails( + id: "month", + price: 9.99, + currency: "usd", + ); + expect(details.formattedPrice(), "\$9.99"); + }); + + test('a non-usd currency formats WITHOUT a "\$"', () { + final details = SubscriptionDetails( + id: "month", + price: 9.99, + currency: "eur", + ); + final formatted = details.formattedPrice(); + expect(formatted, isNot(contains("\$"))); + expect(formatted, contains("9.99")); + }); + + test('localizedPrice wins over currency formatting', () { + final details = SubscriptionDetails( + id: "month", + price: 9.99, + currency: "usd", + localizedPrice: "US\$9.99", + ); + expect(details.formattedPrice(), "US\$9.99"); + }); + + test( + 'mobile (currency == null) is byte-for-byte the prior "\$" fallback', + () { + final details = SubscriptionDetails(id: "month", price: 9.99); + expect(details.formattedPrice(), "\$9.99"); + }, + ); + + test('mobile (currency == null) keeps localizedPrice precedence', () { + final details = SubscriptionDetails( + id: "month", + price: 9.99, + localizedPrice: "R\$9,99", + ); + expect(details.formattedPrice(), "R\$9,99"); + }); + }); + + group('SubscriptionDetails.displayPrice (L10n context)', () { + late L10n l10n; + + Future captureL10n(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: L10n.localizationsDelegates, + supportedLocales: L10n.supportedLocales, + home: Builder( + builder: (context) { + l10n = L10n.of(context); + return const SizedBox(); + }, + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('priced usd product renders the currency-formatted price', ( + tester, + ) async { + String? rendered; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: L10n.localizationsDelegates, + supportedLocales: L10n.supportedLocales, + home: Builder( + builder: (context) { + rendered = SubscriptionDetails( + id: "month", + price: 9.99, + currency: "usd", + ).displayPrice(context); + return const SizedBox(); + }, + ), + ), + ); + await tester.pumpAndSettle(); + expect(rendered, "\$9.99"); + }); + + testWidgets('trial / zero-price product renders the freeTrial copy', ( + tester, + ) async { + await captureL10n(tester); + String? trialRendered; + String? zeroRendered; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: L10n.localizationsDelegates, + supportedLocales: L10n.supportedLocales, + home: Builder( + builder: (context) { + trialRendered = SubscriptionDetails( + id: "trial", + price: 0, + appId: "trial", + ).displayPrice(context); + zeroRendered = SubscriptionDetails( + id: "comp", + price: 0, + currency: "usd", + ).displayPrice(context); + return const SizedBox(); + }, + ), + ), + ); + await tester.pumpAndSettle(); + expect(trialRendered, l10n.freeTrial); + expect(zeroRendered, l10n.freeTrial); + }); + }); +} diff --git a/test/features/subscription/subscription_details_tojson_test.dart b/test/features/subscription/subscription_details_tojson_test.dart new file mode 100644 index 0000000000..bebb37fe6f --- /dev/null +++ b/test/features/subscription/subscription_details_tojson_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fluffychat/features/subscription/models/subscription_details.dart'; +import 'package:fluffychat/features/subscription/utils/subscription_duration_enum.dart'; + +void main() { + group('SubscriptionDetails.toJson currency omission (byte-for-byte)', () { + test('mobile/RC (currency == null) OMITS the currency key entirely', () { + final json = SubscriptionDetails( + id: "rc_month", + price: 9.99, + duration: SubscriptionDuration.month, + appId: "app_store_id", + ).toJson(); + + expect(json.containsKey('currency'), false); + // The serialized shape is exactly today's keys, nothing more. + expect(json.keys.toSet(), { + 'price', + 'id', + 'duration', + 'appId', + 'is_visible', + }); + }); + + test('web v2 (currency != null) includes the currency key', () { + final json = SubscriptionDetails( + id: "month", + price: 9.99, + currency: "usd", + duration: SubscriptionDuration.month, + appId: "stripe_id", + ).toJson(); + + expect(json['currency'], "usd"); + }); + + test('round-trips through fromJson for both shapes', () { + final mobile = SubscriptionDetails(id: "rc_month", price: 9.99); + expect(SubscriptionDetails.fromJson(mobile.toJson()).currency, isNull); + + final web = SubscriptionDetails( + id: "month", + price: 9.99, + currency: "eur", + ); + expect(SubscriptionDetails.fromJson(web.toJson()).currency, "eur"); + }); + }); +} diff --git a/test/features/subscription/subscription_status_v2_test.dart b/test/features/subscription/subscription_status_v2_test.dart new file mode 100644 index 0000000000..67883f4f65 --- /dev/null +++ b/test/features/subscription/subscription_status_v2_test.dart @@ -0,0 +1,242 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fluffychat/features/subscription/models/subscription_status_v2.dart'; + +/// Real-shaped `/subscription/status` fixtures matching choreo +/// `SubscriptionStatusV2Response` (status_v2_schema.py). JSON keys mirror the +/// Pydantic field names verbatim: snake_case for `access_level`, +/// `entitlement_source`, `ends_at`, `cancel_at_period_end`, `manage_action`, +/// `trial_*`; camelCase for `entitlementRef`, `sourceSubscriptionId`, `planId`. +void main() { + Map fullActivePaid() => { + "access_level": "full", + "entitlement_source": "cms", + "winning": { + "type": "paid", + "status": "active", + "ends_at": "2026-08-13T00:00:00+00:00", + "paid_through_at": "2026-08-13T00:00:00+00:00", + "grace_ends_at": null, + "cancel_at_period_end": false, + "provider": "stripe", + "planId": "month", + }, + "billing_issue": null, + "entitlements": [ + { + "entitlementRef": "ent_123", + "type": "paid", + "provider": "stripe", + "sourceSubscriptionId": "sub_abc", + "cancelable": true, + "status": "active", + "ends_at": "2026-08-13T00:00:00+00:00", + "manage_action": {"kind": "portal", "entitlementRef": "ent_123"}, + "planId": "month", + }, + ], + "manage_eligible": true, + "trial_eligible": false, + "trial_claimed": false, + "trial_ends_at": null, + }; + + group('SubscriptionStatusV2.fromJson', () { + test('full active paid with planId', () { + final status = SubscriptionStatusV2.fromJson(fullActivePaid()); + expect(status.accessLevel, "full"); + expect(status.entitlementSource, "cms"); + expect(status.manageEligible, true); + + final winning = status.winning!; + expect(winning.type, "paid"); + expect(winning.status, "active"); + expect(winning.planId, "month"); + expect(winning.provider, "stripe"); + expect(winning.cancelAtPeriodEnd, false); + expect(winning.endsAt, DateTime.utc(2026, 8, 13)); + + expect(status.entitlements.length, 1); + final ent = status.entitlements.single; + expect(ent.entitlementRef, "ent_123"); + expect(ent.cancelable, true); + expect(ent.sourceSubscriptionId, "sub_abc"); + expect(ent.manageAction!.kind, "portal"); + expect(ent.manageAction!.entitlementRef, "ent_123"); + expect(ent.planId, "month"); + }); + + test('none (no access, trial-eligible)', () { + final status = SubscriptionStatusV2.fromJson({ + "access_level": "none", + "entitlement_source": "cms", + "winning": null, + "billing_issue": null, + "entitlements": [], + "manage_eligible": false, + "trial_eligible": true, + "trial_claimed": false, + "trial_ends_at": null, + }); + expect(status.accessLevel, "none"); + expect(status.winning, isNull); + expect(status.entitlements, isEmpty); + expect(status.trialEligible, true); + }); + + test('cancel_at_period_end set on the winning sub', () { + final json = fullActivePaid(); + (json["winning"] as Map)["cancel_at_period_end"] = true; + final status = SubscriptionStatusV2.fromJson(json); + expect(status.winning!.cancelAtPeriodEnd, true); + expect(status.winning!.endsAt, DateTime.utc(2026, 8, 13)); + }); + + test('billing issue (past_due) with update_payment action', () { + final json = fullActivePaid(); + (json["winning"] as Map)["status"] = "past_due"; + json["billing_issue"] = { + "present": true, + "reason": "past_due", + "action": {"kind": "update_payment", "entitlementRef": "ent_123"}, + }; + final status = SubscriptionStatusV2.fromJson(json); + expect(status.billingIssue!.present, true); + expect(status.billingIssue!.reason, "past_due"); + expect(status.billingIssue!.action!.kind, "update_payment"); + expect(status.billingIssue!.action!.entitlementRef, "ent_123"); + }); + + test('comp (promotional, not cancelable)', () { + final status = SubscriptionStatusV2.fromJson({ + "access_level": "full", + "entitlement_source": "cms", + "winning": { + "type": "comp", + "status": "active", + "ends_at": "2026-12-31T00:00:00+00:00", + "cancel_at_period_end": false, + "provider": "manual", + }, + "entitlements": [ + { + "entitlementRef": "ent_comp", + "type": "comp", + "provider": "manual", + "cancelable": false, + "status": "active", + }, + ], + "manage_eligible": false, + "trial_eligible": false, + "trial_claimed": false, + }); + expect(status.winning!.type, "comp"); + expect(status.entitlements.single.cancelable, false); + // A comp's sourceSubscriptionId is suppressed server-side. + expect(status.entitlements.single.sourceSubscriptionId, isNull); + }); + + test('seat (group-managed, not cancelable)', () { + final status = SubscriptionStatusV2.fromJson({ + "access_level": "full", + "entitlement_source": "cms", + "winning": { + "type": "seat", + "status": "active", + "ends_at": "2026-09-01T00:00:00+00:00", + "cancel_at_period_end": false, + "provider": "stripe", + }, + "entitlements": [ + { + "entitlementRef": "ent_seat", + "type": "seat", + "provider": "stripe", + "cancelable": false, + "status": "active", + }, + ], + "manage_eligible": false, + }); + expect(status.winning!.type, "seat"); + expect(status.entitlements.single.cancelable, false); + }); + + test('trial active', () { + final status = SubscriptionStatusV2.fromJson({ + "access_level": "full", + "entitlement_source": "cms", + "winning": { + "type": "trial", + "status": "active", + "ends_at": "2026-07-20T00:00:00+00:00", + "cancel_at_period_end": false, + "provider": "manual", + "planId": null, + }, + "entitlements": [], + "manage_eligible": false, + "trial_eligible": false, + "trial_claimed": true, + "trial_ends_at": "2026-07-20T00:00:00+00:00", + }); + expect(status.winning!.type, "trial"); + expect(status.winning!.planId, isNull); + expect(status.trialClaimed, true); + expect(status.trialEndsAt, DateTime.utc(2026, 7, 20)); + }); + + test('tolerates missing optional keys and bad dates', () { + final status = SubscriptionStatusV2.fromJson({ + "access_level": "full", + "entitlement_source": "cms", + "winning": { + "type": "paid", + "status": "active", + "ends_at": "not-a-date", + }, + }); + expect(status.winning!.endsAt, isNull); + expect(status.winning!.cancelAtPeriodEnd, false); + expect(status.entitlements, isEmpty); + expect(status.manageEligible, false); + }); + + test('reads plan_id fallback when backend uses snake_case', () { + final status = SubscriptionStatusV2.fromJson({ + "access_level": "full", + "entitlement_source": "cms", + "winning": {"type": "paid", "status": "active", "plan_id": "year"}, + }); + expect(status.winning!.planId, "year"); + }); + + test( + 'reads winning.entitlementRef (camelCase and snake_case fallback)', + () { + final camel = SubscriptionStatusV2.fromJson({ + "access_level": "full", + "entitlement_source": "cms", + "winning": { + "type": "paid", + "status": "active", + "entitlementRef": "ent_win", + }, + }); + expect(camel.winning!.entitlementRef, "ent_win"); + + final snake = SubscriptionStatusV2.fromJson({ + "access_level": "full", + "entitlement_source": "cms", + "winning": { + "type": "paid", + "status": "active", + "entitlement_ref": "ent_win", + }, + }); + expect(snake.winning!.entitlementRef, "ent_win"); + }, + ); + }); +} diff --git a/test/features/subscription/v2_subscription_catalog_test.dart b/test/features/subscription/v2_subscription_catalog_test.dart new file mode 100644 index 0000000000..abf200d1a5 --- /dev/null +++ b/test/features/subscription/v2_subscription_catalog_test.dart @@ -0,0 +1,202 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fluffychat/features/subscription/models/products_v2_response.dart'; +import 'package:fluffychat/features/subscription/models/subscription_status_v2.dart'; +import 'package:fluffychat/features/subscription/subscription_constants.dart'; +import 'package:fluffychat/features/subscription/utils/subscription_duration_enum.dart'; +import 'package:fluffychat/features/subscription/utils/v2_subscription_catalog.dart'; +import 'package:fluffychat/features/subscription/utils/v2_ui_gating.dart'; + +void main() { + const stripeAppId = "stripe_app_1"; + + const month = ProductV2( + planId: "month", + amount: 999, + currency: "usd", + interval: "month", + ); + const year = ProductV2( + planId: "year", + amount: 9999, + currency: "usd", + interval: "year", + ); + + SubscriptionStatusV2 status({ + String accessLevel = "none", + bool trialEligible = false, + bool trialClaimed = false, + WinningSummaryV2? winning, + }) => SubscriptionStatusV2( + accessLevel: accessLevel, + entitlementSource: "cms", + trialEligible: trialEligible, + trialClaimed: trialClaimed, + winning: winning, + ); + + group('buildV2SubscriptionCatalog — products (D7 mapping + sort)', () { + test('maps plans to SubscriptionDetails and sorts by price', () { + final catalog = buildV2SubscriptionCatalog( + // Deliberately reversed to prove the sort. + [year, month], + status(), + stripeAppId: stripeAppId, + ); + + expect(catalog.available.map((s) => s.id).toList(), ["month", "year"]); + final first = catalog.available.first; + expect(first.price, 9.99); + expect(first.currency, "usd"); + expect(first.appId, stripeAppId); + expect(first.duration, SubscriptionDuration.month); + // No trial in either list when the user is not eligible and has no active + // trial. + expect(catalog.available.where((s) => s.isTrial), isEmpty); + expect(catalog.all.where((s) => s.isTrial), isEmpty); + expect(catalog.all.map((s) => s.id).toList(), ["month", "year"]); + }); + + test('an unknown plan id throws (I4, fail closed)', () { + const rogue = ProductV2( + planId: "decade", + amount: 100, + currency: "usd", + interval: "decade", + ); + expect( + () => buildV2SubscriptionCatalog( + [month, rogue], + status(), + stripeAppId: stripeAppId, + ), + throwsA(isA()), + ); + }); + + test('a null status yields plans only, no trial anywhere', () { + final catalog = buildV2SubscriptionCatalog( + [month, year], + null, + stripeAppId: stripeAppId, + ); + expect(catalog.available.length, 2); + expect(catalog.all.length, 2); + expect(catalog.available.any((s) => s.isTrial), isFalse); + }); + }); + + group('buildV2SubscriptionCatalog — trial synthesis (D3, finding #11)', () { + test('eligible + unclaimed -> trial card in available AND all', () { + final catalog = buildV2SubscriptionCatalog( + [month, year], + status(trialEligible: true, trialClaimed: false), + stripeAppId: stripeAppId, + ); + + final availableTrial = catalog.available.where((s) => s.isTrial).toList(); + expect(availableTrial.length, 1); + expect(availableTrial.single.id, kV2TrialId); + expect(availableTrial.single.appId, "trial"); + expect(availableTrial.single.price, 0); + // $0 trial sorts to the front of the price-sorted paywall list. + expect(catalog.available.first.isTrial, isTrue); + + expect(catalog.all.where((s) => s.isTrial).length, 1); + }); + + test('eligible but ALREADY claimed -> no card on the paywall, but a trial ' + 'stays in all so an active trial still resolves', () { + final catalog = buildV2SubscriptionCatalog( + [month, year], + status(trialEligible: true, trialClaimed: true), + stripeAppId: stripeAppId, + ); + // Not offered on the paywall (already used) ... + expect(catalog.available.where((s) => s.isTrial), isEmpty); + // ... but still resolvable in `all` (trialEligible true). + expect(catalog.all.where((s) => s.isTrial).length, 1); + }); + + test( + 'active trial (not eligible to start) -> trial present in all only', + () { + final catalog = buildV2SubscriptionCatalog( + [month, year], + status( + accessLevel: "full", + trialEligible: false, + winning: const WinningSummaryV2(type: "trial", status: "active"), + ), + stripeAppId: stripeAppId, + ); + // The current-subscription getter must resolve the active trial tile ... + expect(catalog.all.where((s) => s.isTrial).length, 1); + expect(catalog.all.firstWhere((s) => s.isTrial).id, kV2TrialId); + // ... but the paywall does not re-offer a trial the user is on. + expect(catalog.available.where((s) => s.isTrial), isEmpty); + }, + ); + + test('not eligible, no active trial -> no trial in either list', () { + final catalog = buildV2SubscriptionCatalog( + [month, year], + status(accessLevel: "full"), + stripeAppId: stripeAppId, + ); + expect(catalog.available.where((s) => s.isTrial), isEmpty); + expect(catalog.all.where((s) => s.isTrial), isEmpty); + }); + + test( + 'the trial in `all` and `available` is ONE reused object (finding #3)', + () { + final catalog = buildV2SubscriptionCatalog( + [month, year], + status(trialEligible: true, trialClaimed: false), + stripeAppId: stripeAppId, + ); + final inAll = catalog.all.firstWhere((s) => s.isTrial); + final inAvailable = catalog.available.firstWhere((s) => s.isTrial); + expect(identical(inAll, inAvailable), isTrue); + }, + ); + }); + + // finding #3: after the trial is activated the /status snapshot flips to + // trialClaimed:true + an active trial. The paywall must stop offering it AND + // the active-trial tile must still resolve as the current subscription. + group('trial lifecycle transition (finding #3)', () { + // The exact controller resolution predicate (subscription getter), so the + // "resolves as current" claim is tested against real logic. + bool resolves(List catalog, String id) => + catalog.any((s) => s.id.contains(id) || id.contains(s.id)); + + test( + 'post-activation: not offered on paywall, resolves as current in all', + () { + final afterActivation = status( + accessLevel: "full", + trialEligible: false, + trialClaimed: true, + winning: const WinningSummaryV2(type: "trial", status: "active"), + ); + + // The server signal that drives v2TrialOfferable is now false. + expect(v2TrialOfferableFor(afterActivation), isFalse); + + final catalog = buildV2SubscriptionCatalog( + [month, year], + afterActivation, + stripeAppId: stripeAppId, + ); + + // No longer offered on the paywall ... + expect(catalog.available.where((s) => s.isTrial), isEmpty); + // ... but the active trial (subscriptionId == kV2TrialId) still resolves. + expect(resolves(catalog.all, kV2TrialId), isTrue); + }, + ); + }); +} diff --git a/test/features/subscription/v2_ui_gating_test.dart b/test/features/subscription/v2_ui_gating_test.dart new file mode 100644 index 0000000000..fd78829321 --- /dev/null +++ b/test/features/subscription/v2_ui_gating_test.dart @@ -0,0 +1,334 @@ +import 'package:flutter_test/flutter_test.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/utils/v2_ui_gating.dart'; + +// isV2PaidType is defined in subscription_status_v2.dart (imported above). + +void main() { + SubscriptionStatusV2 status({ + String accessLevel = "none", + bool trialEligible = false, + bool trialClaimed = false, + WinningSummaryV2? winning, + }) => SubscriptionStatusV2( + accessLevel: accessLevel, + entitlementSource: "cms", + trialEligible: trialEligible, + trialClaimed: trialClaimed, + winning: winning, + ); + + group('v2TrialOfferableFor (finding #1 — trial activatable)', () { + test('eligible + unclaimed -> offerable', () { + expect( + v2TrialOfferableFor(status(trialEligible: true, trialClaimed: false)), + isTrue, + ); + }); + test('eligible but already claimed -> not offerable', () { + expect( + v2TrialOfferableFor(status(trialEligible: true, trialClaimed: true)), + isFalse, + ); + }); + test('not eligible -> not offerable', () { + expect(v2TrialOfferableFor(status(trialEligible: false)), isFalse); + }); + test('null status -> not offerable', () { + expect(v2TrialOfferableFor(null), isFalse); + }); + }); + + group('isPaidWithoutPlan (finding #4 — paid access without planId)', () { + test('paid + full + null planId -> true (anomaly)', () { + expect( + isPaidWithoutPlan( + status( + accessLevel: "full", + winning: const WinningSummaryV2(type: "paid", status: "active"), + ), + ), + isTrue, + ); + }); + test('paid + full + planId present -> false', () { + expect( + isPaidWithoutPlan( + status( + accessLevel: "full", + winning: const WinningSummaryV2( + type: "paid", + status: "active", + planId: "month", + ), + ), + ), + isFalse, + ); + }); + test('comp -> false (legitimately no sellable plan)', () { + expect( + isPaidWithoutPlan( + status( + accessLevel: "full", + winning: const WinningSummaryV2(type: "comp", status: "active"), + ), + ), + isFalse, + ); + }); + test('seat -> false', () { + expect( + isPaidWithoutPlan( + status( + accessLevel: "full", + winning: const WinningSummaryV2(type: "seat", status: "active"), + ), + ), + isFalse, + ); + }); + test('trial -> false', () { + expect( + isPaidWithoutPlan( + status( + accessLevel: "full", + winning: const WinningSummaryV2(type: "trial", status: "active"), + ), + ), + isFalse, + ); + }); + test('individual (RC-era paid) + null planId -> true (billable)', () { + expect( + isPaidWithoutPlan( + status( + accessLevel: "full", + winning: const WinningSummaryV2( + type: "individual", + status: "active", + ), + ), + ), + isTrue, + ); + }); + + test('no access -> false', () { + expect(isPaidWithoutPlan(status(accessLevel: "none")), isFalse); + }); + }); + + group('isV2PaidType (finding #1 — individual is billable)', () { + test('paid -> billable', () => expect(isV2PaidType("paid"), isTrue)); + test( + 'individual -> billable', + () => expect(isV2PaidType("individual"), isTrue), + ); + test('seat -> not billable', () => expect(isV2PaidType("seat"), isFalse)); + test('comp -> not billable', () => expect(isV2PaidType("comp"), isFalse)); + test('trial -> not billable', () => expect(isV2PaidType("trial"), isFalse)); + test('null -> not billable', () => expect(isV2PaidType(null), isFalse)); + }); + + group('isTrialOfferable (finding #2 — path-aware trial gating)', () { + test('v2 path uses the server signal (offerable)', () { + expect( + isTrialOfferable( + v2Path: true, + v2TrialOfferable: true, + inTrialWindow: false, // outside the local window, but server-eligible + ), + isTrue, + ); + }); + test('v2 path uses the server signal (not offerable)', () { + expect( + isTrialOfferable( + v2Path: true, + v2TrialOfferable: false, + inTrialWindow: true, // local window open, but server says no + ), + isFalse, + ); + }); + test('off-flag uses inTrialWindow (open)', () { + expect( + isTrialOfferable( + v2Path: false, + v2TrialOfferable: false, + inTrialWindow: true, + ), + isTrue, + ); + }); + test('off-flag uses inTrialWindow (closed)', () { + expect( + isTrialOfferable( + v2Path: false, + v2TrialOfferable: true, + inTrialWindow: false, + ), + isFalse, + ); + }); + }); + + group('classifyCancelClick (finding #3 — cancel handler self-gated)', () { + SubscriptionActive active({ + bool? cancelable, + bool? cancelAtPeriodEnd, + String? entitlementRef, + }) => SubscriptionActive( + subscriptionId: "month", + cancelable: cancelable, + cancelAtPeriodEnd: cancelAtPeriodEnd, + entitlementRef: entitlementRef, + ); + + test('v2 path + eligible active -> v2Cancel', () { + expect( + classifyCancelClick( + v2CancelPath: true, + state: active( + cancelable: true, + cancelAtPeriodEnd: false, + entitlementRef: "ent_1", + ), + ), + CancelClickAction.v2Cancel, + ); + }); + + test('v2 path + already cancelling -> v2NoOp (NEVER legacy)', () { + final action = classifyCancelClick( + v2CancelPath: true, + state: active( + cancelable: true, + cancelAtPeriodEnd: true, + entitlementRef: "ent_1", + ), + ); + expect(action, CancelClickAction.v2NoOp); + expect(action, isNot(CancelClickAction.legacy)); + }); + + test('v2 path + not cancelable -> v2NoOp (NEVER legacy)', () { + expect( + classifyCancelClick( + v2CancelPath: true, + state: active(cancelable: false, entitlementRef: "ent_1"), + ), + CancelClickAction.v2NoOp, + ); + }); + + test('v2 path + inactive -> v2NoOp (NEVER legacy)', () { + expect( + classifyCancelClick(v2CancelPath: true, state: SubscriptionInactive()), + CancelClickAction.v2NoOp, + ); + }); + + test('non-v2 path -> legacy (RC behavior preserved)', () { + expect( + classifyCancelClick( + v2CancelPath: false, + state: active( + cancelable: true, + cancelAtPeriodEnd: false, + entitlementRef: "ent_1", + ), + ), + CancelClickAction.legacy, + ); + }); + }); + + group('showUndatedPromoWarning (finding #2 — comp/seat NPE)', () { + test('promotional with null expiration -> undated copy (no crash)', () { + expect( + showUndatedPromoWarning(isLifetime: false, expiration: null), + isTrue, + ); + }); + test('lifetime -> undated copy', () { + expect( + showUndatedPromoWarning( + isLifetime: true, + expiration: DateTime.utc(2200), + ), + isTrue, + ); + }); + test('dated, not lifetime -> dated copy (format the date)', () { + expect( + showUndatedPromoWarning( + isLifetime: false, + expiration: DateTime.utc(2026, 8, 1), + ), + isFalse, + ); + }); + }); + + group('classifyManagementLaunch (payment method / history routing)', () { + test('v2 path -> billing portal (legacy static URL is NEVER launched)', () { + final route = classifyManagementLaunch(v2Path: true); + expect(route, ManagementLaunchRoute.v2BillingPortal); + expect(route, isNot(ManagementLaunchRoute.legacy)); + }); + + test('off-flag / mobile -> legacy (byte-for-byte unchanged)', () { + expect( + classifyManagementLaunch(v2Path: false), + ManagementLaunchRoute.legacy, + ); + }); + }); + + group('shouldWarnBeforeAccountDelete (finding #4 — delete safety)', () { + test('paid + no management URL + v2 path -> WARN (safety net)', () { + expect( + shouldWarnBeforeAccountDelete( + hasPaidSubscription: true, + hasManagementUrl: false, + v2Path: true, + ), + isTrue, + ); + }); + test('paid + management URL + off v2 -> warn (today behavior)', () { + expect( + shouldWarnBeforeAccountDelete( + hasPaidSubscription: true, + hasManagementUrl: true, + v2Path: false, + ), + isTrue, + ); + }); + test('paid + no management URL + off v2 -> no warn (RC unchanged)', () { + expect( + shouldWarnBeforeAccountDelete( + hasPaidSubscription: true, + hasManagementUrl: false, + v2Path: false, + ), + isFalse, + ); + }); + test('not paid -> never warn', () { + expect( + shouldWarnBeforeAccountDelete( + hasPaidSubscription: false, + hasManagementUrl: true, + v2Path: true, + ), + isFalse, + ); + }); + }); +} diff --git a/test/features/subscription/validate_promo_code_response_test.dart b/test/features/subscription/validate_promo_code_response_test.dart new file mode 100644 index 0000000000..dd4e9e4ed9 --- /dev/null +++ b/test/features/subscription/validate_promo_code_response_test.dart @@ -0,0 +1,146 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fluffychat/features/subscription/repo/validate_promo_code_response.dart'; + +// Field-by-field parse of the choreo `PromoCodeValidationResponse` (the +// display-only discount preview). All terms are top-level & optional; the shape +// is verified against the frontend contract. +void main() { + group('ValidatePromoCodeResponse.fromJson — valid:true', () { + test('percent coupon: full terms, currency null, unix-seconds expiry', () { + final res = ValidatePromoCodeResponse.fromJson({ + "valid": true, + "code": "WELCOME50", + "discount_type": "percent", + "percent_off": 50.0, + "amount_off": null, + "currency": null, + "coupon_duration": "once", + "restrictions": { + "first_time_transaction": false, + "minimum_amount": null, + "minimum_amount_currency": null, + }, + "discounted_price": {"amount": 499, "currency": "usd"}, + "expires_at": 1735689600, + "reason": null, + }); + + expect(res.valid, true); + expect(res.code, "WELCOME50"); + expect(res.discountType, "percent"); + expect(res.percentOff, 50.0); + expect(res.amountOff, isNull); + expect(res.currency, isNull); + expect(res.couponDuration, "once"); + expect(res.expiresAt, 1735689600); + expect(res.reason, isNull); + expect(res.restrictions, isNotNull); + expect(res.restrictions!.firstTimeTransaction, false); + expect(res.discountedPrice, isNotNull); + expect(res.discountedPrice!.amount, 499); + expect(res.discountedPrice!.currency, "usd"); + }); + + test( + 'amount coupon: amount_off + top-level currency, percent_off null', + () { + final res = ValidatePromoCodeResponse.fromJson({ + "valid": true, + "code": "5OFF", + "discount_type": "amount", + "percent_off": null, + "amount_off": 500, + "currency": "usd", + "coupon_duration": "repeating", + "restrictions": {"first_time_transaction": true}, + "discounted_price": null, + "expires_at": null, + }); + + expect(res.discountType, "amount"); + expect(res.amountOff, 500); + expect(res.percentOff, isNull); + expect(res.currency, "usd"); + expect(res.couponDuration, "repeating"); + // discounted_price is present only when ?duration= is a known plan. + expect(res.discountedPrice, isNull); + expect(res.expiresAt, isNull); + expect(res.restrictions!.firstTimeTransaction, true); + }, + ); + }); + + group('ValidatePromoCodeResponse.fromJson — valid:false', () { + test('invalid code carries a reason and null terms', () { + for (final reason in const [ + "not_found_or_inactive", + "expired", + "max_redeemed", + ]) { + final res = ValidatePromoCodeResponse.fromJson({ + "valid": false, + "reason": reason, + }); + expect(res.valid, false); + expect(res.reason, reason); + expect(res.percentOff, isNull); + expect(res.amountOff, isNull); + expect(res.discountedPrice, isNull); + expect(res.restrictions, isNull); + } + }); + + test('below_minimum ALSO echoes the restrictions object', () { + final res = ValidatePromoCodeResponse.fromJson({ + "valid": false, + "reason": "below_minimum", + "restrictions": { + "first_time_transaction": false, + "minimum_amount": 2000, + "minimum_amount_currency": "usd", + }, + }); + + expect(res.valid, false); + expect(res.reason, "below_minimum"); + expect(res.restrictions, isNotNull); + expect(res.restrictions!.minimumAmount, 2000); + expect(res.restrictions!.minimumAmountCurrency, "usd"); + }); + }); + + group('PromoRestrictions.fromJson', () { + test('first_time_transaction defaults to false when absent', () { + final r = PromoRestrictions.fromJson({}); + expect(r.firstTimeTransaction, false); + expect(r.minimumAmount, isNull); + expect(r.minimumAmountCurrency, isNull); + }); + }); + + group('numeric leniency (serializer may emit 500.0 for 500)', () { + test('double-valued money/expiry fields parse to ints', () { + final res = ValidatePromoCodeResponse.fromJson({ + "valid": true, + "code": "5OFF", + "discount_type": "amount", + "amount_off": 500.0, + "currency": "usd", + "coupon_duration": "once", + "restrictions": { + "first_time_transaction": false, + "minimum_amount": 2000.0, + "minimum_amount_currency": "usd", + }, + "discounted_price": {"amount": 499.0, "currency": "usd"}, + "expires_at": 1735689600.0, + }); + + expect(res.amountOff, 500); + expect(res.restrictions!.minimumAmount, 2000); + expect(res.discountedPrice!.amount, 499); + expect(res.expiresAt, 1735689600); + }); + }); +}