diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/handler/MethodHandler.kt b/android/app/src/main/kotlin/org/getlantern/lantern/handler/MethodHandler.kt index 2d46651c8e..cd936875d1 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/handler/MethodHandler.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/handler/MethodHandler.kt @@ -83,6 +83,7 @@ enum class Methods(val method: String) { //Device RemoveDevice("removeDevice"), AttachReferralCode("attachReferralCode"), + AttachReferralCodeV2("attachReferralCodeV2"), // Ad blocking IsBlockAdsEnabled("isBlockAdsEnabled"), @@ -439,7 +440,8 @@ class MethodHandler : FlutterPlugin, val map = call.arguments as Map<*, *> val subscriptionData = Mobile.stripeSubscription( map["email"] as String, - map["planId"] as String + map["planId"] as String, + map["couponCode"] as? String ?: "" ) withContext(Dispatchers.Main) { success(subscriptionData) @@ -460,7 +462,8 @@ class MethodHandler : FlutterPlugin, val map = call.arguments as Map<*, *> val subscriptionData = Mobile.acknowledgeGooglePurchase( map["purchaseToken"] as String, - map["planId"] as String + map["planId"] as String, + map["couponCode"] as? String ?: "" ) withContext(Dispatchers.Main) { success(subscriptionData.toByteArray(Charsets.UTF_8)) @@ -507,7 +510,8 @@ class MethodHandler : FlutterPlugin, map["provider"] as String, map["planId"] as String, map["email"] as String, - idempotencyKey + idempotencyKey, + map["couponCode"] as? String ?: "" ) withContext(Dispatchers.Main) { success(url) @@ -831,6 +835,27 @@ class MethodHandler : FlutterPlugin, } } + Methods.AttachReferralCodeV2.method -> { + scope.launch { + result.runCatching { + val code = call.argument("code") ?: error("Missing code") + val distributionChannel = + call.argument("distributionChannel") ?: "" + val response = + Mobile.referralAttachmentV2(code, distributionChannel) + withContext(Dispatchers.Main) { + success(response) + } + }.onFailure { e -> + result.error( + "AttachReferralCodeV2", + e.localizedMessage ?: "Please try again", + e + ) + } + } + } + // Ad blocking Methods.SetBlockAdsEnabled.method -> { scope.handleResult(result, "set_block_ads_enabled") { diff --git a/assets/locales/en.po b/assets/locales/en.po index 1685b07de5..753ab59599 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -1224,6 +1224,9 @@ msgstr "Server alias cannot be empty" msgid "referral_code_applied" msgstr "Your referral code has been applied!" +msgid "discount_applied" +msgstr "%s discount applied" + msgid "referral_message_1m" msgstr "+ 15 additional days free" @@ -1234,7 +1237,7 @@ msgid "referral_message_2y" msgstr "+ 2 additional month free" msgid "referral_code_invalid" -msgstr "The referral code you entered is invalid. Please check the code and try again." +msgstr "The referral/promo code you entered is invalid. Please check the code and try again." msgid "referral_code_own_invalid" msgstr "You cannot use your own referral code. Please enter a different code." @@ -1423,7 +1426,7 @@ msgid "daily_data_cap_reached_message" msgstr "Speed reduced to 128 kb/sec - Resets at %s." msgid "subscription_renewal_info" -msgstr "Payment is charged to your Apple ID at purchase. Subscriptions renew automatically unless canceled at least 24 hours before the end of the current period. Manage or cancel in App Store Settings." +msgstr "Payment is charged to your Apple ID at purchase. Subscriptions renew automatically unless canceled at least 24 hours before the end of the current period." msgid "failed_to_update_routing_mode" msgstr "Failed to update routing mode. Please try again." @@ -1653,6 +1656,18 @@ msgstr "The VPN needs to be off before you can refresh your configuration. Turn msgid "configuration_message" msgstr "Your old configuration has been cleared. Turn the VPN on to automatically download the latest." +msgid "promo_referral_code" +msgstr "Promo / Referral Code" + +msgid "promo_code" +msgstr "Promo Code" + +msgid "promo_code_with" +msgstr "Promo Code (%s)" + +msgid "order_total" +msgstr "Order Total" + msgid "smart_routing_mode_description" msgstr "Smart Location picks the fastest server and switches automatically as it finds better routes." diff --git a/go.mod b/go.mod index 84d7b49c48..18eb252c45 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/getlantern/lantern go 1.26.2 -// replace github.com/getlantern/radiance => ../radiance +//replace github.com/getlantern/radiance => ../radiance // replace github.com/getlantern/lantern-server-provisioner => ../lantern-server-provisioner @@ -25,7 +25,7 @@ replace github.com/quic-go/qpack => github.com/quic-go/qpack v0.5.1 require ( github.com/alecthomas/assert/v2 v2.3.0 github.com/getlantern/lantern-server-provisioner v0.0.0-20251031121934-8ea031fccfa9 - github.com/getlantern/radiance v0.0.0-20260706185153-bce252479f03 + github.com/getlantern/radiance v0.0.0-20260713105203-0c80efbadf8e github.com/sagernet/sing-box v1.12.22 golang.org/x/mobile v0.0.0-20250711185624-d5bb5ecc55c0 golang.org/x/sys v0.45.0 diff --git a/go.sum b/go.sum index 239e8db7a7..d933553ecb 100644 --- a/go.sum +++ b/go.sum @@ -259,8 +259,8 @@ github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 h1:rtDmW8YL github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535/go.mod h1:WKJEdjMOD4IuTRYwjQHjT4bmqDl5J82RShMLxPAvi0Q= github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b h1:gMYJzEhLrmIqQ+JnjiYNm+UyUDalK3WUmVyecFwmV5g= github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b/go.mod h1:NpfXdK4ldEKkjQ4P1R+DBF4ua5VFOlxmgHROTnYrApg= -github.com/getlantern/radiance v0.0.0-20260706185153-bce252479f03 h1:yKLQK9wMXlghg4ksSDenZVYbUBphblutaFMomS6ZqQU= -github.com/getlantern/radiance v0.0.0-20260706185153-bce252479f03/go.mod h1:p6wcyzCrsFfrdrLNuQirB0ugeg7OjdFlZmBgNTu4D1w= +github.com/getlantern/radiance v0.0.0-20260713105203-0c80efbadf8e h1:54+UT+w8P3dm9rnIQ6/mVJ67WKp1ZQlMlwNbD1SRB1k= +github.com/getlantern/radiance v0.0.0-20260713105203-0c80efbadf8e/go.mod h1:rXbNFXzQvbnlIlaIF/6EJTwxK9DrAyKowkpUq/5udkI= github.com/getlantern/samizdat v0.0.3-0.20260529191731-5ea8ae61ddbf h1:KxiMF+oG0rTtuBi7GiIaHfccYOf69rLJ/VnO5myoYc4= github.com/getlantern/samizdat v0.0.3-0.20260529191731-5ea8ae61ddbf/go.mod h1:uEeykQSW2/6rTjfPlj3MTTo59poSHXfAHTGgzYDkbr0= github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb h1:c5YM7b3a4r2J8Eh89KkI6M/iTFe6Bi+b8AJlfkKdFq4= diff --git a/ios/Runner/Handlers/MethodHandler.swift b/ios/Runner/Handlers/MethodHandler.swift index c1096ef96a..56f4a7c801 100644 --- a/ios/Runner/Handlers/MethodHandler.swift +++ b/ios/Runner/Handlers/MethodHandler.swift @@ -90,7 +90,9 @@ class MethodHandler { ) return } - self.acknowledgeInAppPurchase(token: token, planId: planId, result: result) + let couponCode = map["couponCode"] as? String ?? "" + self.acknowledgeInAppPurchase( + token: token, planId: planId, couponCode: couponCode, result: result) case "restoreInAppPurchase": guard @@ -171,6 +173,13 @@ class MethodHandler { let code = call.arguments as? String ?? "" self.referralAttach(result: result, code: code) + case "attachReferralCodeV2": + let data = call.arguments as? [String: Any] ?? [:] + let code = data["code"] as? String ?? "" + let distributionChannel = data["distributionChannel"] as? String ?? "" + self.referralAttachV2( + result: result, code: code, distributionChannel: distributionChannel) + // Private server methods case "digitalOcean": self.digitalOcean(result: result) @@ -564,10 +573,12 @@ class MethodHandler { } } - func acknowledgeInAppPurchase(token: String, planId: String, result: @escaping FlutterResult) { + func acknowledgeInAppPurchase( + token: String, planId: String, couponCode: String, result: @escaping FlutterResult + ) { Task { var error: NSError? - let json = MobileAcknowledgeApplePurchase(token, planId, &error) + let json = MobileAcknowledgeApplePurchase(token, planId, couponCode, &error) if let error { await self.handleFlutterError(error, result: result, code: "ACKNOWLEDGE_FAILED") return @@ -811,6 +822,24 @@ class MethodHandler { } } + func referralAttachV2( + result: @escaping FlutterResult, code: String, distributionChannel: String + ) { + Task { + var error: NSError? + let json = MobileReferralAttachmentV2(code, distributionChannel, &error) + if let error { + appLogger.error("Failed to attach referral code v2: \(error.localizedDescription)") + await self.handleFlutterError(error, result: result, code: "ATTACH_REFERRAL_CODE_V2_FAILED") + return + } + await MainActor.run { + appLogger.info("Referral code attached successfully (v2).") + result(json) + } + } + } + // MARK: - Private server methods func digitalOcean(result: @escaping FlutterResult) { diff --git a/lantern-core/core.go b/lantern-core/core.go index d206c47efc..e5d3123144 100644 --- a/lantern-core/core.go +++ b/lantern-core/core.go @@ -75,6 +75,7 @@ type App interface { UpdateConfig() error ClearTunnelCache() error ReferralAttachment(referralCode string) (bool, error) + ReferralAttachmentV2(referralCode, channel string) ([]byte, error) UpdateLocale(locale string) error UpdateTelemetryConsent(consent bool) error IsTelemetryEnabled() bool @@ -120,17 +121,17 @@ type PrivateServer interface { } type Payment interface { - StripeSubscription(email, planID string) (string, error) + StripeSubscription(email, planID, couponCode string) (string, error) Plans(channel string) (string, error) StripeBillingPortalUrl() (string, error) - AcknowledgeGooglePurchase(purchaseToken, planId string) (string, error) - AcknowledgeApplePurchase(receipt, planII string) (string, error) + AcknowledgeGooglePurchase(purchaseToken, planId, couponCode string) (string, error) + AcknowledgeApplePurchase(receipt, planII, couponCode string) (string, error) RestoreGooglePlayPurchase(purchaseToken string) (string, error) RestoreApplePurchase(receipt string) (string, error) - PaymentRedirect(provider, planID, email, idempotencyKey string) (string, error) + PaymentRedirect(provider, planID, email, idempotencyKey, couponCode string) (string, error) ActivationCode(email, resellerCode string) error SubscriptionPaymentRedirectURL(redirectBody account.PaymentRedirectData) (string, error) - StripeSubscriptionPaymentRedirect(subscriptionType, planID, email, idempotencyKey string) (string, error) + StripeSubscriptionPaymentRedirect(subscriptionType, planID, email, idempotencyKey, couponCode string) (string, error) } type SplitTunnel interface { @@ -860,15 +861,29 @@ func (lc *LanternCore) CompleteChangeEmail(email, password, code string) error { } func (lc *LanternCore) ReferralAttachment(referralCode string) (bool, error) { - return lc.client.ReferralAttach(lc.ctx, referralCode) + // Empty channel selects the legacy v1 referral-attach API. + if _, err := lc.client.ReferralAttach(lc.ctx, referralCode, ""); err != nil { + return false, err + } + return true, nil +} + +// ReferralAttachmentV2 attaches a referral code and returns the resulting +// plans, providers, code, and discount marshalled as JSON. +func (lc *LanternCore) ReferralAttachmentV2(referralCode, channel string) ([]byte, error) { + resp, err := lc.client.ReferralAttach(lc.ctx, referralCode, channel) + if err != nil { + return nil, err + } + return json.Marshal(resp) } ///////////////// // Payments // ///////////////// -func (lc *LanternCore) StripeSubscription(email, planID string) (string, error) { - return lc.client.NewStripeSubscription(lc.ctx, email, planID) +func (lc *LanternCore) StripeSubscription(email, planID, couponCode string) (string, error) { + return lc.client.NewStripeSubscription(lc.ctx, email, planID, couponCode) } func (lc *LanternCore) Plans(channel string) (string, error) { @@ -879,19 +894,28 @@ func (lc *LanternCore) StripeBillingPortalUrl() (string, error) { return lc.client.StripeBillingPortalURL(lc.ctx) } -func (lc *LanternCore) AcknowledgeGooglePurchase(purchaseToken, planId string) (string, error) { +func (lc *LanternCore) AcknowledgeGooglePurchase(purchaseToken, planId, couponCode string) (string, error) { params := map[string]string{ "purchaseToken": purchaseToken, "planId": planId, } + // Affiliate/referral attribution: forward the applied code so the backend + // can credit the purchase to the affiliate. Google Play already applies the + // price discount via the offer SKU; this only carries attribution. + if couponCode != "" { + params["couponCode"] = couponCode + } return lc.client.VerifySubscription(lc.ctx, account.GoogleService, params) } -func (lc *LanternCore) AcknowledgeApplePurchase(receipt, planII string) (string, error) { +func (lc *LanternCore) AcknowledgeApplePurchase(receipt, planII, couponCode string) (string, error) { params := map[string]string{ "receipt": receipt, "planId": planII, } + if couponCode != "" { + params["couponCode"] = couponCode + } return lc.client.VerifySubscription(lc.ctx, account.AppleService, params) } @@ -931,7 +955,7 @@ func (lc *LanternCore) SubscriptionPaymentRedirectURL(redirectBody account.Payme return lc.client.SubscriptionPaymentRedirectURL(lc.ctx, redirectBody) } -func (lc *LanternCore) StripeSubscriptionPaymentRedirect(subscriptionType, planID, email, idempotencyKey string) (string, error) { +func (lc *LanternCore) StripeSubscriptionPaymentRedirect(subscriptionType, planID, email, idempotencyKey, couponCode string) (string, error) { idempotencyKey, err := normalizePaymentRedirectIdempotencyKey(idempotencyKey) if err != nil { return "", err @@ -944,11 +968,12 @@ func (lc *LanternCore) StripeSubscriptionPaymentRedirect(subscriptionType, planI Email: email, BillingType: account.SubscriptionType(subscriptionType), IdempotencyKey: idempotencyKey, + CouponCode: couponCode, } return lc.SubscriptionPaymentRedirectURL(redirectBody) } -func (lc *LanternCore) PaymentRedirect(provider, planId, email, idempotencyKey string) (string, error) { +func (lc *LanternCore) PaymentRedirect(provider, planId, email, idempotencyKey, couponCode string) (string, error) { idempotencyKey, err := normalizePaymentRedirectIdempotencyKey(idempotencyKey) if err != nil { return "", err @@ -960,6 +985,7 @@ func (lc *LanternCore) PaymentRedirect(provider, planId, email, idempotencyKey s DeviceName: deviceName, Email: email, IdempotencyKey: idempotencyKey, + CouponCode: couponCode, } return lc.client.PaymentRedirect(lc.ctx, body) } diff --git a/lantern-core/ffi/ffi.go b/lantern-core/ffi/ffi.go index cf14d9353c..70072f37e1 100644 --- a/lantern-core/ffi/ffi.go +++ b/lantern-core/ffi/ffi.go @@ -645,17 +645,18 @@ func fetchUserData() *C.char { // Fetch stipe subscription payment redirect link // //export stripeSubscriptionPaymentRedirect -func stripeSubscriptionPaymentRedirect(subType, _planId, _email, _idempotencyKey *C.char) *C.char { +func stripeSubscriptionPaymentRedirect(subType, _planId, _email, _idempotencyKey, _couponCode *C.char) *C.char { subscriptionType := C.GoString(subType) planID := C.GoString(_planId) email := C.GoString(_email) idempotencyKey := C.GoString(_idempotencyKey) + couponCode := C.GoString(_couponCode) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - redirect, err := c.StripeSubscriptionPaymentRedirect(subscriptionType, planID, email, idempotencyKey) + redirect, err := c.StripeSubscriptionPaymentRedirect(subscriptionType, planID, email, idempotencyKey, couponCode) if err != nil { return SendError(err) } @@ -666,17 +667,18 @@ func stripeSubscriptionPaymentRedirect(subType, _planId, _email, _idempotencyKey // Fetch payment redirect link for providers like alipay // //export paymentRedirect -func paymentRedirect(_plan, _provider, _email, _idempotencyKey *C.char) *C.char { +func paymentRedirect(_plan, _provider, _email, _idempotencyKey, _couponCode *C.char) *C.char { plan := C.GoString(_plan) provider := C.GoString(_provider) email := C.GoString(_email) idempotencyKey := C.GoString(_idempotencyKey) + couponCode := C.GoString(_couponCode) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - redirect, err := c.PaymentRedirect(provider, plan, email, idempotencyKey) + redirect, err := c.PaymentRedirect(provider, plan, email, idempotencyKey, couponCode) if err != nil { return SendError(err) } @@ -910,6 +912,26 @@ func verifyPassword(_email, _password *C.char) *C.char { }) } +// referralAttachmentV2 attaches a referral code to the user's account and +// returns the resulting plans, providers, code, and discount as JSON. +// +//export referralAttachmentV2 +func referralAttachmentV2(_referralCode, _channel *C.char) *C.char { + referralCode := C.GoString(_referralCode) + channel := C.GoString(_channel) + return runOnGoStack(func() *C.char { + c, errStr := requireCore() + if errStr != nil { + return errStr + } + bytes, err := c.ReferralAttachmentV2(referralCode, channel) + if err != nil { + return SendError(err) + } + return C.CString(string(bytes)) + }) +} + // startChangeEmail initiates the process of changing the user's email address. // //export startChangeEmail diff --git a/lantern-core/mobile/mobile.go b/lantern-core/mobile/mobile.go index c9918eb90e..bab8a15c02 100644 --- a/lantern-core/mobile/mobile.go +++ b/lantern-core/mobile/mobile.go @@ -493,8 +493,10 @@ func OAuthLoginCallback(oAuthToken string) (string, error) { }) } -func StripeSubscription(email, planID string) (string, error) { - return withCoreR(func(c lanterncore.Core) (string, error) { return c.StripeSubscription(email, planID) }) +func StripeSubscription(email, planID, couponCode string) (string, error) { + return withCoreR(func(c lanterncore.Core) (string, error) { + return c.StripeSubscription(email, planID, couponCode) + }) } func Plans(channel string) (string, error) { @@ -504,9 +506,9 @@ func StripeBillingPortalUrl() (string, error) { return withCoreR(func(c lanterncore.Core) (string, error) { return c.StripeBillingPortalUrl() }) } -func AcknowledgeGooglePurchase(purchaseToken, planId string) (string, error) { +func AcknowledgeGooglePurchase(purchaseToken, planId, couponCode string) (string, error) { return withCoreR(func(c lanterncore.Core) (string, error) { - data, err := c.AcknowledgeGooglePurchase(purchaseToken, planId) + data, err := c.AcknowledgeGooglePurchase(purchaseToken, planId, couponCode) if err != nil { return "", err } @@ -536,9 +538,9 @@ func AcknowledgeGooglePurchase(purchaseToken, planId string) (string, error) { }) } -func AcknowledgeApplePurchase(receipt, planII string) (string, error) { +func AcknowledgeApplePurchase(receipt, planII, couponCode string) (string, error) { return withCoreR(func(c lanterncore.Core) (string, error) { - data, err := c.AcknowledgeApplePurchase(receipt, planII) + data, err := c.AcknowledgeApplePurchase(receipt, planII, couponCode) if err != nil { return "", err } @@ -601,18 +603,18 @@ func restoreSubscription(c lanterncore.Core, fn func(string) (string, error), to return data, nil } -func PaymentRedirect(provider, planId, email, idempotencyKey string) (string, error) { +func PaymentRedirect(provider, planId, email, idempotencyKey, couponCode string) (string, error) { return withCoreR(func(c lanterncore.Core) (string, error) { - return c.PaymentRedirect(provider, planId, email, idempotencyKey) + return c.PaymentRedirect(provider, planId, email, idempotencyKey, couponCode) }) } // /This is specifically for stripe subscriptions that require a redirect to complete the payment // This is only used for macos -func StripeSubscriptionPaymentRedirect(subType, planId, email, idempotencyKey string) (string, error) { +func StripeSubscriptionPaymentRedirect(subType, planId, email, idempotencyKey, couponCode string) (string, error) { slog.Debug("stripeSubscriptionPaymentRedirect called") return withCoreR(func(c lanterncore.Core) (string, error) { - return c.StripeSubscriptionPaymentRedirect(subType, planId, email, idempotencyKey) + return c.StripeSubscriptionPaymentRedirect(subType, planId, email, idempotencyKey, couponCode) }) } @@ -689,6 +691,15 @@ func ReferralAttachment(referralCode string) error { }) } +// ReferralAttachmentV2 attaches a referral code and returns the resulting +// plans, providers, code, and discount as a JSON string. +func ReferralAttachmentV2(referralCode, channel string) (string, error) { + return withCoreR(func(c lanterncore.Core) (string, error) { + b, err := c.ReferralAttachmentV2(referralCode, channel) + return string(b), err + }) +} + func DeleteAccount(email, password string) (string, error) { return withCoreR(func(c lanterncore.Core) (string, error) { b, err := c.DeleteAccount(email, password) diff --git a/lib/core/common/app_dialog.dart b/lib/core/common/app_dialog.dart index 33cd5cdc8b..c32405998c 100644 --- a/lib/core/common/app_dialog.dart +++ b/lib/core/common/app_dialog.dart @@ -186,7 +186,7 @@ class AppDialog { actionsAlignment: MainAxisAlignment.end, actionsOverflowAlignment: OverflowBarAlignment.end, // backgroundColor and shape come from dialogTheme in app_theme.dart - contentPadding: EdgeInsets.symmetric(horizontal: size24), + contentPadding: EdgeInsets.symmetric(horizontal: defaultSize), actionsPadding: actionPadding ?? EdgeInsets.only( @@ -195,7 +195,10 @@ class AppDialog { left: defaultSize, right: defaultSize, ), - content: content, + // AlertDialog sizes to the content's intrinsic width, which collapses + // this custom content. maxFinite makes it fill instead; the min/max + // width bounds come from dialogTheme (Material 280–560dp). + content: SizedBox(width: double.maxFinite, child: content), actions: action, ); }, diff --git a/lib/core/common/app_semantic_colors.dart b/lib/core/common/app_semantic_colors.dart index 06746aeaec..059613d1b9 100644 --- a/lib/core/common/app_semantic_colors.dart +++ b/lib/core/common/app_semantic_colors.dart @@ -135,6 +135,15 @@ extension AppSemanticColors on BuildContext { Color get statusWarningBorderDot => _isDark ? AppColors.yellow4 : AppColors.yellow2; + /// text.attention Yellow.1000 — vivid attention tag, same in both themes. + Color get textAttention => AppColors.yellow10; + + /// bg.attention Yellow.300 — vivid attention tag, same in both themes. + Color get bgAttention => AppColors.yellow3; + + /// border.attention Yellow.400 — vivid attention tag, same in both themes. + Color get borderAttention => AppColors.yellow4; + /// status.success-bg-dot Green.600 light / Green.700 dark Color get statusSuccessBgDot => _isDark ? AppColors.green7 : AppColors.green6; diff --git a/lib/core/common/app_theme.dart b/lib/core/common/app_theme.dart index 5795a3b3cd..fa985bc75a 100644 --- a/lib/core/common/app_theme.dart +++ b/lib/core/common/app_theme.dart @@ -141,8 +141,10 @@ class AppTheme { inputDecorationTheme: InputDecorationTheme( filled: true, fillColor: cs.surface, // bg.input = bg.elevated - contentPadding: - const EdgeInsets.symmetric(vertical: 20, horizontal: 16), + contentPadding: const EdgeInsets.symmetric( + vertical: 20, + horizontal: 16, + ), hintStyle: TextStyle(color: AppColors.gray4), labelStyle: TextStyle(color: cs.onSurfaceVariant), // text.secondary border: OutlineInputBorder( @@ -156,7 +158,9 @@ class AppTheme { focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), borderSide: BorderSide( - color: AppColors.blue8, width: 2), // border.input-focus + color: AppColors.blue8, + width: 2, + ), // border.input-focus ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), @@ -179,8 +183,10 @@ class AppTheme { borderRadius: BorderRadius.circular(16), side: BorderSide(color: cs.outlineVariant, width: 1), ), - titleTextStyle: AppTextStyles.headingSmall, - contentTextStyle: AppTextStyles.bodyMedium, + // Material dialog width: min 280dp, max 520dp (M3 guideline). Applied + // app-wide so dialogs stay a sensible width on phones, tablets and + // desktop instead of collapsing to content or going full-bleed. + constraints: const BoxConstraints(minWidth: 280, maxWidth: 520), ), // ── Bottom Sheet ───────────────────────────────────────────────────────── @@ -222,8 +228,10 @@ class AppTheme { backgroundColor: cs.primary, // action.primary.primary-bg enableFeedback: true, foregroundColor: cs.onPrimary, // action.primary.primary-text - textStyle: AppTextStyles.primaryButtonTextStyle - .copyWith(fontSize: 18.0, color: cs.onPrimary), + textStyle: AppTextStyles.primaryButtonTextStyle.copyWith( + fontSize: 18.0, + color: cs.onPrimary, + ), overlayColor: AppColors.blue6, minimumSize: const Size(double.infinity, 52), tapTargetSize: MaterialTapTargetSize.padded, @@ -292,25 +300,26 @@ class AppTheme { selectionColor: AppColors.blue7, selectionHandleColor: AppColors.blue5, ), - textTheme: GoogleFonts.urbanistTextTheme( - ThemeData(brightness: Brightness.dark).textTheme, - ).copyWith( - bodyLarge: AppTextStyles.bodyLarge, - bodyMedium: AppTextStyles.bodyMedium, - bodySmall: AppTextStyles.bodySmall, - displayLarge: AppTextStyles.displayLarge, - displayMedium: AppTextStyles.displayMedium, - displaySmall: AppTextStyles.displaySmall, - headlineLarge: AppTextStyles.headingLarge, - headlineMedium: AppTextStyles.headingMedium, - headlineSmall: AppTextStyles.headingSmall, - labelLarge: AppTextStyles.labelLarge, - labelMedium: AppTextStyles.labelMedium, - labelSmall: AppTextStyles.labelSmall, - titleLarge: AppTextStyles.titleLarge, - titleMedium: AppTextStyles.titleMedium, - titleSmall: AppTextStyles.titleSmall, - ), + textTheme: + GoogleFonts.urbanistTextTheme( + ThemeData(brightness: Brightness.dark).textTheme, + ).copyWith( + bodyLarge: AppTextStyles.bodyLarge, + bodyMedium: AppTextStyles.bodyMedium, + bodySmall: AppTextStyles.bodySmall, + displayLarge: AppTextStyles.displayLarge, + displayMedium: AppTextStyles.displayMedium, + displaySmall: AppTextStyles.displaySmall, + headlineLarge: AppTextStyles.headingLarge, + headlineMedium: AppTextStyles.headingMedium, + headlineSmall: AppTextStyles.headingSmall, + labelLarge: AppTextStyles.labelLarge, + labelMedium: AppTextStyles.labelMedium, + labelSmall: AppTextStyles.labelSmall, + titleLarge: AppTextStyles.titleLarge, + titleMedium: AppTextStyles.titleMedium, + titleSmall: AppTextStyles.titleSmall, + ), // ── AppBar ────────────────────────────────────────────────────────────── appBarTheme: AppBarTheme( @@ -354,11 +363,14 @@ class AppTheme { inputDecorationTheme: InputDecorationTheme( filled: true, fillColor: cs.surface, // bg.input dark (gray850) - contentPadding: - const EdgeInsets.symmetric(vertical: 20, horizontal: 16), + contentPadding: const EdgeInsets.symmetric( + vertical: 20, + horizontal: 16, + ), hintStyle: TextStyle(color: AppColors.gray5), // text.disabled dark - labelStyle: - TextStyle(color: cs.onSurfaceVariant), // text.secondary dark + labelStyle: TextStyle( + color: cs.onSurfaceVariant, + ), // text.secondary dark border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: cs.outlineVariant), // border.input dark @@ -370,7 +382,9 @@ class AppTheme { focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), borderSide: BorderSide( - color: AppColors.blue2, width: 2), // border.input-focus dark + color: AppColors.blue2, + width: 2, + ), // border.input-focus dark ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), @@ -395,6 +409,10 @@ class AppTheme { ), titleTextStyle: AppTextStyles.headingSmall, contentTextStyle: AppTextStyles.bodyMedium, + // Material dialog width: min 280dp, max 560dp (M3 guideline). Applied + // app-wide so dialogs stay a sensible width on phones, tablets and + // desktop instead of collapsing to content or going full-bleed. + constraints: const BoxConstraints(minWidth: 280, maxWidth: 560), ), // ── Bottom Sheet ───────────────────────────────────────────────────────── @@ -436,8 +454,10 @@ class AppTheme { backgroundColor: cs.primary, // action.primary.primary-bg dark enableFeedback: true, foregroundColor: cs.onPrimary, // action.primary.primary-text - textStyle: AppTextStyles.primaryButtonTextStyle - .copyWith(fontSize: 18.0, color: cs.onPrimary), + textStyle: AppTextStyles.primaryButtonTextStyle.copyWith( + fontSize: 18.0, + color: cs.onPrimary, + ), overlayColor: AppColors.blue5, // action.primary.primary-bg-hover dark minimumSize: const Size(double.infinity, 52), tapTargetSize: MaterialTapTargetSize.padded, diff --git a/lib/core/extensions/error.dart b/lib/core/extensions/error.dart index ba40f2ce3f..6830e7309c 100644 --- a/lib/core/extensions/error.dart +++ b/lib/core/extensions/error.dart @@ -72,7 +72,8 @@ extension ErrorExetension on Object { if (description.contains("error restoring purchase")) { return "purchase_restored_error".i18n; } - if (description.contains('Invalid referral')) { + if (description.contains('Invalid referral') || + description.contains('invalid-referral-code')) { return "referral_code_invalid".i18n; } if (description.contains('Cannot use your own code for promotion')) { diff --git a/lib/core/extensions/plan.dart b/lib/core/extensions/plan.dart index 05da9113fc..b608941e67 100644 --- a/lib/core/extensions/plan.dart +++ b/lib/core/extensions/plan.dart @@ -1,30 +1,46 @@ import 'package:intl/intl.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/plan_data.dart'; -import 'package:lantern/core/utils/currency_utils.dart'; import 'package:lantern/core/models/user.dart'; +import 'package:lantern/core/utils/currency_utils.dart'; final _ddmmyyFormatter = DateFormat('dd/MM/yy'); final _mmddyyFormatter = DateFormat('MM/dd/yy'); extension PlanExtension on Plan { - String get formattedYearlyPrice { - return CurrencyUtils.formatCurrency( - double.parse(price.values.first.toString()), price.keys.first); + String get formattedYearlyPrice => _formatPriceMap(price); + + String get formattedMonthlyPrice => _formatPriceMap(expectedMonthlyPrice); + + /// The original (pre-discount) yearly price, taken directly from the + /// backend's `originalPrice` (no calculation). Shown as the strikethrough + /// price next to the discounted price when an affiliate code is applied. + /// Empty when the backend didn't supply an original price. + String get formatOriginalPrice => _formatPriceMap(originalPrice); + + /// The amount deducted by the affiliate discount: original − discounted + /// yearly price, both taken directly from the backend (no percentage math). + /// Shown as the "Promo Code" deduction at checkout. Empty when the backend + /// didn't supply an original price. + String get formatDiscountAmount { + final original = originalPrice; + if (original == null || original.isEmpty) return ''; + final deducted = _amountOf(original) - _amountOf(price); + return CurrencyUtils.formatCurrency(deducted, price.keys.first); } - String get formattedMonthlyPrice { - return CurrencyUtils.formatCurrency( - double.parse(expectedMonthlyPrice.values.first.toString()), - expectedMonthlyPrice.keys.first); + /// Formats the first `: amount` entry of [prices] as currency; + /// returns '' when [prices] is null or empty. + String _formatPriceMap(Map? prices) { + if (prices == null || prices.isEmpty) return ''; + return CurrencyUtils.formatCurrency(_amountOf(prices), prices.keys.first); } + double _amountOf(Map prices) => + double.parse(prices.values.first.toString()); + String getDurationText() { - final durationMap = { - '1y': 'year', - '2y': 'two year', - '1m': 'month', - }; + final durationMap = {'1y': 'year', '2y': 'two year', '1m': 'month'}; final key = id.split('-').first; return durationMap[key] ?? ''; diff --git a/lib/core/models/plan_data.dart b/lib/core/models/plan_data.dart index ae9f863e60..2ee56847e2 100644 --- a/lib/core/models/plan_data.dart +++ b/lib/core/models/plan_data.dart @@ -1,16 +1,37 @@ +import 'package:lantern/core/common/common.dart'; + class PlansData { Providers providers; List plans; - PlansData({ - required this.providers, - required this.plans, - }); + PlansData({required this.providers, required this.plans}); + + /// Sorts plans (best-value first, then by descending price) and orders the + /// platform's payment providers so subscription-capable ones come first. + /// Applied after fetching/attaching plans (including referral V2). + void sortPlansAndProviders() { + plans.sort((a, b) { + if (a.bestValue != b.bestValue) { + return a.bestValue ? -1 : 1; + } + // Then: sort by usdPrice descending + return b.usdPrice.compareTo(a.usdPrice); + }); + + int bySubscription(Android a, Android b) => + (b.providers.supportSubscription ? 1 : 0) - + (a.providers.supportSubscription ? 1 : 0); + if (PlatformUtils.isMobile) { + providers.android.sort(bySubscription); + } else { + providers.desktop.sort(bySubscription); + } + } factory PlansData.fromJson(Map json) => PlansData( - providers: Providers.fromJson(json["providers"]), - plans: List.from(json["plans"].map((x) => Plan.fromJson(x))), - ); + providers: Providers.fromJson(json["providers"]), + plans: List.from(json["plans"].map((x) => Plan.fromJson(x))), + ); PlansData copyWith({ Providers? providers, @@ -24,9 +45,9 @@ class PlansData { } Map toJson() => { - "providers": providers.toJson(), - "plans": List.from(plans.map((x) => x.toJson())), - }; + "providers": providers.toJson(), + "plans": List.from(plans.map((x) => x.toJson())), + }; } class Plan { @@ -37,6 +58,14 @@ class Plan { Map expectedMonthlyPrice; bool bestValue; + // Original (pre-discount) prices. Only present when a discount (affiliate + // code) is applied; null otherwise. + int? originalUsdPrice; + int? originalUsdPrice1Y; + int? originalUsdPrice2Y; + Map? originalPrice; + Map? originalExpectedMonthlyPrice; + Plan({ required this.id, required this.description, @@ -44,65 +73,82 @@ class Plan { required this.price, required this.expectedMonthlyPrice, this.bestValue = false, + this.originalUsdPrice, + this.originalUsdPrice1Y, + this.originalUsdPrice2Y, + this.originalPrice, + this.originalExpectedMonthlyPrice, }); factory Plan.fromJson(Map json) => Plan( - id: json["id"], - description: json["description"], - usdPrice: json["usdPrice"], - price: json["price"], - expectedMonthlyPrice: json["expectedMonthlyPrice"], - bestValue: json["bestValue"] ?? false, - ); + id: json["id"], + description: json["description"], + usdPrice: json["usdPrice"], + price: json["price"], + expectedMonthlyPrice: json["expectedMonthlyPrice"], + bestValue: json["bestValue"] ?? false, + originalUsdPrice: json["originalUsdPrice"], + originalUsdPrice1Y: json["originalUsdPrice1Y"], + originalUsdPrice2Y: json["originalUsdPrice2Y"], + originalPrice: json["originalPrice"] == null + ? null + : Map.from(json["originalPrice"]), + originalExpectedMonthlyPrice: json["originalExpectedMonthlyPrice"] == null + ? null + : Map.from(json["originalExpectedMonthlyPrice"]), + ); Map toJson() => { - "id": id, - "description": description, - "usdPrice": usdPrice, - "price": price, - "expectedMonthlyPrice": expectedMonthlyPrice, - "bestValue": bestValue, - }; + "id": id, + "description": description, + "usdPrice": usdPrice, + "price": price, + "expectedMonthlyPrice": expectedMonthlyPrice, + "bestValue": bestValue, + "originalUsdPrice": originalUsdPrice, + "originalUsdPrice1Y": originalUsdPrice1Y, + "originalUsdPrice2Y": originalUsdPrice2Y, + "originalPrice": originalPrice, + "originalExpectedMonthlyPrice": originalExpectedMonthlyPrice, + }; } class Providers { List android; List desktop; - Providers({ - required this.android, - required this.desktop, - }); + Providers({required this.android, required this.desktop}); factory Providers.fromJson(Map json) => Providers( - android: - List.from(json["android"].map((x) => Android.fromJson(x))), - desktop: - List.from(json["desktop"].map((x) => Android.fromJson(x))), - ); + android: List.from( + json["android"].map((x) => Android.fromJson(x)), + ), + desktop: List.from( + json["desktop"].map((x) => Android.fromJson(x)), + ), + ); Map toJson() => { - "android": List.from(android.map((x) => x.toJson())), - "desktop": List.from(desktop.map((x) => x.toJson())), - }; + "android": List.from(android.map((x) => x.toJson())), + "desktop": List.from(desktop.map((x) => x.toJson())), + }; } class Android { String method; Provider providers; - Android({ - required this.method, - required this.providers, - }); + Android({required this.method, required this.providers}); factory Android.fromJson(Map json) => Android( - method: json["method"], - providers: Provider.fromJson(json["provider"]), - ); + method: json["method"], + providers: Provider.fromJson(json["provider"]), + ); - Map toJson() => - {"method": method, "provider": providers.toJson()}; + Map toJson() => { + "method": method, + "provider": providers.toJson(), + }; } class Provider { @@ -119,19 +165,20 @@ class Provider { }); factory Provider.fromJson(Map json) => Provider( - name: json["name"], - data: json["data"] == null - ? {} - : (json["data"] as Map) - .map((key, value) => MapEntry(key, value)), - icons: List.from(json["icons"].map((x) => x)), - supportSubscription: json["supportsSubscription"] ?? false, - ); + name: json["name"], + data: json["data"] == null + ? {} + : (json["data"] as Map).map( + (key, value) => MapEntry(key, value), + ), + icons: List.from(json["icons"].map((x) => x)), + supportSubscription: json["supportsSubscription"] ?? false, + ); Map toJson() => { - "name": name, - "data": data, - "icons": List.from(icons.map((x) => x)), - "supportsSubscription": supportSubscription, - }; + "name": name, + "data": data, + "icons": List.from(icons.map((x) => x)), + "supportsSubscription": supportSubscription, + }; } diff --git a/lib/core/models/referral_attach_response.dart b/lib/core/models/referral_attach_response.dart new file mode 100644 index 0000000000..acc16385e4 --- /dev/null +++ b/lib/core/models/referral_attach_response.dart @@ -0,0 +1,64 @@ +import 'package:lantern/core/models/plan_data.dart'; + +/// The kind of code applied via the referral-attach V2 endpoint. +/// +/// - [none] nothing applied (the notifier's default/null state). +/// - [affiliate] applies a discount to the plans (strikethrough pricing UI). +/// - [referral] keeps the existing per-plan bonus message UI. +enum ReferralType { + none, + referral, + affiliate; + + static ReferralType fromString(String? value) => switch (value) { + 'referall' => ReferralType.referral, + 'affiliate' => ReferralType.affiliate, + _ => ReferralType.none, + }; +} + +class ReferralAttachV2Response { + final PlansData? plansData; + final String code; + final int discountPct; + final ReferralType type; + + ReferralAttachV2Response({ + required this.plansData, + required this.code, + required this.discountPct, + required this.type, + }); + + factory ReferralAttachV2Response.fromJson(Map json) => + ReferralAttachV2Response( + // `plans` is only present for the affiliate flow; when it's absent this + // is a referral-type response, so leave plansData null. + plansData: json['plans'] != null + ? (PlansData.fromJson(json)..sortPlansAndProviders()) + : null, + code: json["code"] ?? '', + type: ReferralType.fromString(json["referralType"]), + discountPct: json["discountPct"] ?? 0, + ); + + Map toJson() => { + ...?plansData?.toJson(), + "code": code, + "discountPct": discountPct, + "type": type.name, + }; +} + +/// Null-safe accessors for the referral notifier state +/// (`ReferralAttachV2Response?`). Lets callers branch on [type] and read +/// [code] / [discountPct] directly, without repeated `!= null` checks — a +/// null state simply reports [ReferralType.none], empty code, and 0 discount. +extension ReferralStateX on ReferralAttachV2Response? { + ReferralType get type => this?.type ?? ReferralType.none; + bool get isApplied => this != null; + bool get isAffiliate => type == ReferralType.affiliate; + bool get isReferral => type == ReferralType.referral; + String get code => this?.code ?? ''; + int get discountPct => this?.discountPct ?? 0; +} diff --git a/lib/core/services/app_purchase.dart b/lib/core/services/app_purchase.dart index f4de000bf0..825673496f 100644 --- a/lib/core/services/app_purchase.dart +++ b/lib/core/services/app_purchase.dart @@ -5,6 +5,8 @@ import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:in_app_purchase_android/in_app_purchase_android.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/user.dart'; +import 'package:lantern/core/services/purchase/pending_purchase_store.dart'; +import 'package:lantern/core/services/purchase/purchase_acknowledger.dart'; import 'package:lantern/core/utils/country_code.dart'; import 'package:lantern/lantern/lantern_platform_service.dart'; @@ -14,45 +16,80 @@ import 'local_storage_service.dart'; typedef PaymentSuccessCallback = void Function(PurchaseDetails purchase); typedef PaymentErrorCallback = void Function(String error); +/// Mutable state of the in-progress purchase/restore flow, grouped so the +/// callbacks and pending selections have a single home and reset point. +class _PurchaseSession { + PaymentSuccessCallback? onSuccess; + PaymentErrorCallback? onError; + + /// The exact plan id the user chose (e.g. "1y-usd-10"). + String? pendingPlanId; + + /// Affiliate code applied at purchase start; forwarded to the backend for + /// attribution. Empty when none. + String pendingCouponCode = ''; + + bool isRestoreFlow = false; + + /// Set once a restored receipt arrives, so iOS can detect "nothing to + /// restore" (StoreKit emits no empty-stream event the way Play Billing does). + bool restoreReceivedAny = false; +} + class AppPurchase { - static const _pendingPurchasePlansKey = 'pending_purchase_plans_json'; - static const _productPlanKeyPrefix = 'product:'; - static const _transactionPlanKeyPrefix = 'transaction:'; - static const _ackRetryDelays = [ - Duration(seconds: 5), - Duration(seconds: 15), - Duration(seconds: 45), - Duration(minutes: 2), - Duration(minutes: 5), - ]; + /// [inAppPurchase], [pendingStore] and [acknowledger] are injectable for + /// tests; production uses the store plugin singleton and the service locator. + AppPurchase({ + InAppPurchase? inAppPurchase, + PendingPurchaseStore? pendingStore, + PurchaseAcknowledger? acknowledger, + }) : _inAppPurchase = inAppPurchase ?? InAppPurchase.instance { + _pendingStore = + pendingStore ?? PendingPurchaseStore(() => sl()); + _acknowledger = + acknowledger ?? + PurchaseAcknowledger( + acknowledgeReceipt: + ({ + required String purchaseToken, + required String planId, + required String couponCode, + }) => sl().acknowledgeInAppPurchase( + purchaseToken: purchaseToken, + planId: planId, + couponCode: couponCode, + ), + resolvePlanId: _resolvePlanId, + resolveCouponCode: _resolveCouponCode, + retryKeyFor: (purchase) => _pendingStore.retryKeyFor(purchase), + isAlreadyActive: _checkIfAlreadyPurchased, + onAcknowledged: _onAcknowledged, + ); + } + + final InAppPurchase _inAppPurchase; + late final PendingPurchaseStore _pendingStore; + late final PurchaseAcknowledger _acknowledger; - final InAppPurchase _inAppPurchase = InAppPurchase.instance; StreamSubscription>? _subscription; final List _subscriptionSku = []; final List _subscriptionIds = ['1m_sub', '1y_sub']; - final Set _acknowledgeInFlight = {}; - final Map _ackRetryAttempts = {}; - final Map _ackRetryTimers = {}; - PaymentSuccessCallback? _onSuccess; - PaymentErrorCallback? _onError; + // iOS-only affiliate SKUs. Each mirrors a base plan but carries an + // introductory offer configured in App Store Connect, and is only ever + // surfaced once an affiliate/referral code is applied. StoreKit has no + // per-offer concept, so the discount has to live on a dedicated product; + // Android instead exposes the discount as a Play Console offer on the base + // product, so these are never queried there. + static const List _iosAffiliateIds = [ + '1m_sub_affiliate', + '1y_sub_affiliate', + ]; - // Tracks whether we have real product details loaded bool _productsLoaded = false; Completer? _productsLoadedCompleter; - // Track what plan the user selected - String? _pendingPlanId; - - // True while a restore flow is in progress; restored receipts should be - // acknowledged so the backend can reassociate the user, even when the - // device has no active subscription cached locally. - bool _isRestoreFlow = false; - - // Set true when a restored receipt is delivered through the stream during - // a restore flow. Used on iOS to detect "no purchases" (StoreKit doesn't - // emit an empty stream event the way Play Billing does). - bool _restoreReceivedAny = false; + final _PurchaseSession _session = _PurchaseSession(); /// Starts listening for store purchase updates without fetching product /// details. Product lookup stays user-initiated to keep StoreKit out of the @@ -126,22 +163,46 @@ class AppPurchase { return ready; } - Future fetchSubscriptions({int maxAttempts = 3}) async { - // If a fetch is already running, piggy-back on its result. - if (_productsLoadedCompleter != null) { - return _productsLoadedCompleter!.future; + /// The product IDs to query from the store. iOS additionally queries the + /// dedicated affiliate SKUs (see [_iosAffiliateIds]); [_selectSkus] then + /// keeps whichever set matches the requested mode. Android never queries + /// them because its discount is an offer on the base product. + Set get _productIdsToQuery => { + ..._subscriptionIds, + if (Platform.isIOS) ..._iosAffiliateIds, + }; + + /// Loads the subscription SKUs to purchase from. + /// + /// By default only base plans are kept ([includeOffers] false), so the first + /// fetch never charges a promotional offer. After a referral/affiliate code + /// is applied, call again with [includeOffers] true to load only the offer + /// SKUs — then the discounted offer is what gets charged. Either way + /// [_normalizePlan] just matches by plan prefix, so the rest of the purchase + /// logic is identical for plans and offers. + Future fetchSubscriptions({ + bool includeOffers = false, + int maxAttempts = 3, + }) async { + // Piggy-back only on a fetch that's still running; a completed one must + // re-run so the SKU set can switch between base plans and offers. + final inFlight = _productsLoadedCompleter; + if (inFlight != null && !inFlight.isCompleted) { + return inFlight.future; } _productsLoaded = false; - _productsLoadedCompleter = Completer(); + final completer = Completer(); + _productsLoadedCompleter = completer; for (int attempt = 0; attempt < maxAttempts; attempt++) { try { appLogger.info( - '[AppPurchase] Fetching subscriptions, attempt: ${attempt + 1}/$maxAttempts', + '[AppPurchase] Fetching subscriptions (includeOffers=$includeOffers), ' + 'attempt: ${attempt + 1}/$maxAttempts', ); final response = await _inAppPurchase.queryProductDetails( - _subscriptionIds.toSet(), + _productIdsToQuery, ); if (response.error != null) { @@ -153,19 +214,37 @@ class AppPurchase { '[AppPurchase] Fetched 0 subscriptions. notFoundIDs=${response.notFoundIDs}', ); } else { - _subscriptionSku - ..clear() - ..addAll(response.productDetails); - - _productsLoaded = true; - if (!(_productsLoadedCompleter?.isCompleted ?? true)) { - _productsLoadedCompleter?.complete(); - } + final products = _selectSkus( + response.productDetails, + includeOffers: includeOffers, + ); appLogger.info( - '[AppPurchase] Fetched subscriptions: ${_subscriptionSku.length} items', + '[AppPurchase] Fetched ${response.productDetails.length} ' + 'subscriptions, ${products.length} match ' + '(includeOffers=$includeOffers)', ); - return; + if (products.isNotEmpty) { + _subscriptionSku + ..clear() + ..addAll(products); + _productsLoaded = true; + if (!completer.isCompleted) completer.complete(); + return; + } + + // The store is reachable and returned SKUs, but none matched the + // requested set (e.g. offers requested but none are active). + // Retrying the same query won't change that, and this isn't a + // Play-unreachable signal, so fail fast without marking the region + // censored. Callers fall back to the base-plan fetch. + final error = StateError( + 'No ${includeOffers ? 'offer' : 'base plan'} SKUs available', + ); + if (!completer.isCompleted) completer.completeError(error); + throw error; } + } on StateError { + rethrow; } catch (e, st) { appLogger.error('[AppPurchase] Error fetching subscriptions', e, st); } @@ -189,11 +268,7 @@ class AppPurchase { final error = StateError( 'Unable to load in-app purchase products after $maxAttempts attempts', ); - // Safely complete the completer with an error, if it is still pending. - if (_productsLoadedCompleter != null && - !_productsLoadedCompleter!.isCompleted) { - _productsLoadedCompleter!.completeError(error); - } + if (!completer.isCompleted) completer.completeError(error); throw error; } @@ -222,14 +297,19 @@ class AppPurchase { required String plan, required PaymentSuccessCallback onSuccess, required void Function(String error) onError, + String couponCode = '', }) async { - _onSuccess = onSuccess; - _onError = onError; + _session.onSuccess = onSuccess; + _session.onError = onError; // Store the exact plan id user chose (ex: "1y-usd-10") - _pendingPlanId = plan; + _session.pendingPlanId = plan; + // Capture the applied affiliate/referral code so it can be sent with the + // acknowledgment. Reset to '' when none so a prior purchase's code never + // leaks into an unrelated one. + _session.pendingCouponCode = couponCode; if (!await _ensurePurchaseStreamReady()) { - _onError?.call( + _session.onError?.call( "Unable to access in-app purchases. Check your network and try again.", ); return; @@ -238,7 +318,7 @@ class AppPurchase { try { await _waitForProducts(); } catch (_) { - _onError?.call( + _session.onError?.call( "Unable to load in-app purchase products. Check your network and try again.", ); return; @@ -246,7 +326,7 @@ class AppPurchase { final product = _normalizePlan(plan); if (product == null) { - _onError?.call("Invalid plan: $plan"); + _session.onError?.call("Invalid plan: $plan"); return; } @@ -262,19 +342,23 @@ class AppPurchase { try { appLogger.info( - '[AppPurchase] Initiating purchase for product: ${product.id} with pendingPlanId: $_pendingPlanId', + '[AppPurchase] Initiating purchase for product: ${product.id} with pendingPlanId: ${_session.pendingPlanId}', + ); + await _pendingStore.rememberForProduct( + product.id, + planId: plan, + couponCode: couponCode, ); - await _rememberPendingPlanForProduct(product.id, plan); final started = await _inAppPurchase.buyNonConsumable( purchaseParam: purchaseParam, ); if (!started) { - await _forgetPendingPlanForProduct(product.id); - _onError?.call("Failed to initiate purchase flow."); + await _pendingStore.forgetProduct(product.id); + _session.onError?.call("Failed to initiate purchase flow."); } } catch (e) { - await _forgetPendingPlanForProduct(product.id); - _onError?.call("Error starting subscription: $e"); + await _pendingStore.forgetProduct(product.id); + _session.onError?.call("Error starting subscription: $e"); } } @@ -289,15 +373,16 @@ class AppPurchase { required PaymentSuccessCallback onSuccess, required PaymentErrorCallback onError, }) async { - _onSuccess = onSuccess; - _onError = onError; - _isRestoreFlow = true; - _restoreReceivedAny = false; - _pendingPlanId = null; + _session.onSuccess = onSuccess; + _session.onError = onError; + _session.isRestoreFlow = true; + _session.restoreReceivedAny = false; + _session.pendingPlanId = null; + _session.pendingCouponCode = ''; if (!await _ensurePurchaseStreamReady()) { - _isRestoreFlow = false; - final onError = _onError; + _session.isRestoreFlow = false; + final onError = _session.onError; clearCallbacks(); onError?.call( "Unable to access in-app purchases. Check your network and try again.", @@ -313,10 +398,10 @@ class AppPurchase { // purchases to restore, so the only signal "nothing to restore" is // the absence of a stream event within a reasonable window. Future.delayed(const Duration(seconds: 10), () { - if (_isRestoreFlow && !_restoreReceivedAny) { + if (_session.isRestoreFlow && !_session.restoreReceivedAny) { appLogger.info('[AppPurchase] iOS restore: no purchases delivered'); - _isRestoreFlow = false; - final onError = _onError; + _session.isRestoreFlow = false; + final onError = _session.onError; clearCallbacks(); onError?.call('No previous purchases found to restore.'); } @@ -324,8 +409,8 @@ class AppPurchase { } } catch (e, st) { appLogger.error('[AppPurchase] Error restoring purchases', e, st); - _isRestoreFlow = false; - final onError = _onError; + _session.isRestoreFlow = false; + final onError = _session.onError; clearCallbacks(); onError?.call('Error restoring purchases: $e'); } @@ -335,12 +420,12 @@ class AppPurchase { appLogger.info( '[AppPurchase] Received purchase updates: ${purchases.length}', ); - if (_isRestoreFlow && purchases.isEmpty) { + if (_session.isRestoreFlow && purchases.isEmpty) { appLogger.info( '[AppPurchase] Restore flow: purchase stream emitted empty list', ); - _isRestoreFlow = false; - final onError = _onError; + _session.isRestoreFlow = false; + final onError = _session.onError; clearCallbacks(); onError?.call('No previous purchases found to restore.'); return; @@ -348,9 +433,9 @@ class AppPurchase { /// During restore, if more than one restored receipt comes back, batch /// them: pick one to surface and finalize the rest. Otherwise _finalize - /// would clear _isRestoreFlow after the first item and the second + /// would clear isRestoreFlow after the first item and the second /// iteration would fall into the regular acknowledge path. - if (_isRestoreFlow && purchases.length > 1) { + if (_session.isRestoreFlow && purchases.length > 1) { final restored = purchases .where( (p) => @@ -370,7 +455,7 @@ class AppPurchase { } /// Picks the latest restored receipt, finalizes every restored receipt - /// with the store, and fires `_onSuccess` once. + /// with the store, and fires `onSuccess` once. Future _handleRestoreBatch(List restored) async { ///Pick the latest purchase restored.sort((a, b) { @@ -384,10 +469,10 @@ class AppPurchase { '[AppPurchase] Restore batch: ${restored.length} purchases, choosing ${chosen.productID}', ); - _restoreReceivedAny = true; + _session.restoreReceivedAny = true; // Complete each receipt inline (don't call _finalize — its `finally` - // clears _isRestoreFlow, which would mis-route any re-entrant stream + // clears isRestoreFlow, which would mis-route any re-entrant stream // emissions through the regular acknowledge path mid-batch and fire // a stray error before our success callback. for (final purchase in restored) { @@ -400,9 +485,9 @@ class AppPurchase { } } - final onSuccess = _onSuccess; - _isRestoreFlow = false; - _pendingPlanId = null; + final onSuccess = _session.onSuccess; + _session.isRestoreFlow = false; + _session.pendingPlanId = null; clearCallbacks(); onSuccess?.call(chosen); } @@ -412,97 +497,130 @@ class AppPurchase { '[AppPurchase] Handling purchase: ${purchaseDetails.productID} with status: ${purchaseDetails.status}', ); try { - final status = purchaseDetails.status; - if (status == PurchaseStatus.error) { - /// Error occurred during purchase - appLogger.error('Purchase error: ${purchaseDetails.error}'); - final errorMessage = purchaseDetails.error?.message ?? "Unknown error"; - await _forgetPendingPlan(purchaseDetails); - _pendingPlanId = null; - - /// Invoke error callback - _onError?.call(errorMessage); - return; - } - if (status == PurchaseStatus.canceled) { - // `canceled` is not necessarily a real user cancel — the - // in_app_purchase plugin also maps several silent Google Play - // rejections (offer ineligibility, missing payment method, region - // mismatch, billing-service hiccups) to the same status. Without - // capturing the underlying error we can't distinguish those from a - // user who actually X'd the dialog, which is the difference between - // "expected" and "a bug to fix." For Android, `error.details` - // typically contains a Map with the raw BillingClient - // `response_code` and `debug_message`. - appLogger.info( - '[AppPurchase] Purchase canceled: productID=${purchaseDetails.productID}' - ' errorCode=${purchaseDetails.error?.code}' - ' message=${purchaseDetails.error?.message}' - ' details=${purchaseDetails.error?.details}', - ); - await _forgetPendingPlan(purchaseDetails); - _pendingPlanId = null; - _onError?.call("Purchase canceled"); - return; - } - if (status == PurchaseStatus.pending) { - /// Purchase is pending (e.g. deferred payment method on Android). - /// Dismiss loading and inform the user — the purchase will complete - /// asynchronously when the payment is confirmed. - appLogger.info( - '[AppPurchase] Purchase is pending: ${purchaseDetails.productID}', - ); - _onError?.call( - "Purchase is pending. You will be notified when it completes.", - ); - return; - } - if (status == PurchaseStatus.purchased || - status == PurchaseStatus.restored) { - /// During an explicit restore flow, skip the backend acknowledge call - if (_isRestoreFlow) { - appLogger.info( - '[AppPurchase] Found restore purchase calling success', - ); - _restoreReceivedAny = true; - await _finalize(purchaseDetails); - final onSuccess = _onSuccess; - clearCallbacks(); - onSuccess?.call(purchaseDetails); - return; - } - - /// Apple sends purchase updates for previously purchased items when the app starts. - /// This check prevents processing the same subscription multiple times. - if (await _checkIfAlreadyPurchased()) { - appLogger.info( - '[AppPurchase] User has already purchased the subscription. Finalizing purchase without processing.', - ); - await _forgetPendingPlan(purchaseDetails); - await _finalize(purchaseDetails); - _onError?.call('You have already purchased this subscription.'); - return; - } - - try { - appLogger.info( - '[AppPurchase] Purchase successful: ${purchaseDetails.productID}', - ); - final planId = await _resolvePlanId(purchaseDetails); - await _rememberPendingPlanForPurchase(purchaseDetails, planId); - await _acknowledgePurchase(purchaseDetails, planId: planId); - } catch (e, st) { - await _handleAcknowledgeFailure(purchaseDetails, e, stackTrace: st); - } - return; + switch (purchaseDetails.status) { + case PurchaseStatus.error: + await _handlePurchaseError(purchaseDetails); + case PurchaseStatus.canceled: + await _handlePurchaseCanceled(purchaseDetails); + case PurchaseStatus.pending: + _handlePurchasePending(purchaseDetails); + case PurchaseStatus.purchased: + case PurchaseStatus.restored: + await _handleCompletedPurchase(purchaseDetails); } } catch (e) { appLogger.error('[AppPurchase] Error handling purchase: $e', e); - _onError?.call(e.toString()); + // Capture-clear-fire: clear the session before surfacing the error so a + // stale onSuccess/onError can't fire again when Apple re-delivers this + // pending transaction on the next launch (matches the restore paths). + final onError = _session.onError; + clearCallbacks(); + onError?.call(e.toString()); + } + } + + Future _handlePurchaseError(PurchaseDetails purchase) async { + appLogger.error('Purchase error: ${purchase.error}'); + final errorMessage = purchase.error?.message ?? 'Unknown error'; + await _pendingStore.forgetPurchase(purchase); + _session.pendingPlanId = null; + _session.onError?.call(errorMessage); + } + + Future _handlePurchaseCanceled(PurchaseDetails purchase) async { + // `canceled` is not necessarily a real user cancel — the in_app_purchase + // plugin also maps several silent Google Play rejections (offer + // ineligibility, missing payment method, region mismatch, billing-service + // hiccups) to the same status. Without capturing the underlying error we + // can't distinguish those from a user who actually X'd the dialog, which is + // the difference between "expected" and "a bug to fix." For Android, + // `error.details` typically contains a Map with the raw BillingClient + // `response_code` and `debug_message`. + appLogger.info( + '[AppPurchase] Purchase canceled: productID=${purchase.productID}' + ' errorCode=${purchase.error?.code}' + ' message=${purchase.error?.message}' + ' details=${purchase.error?.details}', + ); + await _pendingStore.forgetPurchase(purchase); + _session.pendingPlanId = null; + _session.onError?.call('Purchase canceled'); + } + + void _handlePurchasePending(PurchaseDetails purchase) { + // Deferred payment method (e.g. on Android): the purchase completes + // asynchronously when the payment is confirmed; just inform the user. + appLogger.info('[AppPurchase] Purchase is pending: ${purchase.productID}'); + _session.onError?.call( + 'Purchase is pending. You will be notified when it completes.', + ); + } + + /// Handles a `purchased`/`restored` receipt: during an explicit restore just + /// finalize and report success; otherwise verify it with the backend (unless + /// the account is already active) through the acknowledger. + Future _handleCompletedPurchase(PurchaseDetails purchase) async { + if (_session.isRestoreFlow) { + appLogger.info('[AppPurchase] Found restore purchase calling success'); + _session.restoreReceivedAny = true; + await _finalize(purchase); + final onSuccess = _session.onSuccess; + clearCallbacks(); + onSuccess?.call(purchase); + return; + } + + // Apple re-delivers previously purchased items at launch; trust fresh + // backend state so the same subscription isn't processed twice. + if (await _checkIfAlreadyPurchased()) { + appLogger.info( + '[AppPurchase] User has already purchased the subscription. ' + 'Finalizing purchase without processing.', + ); + await _pendingStore.forgetPurchase(purchase); + await _finalize(purchase); + _session.onError?.call('You have already purchased this subscription.'); + return; } + + appLogger.info('[AppPurchase] Purchase successful: ${purchase.productID}'); + final planId = await _resolvePlanId(purchase); + final couponCode = await _resolveCouponCode(purchase); + await _pendingStore.rememberForPurchase( + purchase, + planId: planId, + couponCode: couponCode, + ); + await _acknowledger.acknowledge( + purchase, + planId: planId, + couponCode: couponCode, + onSuccess: _onAckSuccess, + onError: _onAckError, + ); + } + + void _onAckSuccess(PurchaseDetails purchase) => + _session.onSuccess?.call(purchase); + + /// Surfaces the "will retry in the background" message and clears the + /// in-memory session, so a later background retry resolves the plan from + /// persisted metadata and a stray success can't fire the now-stale callback. + void _onAckError(String message) { + final onError = _session.onError; + _session.onSuccess = null; + _session.onError = null; + _session.pendingPlanId = null; + onError?.call(message); + } + + /// Runs once the backend confirms a purchase (or the account is found already + /// active): drop its pending metadata and complete the store transaction. + Future _onAcknowledged(PurchaseDetails purchase) async { + await _pendingStore.forgetPurchase(purchase); + await _finalize(purchase); } - // Separate helper to ensure the Store is cleared Future _finalize(PurchaseDetails purchaseDetails) async { try { if (purchaseDetails.pendingCompletePurchase) { @@ -515,8 +633,9 @@ class AppPurchase { } catch (e) { appLogger.error('[AppPurchase] Error finalizing purchase: $e', e); } finally { - _pendingPlanId = null; - _isRestoreFlow = false; + _session.pendingPlanId = null; + _session.pendingCouponCode = ''; + _session.isRestoreFlow = false; } } @@ -527,20 +646,72 @@ class AppPurchase { void _updateStreamOnError(Object error) { appLogger.error('[AppPurchase] Purchase stream error: $error'); - _onError?.call(error.toString()); + _session.onError?.call(error.toString()); + } + + /// Keeps only the SKUs matching the requested mode. + /// + /// On Android each Play Console offer comes back as its own + /// [GooglePlayProductDetails]: base plans have a null (or empty) offerId, + /// discount offers have a non-null offerId. When [includeOffers] is false we + /// keep base plans (a normal purchase), when true we keep only offers (an + /// applied affiliate/referral discount). + /// + /// iOS has no per-offer concept, so the discount lives on a dedicated + /// affiliate product ([_iosAffiliateIds]). We apply the same split there: + /// keep only the affiliate SKUs when an offer was requested, otherwise only + /// the base SKUs. Either way `_subscriptionSku` ends up holding just one set, + /// so the downstream `_normalizePlan` prefix match resolves unambiguously. + List _selectSkus( + List products, { + required bool includeOffers, + }) { + if (!Platform.isAndroid) { + return products.where((product) { + final isAffiliate = _iosAffiliateIds.contains(product.id); + return includeOffers ? isAffiliate : !isAffiliate; + }).toList(); + } + return products.where((product) { + final offerId = _offerIdFor(product); + final isOffer = offerId != null && offerId.isNotEmpty; + return includeOffers ? isOffer : !isOffer; + }).toList(); + } + + /// The Play Console offerId backing [product], or null when it's a base plan + /// (or not an Android subscription product). + String? _offerIdFor(ProductDetails product) { + if (product is! GooglePlayProductDetails) { + return null; + } + final index = product.subscriptionIndex; + final offers = product.productDetails.subscriptionOfferDetails; + if (index == null || offers == null || index >= offers.length) { + return null; + } + return offers[index].offerId; } + /// The plan-family prefix shared by a plan id and its store product id: + /// "1y-usd-10" → "1y", "1y_sub" → "1y", "1y_sub_affiliate" → "1y". Splitting + /// on either delimiter lets a plan and its (base or affiliate) SKU match. + static String _planPrefix(String id) => id.split(RegExp('[-_]')).first; + + /// Resolves the store product to purchase for [planId] by matching the plan + /// prefix (e.g. "1y"). Whether this returns a base plan or a discount offer + /// depends purely on which SKU set was loaded by [fetchSubscriptions]. ProductDetails? _normalizePlan(String planId) { - final plan = planId.split('-').first; + final plan = _planPrefix(planId); appLogger.info('[AppPurchase] Normalizing planId: $planId to plan: $plan'); for (final sku in _subscriptionSku) { - final subId = sku.id.split('_').first; - if (subId == plan) { + if (_planPrefix(sku.id) == plan) { return sku; } } appLogger.error( - '[AppPurchase] No matching product found for planId: $planId _subscriptionSku length: ${_subscriptionSku.length}', + '[AppPurchase] No matching product found for planId: $planId ' + '_subscriptionSku length: ${_subscriptionSku.length}', ); return null; } @@ -575,13 +746,17 @@ class AppPurchase { /// Determines the plan id to send to the backend for acknowledgment. /// - /// Prefers the exact plan the user selected, then falls back to a default. + /// Prefers the exact plan the user selected, then the value persisted for + /// this purchase (so background retries and post-restart re-delivery still + /// resolve it), then a cached plan matching the product prefix, then a + /// default. Future _resolvePlanId(PurchaseDetails purchase) async { - if (_pendingPlanId != null && _pendingPlanId!.isNotEmpty) { - return _pendingPlanId!; + final pendingPlanId = _session.pendingPlanId; + if (pendingPlanId != null && pendingPlanId.isNotEmpty) { + return pendingPlanId; } - final pendingPlan = await _pendingPlanForPurchase(purchase); + final pendingPlan = await _pendingStore.planFor(purchase); if (pendingPlan != null) { appLogger.info( '[AppPurchase] Resolved plan from pending purchase metadata: $pendingPlan', @@ -589,7 +764,7 @@ class AppPurchase { return pendingPlan; } - final prefix = purchase.productID.split('_').first; // "1y" or "1m" + final prefix = _planPrefix(purchase.productID); // "1y" or "1m" final localPlans = sl().getPlans(); if (localPlans != null) { for (final plan in localPlans.plans) { @@ -606,282 +781,24 @@ class AppPurchase { return '$prefix-usd-10'; } - void clearCallbacks() { - _onSuccess = null; - _onError = null; - _pendingPlanId = null; - _isRestoreFlow = false; - } - - Future _acknowledgePurchase( - PurchaseDetails purchaseDetails, { - required String planId, - bool isRetry = false, - }) async { - final key = _purchaseRetryKey(purchaseDetails); - if (!_acknowledgeInFlight.add(key)) { - appLogger.info( - '[AppPurchase] Acknowledgment already in flight: ' - 'productID=${purchaseDetails.productID} key=$key', - ); - return; - } - - try { - final purchaseToken = - purchaseDetails.verificationData.serverVerificationData; - appLogger.info( - '[AppPurchase] Acknowledgment ${isRetry ? 'retry' : 'start'}: ' - 'productID=${purchaseDetails.productID} ' - 'purchaseID=${purchaseDetails.purchaseID} ' - 'planId=$planId receiptLength=${purchaseToken.length}', - ); - - if (purchaseToken.isEmpty) { - await _handleAcknowledgeFailure( - purchaseDetails, - 'Missing purchase receipt', - isRetry: isRetry, - ); - return; - } - - final lanternService = sl(); - final ack = await lanternService.acknowledgeInAppPurchase( - purchaseToken: purchaseToken, - planId: planId, - ); - - await ack.fold( - (error) async { - await _handleAcknowledgeFailure( - purchaseDetails, - error, - isRetry: isRetry, - ); - }, - (success) async { - appLogger.info( - '[AppPurchase] Acknowledgment successful: ' - 'productID=${purchaseDetails.productID} purchaseID=${purchaseDetails.purchaseID}', - ); - _clearAcknowledgeRetry(key); - await _forgetPendingPlan(purchaseDetails); - await _finalize(purchaseDetails); - if (!isRetry) { - _onSuccess?.call(purchaseDetails); - } - }, - ); - } finally { - _acknowledgeInFlight.remove(key); - } - } - - Future _handleAcknowledgeFailure( - PurchaseDetails purchaseDetails, - Object error, { - bool isRetry = false, - StackTrace? stackTrace, - }) async { - appLogger.error( - '[AppPurchase] Acknowledgment failed; leaving store transaction pending ' - 'for retry: productID=${purchaseDetails.productID} ' - 'purchaseID=${purchaseDetails.purchaseID}', - error, - stackTrace, - ); - - if (await _checkIfAlreadyPurchased()) { - appLogger.info( - '[AppPurchase] Account is already active after acknowledgment failure; ' - 'finalizing store transaction', - ); - _clearAcknowledgeRetry(_purchaseRetryKey(purchaseDetails)); - await _forgetPendingPlan(purchaseDetails); - await _finalize(purchaseDetails); - if (!isRetry) { - _onSuccess?.call(purchaseDetails); - } - return; - } - - if (!isRetry) { - final onError = _onError; - _onSuccess = null; - _onError = null; - _pendingPlanId = null; - onError?.call( - 'Purchase verification failed. We will retry in the background.', - ); - } - _scheduleAcknowledgeRetry(purchaseDetails); - } - - void _scheduleAcknowledgeRetry(PurchaseDetails purchaseDetails) { - final key = _purchaseRetryKey(purchaseDetails); - if (_ackRetryTimers.containsKey(key)) { - appLogger.info( - '[AppPurchase] Acknowledgment retry already scheduled: ' - 'productID=${purchaseDetails.productID} key=$key', - ); - return; - } - - final attempt = _ackRetryAttempts[key] ?? 0; - final delay = _ackRetryDelayForAttempt(attempt); - appLogger.info( - '[AppPurchase] Scheduling acknowledgment retry ${attempt + 1} in $delay: ' - 'productID=${purchaseDetails.productID} purchaseID=${purchaseDetails.purchaseID}', - ); - - _ackRetryTimers[key] = Timer(delay, () { - unawaited(() async { - try { - final planId = await _resolvePlanId(purchaseDetails); - _ackRetryTimers.remove(key); - _ackRetryAttempts[key] = (_ackRetryAttempts[key] ?? 0) + 1; - await _acknowledgePurchase( - purchaseDetails, - planId: planId, - isRetry: true, - ); - } catch (e, st) { - _ackRetryTimers.remove(key); - await _handleAcknowledgeFailure( - purchaseDetails, - e, - isRetry: true, - stackTrace: st, - ); - } - }()); - }); - } - - Duration _ackRetryDelayForAttempt(int attempt) { - final index = attempt >= _ackRetryDelays.length - ? _ackRetryDelays.length - 1 - : attempt; - return _ackRetryDelays[index]; - } - - void _clearAcknowledgeRetry(String key) { - _ackRetryAttempts.remove(key); - _ackRetryTimers.remove(key)?.cancel(); - } - - // The transaction key when available, otherwise the product key — i.e. the - // most specific key `_planKeysForPurchase` would store this purchase under. - String _purchaseRetryKey(PurchaseDetails purchase) => - _planKeysForPurchase(purchase).first; - - String _productPlanKey(String productID) => - '$_productPlanKeyPrefix$productID'; - - String? _transactionPlanKey(PurchaseDetails purchase) { - final purchaseID = purchase.purchaseID; - if (purchaseID != null && purchaseID.isNotEmpty) { - return '$_transactionPlanKeyPrefix$purchaseID'; - } - final transactionDate = purchase.transactionDate; - if (transactionDate != null && transactionDate.isNotEmpty) { - return '$_transactionPlanKeyPrefix${purchase.productID}:$transactionDate'; - } - return null; - } - - List _planKeysForPurchase(PurchaseDetails purchase) { - final transactionKey = _transactionPlanKey(purchase); - final keys = [_productPlanKey(purchase.productID)]; - if (transactionKey != null) { - keys.insert(0, transactionKey); - } - return keys; - } - - Future _pendingPlanForPurchase(PurchaseDetails purchase) async { - final pending = await _loadPendingPurchasePlans(); - for (final key in _planKeysForPurchase(purchase)) { - final planId = pending[key]; - if (planId != null && planId.isNotEmpty) { - return planId; - } - } - return null; - } - - Future _rememberPendingPlanForProduct( - String productID, - String planId, - ) async { - if (await _rememberPendingPlan([_productPlanKey(productID)], planId)) { - appLogger.info( - '[AppPurchase] Stored pending purchase plan: productID=$productID planId=$planId', - ); - } - } - - Future _rememberPendingPlanForPurchase( - PurchaseDetails purchase, - String planId, - ) async { - if (await _rememberPendingPlan(_planKeysForPurchase(purchase), planId)) { - appLogger.info( - '[AppPurchase] Stored pending purchase plan: ' - 'productID=${purchase.productID} purchaseID=${purchase.purchaseID} planId=$planId', - ); - } - } - - /// Stores [planId] under each of [keys], persisting only when [planId] is - /// non-empty. Returns whether anything was written. - Future _rememberPendingPlan(List keys, String planId) async { - if (planId.isEmpty) { - return false; - } - final pending = await _loadPendingPurchasePlans(); - for (final key in keys) { - pending[key] = planId; - } - await _savePendingPurchasePlans(pending); - return true; - } - - Future _forgetPendingPlan(PurchaseDetails purchase) async { - if (await _forgetPendingPlanKeys(_planKeysForPurchase(purchase))) { - appLogger.info( - '[AppPurchase] Cleared pending purchase plan: ' - 'productID=${purchase.productID} purchaseID=${purchase.purchaseID}', - ); - } - } - - Future _forgetPendingPlanForProduct(String productID) async { - if (await _forgetPendingPlanKeys([_productPlanKey(productID)])) { - appLogger.info( - '[AppPurchase] Cleared pending purchase plan for productID=$productID', - ); + /// Resolves the affiliate/referral code to send with acknowledgment. + /// + /// Prefers the code captured for the in-flight purchase; falls back to the + /// value persisted for this purchase so background retries and store + /// re-delivery after a restart still attribute the sale. Returns '' when no + /// code was applied. + Future _resolveCouponCode(PurchaseDetails purchase) async { + if (_session.pendingCouponCode.isNotEmpty) { + return _session.pendingCouponCode; } + return await _pendingStore.couponFor(purchase) ?? ''; } - /// Removes [keys] from the pending plans map, persisting only when something - /// actually changed. Returns whether any entry was removed. - Future _forgetPendingPlanKeys(List keys) async { - final pending = await _loadPendingPurchasePlans(); - var changed = false; - for (final key in keys) { - changed = pending.remove(key) != null || changed; - } - if (changed) { - await _savePendingPurchasePlans(pending); - } - return changed; + void clearCallbacks() { + _session.onSuccess = null; + _session.onError = null; + _session.pendingPlanId = null; + _session.pendingCouponCode = ''; + _session.isRestoreFlow = false; } - - Future> _loadPendingPurchasePlans() => - sl().getStringMap(_pendingPurchasePlansKey); - - Future _savePendingPurchasePlans(Map pending) => - sl().setStringMap(_pendingPurchasePlansKey, pending); } diff --git a/lib/core/services/purchase/pending_purchase_store.dart b/lib/core/services/purchase/pending_purchase_store.dart new file mode 100644 index 0000000000..039890e01a --- /dev/null +++ b/lib/core/services/purchase/pending_purchase_store.dart @@ -0,0 +1,144 @@ +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:lantern/core/services/local_storage_service.dart'; + +/// Persists the plan id and affiliate code chosen for an in-flight purchase, so +/// background acknowledge retries and post-restart re-delivery can still +/// attribute the sale. +/// +/// Each purchase is keyed by its store transaction id when present, falling +/// back to the product id. Plan and coupon are kept in two separate maps to +/// preserve the original on-disk format; the single remember/forget API writes +/// and clears both together so they can't drift apart. +class PendingPurchaseStore { + PendingPurchaseStore(this._storage); + + /// Resolved lazily so construction doesn't require [LocalStorageService] to + /// be registered yet, and tests can inject a fake. + final LocalStorageService Function() _storage; + + static const _plansStorageKey = 'pending_purchase_plans_json'; + static const _couponsStorageKey = 'pending_purchase_coupons_json'; + static const _productKeyPrefix = 'product:'; + static const _transactionKeyPrefix = 'transaction:'; + + // --- Public API -------------------------------------------------------- + + /// Remembers the plan/coupon at purchase initiation, keyed by product id only + /// (no transaction id exists yet). + Future rememberForProduct( + String productID, { + String planId = '', + String couponCode = '', + }) => _remember( + [_productKey(productID)], + planId: planId, + couponCode: couponCode, + ); + + /// Remembers the plan/coupon for a delivered purchase, keyed by both its + /// transaction id (when present) and product id. + Future rememberForPurchase( + PurchaseDetails purchase, { + String planId = '', + String couponCode = '', + }) => _remember( + _keysFor(purchase), + planId: planId, + couponCode: couponCode, + ); + + Future planFor(PurchaseDetails purchase) => + _firstNonEmpty(_plansStorageKey, _keysFor(purchase)); + + Future couponFor(PurchaseDetails purchase) => + _firstNonEmpty(_couponsStorageKey, _keysFor(purchase)); + + Future forgetPurchase(PurchaseDetails purchase) => + _forget(_keysFor(purchase)); + + Future forgetProduct(String productID) => + _forget([_productKey(productID)]); + + /// The most specific key for [purchase] (transaction id, else product id), + /// used to dedupe in-flight acknowledgements and their retries. + String retryKeyFor(PurchaseDetails purchase) => _keysFor(purchase).first; + + // --- Key derivation ---------------------------------------------------- + + String _productKey(String productID) => '$_productKeyPrefix$productID'; + + String? _transactionKey(PurchaseDetails purchase) { + final purchaseID = purchase.purchaseID; + if (purchaseID != null && purchaseID.isNotEmpty) { + return '$_transactionKeyPrefix$purchaseID'; + } + final transactionDate = purchase.transactionDate; + if (transactionDate != null && transactionDate.isNotEmpty) { + return '$_transactionKeyPrefix${purchase.productID}:$transactionDate'; + } + return null; + } + + /// Transaction key first (most specific) when present, then product key. + List _keysFor(PurchaseDetails purchase) { + final keys = [_productKey(purchase.productID)]; + final transactionKey = _transactionKey(purchase); + if (transactionKey != null) { + keys.insert(0, transactionKey); + } + return keys; + } + + // --- Storage internals ------------------------------------------------- + + Future _remember( + List keys, { + required String planId, + required String couponCode, + }) async { + await _putAll(_plansStorageKey, keys, planId); + await _putAll(_couponsStorageKey, keys, couponCode); + } + + Future _forget(List keys) async { + await _removeAll(_plansStorageKey, keys); + await _removeAll(_couponsStorageKey, keys); + } + + /// No-op when [value] is empty, so an absent plan/coupon never overwrites a + /// previously stored one. + Future _putAll( + String storageKey, + List keys, + String value, + ) async { + if (value.isEmpty) return; + final map = await _storage().getStringMap(storageKey); + for (final key in keys) { + map[key] = value; + } + await _storage().setStringMap(storageKey, map); + } + + Future _removeAll(String storageKey, List keys) async { + final map = await _storage().getStringMap(storageKey); + var changed = false; + for (final key in keys) { + changed = map.remove(key) != null || changed; + } + if (changed) { + await _storage().setStringMap(storageKey, map); + } + } + + Future _firstNonEmpty(String storageKey, List keys) async { + final map = await _storage().getStringMap(storageKey); + for (final key in keys) { + final value = map[key]; + if (value != null && value.isNotEmpty) { + return value; + } + } + return null; + } +} diff --git a/lib/core/services/purchase/purchase_acknowledger.dart b/lib/core/services/purchase/purchase_acknowledger.dart new file mode 100644 index 0000000000..a9fa8bbdb1 --- /dev/null +++ b/lib/core/services/purchase/purchase_acknowledger.dart @@ -0,0 +1,293 @@ +import 'dart:async'; + +import 'package:fpdart/fpdart.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:lantern/core/services/logger_service.dart'; +import 'package:lantern/core/utils/failure.dart'; + +/// Sends a purchase receipt to the backend. Returns the backend response on +/// success, or a [Failure] to be retried. +typedef ReceiptAcknowledger = + Future> Function({ + required String purchaseToken, + required String planId, + required String couponCode, + }); + +/// Resolves the plan id / coupon code to send for a purchase. Re-invoked on +/// each retry so a background attempt reads the latest persisted attribution. +typedef PurchaseResolver = Future Function(PurchaseDetails purchase); + +/// Runs after a purchase is confirmed by the backend (or found already active): +/// clear its pending metadata and complete the store transaction. +typedef PurchaseSideEffect = Future Function(PurchaseDetails purchase); + +/// The stable key a purchase is deduped/retried under. +typedef RetryKey = String Function(PurchaseDetails purchase); + +typedef PurchaseSuccess = void Function(PurchaseDetails purchase); +typedef PurchaseError = void Function(String message); + +class _RetryState { + bool inFlight = false; + int attempts = 0; + Timer? timer; +} + +/// Owns the "get this receipt confirmed by the backend" state machine: +/// de-duplication of concurrent attempts and background retries with backoff, +/// until the backend confirms or the account is already active. +/// +/// All domain operations are injected, so it has no dependency on the service +/// locator or store plugin and is unit-testable in isolation. +class PurchaseAcknowledger { + PurchaseAcknowledger({ + required ReceiptAcknowledger acknowledgeReceipt, + required PurchaseResolver resolvePlanId, + required PurchaseResolver resolveCouponCode, + required RetryKey retryKeyFor, + required Future Function() isAlreadyActive, + required PurchaseSideEffect onAcknowledged, + List retryDelays = defaultRetryDelays, + int? maxRetries, + }) : _acknowledgeReceipt = acknowledgeReceipt, + _resolvePlanId = resolvePlanId, + _resolveCouponCode = resolveCouponCode, + _retryKeyFor = retryKeyFor, + _isAlreadyActive = isAlreadyActive, + _onAcknowledged = onAcknowledged, + _retryDelays = retryDelays, + // Default to the backoff schedule's length so each defined delay runs + // exactly once. Previously the final delay repeated forever; capping to + // the schedule keeps the deliberate 5s→…→5min progression and no more. + _maxRetries = maxRetries ?? retryDelays.length; + + static const List defaultRetryDelays = [ + Duration(seconds: 5), + Duration(seconds: 15), + Duration(seconds: 45), + Duration(minutes: 2), + Duration(minutes: 5), + ]; + + final ReceiptAcknowledger _acknowledgeReceipt; + final PurchaseResolver _resolvePlanId; + final PurchaseResolver _resolveCouponCode; + final RetryKey _retryKeyFor; + final Future Function() _isAlreadyActive; + final PurchaseSideEffect _onAcknowledged; + final List _retryDelays; + + /// Max background retries before giving up for this session. The store keeps + /// the transaction pending (never completed until acknowledged), so it is + /// re-delivered on the next launch and acknowledgment resumes — capping here + /// only stops the in-session loop from repeating the final backoff delay + /// forever (draining battery/network) when the backend is unreachable. + final int _maxRetries; + + final Map _retries = {}; + + /// Acknowledges a freshly delivered [purchase]. On success completes the + /// store transaction (via `onAcknowledged`) and fires [onSuccess]. On + /// failure, fires [onError] once and retries in the background with backoff; + /// background attempts never fire the callbacks again. + Future acknowledge( + PurchaseDetails purchase, { + required String planId, + required String couponCode, + required PurchaseSuccess onSuccess, + required PurchaseError onError, + }) => _attempt( + purchase, + planId: planId, + couponCode: couponCode, + onSuccess: onSuccess, + onError: onError, + isRetry: false, + ); + + /// Cancels all pending background retries. Not called during normal operation + /// (retries are meant to outlive a single UI flow); provided for teardown. + void dispose() { + for (final state in _retries.values) { + state.timer?.cancel(); + } + _retries.clear(); + } + + Future _attempt( + PurchaseDetails purchase, { + required String planId, + required String couponCode, + required bool isRetry, + PurchaseSuccess? onSuccess, + PurchaseError? onError, + }) async { + final key = _retryKeyFor(purchase); + final state = _retries.putIfAbsent(key, () => _RetryState()); + if (state.inFlight) { + appLogger.info( + '[PurchaseAck] Acknowledgment already in flight: ' + 'productID=${purchase.productID} key=$key', + ); + return; + } + state.inFlight = true; + + try { + final purchaseToken = purchase.verificationData.serverVerificationData; + appLogger.info( + '[PurchaseAck] ${isRetry ? 'retry' : 'start'}: ' + 'productID=${purchase.productID} purchaseID=${purchase.purchaseID} ' + 'planId=$planId receiptLength=${purchaseToken.length}', + ); + + if (purchaseToken.isEmpty) { + await _handleFailure( + purchase, + 'Missing purchase receipt', + isRetry: isRetry, + onSuccess: onSuccess, + onError: onError, + ); + return; + } + + // A thrown exception from the backend call (e.g. a network error) is + // treated exactly like a returned failure, so it is retried rather than + // propagated to the caller. + try { + final result = await _acknowledgeReceipt( + purchaseToken: purchaseToken, + planId: planId, + couponCode: couponCode, + ); + + await result.fold( + (failure) => _handleFailure( + purchase, + failure, + isRetry: isRetry, + onSuccess: onSuccess, + onError: onError, + ), + (_) async { + appLogger.info( + '[PurchaseAck] Acknowledgment successful: ' + 'productID=${purchase.productID} purchaseID=${purchase.purchaseID}', + ); + _clear(key); + await _onAcknowledged(purchase); + if (!isRetry) onSuccess?.call(purchase); + }, + ); + } catch (e) { + await _handleFailure( + purchase, + e, + isRetry: isRetry, + onSuccess: onSuccess, + onError: onError, + ); + } + } finally { + // May already be gone from the map after a successful `_clear`. + _retries[key]?.inFlight = false; + } + } + + Future _handleFailure( + PurchaseDetails purchase, + Object error, { + required bool isRetry, + PurchaseSuccess? onSuccess, + PurchaseError? onError, + }) async { + appLogger.error( + '[PurchaseAck] Acknowledgment failed; leaving store transaction pending ' + 'for retry: productID=${purchase.productID} ' + 'purchaseID=${purchase.purchaseID}', + error, + ); + + // The backend may have granted access even though this call failed (e.g. a + // prior attempt won the race). Trust fresh account state over the error. + if (await _isAlreadyActive()) { + appLogger.info( + '[PurchaseAck] Account already active after failure; finalizing: ' + 'productID=${purchase.productID}', + ); + _clear(_retryKeyFor(purchase)); + await _onAcknowledged(purchase); + if (!isRetry) onSuccess?.call(purchase); + return; + } + + if (!isRetry) { + onError?.call( + 'Purchase verification failed. We will retry in the background.', + ); + } + _scheduleRetry(purchase); + } + + void _scheduleRetry(PurchaseDetails purchase) { + final key = _retryKeyFor(purchase); + final state = _retries.putIfAbsent(key, () => _RetryState()); + if (state.timer != null) { + appLogger.info( + '[PurchaseAck] Retry already scheduled: ' + 'productID=${purchase.productID} key=$key', + ); + return; + } + + if (state.attempts >= _maxRetries) { + appLogger.warning( + '[PurchaseAck] Max retries ($_maxRetries) exhausted this session; ' + 'giving up until re-delivery on next launch: ' + 'productID=${purchase.productID} purchaseID=${purchase.purchaseID}', + ); + _clear(_retryKeyFor(purchase)); + return; + } + + final delay = _delayForAttempt(state.attempts); + appLogger.info( + '[PurchaseAck] Scheduling retry ${state.attempts + 1} in $delay: ' + 'productID=${purchase.productID} purchaseID=${purchase.purchaseID}', + ); + + state.timer = Timer(delay, () { + unawaited(() async { + try { + final planId = await _resolvePlanId(purchase); + final couponCode = await _resolveCouponCode(purchase); + state.timer = null; + state.attempts += 1; + await _attempt( + purchase, + planId: planId, + couponCode: couponCode, + isRetry: true, + ); + } catch (e, st) { + state.timer = null; + appLogger.error('[PurchaseAck] Retry attempt threw', e, st); + await _handleFailure(purchase, e, isRetry: true); + } + }()); + }); + } + + Duration _delayForAttempt(int attempt) { + final index = attempt >= _retryDelays.length + ? _retryDelays.length - 1 + : attempt; + return _retryDelays[index]; + } + + void _clear(String key) { + _retries.remove(key)?.timer?.cancel(); + } +} diff --git a/lib/core/widgets/base_screen.dart b/lib/core/widgets/base_screen.dart index 37cf392dc5..8889ba2d98 100644 --- a/lib/core/widgets/base_screen.dart +++ b/lib/core/widgets/base_screen.dart @@ -11,6 +11,7 @@ class BaseScreen extends StatelessWidget { final Widget? bottomSheet; final Color? backgroundColor; final bool extendBody; + final bool? resizeToAvoidBottomInset; const BaseScreen({ super.key, @@ -22,6 +23,7 @@ class BaseScreen extends StatelessWidget { this.backgroundColor, this.extendBody = false, this.bottomSheet, + this.resizeToAvoidBottomInset, }); @override @@ -41,6 +43,7 @@ class BaseScreen extends StatelessWidget { bottomSheet: bottomSheet, bottomNavigationBar: bottomNavigationBar, extendBody: extendBody, + resizeToAvoidBottomInset: resizeToAvoidBottomInset, ); } } diff --git a/lib/features/auth/choose_payment_method.dart b/lib/features/auth/choose_payment_method.dart index 935f96379f..87000e0beb 100644 --- a/lib/features/auth/choose_payment_method.dart +++ b/lib/features/auth/choose_payment_method.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/extensions/plan.dart'; import 'package:lantern/core/models/plan_data.dart'; +import 'package:lantern/core/models/referral_attach_response.dart'; import 'package:lantern/core/services/injection_container.dart'; import 'package:lantern/core/services/stripe_service.dart'; import 'package:lantern/core/widgets/logs_path.dart'; @@ -89,7 +90,7 @@ class ChoosePaymentMethod extends HookConsumerWidget { icon: AppImagePaths.star, onPressed: () { appRouter.pop(); - showRferralCodeDialog(context); + showReferralCodeDialog(context); }, ), DividerSpace(), @@ -99,7 +100,7 @@ class ChoosePaymentMethod extends HookConsumerWidget { ); } - void showRferralCodeDialog(BuildContext context) { + void showReferralCodeDialog(BuildContext context) { final textTheme = Theme.of(context).textTheme; AppDialog.customDialog( context: context, @@ -132,6 +133,13 @@ class ChoosePaymentMethod extends HookConsumerWidget { ); } + /// The applied affiliate code (coupon) to forward to Stripe, or '' when the + /// applied code is a plain referral / nothing is applied. + String _affiliateCoupon(WidgetRef ref) { + final referral = ref.read(referralProvider); + return referral.isAffiliate ? referral.code : ''; + } + Future onSubscribe( Android provider, WidgetRef ref, @@ -192,7 +200,11 @@ class ChoosePaymentMethod extends HookConsumerWidget { context.showLoadingDialog(); ///get stripe details - final result = await payments.stripeSubscription(userPlan.id, email); + final result = await payments.stripeSubscription( + userPlan.id, + email, + couponCode: _affiliateCoupon(ref), + ); result.fold( (error) { context.showSnackBar(error.localizedErrorMessage); @@ -248,6 +260,7 @@ class ChoosePaymentMethod extends HookConsumerWidget { BillingType.subscription, userPlan.id, email, + couponCode: _affiliateCoupon(ref), ); if (!context.mounted) return; await result.fold>( @@ -317,6 +330,7 @@ class ChoosePaymentMethod extends HookConsumerWidget { provider: provider, planId: userPlan.id, email: email, + couponCode: _affiliateCoupon(ref), ); if (!context.mounted) return; @@ -446,8 +460,16 @@ class PaymentCheckoutMethods extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final referralEnable = ref.watch(referralProvider); + final referral = ref.watch(referralProvider); final theme = Theme.of(context).textTheme; + // Affiliate: show the full (pre-discount) price on the plan line and the + // deducted amount on the promo line; the discounted total stays in Order + // Total. Computed once here (constant across the provider list). + final originalPrice = userPlan.formatOriginalPrice; + final discountAmount = userPlan.formatDiscountAmount; + final showOriginalPrice = referral.isAffiliate && originalPrice.isNotEmpty; + final showDiscountDeduction = + referral.isAffiliate && discountAmount.isNotEmpty; return ListView.builder( shrinkWrap: true, itemCount: providers.length, @@ -492,7 +514,9 @@ class PaymentCheckoutMethods extends HookConsumerWidget { children: [ Text(userPlan.description, style: theme.bodyMedium), Text( - '${userPlan.formattedMonthlyPrice}/month', + showOriginalPrice + ? originalPrice + : userPlan.formattedYearlyPrice, style: theme.bodyMedium!.copyWith( color: context.textDisabled, ), @@ -500,7 +524,7 @@ class PaymentCheckoutMethods extends HookConsumerWidget { ], ), DividerSpace(padding: EdgeInsets.symmetric(vertical: 10)), - if (referralEnable) ...[ + if (referral.isReferral) ...[ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -520,11 +544,34 @@ class PaymentCheckoutMethods extends HookConsumerWidget { ), DividerSpace(padding: EdgeInsets.symmetric(vertical: 10)), ], + // Affiliate codes: show the applied promo code and the amount + // deducted (original − discounted, from the backend). + if (showDiscountDeduction) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + 'promo_code_with'.i18n.fill([referral.code]), + style: theme.bodyMedium, + ), + ), + SizedBox(width: defaultSize), + Text( + '-$discountAmount', + style: theme.bodyMedium!.copyWith( + color: context.textDisabled, + ), + ), + ], + ), + DividerSpace(padding: EdgeInsets.symmetric(vertical: 10)), + ], Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'Order Total:', + 'order_total'.i18n, style: theme.titleSmall!.copyWith( color: context.textPrimary, ), diff --git a/lib/features/home/provider/app_event_notifier.g.dart b/lib/features/home/provider/app_event_notifier.g.dart index bffe906006..0524ea3035 100644 --- a/lib/features/home/provider/app_event_notifier.g.dart +++ b/lib/features/home/provider/app_event_notifier.g.dart @@ -42,7 +42,7 @@ final class AppEventNotifierProvider AppEventNotifier create() => AppEventNotifier(); } -String _$appEventNotifierHash() => r'12499360289be87d473f88c3ddcfd6366a523e30'; +String _$appEventNotifierHash() => r'69390162c5c3a546c5733d72bf859c624a7ca70c'; /// Listens for application-wide events and triggers corresponding actions. /// This can be used for all listening to events that go sends and handling them diff --git a/lib/features/home/provider/country_code_notifier.g.dart b/lib/features/home/provider/country_code_notifier.g.dart index 4d99d6efcb..dfbb6577a9 100644 --- a/lib/features/home/provider/country_code_notifier.g.dart +++ b/lib/features/home/provider/country_code_notifier.g.dart @@ -42,7 +42,7 @@ final class CountryCodeNotifierProvider } String _$countryCodeNotifierHash() => - r'0c1a08bc664de1c57b27ed7b9ab5872e929718d1'; + r'6d4e2e5976694966396a758ca782c20d16b3a474'; abstract class _$CountryCodeNotifier extends $Notifier { String build(); diff --git a/lib/features/plans/feature_list.dart b/lib/features/plans/feature_list.dart index 199d4491ed..4f73c3a880 100644 --- a/lib/features/plans/feature_list.dart +++ b/lib/features/plans/feature_list.dart @@ -11,27 +11,30 @@ class FeatureList extends StatelessWidget { return Column( mainAxisSize: MainAxisSize.min, children: [ - SizedBox(height: size24), _FeatureTile( - image: AppImagePaths.location, - title: 'select_your_server_location'.i18n), + image: AppImagePaths.location, + title: 'select_your_server_location'.i18n, + ), _FeatureTile( - image: AppImagePaths.blot, - title: 'faster_speeds_unlimited_bandwidth'.i18n), + image: AppImagePaths.blot, + title: 'faster_speeds_unlimited_bandwidth'.i18n, + ), _FeatureTile( - image: AppImagePaths.premium, - title: 'premium_servers_less_congestion'.i18n), + image: AppImagePaths.premium, + title: 'premium_servers_less_congestion'.i18n, + ), _FeatureTile( - image: AppImagePaths.eyeHide, - title: 'advanced_anti_censorship'.i18n), + image: AppImagePaths.eyeHide, + title: 'advanced_anti_censorship'.i18n, + ), _FeatureTile( - image: AppImagePaths.roundCorrect, - title: 'exclusive_access_new_features'.i18n), + image: AppImagePaths.connectDevice, + title: 'connect_up_to_five_devices'.i18n, + ), _FeatureTile( - image: AppImagePaths.connectDevice, - title: 'connect_up_to_five_devices'.i18n), - _FeatureTile( - image: AppImagePaths.adBlock, title: 'built_in_ad_blocking'.i18n), + image: AppImagePaths.adBlock, + title: 'built_in_ad_blocking'.i18n, + ), ], ); } @@ -51,11 +54,7 @@ class _FeatureTile extends StatelessWidget { padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ - AppImage( - path: image, - color: context.textPrimary, - height: 24, - ), + AppImage(path: image, color: context.textPrimary, height: 24), SizedBox(width: defaultSize), Expanded( child: AutoSizeText( diff --git a/lib/features/plans/plan_item.dart b/lib/features/plans/plan_item.dart index 56d4a1da44..cdc7c68461 100644 --- a/lib/features/plans/plan_item.dart +++ b/lib/features/plans/plan_item.dart @@ -11,12 +11,17 @@ class PlanItem extends StatelessWidget { final Function(Plan plans) onPressed; final String referralMessage; + /// When > 0, an affiliate discount is applied: the discounted price is shown + /// alongside the strikethrough original price. + final int discountPct; + const PlanItem({ super.key, required this.plan, required this.planSelected, required this.onPressed, this.referralMessage = '', + this.discountPct = 0, }); @override @@ -24,33 +29,29 @@ class PlanItem extends StatelessWidget { final textTheme = Theme.of(context).textTheme; final width = MediaQuery.of(context).size.width; final finalSize = (width * 0.5) - (defaultSize * 3); + final originalPrice = plan.formatOriginalPrice; + final showOriginalPrice = discountPct > 0 && originalPrice.isNotEmpty; return badges.Badge( - showBadge: plan.bestValue ?? false, - badgeAnimation: badges.BadgeAnimation.scale( - toAnimate: false, - ), + // Hide the Best Value badge when an affiliate discount is applied so it + // doesn't compete with the discounted strikethrough pricing. + showBadge: (plan.bestValue) && discountPct == 0, + badgeAnimation: badges.BadgeAnimation.scale(toAnimate: false), position: badges.BadgePosition.custom(start: (finalSize - 10)), // Adjust values as needed badgeStyle: badges.BadgeStyle( shape: badges.BadgeShape.square, - borderSide: BorderSide( - color: context.statusWarningText, - width: 1, - ), + borderSide: BorderSide(color: context.statusWarningText, width: 1), borderRadius: BorderRadius.circular(16), badgeColor: context.statusWarningBgDot, padding: EdgeInsets.symmetric(horizontal: 10, vertical: 6), ), - badgeContent: Text( - 'best_value'.i18n, - style: textTheme.labelMedium!, - ), + badgeContent: Text('best_value'.i18n, style: textTheme.labelMedium!), child: GestureDetector( onTap: () { onPressed.call(plan); }, child: AnimatedContainer( - margin: EdgeInsets.only(top: 16), + margin: EdgeInsets.only(top: 14), padding: EdgeInsets.symmetric(horizontal: defaultSize, vertical: 12), duration: Duration(milliseconds: 300), decoration: getPlanDecoration(planSelected, context), @@ -66,21 +67,35 @@ class PlanItem extends StatelessWidget { ), ), if (referralMessage.isNotEmpty) - Text( - referralMessage, - style: textTheme.labelMedium, - ), + Text(referralMessage, style: textTheme.labelMedium), ], ), Spacer(), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Text( - plan.formattedYearlyPrice, - style: textTheme.titleMedium!.copyWith( - color: context.textLink, - ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + plan.formattedYearlyPrice, + style: textTheme.titleMedium!.copyWith( + color: context.textLink, + ), + ), + if (showOriginalPrice) ...[ + SizedBox(width: 6), + Text( + originalPrice, + style: textTheme.labelMedium!.copyWith( + color: context.textTertiary, + decoration: TextDecoration.lineThrough, + decorationColor: context.textTertiary, + decorationThickness: 4, + ), + ), + ], + ], ), Text( '${plan.formattedMonthlyPrice}/month', diff --git a/lib/features/plans/plans.dart b/lib/features/plans/plans.dart index 07e5042c14..627109d42b 100644 --- a/lib/features/plans/plans.dart +++ b/lib/features/plans/plans.dart @@ -9,7 +9,6 @@ import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/services/app_purchase.dart'; import 'package:lantern/core/services/injection_container.dart'; import 'package:lantern/core/utils/country_code.dart'; -import 'package:lantern/core/utils/formatter.dart'; import 'package:lantern/core/utils/screen_utils.dart'; import 'package:lantern/core/widgets/app_rich_text.dart'; import 'package:lantern/core/widgets/loading_indicator.dart'; @@ -23,6 +22,7 @@ import 'package:lantern/features/plans/provider/referral_notifier.dart'; import 'package:lantern/features/plans/restore_purchase_mixin.dart'; import '../../core/models/plan_data.dart'; +import '../../core/models/referral_attach_response.dart'; @RoutePage(name: 'Plans') class Plans extends StatefulHookConsumerWidget { @@ -42,6 +42,7 @@ class _PlansState extends ConsumerState return BaseScreen( backgroundColor: context.bgElevated, padded: false, + resizeToAvoidBottomInset: false, appBar: CustomAppBar( title: SizedBox( height: 20.h, @@ -65,133 +66,194 @@ class _PlansState extends ConsumerState Widget _buildBody() { final plansState = ref.watch(plansProvider); - final size = MediaQuery.of(context).size; return Column( children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: defaultSize), - child: SizedBox( - height: context.isSmallDevice - ? size.height * 0.4 - : size.height * 0.37, - child: SingleChildScrollView(child: FeatureList()), + // Features fill whatever space the bottom section leaves, centered. + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: defaultSize), + child: Center(child: FeatureList()), ), ), SizedBox(height: defaultSize), DividerSpace(padding: EdgeInsets.zero), - Expanded( - child: Container( - color: context.bgSurface, - padding: EdgeInsets.symmetric( - horizontal: context.isSmallDevice ? 0 : defaultSize, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox(height: 10), - Padding( - padding: EdgeInsets.only( - left: context.isSmallDevice ? 16 : 0, - ), - child: plansState.when( - data: (data) { - return PlansListView(data: data); - }, - loading: () { - return Center(child: LoadingIndicator()); - }, - error: (error, stackTrace) { - return Column( - children: [ - Text( - 'plans_fetch_error'.i18n, - style: textTheme.labelLarge, - ), - AppTextButton( - label: 'Try again', - onPressed: () { - ref.read(plansProvider.notifier).fetchPlans(); - }, - ), - ], - ); - }, - ), - ), - SizedBox(height: 24), - Padding( - padding: EdgeInsets.symmetric( - horizontal: context.isSmallDevice ? defaultSize : 0, - ), - child: PrimaryButton( - label: 'get_lantern_pro'.i18n, - isTaller: true, - onPressed: onGetLanternProTap, - ), - ), - if (isStoreVersion()) ...[ - SizedBox(height: 8), - Center( - child: AppRichText( - texts: '${'already_purchased'.i18n} ', - boldTexts: 'restore_purchase'.i18n, - boldUnderline: true, - boldOnPressed: _restorePurchaseFlow, - ), - ), - ], - if (PlatformUtils.isIOS) ...{ - SizedBox(height: 8), - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Text( - 'subscription_renewal_info'.i18n, - style: textTheme.labelMedium!.copyWith( - color: context.textTertiary, - ), - ), - ), - IntrinsicHeight( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - AppTextButton( - label: 'privacy_policy'.i18n, - fontSize: 12, - textColor: context.textTertiary, - onPressed: () { - UrlUtils.openWithSystemBrowser( - AppUrls.privacyPolicy, - ); - }, + // Bottom section sizes to its content: plans, then the CTA button. + Container( + color: context.bgSurface, + padding: EdgeInsets.symmetric( + horizontal: context.isSmallDevice ? 0 : defaultSize, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: 10), + _buildAffiliateBanner(), + Padding( + padding: EdgeInsets.only(left: context.isSmallDevice ? 16 : 0), + child: plansState.when( + data: (data) { + return PlansListView(data: data); + }, + loading: () { + return Center(child: LoadingIndicator()); + }, + error: (error, stackTrace) { + return Column( + children: [ + Text( + 'plans_fetch_error'.i18n, + style: textTheme.labelLarge, ), - VerticalDivider(indent: 10, endIndent: 10), AppTextButton( - label: 'terms_of_service'.i18n, - fontSize: 12, - textColor: context.textTertiary, + label: 'Try again', onPressed: () { - UrlUtils.openWithSystemBrowser( - AppUrls.termsOfService, - ); + ref.read(plansProvider.notifier).fetchPlans(); }, ), ], - ), + ); + }, + ), + ), + SizedBox(height: defaultSize), + Padding( + padding: EdgeInsets.symmetric( + horizontal: context.isSmallDevice ? defaultSize : 0, + ), + child: PrimaryButton( + label: 'get_lantern_pro'.i18n, + isTaller: true, + onPressed: onGetLanternProTap, + ), + ), + if (isStoreVersion()) ...[ + SizedBox(height: 8), + Center( + child: AppRichText( + texts: '${'already_purchased'.i18n} ', + boldTexts: 'restore_purchase'.i18n, + boldUnderline: true, + boldOnPressed: _restorePurchaseFlow, ), - }, - SizedBox(height: size24), + ), ], - ), + if (PlatformUtils.isIOS) ...{ + SizedBox(height: 8), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + 'subscription_renewal_info'.i18n, + style: textTheme.labelMedium!.copyWith( + color: context.textTertiary, + ), + ), + ), + IntrinsicHeight( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + AppTextButton( + label: 'privacy_policy'.i18n, + fontSize: 12, + textColor: context.textTertiary, + onPressed: () { + UrlUtils.openWithSystemBrowser(AppUrls.privacyPolicy); + }, + ), + VerticalDivider(indent: 10, endIndent: 10), + AppTextButton( + label: 'terms_of_service'.i18n, + fontSize: 12, + textColor: context.textTertiary, + onPressed: () { + UrlUtils.openWithSystemBrowser( + AppUrls.termsOfService, + ); + }, + ), + ], + ), + ), + }, + SizedBox(height: size24), + ], ), ), ], ); } + /// Banner shown above the plans list when an affiliate code is applied: + /// the discount percentage plus a removable chip with the applied code. + /// Referral (non-affiliate) codes keep the existing per-plan bonus message. + Widget _buildAffiliateBanner() { + final response = ref.watch(referralProvider); + if (!response.isAffiliate) { + return const SizedBox.shrink(); + } + return Padding( + padding: EdgeInsets.only(left: context.isSmallDevice ? 16 : 0, bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: defaultSize), + child: Text( + 'discount_applied'.i18n.fill(['${response.discountPct}%']), + style: textTheme.labelMedium!.copyWith( + color: context.textSecondary, + ), + ), + ), + SizedBox(height: 6), + Align( + alignment: Alignment.centerLeft, + child: Chip( + label: Text(response.code), + labelStyle: textTheme.titleSmall!.copyWith( + color: context.textAttention, + ), + backgroundColor: context.bgAttention, + deleteIcon: Icon( + Icons.close, + size: 18, + color: context.textAttention, + ), + onDeleted: _onRemoveAffiliateCode, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + side: BorderSide(color: context.borderAttention), + ), + ), + ), + ], + ), + ); + } + + /// Removes the applied affiliate code: clears the referral state and + /// re-fetches the original (non-discounted) plans. On a Play Store build the + /// SKUs are also reloaded back to base plans so a later purchase is charged + /// at full price rather than the (now removed) offer. + Future _onRemoveAffiliateCode() async { + appLogger.info('Removing applied affiliate code'); + ref.read(referralProvider.notifier).resetReferral(); + ref.read(plansProvider.notifier).fetchPlans(); + if (isStoreVersion()) { + try { + await sl().fetchSubscriptions(); + } catch (e) { + appLogger.warning( + '[Plans] Failed to reload base SKUs after removing code: $e', + ); + } + } + } + void onMenuTap() { - final isReferralApplied = ref.read(referralProvider); + final isReferralApplied = ref.read(referralProvider).isApplied; showAppBottomSheet( context: context, title: 'payment_options'.i18n, @@ -202,10 +264,12 @@ class _PlansState extends ConsumerState padding: EdgeInsets.zero, controller: scrollController, children: [ - if (!isStoreVersion() && !isReferralApplied) ...{ + if (!isReferralApplied) ...{ AppTile( icon: AppImagePaths.star, - label: 'referral_code'.i18n, + label: isStoreVersion() + ? 'promo_code'.i18n + : 'promo_referral_code'.i18n, onPressed: () { context.pop(); showReferralCodeDialog(); @@ -246,18 +310,23 @@ class _PlansState extends ConsumerState AppImage(path: AppImagePaths.star, height: 48), SizedBox(height: defaultSize), Text( - 'referral_code'.i18n, + isStoreVersion() ? 'promo_code'.i18n : 'promo_referral_code'.i18n, style: textTheme.headlineSmall!.copyWith( color: context.textPrimary, ), ), SizedBox(height: 24), AppTextField( - label: 'referral_code'.i18n, + label: isStoreVersion() + ? 'promo_code'.i18n + : 'promo_referral_code'.i18n, controller: referralCodeController, - inputFormatters: [UpperCaseTextFormatter()], hintText: 'XXXXXX', prefixIcon: AppImagePaths.star, + autofocus: true, + textInputAction: TextInputAction.done, + onSubmitted: (value) => + onReferralCodeContinue(value.toUpperCase().trim()), ), ], ), @@ -289,30 +358,36 @@ class _PlansState extends ConsumerState context.showLoadingDialog(); final result = await ref .read(referralProvider.notifier) - .applyReferralCode(code); + .applyReferralCodeV2(code); + if (!mounted) return; - result.fold( - (error) { - if (!mounted) { - return; - } - appLogger.error('Error applying referral code: $error'); - context.hideLoadingDialog(); - AppDialog.errorDialog( - context: context, - title: 'error'.i18n, - content: error.localizedErrorMessage, + final failure = result.getLeft().toNullable(); + if (failure != null) { + appLogger.error('Error applying referral code: $failure'); + context.hideLoadingDialog(); + AppDialog.errorDialog( + context: context, + title: 'error'.i18n, + content: failure.localizedErrorMessage, + ); + return; + } + + appLogger.info('Successfully applied referral code'); + if (isStoreVersion()) { + try { + appLogger.info('Reloading SKUs after applying referral code'); + await sl().fetchSubscriptions(includeOffers: true); + } catch (e) { + appLogger.warning( + '[Plans] No offer SKUs after applying code; base plan will be used: $e', ); - }, - (success) { - if (!mounted) { - return; - } - context.hideLoadingDialog(); - context.showSnackBar('referral_code_applied'.i18n); - appLogger.info('Successfully applied referral code'); - }, - ); + } + if (!mounted) return; + } + + context.hideLoadingDialog(); + context.showSnackBar('referral_code_applied'.i18n); } void onGetLanternProTap() { @@ -389,9 +464,13 @@ class _PlansState extends ConsumerState // backs out before signup completes — same protection Stripe and the // external paymentRedirect flow already get. ref.read(paymentSessionProvider.notifier).markRedirectInitiated(); + // Forward any applied affiliate/referral code so the purchase is attributed + // to the affiliate at acknowledgment. Empty when no code is applied. + final couponCode = ref.read(referralProvider).code; final payments = ref.read(paymentProvider.notifier); final result = await payments.startInAppPurchaseFlow( planId: plan.id, + couponCode: couponCode, onSuccess: (purchase) => processPurchase(purchase, plan), onError: (error) { ref.read(paymentSessionProvider.notifier).clearRedirect(); @@ -408,7 +487,7 @@ class _PlansState extends ConsumerState context.hideLoadingDialog(); if (_redirectToSignupIfPlayBlocked()) return; context.showSnackBar(error.localizedErrorMessage); - appLogger.error('Error subscribing to plan: $error'); + appLogger.error('Error subscribing to plan: $error', error); }, (_) {}); } diff --git a/lib/features/plans/plans_list.dart b/lib/features/plans/plans_list.dart index b2b0e67f23..41d92ae2b0 100644 --- a/lib/features/plans/plans_list.dart +++ b/lib/features/plans/plans_list.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/plan_data.dart'; +import 'package:lantern/core/models/referral_attach_response.dart'; import 'package:lantern/core/utils/screen_utils.dart'; import 'package:lantern/features/plans/plan_item.dart'; import 'package:lantern/features/plans/provider/plans_notifier.dart'; @@ -11,37 +12,51 @@ import 'package:lantern/features/plans/provider/referral_notifier.dart'; class PlansListView extends HookConsumerWidget { final PlansData data; - const PlansListView({ - super.key, - required this.data, - }); + const PlansListView({super.key, required this.data}); @override Widget build(BuildContext context, WidgetRef ref) { - final referralEnable = ref.watch(referralProvider); + final referral = ref.watch(referralProvider); + // Referral codes show the per-plan bonus message; affiliate codes instead + // show strikethrough discount pricing. + final showReferralBonus = referral.isReferral; + final discountPct = referral.isAffiliate ? referral.discountPct : 0; final size = MediaQuery.of(context).size; - final plan = useState( - data.plans.firstWhere((Plan plan) => plan.bestValue == true)); - ref.read(plansProvider.notifier).setSelectedPlan(plan.value); + final selectedId = useState( + data.plans.firstWhere((Plan plan) => plan.bestValue == true).id, + ); + final selectedPlan = data.plans.firstWhere( + (Plan plan) => plan.id == selectedId.value, + orElse: () => + data.plans.firstWhere((Plan plan) => plan.bestValue == true), + ); + useEffect(() { + ref.read(plansProvider.notifier).setSelectedPlan(selectedPlan); + return null; + }, [data, selectedId.value]); return SizedBox( height: context.isSmallDevice ? size.height * 0.21 : null, child: ListView.builder( shrinkWrap: true, itemCount: data.plans.length, - scrollDirection: - context.isSmallDevice ? Axis.horizontal : Axis.vertical, + scrollDirection: context.isSmallDevice + ? Axis.horizontal + : Axis.vertical, padding: EdgeInsets.zero, - physics: - context.isSmallDevice ? null : const NeverScrollableScrollPhysics(), + physics: context.isSmallDevice + ? null + : const NeverScrollableScrollPhysics(), itemBuilder: (context, index) { final item = data.plans[index]; return PlanItem( plan: item, - planSelected: plan.value.id == item.id, - referralMessage: referralEnable ? getReferralMessage(item.id) : '', + planSelected: selectedPlan.id == item.id, + referralMessage: showReferralBonus + ? getReferralMessage(item.id) + : '', + discountPct: discountPct, onPressed: (plans) { - plan.value = plans; - ref.read(plansProvider.notifier).setSelectedPlan(plans); + selectedId.value = plans.id; }, ); }, diff --git a/lib/features/plans/provider/payment_notifier.dart b/lib/features/plans/provider/payment_notifier.dart index 6dfde625b0..3c18dc76bf 100644 --- a/lib/features/plans/provider/payment_notifier.dart +++ b/lib/features/plans/provider/payment_notifier.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:fpdart/fpdart.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/restore_subscription_response.dart'; @@ -25,12 +23,11 @@ class PaymentNotifier extends _$PaymentNotifier { @override void build() {} - bool get _isAndroidStoreBuild => Platform.isAndroid && isStoreVersion(); - Future> startInAppPurchaseFlow({ required String planId, required PaymentSuccessCallback onSuccess, required PaymentErrorCallback onError, + String couponCode = '', }) async { return ref .read(lanternServiceProvider) @@ -38,16 +35,22 @@ class PaymentNotifier extends _$PaymentNotifier { planId: planId, onSuccess: onSuccess, onError: onError, + couponCode: couponCode, ); } Future> acknowledgeInAppPurchase({ required String purchaseToken, required String planId, + String couponCode = '', }) async { return ref .read(lanternServiceProvider) - .acknowledgeInAppPurchase(purchaseToken: purchaseToken, planId: planId); + .acknowledgeInAppPurchase( + purchaseToken: purchaseToken, + planId: planId, + couponCode: couponCode, + ); } Future> restoreInAppPurchase({ @@ -61,8 +64,9 @@ class PaymentNotifier extends _$PaymentNotifier { Future> stripeSubscriptionLink( BillingType type, String planId, - String email, - ) async { + String email, { + String couponCode = '', + }) async { final idempotencyKey = generatePaymentRedirectIdempotencyKey(); return ref .read(lanternServiceProvider) @@ -71,22 +75,29 @@ class PaymentNotifier extends _$PaymentNotifier { planId: planId, email: email, idempotencyKey: idempotencyKey, + couponCode: couponCode, ); } Future>> stripeSubscription( String planId, - String email, - ) async { + String email, { + String couponCode = '', + }) async { return ref .read(lanternServiceProvider) - .stipeSubscription(planId: planId, email: email); + .stipeSubscription( + planId: planId, + email: email, + couponCode: couponCode, + ); } Future> paymentRedirect({ required String provider, required String planId, required String email, + String couponCode = '', }) async { final idempotencyKey = generatePaymentRedirectIdempotencyKey(); return ref @@ -96,6 +107,7 @@ class PaymentNotifier extends _$PaymentNotifier { planId: planId, email: email, idempotencyKey: idempotencyKey, + couponCode: couponCode, ); } @@ -106,13 +118,15 @@ class PaymentNotifier extends _$PaymentNotifier { required PaymentSuccessCallback onSuccess, required PaymentErrorCallback onError, required String provider, + String couponCode = '', }) async { - if (_isAndroidStoreBuild) { + if (isStoreVersion()) { // Google Play build uses IAP final result = await startInAppPurchaseFlow( planId: planId, onSuccess: onSuccess, onError: onError, + couponCode: couponCode, ); return result.match((failure) => left(failure), (_) => right(null)); @@ -123,6 +137,7 @@ class PaymentNotifier extends _$PaymentNotifier { provider: provider, planId: planId, email: email, + couponCode: couponCode, ); return redirectResult.match( diff --git a/lib/features/plans/provider/payment_notifier.g.dart b/lib/features/plans/provider/payment_notifier.g.dart index 996d453f21..9068573b14 100644 --- a/lib/features/plans/provider/payment_notifier.g.dart +++ b/lib/features/plans/provider/payment_notifier.g.dart @@ -99,7 +99,7 @@ final class PaymentNotifierProvider } } -String _$paymentNotifierHash() => r'd31174207a822e6026a5bc9647d27fe98a50a488'; +String _$paymentNotifierHash() => r'bcdbccc7c098cfc6693ee2219d7853503c8e397f'; abstract class _$PaymentNotifier extends $Notifier { void build(); diff --git a/lib/features/plans/provider/plans_notifier.dart b/lib/features/plans/provider/plans_notifier.dart index c341bab680..8c4bd11090 100644 --- a/lib/features/plans/provider/plans_notifier.dart +++ b/lib/features/plans/provider/plans_notifier.dart @@ -78,6 +78,11 @@ class PlansNotifier extends _$PlansNotifier { '[PlansNotifier] Plans fetched successfully: ${remote.plans.length} plans', ); unawaited(_storage.savePlans(remote)); + // Publish the result so standalone callers (the "Try again" button and + // removing an affiliate code) leave the loading state. When invoked + // from build() the framework also assigns the returned value — this + // extra set is harmless and redundant there. + state = AsyncData(remote); return remote; }, ); @@ -92,7 +97,17 @@ class PlansNotifier extends _$PlansNotifier { state = AsyncData(remotePlans); } + /// Replaces the current plans with [plans] — e.g. the discounted plans + /// returned after applying a referral code. Kept in-memory only: the + /// discount is session-specific and must not overwrite the cached base + /// plans used by non-referral sessions. + void updatePlans(PlansData plans) { + appLogger.info('[PlansNotifier] updatePlans: ${plans.plans.length} plans'); + state = AsyncData(plans); + } + void setSelectedPlan(Plan plan) { + appLogger.info('[PlansNotifier] setSelectedPlan: ${plan.id}'); userSelectedPlan = plan; } diff --git a/lib/features/plans/provider/plans_notifier.g.dart b/lib/features/plans/provider/plans_notifier.g.dart index e37dcfedf6..0c6c8c325a 100644 --- a/lib/features/plans/provider/plans_notifier.g.dart +++ b/lib/features/plans/provider/plans_notifier.g.dart @@ -33,7 +33,7 @@ final class PlansNotifierProvider PlansNotifier create() => PlansNotifier(); } -String _$plansNotifierHash() => r'7e0420b132720fc5ea3d1e3f19c4ba35889e93e8'; +String _$plansNotifierHash() => r'e0e759143b5bc1ded9638c05dc1af255b78a45a2'; abstract class _$PlansNotifier extends $AsyncNotifier { FutureOr build(); diff --git a/lib/features/plans/provider/referral_notifier.dart b/lib/features/plans/provider/referral_notifier.dart index d5c00a4d37..687ffb639d 100644 --- a/lib/features/plans/provider/referral_notifier.dart +++ b/lib/features/plans/provider/referral_notifier.dart @@ -1,27 +1,52 @@ import 'package:fpdart/fpdart.dart'; import 'package:lantern/core/common/common.dart'; +import 'package:lantern/core/models/referral_attach_response.dart'; +import 'package:lantern/features/plans/provider/plans_notifier.dart'; import 'package:lantern/lantern/lantern_service_notifier.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../../../core/models/plan_data.dart'; + part 'referral_notifier.g.dart'; @Riverpod(keepAlive: true) class ReferralNotifier extends _$ReferralNotifier { @override - bool build() { - return false; - } + ReferralAttachV2Response? build() => null; - Future> applyReferralCode(String code) async { - final result = - await ref.read(lanternServiceProvider).attachReferralCode(code); + /// Applies a referral code via the V2 endpoint. On success the returned + /// discounted plans are pushed into [plansProvider] so the plans UI updates + /// immediately, the response becomes the notifier state, and it is returned + /// to the caller. + Future> applyReferralCodeV2( + String code, + ) async { + final result = await ref + .read(lanternServiceProvider) + .attachReferralCodeV2(code); if (result.isRight()) { - state = true; + final response = result.getRight().toNullable(); + state = response; + // plansData is only present for the affiliate flow; the referral flow + // has no discounted plans to push into the plans UI. + final plansData = response?.plansData; + if (plansData != null) { + ref.read(plansProvider.notifier).updatePlans(plansData); + final plans = plansData.plans; + if (plans.isNotEmpty) { + // The backend may not flag a best-value plan (and discounted sets + // may omit it entirely), so fall back to the first plan rather than + // let firstWhere throw and crash the apply-code flow. + final defaultPlan = plans.firstWhere( + (Plan plan) => plan.bestValue == true, + orElse: () => plans.first, + ); + ref.read(plansProvider.notifier).setSelectedPlan(defaultPlan); + } + } } return result; } - void resetReferral() { - state = false; - } + void resetReferral() => state = null; } diff --git a/lib/features/plans/provider/referral_notifier.g.dart b/lib/features/plans/provider/referral_notifier.g.dart index 35265a3e28..43fec261d7 100644 --- a/lib/features/plans/provider/referral_notifier.g.dart +++ b/lib/features/plans/provider/referral_notifier.g.dart @@ -13,7 +13,7 @@ part of 'referral_notifier.dart'; final referralProvider = ReferralNotifierProvider._(); final class ReferralNotifierProvider - extends $NotifierProvider { + extends $NotifierProvider { ReferralNotifierProvider._() : super( from: null, @@ -33,27 +33,33 @@ final class ReferralNotifierProvider ReferralNotifier create() => ReferralNotifier(); /// {@macro riverpod.override_with_value} - Override overrideWithValue(bool value) { + Override overrideWithValue(ReferralAttachV2Response? value) { return $ProviderOverride( origin: this, - providerOverride: $SyncValueProvider(value), + providerOverride: $SyncValueProvider(value), ); } } -String _$referralNotifierHash() => r'6b028ac616c6b663b1e38d2769ca69f063924721'; +String _$referralNotifierHash() => r'a00524678cd445e36352bf7f53184c4da3e5095d'; -abstract class _$ReferralNotifier extends $Notifier { - bool build(); +/// Holds the applied V2 referral/affiliate response, or null when no code is +/// applied. The response's [ReferralType] drives the UI, so callers branch on +/// `state == null` (none) / `state!.isAffiliate` (affiliate) / else (referral) +/// and read `code`/`discountPct`/`plansData` off the same object. + +abstract class _$ReferralNotifier extends $Notifier { + ReferralAttachV2Response? build(); @$mustCallSuper @override void runBuild() { - final ref = this.ref as $Ref; + final ref = + this.ref as $Ref; final element = ref.element as $ClassProviderElement< - AnyNotifier, - bool, + AnyNotifier, + ReferralAttachV2Response?, Object?, Object? >; diff --git a/lib/features/report_issue/provider/report_issue_draft_notifier.g.dart b/lib/features/report_issue/provider/report_issue_draft_notifier.g.dart index 90bc01e8af..dd1d959589 100644 --- a/lib/features/report_issue/provider/report_issue_draft_notifier.g.dart +++ b/lib/features/report_issue/provider/report_issue_draft_notifier.g.dart @@ -41,7 +41,7 @@ final class ReportIssueDraftProvider } } -String _$reportIssueDraftHash() => r'b52dd92e1927fe7a602c4b9751d238f7763f162b'; +String _$reportIssueDraftHash() => r'640389ac3ba527924290e119cc6d6402f297f30b'; abstract class _$ReportIssueDraft extends $Notifier { ReportIssueDraftState build(); diff --git a/lib/features/vpn/provider/available_servers_notifier.g.dart b/lib/features/vpn/provider/available_servers_notifier.g.dart index 14df53e059..6c4ce5d190 100644 --- a/lib/features/vpn/provider/available_servers_notifier.g.dart +++ b/lib/features/vpn/provider/available_servers_notifier.g.dart @@ -34,7 +34,7 @@ final class AvailableServersNotifierProvider } String _$availableServersNotifierHash() => - r'3931c38148c638b798839183906bdf00056326b5'; + r'a8f79742495ec1900d0c79e861a1d25d6ed4003d'; abstract class _$AvailableServersNotifier extends $AsyncNotifier { diff --git a/lib/lantern/lantern_core_service.dart b/lib/lantern/lantern_core_service.dart index 6cdad82bcb..f1a9e47d80 100644 --- a/lib/lantern/lantern_core_service.dart +++ b/lib/lantern/lantern_core_service.dart @@ -10,6 +10,7 @@ import 'package:lantern/core/models/datacap_info.dart'; import 'package:lantern/core/models/lantern_status.dart'; import 'package:lantern/core/models/macos_extension_state.dart'; import 'package:lantern/core/models/plan_data.dart'; +import 'package:lantern/core/models/referral_attach_response.dart'; import 'package:lantern/core/models/restore_subscription_response.dart'; import 'package:lantern/core/models/private_server_status.dart'; import 'package:lantern/features/report_issue/models/report_issue_attachment.dart'; @@ -93,6 +94,7 @@ abstract class LanternCoreService { required String planId, required String email, required String idempotencyKey, + String couponCode = '', }); Future> paymentRedirect({ @@ -100,12 +102,14 @@ abstract class LanternCoreService { required String planId, required String email, required String idempotencyKey, + String couponCode = '', }); // this is used for stripe subscription Future>> stipeSubscription({ required String planId, required String email, + String couponCode = '', }); Future> stripeBillingPortal(); @@ -115,11 +119,13 @@ abstract class LanternCoreService { required String planId, required PaymentSuccessCallback onSuccess, required PaymentErrorCallback onError, + String couponCode = '', }); Future> acknowledgeInAppPurchase({ required String purchaseToken, required String planId, + String couponCode = '', }); /// Restores a previously purchased subscription. Mobile-only. @@ -251,6 +257,11 @@ abstract class LanternCoreService { //Referral attachment Future> attachReferralCode(String code); + //Referral attachment V2 — returns discounted plans, providers and discount + Future> attachReferralCodeV2( + String code, + ); + /// Private server methods Future> digitalOceanPrivateServer(); diff --git a/lib/lantern/lantern_ffi_service.dart b/lib/lantern/lantern_ffi_service.dart index afd9854516..0187a296f3 100644 --- a/lib/lantern/lantern_ffi_service.dart +++ b/lib/lantern/lantern_ffi_service.dart @@ -29,6 +29,7 @@ import '../core/models/available_servers.dart'; import '../core/models/server_location.dart'; import '../core/models/macos_extension_state.dart'; import '../core/models/plan_data.dart'; +import '../core/models/referral_attach_response.dart'; import '../core/utils/compute_worker.dart'; export 'dart:convert'; @@ -733,6 +734,7 @@ class LanternFFIService implements LanternCoreService { required String planId, required PaymentSuccessCallback onSuccess, required PaymentErrorCallback onError, + String couponCode = '', }) { throw UnimplementedError("This not supported on desktop"); } @@ -743,6 +745,7 @@ class LanternFFIService implements LanternCoreService { required String planId, required String email, required String idempotencyKey, + String couponCode = '', }) async { try { appLogger.debug('Starting Stripe Subscription Payment Redirect'); @@ -752,6 +755,7 @@ class LanternFFIService implements LanternCoreService { planId.toCharPtr, email.toCharPtr, idempotencyKey.toCharPtr, + couponCode.toCharPtr, ); try { return resultPtr.toDartString(); @@ -776,6 +780,7 @@ class LanternFFIService implements LanternCoreService { Future>> stipeSubscription({ required String planId, required String email, + String couponCode = '', }) { throw Exception("Desktop flow should not be here, this is just for mobile"); } @@ -807,22 +812,7 @@ class LanternFFIService implements LanternCoreService { }); checkAPIError(result); final map = jsonDecode(result); - final plans = PlansData.fromJson(map); - - // Sort plans - plans.plans.sort((a, b) { - if (a.bestValue != b.bestValue) { - return a.bestValue ? -1 : 1; - } - // Then: sort by usdPrice descending - return b.usdPrice.compareTo(a.usdPrice); - }); - - plans.providers.desktop.sort((a, b) { - return (b.providers.supportSubscription ? 1 : 0) - - (a.providers.supportSubscription ? 1 : 0); - }); - + final plans = PlansData.fromJson(map)..sortPlansAndProviders(); appLogger.info('Plans: $map'); return Right(plans); } catch (e, stackTrace) { @@ -905,6 +895,7 @@ class LanternFFIService implements LanternCoreService { Future> acknowledgeInAppPurchase({ required String purchaseToken, required String planId, + String couponCode = '', }) { throw UnimplementedError( "This is not supported on desktop; this is only for mobile", @@ -926,6 +917,7 @@ class LanternFFIService implements LanternCoreService { required String planId, required String email, required String idempotencyKey, + String couponCode = '', }) async { try { final result = await runInBackground(() async { @@ -934,6 +926,7 @@ class LanternFFIService implements LanternCoreService { provider.toCharPtr, email.toCharPtr, idempotencyKey.toCharPtr, + couponCode.toCharPtr, ); try { return resultPtr.toDartString(); @@ -1430,6 +1423,32 @@ class LanternFFIService implements LanternCoreService { } } + @override + Future> attachReferralCodeV2( + String code, + ) async { + try { + final distributionChannel = isStoreVersion() ? 'store' : 'non-store'; + final result = await runInBackground(() async { + final resultPtr = _ffiService.referralAttachmentV2( + code.toCharPtr, + distributionChannel.toCharPtr, + ); + try { + return resultPtr.cast().toDartString(); + } finally { + _ffiService.freeCString(resultPtr); + } + }); + checkAPIError(result); + final response = ReferralAttachV2Response.fromJson(jsonDecode(result)); + return Right(response); + } catch (e, stackTrace) { + appLogger.error('Error attaching referral code v2', e, stackTrace); + return Left(e.toFailure()); + } + } + @override Future> completeChangeEmail({ required String newEmail, diff --git a/lib/lantern/lantern_generated_bindings.dart b/lib/lantern/lantern_generated_bindings.dart index 8a58d1d253..eb73933dce 100644 --- a/lib/lantern/lantern_generated_bindings.dart +++ b/lib/lantern/lantern_generated_bindings.dart @@ -5681,12 +5681,14 @@ class LanternBindings { ffi.Pointer _planId, ffi.Pointer _email, ffi.Pointer _idempotencyKey, + ffi.Pointer _couponCode, ) { return _stripeSubscriptionPaymentRedirect( subType, _planId, _email, _idempotencyKey, + _couponCode, ); } @@ -5698,6 +5700,7 @@ class LanternBindings { ffi.Pointer, ffi.Pointer, ffi.Pointer, + ffi.Pointer, ) > >('stripeSubscriptionPaymentRedirect'); @@ -5709,6 +5712,7 @@ class LanternBindings { ffi.Pointer, ffi.Pointer, ffi.Pointer, + ffi.Pointer, ) >(); @@ -5717,8 +5721,15 @@ class LanternBindings { ffi.Pointer _provider, ffi.Pointer _email, ffi.Pointer _idempotencyKey, + ffi.Pointer _couponCode, ) { - return _paymentRedirect(_plan, _provider, _email, _idempotencyKey); + return _paymentRedirect( + _plan, + _provider, + _email, + _idempotencyKey, + _couponCode, + ); } late final _paymentRedirectPtr = @@ -5729,6 +5740,7 @@ class LanternBindings { ffi.Pointer, ffi.Pointer, ffi.Pointer, + ffi.Pointer, ) > >('paymentRedirect'); @@ -5739,6 +5751,7 @@ class LanternBindings { ffi.Pointer, ffi.Pointer, ffi.Pointer, + ffi.Pointer, ) >(); @@ -5964,6 +5977,30 @@ class LanternBindings { ) >(); + ffi.Pointer referralAttachmentV2( + ffi.Pointer _referralCode, + ffi.Pointer _channel, + ) { + return _referralAttachmentV2(_referralCode, _channel); + } + + late final _referralAttachmentV2Ptr = + _lookup< + ffi.NativeFunction< + ffi.Pointer Function( + ffi.Pointer, + ffi.Pointer, + ) + > + >('referralAttachmentV2'); + late final _referralAttachmentV2 = _referralAttachmentV2Ptr + .asFunction< + ffi.Pointer Function( + ffi.Pointer, + ffi.Pointer, + ) + >(); + ffi.Pointer startChangeEmail( ffi.Pointer _newEmail, ffi.Pointer _password, diff --git a/lib/lantern/lantern_platform_service.dart b/lib/lantern/lantern_platform_service.dart index c2d4e477fc..6957e4a391 100644 --- a/lib/lantern/lantern_platform_service.dart +++ b/lib/lantern/lantern_platform_service.dart @@ -12,17 +12,18 @@ import 'package:lantern/core/models/available_servers.dart'; import 'package:lantern/core/models/datacap_info.dart'; import 'package:lantern/core/models/macos_extension_state.dart'; import 'package:lantern/core/models/plan_data.dart'; -import 'package:lantern/core/models/restore_subscription_response.dart'; import 'package:lantern/core/models/private_server_status.dart'; +import 'package:lantern/core/models/referral_attach_response.dart'; +import 'package:lantern/core/models/restore_subscription_response.dart'; import 'package:lantern/core/models/server_location.dart'; import 'package:lantern/core/models/user.dart'; import 'package:lantern/core/services/app_purchase.dart'; import 'package:lantern/core/services/injection_container.dart'; import 'package:lantern/core/utils/app_data_utils.dart'; import 'package:lantern/core/utils/enabled_apps.dart'; +import 'package:lantern/features/report_issue/models/report_issue_attachment.dart'; import 'package:lantern/lantern/lantern_core_service.dart'; import 'package:lantern/lantern/lantern_ffi_service.dart'; -import 'package:lantern/features/report_issue/models/report_issue_attachment.dart'; import '../core/models/lantern_status.dart'; import '../core/services/injection_container.dart' show sl; @@ -635,12 +636,14 @@ class LanternPlatformService implements LanternCoreService { required String planId, required PaymentSuccessCallback onSuccess, required PaymentErrorCallback onError, + String couponCode = '', }) async { try { await sl().startSubscription( plan: planId, onSuccess: onSuccess, onError: onError, + couponCode: couponCode, ); return Right(unit); } catch (e) { @@ -654,6 +657,7 @@ class LanternPlatformService implements LanternCoreService { required String planId, required String email, required String idempotencyKey, + String couponCode = '', }) async { if (!PlatformUtils.isMacOS) { return left( @@ -670,6 +674,7 @@ class LanternPlatformService implements LanternCoreService { "planId": planId, "email": email, "idempotencyKey": idempotencyKey, + "couponCode": couponCode, }); if (redirectUrl == null || redirectUrl.isEmpty) { return Left( @@ -693,11 +698,12 @@ class LanternPlatformService implements LanternCoreService { Future>> stipeSubscription({ required String planId, required String email, + String couponCode = '', }) async { try { final subData = await _methodChannel.invokeMethod( 'stripeSubscription', - {"planId": planId, "email": email}, + {"planId": planId, "email": email, "couponCode": couponCode}, ); final map = jsonDecode(subData!); return Right(map); @@ -732,28 +738,7 @@ class LanternPlatformService implements LanternCoreService { channel, ); final map = jsonDecode(subData!); - final plans = PlansData.fromJson(map); - //Sort plans - plans.plans.sort((a, b) { - if (a.bestValue != b.bestValue) { - return a.bestValue ? -1 : 1; - } - // Then: sort by usdPrice descending - return b.usdPrice.compareTo(a.usdPrice); - }); - - //Sort provider - if (PlatformUtils.isMobile) { - plans.providers.android.sort((a, b) { - return (b.providers.supportSubscription ? 1 : 0) - - (a.providers.supportSubscription ? 1 : 0); - }); - } else { - plans.providers.desktop.sort((a, b) { - return (b.providers.supportSubscription ? 1 : 0) - - (a.providers.supportSubscription ? 1 : 0); - }); - } + final plans = PlansData.fromJson(map)..sortPlansAndProviders(); return Right(plans); } catch (e, stackTrace) { appLogger.error('Error fetching plans', e, stackTrace); @@ -772,6 +757,7 @@ class LanternPlatformService implements LanternCoreService { required String planId, required String email, required String idempotencyKey, + String couponCode = '', }) async { if (PlatformUtils.isIOS) { throw UnimplementedError("This not supported on IOS"); @@ -783,6 +769,7 @@ class LanternPlatformService implements LanternCoreService { 'planId': planId, 'email': email, 'idempotencyKey': idempotencyKey, + 'couponCode': couponCode, }); if (redirectUrl == null || redirectUrl.isEmpty) { return Left(Exception('No payment redirect URL returned').toFailure()); @@ -819,15 +806,21 @@ class LanternPlatformService implements LanternCoreService { Future> acknowledgeInAppPurchase({ required String purchaseToken, required String planId, + String couponCode = '', }) async { try { await _methodChannel.invokeMethod('acknowledgeInAppPurchase', { 'purchaseToken': purchaseToken, 'planId': planId, + 'couponCode': couponCode, }); return Right('ok'); } catch (e, stackTrace) { - appLogger.error('Error acknowledging in-app purchase', e, stackTrace); + appLogger.error( + 'Error acknowledging in-app purchase ${e.toString()} ', + e, + stackTrace, + ); return Left(e.toFailure()); } } @@ -1547,6 +1540,24 @@ class LanternPlatformService implements LanternCoreService { } } + @override + Future> attachReferralCodeV2( + String code, + ) async { + try { + final distributionChannel = isStoreVersion() ? 'store' : 'non-store'; + final result = await _methodChannel.invokeMethod( + 'attachReferralCodeV2', + {'code': code, 'distributionChannel': distributionChannel}, + ); + final response = ReferralAttachV2Response.fromJson(jsonDecode(result!)); + return right(response); + } catch (e, stackTrace) { + appLogger.error('Error attaching referral code v2', e, stackTrace); + return Left(e.toFailure()); + } + } + EnabledAppsSnapshot _enabledApps = const EnabledAppsSnapshot.empty(); EnabledAppsSnapshot get enabledAppsSnapshot => _enabledApps; diff --git a/lib/lantern/lantern_service.dart b/lib/lantern/lantern_service.dart index 75cf1950fc..d3074e3292 100644 --- a/lib/lantern/lantern_service.dart +++ b/lib/lantern/lantern_service.dart @@ -8,6 +8,7 @@ import 'package:lantern/core/models/lantern_status.dart'; import 'package:lantern/core/models/server_location.dart'; import 'package:lantern/core/models/macos_extension_state.dart'; import 'package:lantern/core/models/plan_data.dart'; +import 'package:lantern/core/models/referral_attach_response.dart'; import 'package:lantern/core/models/restore_subscription_response.dart'; import 'package:lantern/core/models/private_server_status.dart'; import 'package:lantern/core/services/app_purchase.dart'; @@ -153,6 +154,7 @@ class LanternService implements LanternCoreService { required String planId, required PaymentSuccessCallback onSuccess, required PaymentErrorCallback onError, + String couponCode = '', }) { if (PlatformUtils.isFFISupported) { throw UnimplementedError(); @@ -161,6 +163,7 @@ class LanternService implements LanternCoreService { planId: planId, onSuccess: onSuccess, onError: onError, + couponCode: couponCode, ); } @@ -178,6 +181,7 @@ class LanternService implements LanternCoreService { required String planId, required String email, required String idempotencyKey, + String couponCode = '', }) { if (PlatformUtils.isFFISupported) { return _ffiService.stipeSubscriptionPaymentRedirect( @@ -185,6 +189,7 @@ class LanternService implements LanternCoreService { planId: planId, email: email, idempotencyKey: idempotencyKey, + couponCode: couponCode, ); } return _platformService.stipeSubscriptionPaymentRedirect( @@ -192,6 +197,7 @@ class LanternService implements LanternCoreService { planId: planId, email: email, idempotencyKey: idempotencyKey, + couponCode: couponCode, ); } @@ -199,11 +205,16 @@ class LanternService implements LanternCoreService { Future>> stipeSubscription({ required String planId, required String email, + String couponCode = '', }) { if (PlatformUtils.isFFISupported) { throw UnimplementedError(); } - return _platformService.stipeSubscription(planId: planId, email: email); + return _platformService.stipeSubscription( + planId: planId, + email: email, + couponCode: couponCode, + ); } @override @@ -336,16 +347,19 @@ class LanternService implements LanternCoreService { Future> acknowledgeInAppPurchase({ required String purchaseToken, required String planId, + String couponCode = '', }) { if (PlatformUtils.isFFISupported) { return _ffiService.acknowledgeInAppPurchase( purchaseToken: purchaseToken, planId: planId, + couponCode: couponCode, ); } return _platformService.acknowledgeInAppPurchase( purchaseToken: purchaseToken, planId: planId, + couponCode: couponCode, ); } @@ -373,6 +387,7 @@ class LanternService implements LanternCoreService { required String planId, required String email, required String idempotencyKey, + String couponCode = '', }) { if (PlatformUtils.isFFISupported) { return _ffiService.paymentRedirect( @@ -380,6 +395,7 @@ class LanternService implements LanternCoreService { planId: planId, email: email, idempotencyKey: idempotencyKey, + couponCode: couponCode, ); } return _platformService.paymentRedirect( @@ -387,6 +403,7 @@ class LanternService implements LanternCoreService { planId: planId, email: email, idempotencyKey: idempotencyKey, + couponCode: couponCode, ); } @@ -845,6 +862,16 @@ class LanternService implements LanternCoreService { return _platformService.attachReferralCode(code); } + @override + Future> attachReferralCodeV2( + String code, + ) { + if (PlatformUtils.isFFISupported) { + return _ffiService.attachReferralCodeV2(code); + } + return _platformService.attachReferralCodeV2(code); + } + @override Future>> getSplitTunnelItems( SplitTunnelFilterType type, diff --git a/macos/Runner/Handlers/MethodHandler.swift b/macos/Runner/Handlers/MethodHandler.swift index eff5900619..54d9a517c3 100644 --- a/macos/Runner/Handlers/MethodHandler.swift +++ b/macos/Runner/Handlers/MethodHandler.swift @@ -89,7 +89,9 @@ class MethodHandler { ) return } - self.acknowledgeInAppPurchase(token: token, planId: planId, result: result) + let couponCode = map["couponCode"] as? String ?? "" + self.acknowledgeInAppPurchase( + token: token, planId: planId, couponCode: couponCode, result: result) // user management case "startRecoveryByEmail": @@ -161,6 +163,13 @@ class MethodHandler { let code = call.arguments as? String ?? "" self.referralAttach(result: result, code: code) + case "attachReferralCodeV2": + let data = call.arguments as? [String: Any] ?? [:] + let code = data["code"] as? String ?? "" + let distributionChannel = data["distributionChannel"] as? String ?? "" + self.referralAttachV2( + result: result, code: code, distributionChannel: distributionChannel) + // Private server methods case "digitalOcean": self.digitalOcean(result: result) @@ -609,10 +618,12 @@ class MethodHandler { } } - func acknowledgeInAppPurchase(token: String, planId: String, result: @escaping FlutterResult) { + func acknowledgeInAppPurchase( + token: String, planId: String, couponCode: String, result: @escaping FlutterResult + ) { Task { var error: NSError? - let json = MobileAcknowledgeApplePurchase(token, planId, &error) + let json = MobileAcknowledgeApplePurchase(token, planId, couponCode, &error) if let error { await self.handleFlutterError(error, result: result, code: "ACKNOWLEDGE_FAILED") return @@ -842,6 +853,24 @@ class MethodHandler { } } + func referralAttachV2( + result: @escaping FlutterResult, code: String, distributionChannel: String + ) { + Task { + var error: NSError? + let json = MobileReferralAttachmentV2(code, distributionChannel, &error) + if let error { + appLogger.error("Failed to attach referral code v2: \(error.localizedDescription)") + await self.handleFlutterError(error, result: result, code: "ATTACH_REFERRAL_CODE_V2_FAILED") + return + } + await MainActor.run { + appLogger.info("Referral code attached successfully (v2).") + result(json) + } + } + } + // MARK: - Private server methods func digitalOcean(result: @escaping FlutterResult) { @@ -1296,6 +1325,7 @@ class MethodHandler { let email = data["email"] as? String ?? "" let planId = data["planId"] as? String ?? "" let type = data["type"] as? String ?? "" + let couponCode = data["couponCode"] as? String ?? "" let idempotencyKey: String do { idempotencyKey = try self.paymentRedirectIdempotencyKey(from: data) @@ -1309,6 +1339,7 @@ class MethodHandler { planId, email, idempotencyKey, + couponCode, &error ) if let err = error { @@ -1326,6 +1357,7 @@ class MethodHandler { let provider = data["provider"] as? String ?? "" let planId = data["planId"] as? String ?? "" let email = data["email"] as? String ?? "" + let couponCode = data["couponCode"] as? String ?? "" let idempotencyKey: String do { idempotencyKey = try self.paymentRedirectIdempotencyKey(from: data) @@ -1339,6 +1371,7 @@ class MethodHandler { planId, email, idempotencyKey, + couponCode, &error ) if let err = error { diff --git a/test/core/services/purchase/pending_purchase_store_test.dart b/test/core/services/purchase/pending_purchase_store_test.dart new file mode 100644 index 0000000000..814899c036 --- /dev/null +++ b/test/core/services/purchase/pending_purchase_store_test.dart @@ -0,0 +1,156 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:lantern/core/services/local_storage_service.dart'; +import 'package:lantern/core/services/purchase/pending_purchase_store.dart'; + +/// In-memory [LocalStorageService] backing only the string-map API the store +/// uses. `_prefs` is `late` and never touched, so subclassing is safe. +class _FakeStorage extends LocalStorageService { + final Map> maps = {}; + + @override + Future> getStringMap(String key) async => + Map.from(maps[key] ?? const {}); + + @override + Future setStringMap(String key, Map value) async { + if (value.isEmpty) { + maps.remove(key); + return; + } + maps[key] = Map.from(value); + } +} + +PurchaseDetails _purchase({ + String productID = '1y_sub', + String? purchaseID, + String? transactionDate, +}) => PurchaseDetails( + purchaseID: purchaseID, + productID: productID, + transactionDate: transactionDate, + status: PurchaseStatus.purchased, + verificationData: PurchaseVerificationData( + localVerificationData: 'local', + serverVerificationData: 'server', + source: 'test', + ), +); + +void main() { + late _FakeStorage storage; + late PendingPurchaseStore store; + + setUp(() { + storage = _FakeStorage(); + store = PendingPurchaseStore(() => storage); + }); + + group('rememberForPurchase / resolve', () { + test('round-trips plan and coupon for a delivered purchase', () async { + final purchase = _purchase(purchaseID: 'txn-1'); + await store.rememberForPurchase( + purchase, + planId: '1y-usd-10', + couponCode: 'AFF20', + ); + + expect(await store.planFor(purchase), '1y-usd-10'); + expect(await store.couponFor(purchase), 'AFF20'); + }); + + test('stores under both the transaction and product keys', () async { + final purchase = _purchase(purchaseID: 'txn-1'); + await store.rememberForPurchase(purchase, planId: '1y-usd-10'); + + expect(storage.maps['pending_purchase_plans_json'], { + 'transaction:txn-1': '1y-usd-10', + 'product:1y_sub': '1y-usd-10', + }); + }); + + test('resolves by product key when no transaction id was assigned', + () async { + // Remembered at initiation (product key only)... + await store.rememberForProduct('1y_sub', planId: '1y-usd-10'); + // ...then delivered with a transaction id. + final delivered = _purchase(purchaseID: 'txn-1'); + + expect(await store.planFor(delivered), '1y-usd-10'); + }); + + test('returns null when nothing was stored', () async { + final purchase = _purchase(purchaseID: 'txn-1'); + expect(await store.planFor(purchase), isNull); + expect(await store.couponFor(purchase), isNull); + }); + }); + + group('empty values never overwrite', () { + test('an empty coupon leaves a previously stored plan intact', () async { + final purchase = _purchase(purchaseID: 'txn-1'); + await store.rememberForPurchase(purchase, planId: '1y-usd-10'); + // Re-remember with no coupon (the common "plan only" case). + await store.rememberForPurchase(purchase, couponCode: ''); + + expect(await store.planFor(purchase), '1y-usd-10'); + expect(await store.couponFor(purchase), isNull); + expect(storage.maps.containsKey('pending_purchase_coupons_json'), isFalse); + }); + }); + + group('forgetPurchase', () { + test('clears both plan and coupon in lockstep', () async { + final purchase = _purchase(purchaseID: 'txn-1'); + await store.rememberForPurchase( + purchase, + planId: '1y-usd-10', + couponCode: 'AFF20', + ); + + await store.forgetPurchase(purchase); + + expect(await store.planFor(purchase), isNull); + expect(await store.couponFor(purchase), isNull); + expect(storage.maps, isEmpty); + }); + + test('leaves an unrelated purchase untouched', () async { + final a = _purchase(productID: '1y_sub', purchaseID: 'txn-a'); + final b = _purchase(productID: '1m_sub', purchaseID: 'txn-b'); + await store.rememberForPurchase(a, planId: '1y-usd-10'); + await store.rememberForPurchase(b, planId: '1m-usd-10'); + + await store.forgetPurchase(a); + + expect(await store.planFor(a), isNull); + expect(await store.planFor(b), '1m-usd-10'); + }); + }); + + group('retryKeyFor', () { + test('prefers the transaction id', () { + expect( + store.retryKeyFor(_purchase(purchaseID: 'txn-1')), + 'transaction:txn-1', + ); + }); + + test('falls back to productID:date when there is no purchase id', () { + expect( + store.retryKeyFor( + _purchase(productID: '1y_sub', transactionDate: '12345'), + ), + 'transaction:1y_sub:12345', + ); + }); + + test('falls back to the product key when nothing identifies the txn', () { + expect( + store.retryKeyFor(_purchase(productID: '1y_sub')), + 'product:1y_sub', + ); + }); + }); +} diff --git a/test/core/services/purchase/purchase_acknowledger_test.dart b/test/core/services/purchase/purchase_acknowledger_test.dart new file mode 100644 index 0000000000..632e514a93 --- /dev/null +++ b/test/core/services/purchase/purchase_acknowledger_test.dart @@ -0,0 +1,238 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:lantern/core/services/purchase/purchase_acknowledger.dart'; +import 'package:lantern/core/utils/failure.dart'; + +PurchaseDetails _purchase({ + String productID = '1y_sub', + String purchaseID = 'txn-1', + String receipt = 'server-receipt', +}) => PurchaseDetails( + purchaseID: purchaseID, + productID: productID, + transactionDate: '1000', + status: PurchaseStatus.purchased, + verificationData: PurchaseVerificationData( + localVerificationData: 'local', + serverVerificationData: receipt, + source: 'test', + ), +); + +Failure _failure() => + Failure(error: 'boom', localizedErrorMessage: 'boom'); + +/// Collects callback invocations so tests can assert exact counts. +class _Recorder { + int success = 0; + int error = 0; + int acknowledged = 0; +} + +void main() { + late _Recorder rec; + + setUp(() => rec = _Recorder()); + + /// Builds an acknowledger with sensible fakes; each collaborator is + /// overridable per test. + PurchaseAcknowledger build({ + required ReceiptAcknowledger acknowledgeReceipt, + Future Function()? isAlreadyActive, + List? retryDelays, + int? maxRetries, + }) => PurchaseAcknowledger( + acknowledgeReceipt: acknowledgeReceipt, + resolvePlanId: (_) async => '1y-usd-10', + resolveCouponCode: (_) async => '', + retryKeyFor: (p) => p.purchaseID ?? p.productID, + isAlreadyActive: isAlreadyActive ?? () async => false, + onAcknowledged: (_) async => rec.acknowledged++, + retryDelays: retryDelays ?? const [Duration(milliseconds: 5)], + maxRetries: maxRetries, + ); + + test('confirms on the first try: finalizes and fires onSuccess once', () async { + final ack = build( + acknowledgeReceipt: ({required purchaseToken, required planId, required couponCode}) async => + right('ok'), + ); + + await ack.acknowledge( + _purchase(), + planId: '1y-usd-10', + couponCode: '', + onSuccess: (_) => rec.success++, + onError: (_) => rec.error++, + ); + + expect(rec.acknowledged, 1); + expect(rec.success, 1); + expect(rec.error, 0); + }); + + test('backend error but account already active: finalizes as success', () async { + final ack = build( + acknowledgeReceipt: ({required purchaseToken, required planId, required couponCode}) async => + left(_failure()), + isAlreadyActive: () async => true, + ); + + await ack.acknowledge( + _purchase(), + planId: '1y-usd-10', + couponCode: '', + onSuccess: (_) => rec.success++, + onError: (_) => rec.error++, + ); + + expect(rec.acknowledged, 1); + expect(rec.success, 1); + expect(rec.error, 0); + }); + + test('transient failure then background-retry success', () async { + var calls = 0; + final ack = build( + acknowledgeReceipt: ({required purchaseToken, required planId, required couponCode}) async { + calls++; + return calls == 1 ? left(_failure()) : right('ok'); + }, + ); + + await ack.acknowledge( + _purchase(), + planId: '1y-usd-10', + couponCode: '', + onSuccess: (_) => rec.success++, + onError: (_) => rec.error++, + ); + + // Initial attempt failed: user was told once, transaction not yet finalized. + expect(rec.error, 1); + expect(rec.acknowledged, 0); + + // Let the scheduled retry fire. + await Future.delayed(const Duration(milliseconds: 40)); + + expect(calls, 2); + expect(rec.acknowledged, 1); // retry finalized the transaction + expect(rec.success, 0); // background retries stay silent + expect(rec.error, 1); // no second error surfaced + }); + + test('empty receipt is treated as a failure and surfaced once', () async { + var receiptCalls = 0; + final ack = build( + acknowledgeReceipt: ({required purchaseToken, required planId, required couponCode}) async { + receiptCalls++; + return right('ok'); + }, + retryDelays: const [Duration(seconds: 30)], // don't fire during the test + ); + + await ack.acknowledge( + _purchase(receipt: ''), + planId: '1y-usd-10', + couponCode: '', + onSuccess: (_) => rec.success++, + onError: (_) => rec.error++, + ); + + expect(receiptCalls, 0); // never hit the backend with an empty receipt + expect(rec.error, 1); + expect(rec.acknowledged, 0); + ack.dispose(); // cancel the pending retry timer + }); + + test('a thrown backend error is retried, not propagated', () async { + var calls = 0; + final ack = build( + acknowledgeReceipt: ({required purchaseToken, required planId, required couponCode}) async { + calls++; + if (calls == 1) throw Exception('network down'); + return right('ok'); + }, + ); + + // Must not throw out of acknowledge(). + await ack.acknowledge( + _purchase(), + planId: '1y-usd-10', + couponCode: '', + onSuccess: (_) => rec.success++, + onError: (_) => rec.error++, + ); + + expect(rec.error, 1); // surfaced once + await Future.delayed(const Duration(milliseconds: 40)); + expect(calls, 2); // retried + expect(rec.acknowledged, 1); // and succeeded + }); + + test('background retries stop after maxRetries when the backend never recovers', () async { + var calls = 0; + final ack = build( + acknowledgeReceipt: ({required purchaseToken, required planId, required couponCode}) async { + calls++; + return left(_failure()); + }, + retryDelays: const [Duration(milliseconds: 1)], + maxRetries: 3, + ); + + await ack.acknowledge( + _purchase(), + planId: '1y-usd-10', + couponCode: '', + onSuccess: (_) => rec.success++, + onError: (_) => rec.error++, + ); + + // Give the bounded retry chain time to run to exhaustion. + await Future.delayed(const Duration(milliseconds: 60)); + + // 1 initial attempt + 3 retries, then the cap stops further scheduling. + expect(calls, 4); + expect(rec.error, 1); // surfaced once on the first failure + expect(rec.acknowledged, 0); + + // No more calls happen after exhaustion. + await Future.delayed(const Duration(milliseconds: 30)); + expect(calls, 4); + }); + + test('a duplicate in-flight acknowledge for the same purchase is ignored', () async { + final completer = Completer>(); + final ack = build( + acknowledgeReceipt: ({required purchaseToken, required planId, required couponCode}) => + completer.future, + ); + + final purchase = _purchase(); + // Start one (does not complete yet), then a second for the same key. + final first = ack.acknowledge( + purchase, + planId: '1y-usd-10', + couponCode: '', + onSuccess: (_) => rec.success++, + onError: (_) => rec.error++, + ); + await ack.acknowledge( + purchase, + planId: '1y-usd-10', + couponCode: '', + onSuccess: (_) => rec.success++, + onError: (_) => rec.error++, + ); + + completer.complete(right('ok')); + await first; + + expect(rec.success, 1); // only the first attempt ran + expect(rec.acknowledged, 1); + }); +}