diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 1948926bc3..72cd26e614 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -60,48 +60,42 @@
-
-
+
-
+
-
+
-
+
+
-
-
-
-
-
-
-
+
-
+
-
+
-
+
-
+
-
+
-
+
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 d6987d9a3a..3b0c1510ed 100644
--- a/go.mod
+++ b/go.mod
@@ -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-20260713183736-b48373539f5e
+ github.com/getlantern/radiance v0.0.0-20260713200049-3739d39b1733
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 5e2143d630..0dda986a86 100644
--- a/go.sum
+++ b/go.sum
@@ -261,6 +261,8 @@ github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b h1:gMYJzEhLrmI
github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b/go.mod h1:NpfXdK4ldEKkjQ4P1R+DBF4ua5VFOlxmgHROTnYrApg=
github.com/getlantern/radiance v0.0.0-20260713183736-b48373539f5e h1:S1YPGuMuUUs0fxNB9/hBu9+VEEHswl8a47sKaSAb6vs=
github.com/getlantern/radiance v0.0.0-20260713183736-b48373539f5e/go.mod h1:4eVZwxd9J7y113n/NGZpexis2MH06MoaA8uDUO4TXOU=
+github.com/getlantern/radiance v0.0.0-20260713200049-3739d39b1733 h1:2oPAVNiKxx16TOYt89lO0R9/vZY2HHm9ZlMy+5ZXFXc=
+github.com/getlantern/radiance v0.0.0-20260713200049-3739d39b1733/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/router/router.gr.dart b/lib/core/router/router.gr.dart
index 4f9329ba4f..b7edcc14fb 100644
--- a/lib/core/router/router.gr.dart
+++ b/lib/core/router/router.gr.dart
@@ -808,20 +808,51 @@ class Onboarding extends _i44.PageRouteInfo {
/// generated route for
/// [_i24.Plans]
-class Plans extends _i44.PageRouteInfo {
- const Plans({List<_i44.PageRouteInfo>? children})
- : super(Plans.name, initialChildren: children);
+class Plans extends _i44.PageRouteInfo {
+ Plans({
+ _i45.Key? key,
+ String? referralCode,
+ List<_i44.PageRouteInfo>? children,
+ }) : super(
+ Plans.name,
+ args: PlansArgs(key: key, referralCode: referralCode),
+ initialChildren: children,
+ );
static const String name = 'Plans';
static _i44.PageInfo page = _i44.PageInfo(
name,
builder: (data) {
- return const _i24.Plans();
+ final args = data.argsAs(orElse: () => const PlansArgs());
+ return _i24.Plans(key: args.key, referralCode: args.referralCode);
},
);
}
+class PlansArgs {
+ const PlansArgs({this.key, this.referralCode});
+
+ final _i45.Key? key;
+
+ final String? referralCode;
+
+ @override
+ String toString() {
+ return 'PlansArgs{key: $key, referralCode: $referralCode}';
+ }
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! PlansArgs) return false;
+ return key == other.key && referralCode == other.referralCode;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ referralCode.hashCode;
+}
+
/// generated route for
/// [_i25.PrivateServerAddBilling]
class PrivateServerAddBilling extends _i44.PageRouteInfo {
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