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 42c271ec81..7b066670a9 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 @@ -469,10 +469,15 @@ class MethodHandler : FlutterPlugin, scope.launch { result.runCatching { val map = call.arguments as Map<*, *> + val idempotencyKey = map["idempotencyKey"] as? String + if (idempotencyKey.isNullOrBlank()) { + throw IllegalArgumentException("Payment redirect idempotency key is required") + } val url = Mobile.paymentRedirect( map["provider"] as String, map["planId"] as String, - map["email"] as String + map["email"] as String, + idempotencyKey ) withContext(Dispatchers.Main) { success(url) diff --git a/go.mod b/go.mod index 75388a1811..a2c9b48f8e 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-20260507232251-71c8a8b5b1c2 + github.com/getlantern/radiance v0.0.0-20260508143852-f81a61fbcd92 github.com/sagernet/sing-box v1.12.22 golang.org/x/mobile v0.0.0-20250711185624-d5bb5ecc55c0 golang.org/x/sys v0.41.0 diff --git a/go.sum b/go.sum index d2f2cdc3ff..7d1dbcf280 100644 --- a/go.sum +++ b/go.sum @@ -263,6 +263,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-20260507232251-71c8a8b5b1c2 h1:O6B+kPVuHOPFayuiRZhBz0EqPPnXSf/NlbftgMaVIPY= github.com/getlantern/radiance v0.0.0-20260507232251-71c8a8b5b1c2/go.mod h1:oBXKRBE6qxdBmxnjV9NI3CSOSy4zDlm1f7haUVWpwBQ= +github.com/getlantern/radiance v0.0.0-20260508143852-f81a61fbcd92 h1:xZQFiQcv8AT5Uxjkj/5jlO1By8/X8ZYLflx2eMFxUp4= +github.com/getlantern/radiance v0.0.0-20260508143852-f81a61fbcd92/go.mod h1:oBXKRBE6qxdBmxnjV9NI3CSOSy4zDlm1f7haUVWpwBQ= github.com/getlantern/samizdat v0.0.3-0.20260327203406-ef7323341974 h1:k+/qNo5YNO+8M8LVUp6G5Evm1OQdEs3Z4ye8top4AhI= github.com/getlantern/samizdat v0.0.3-0.20260327203406-ef7323341974/go.mod h1:uEeykQSW2/6rTjfPlj3MTTo59poSHXfAHTGgzYDkbr0= github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb h1:c5YM7b3a4r2J8Eh89KkI6M/iTFe6Bi+b8AJlfkKdFq4= diff --git a/lantern-core/core.go b/lantern-core/core.go index f63b9019db..e08dce43dd 100644 --- a/lantern-core/core.go +++ b/lantern-core/core.go @@ -122,10 +122,10 @@ type Payment interface { StripeBillingPortalUrl() (string, error) AcknowledgeGooglePurchase(purchaseToken, planId string) (string, error) AcknowledgeApplePurchase(receipt, planII string) (string, error) - PaymentRedirect(provider, planID, email string) (string, error) + PaymentRedirect(provider, planID, email, idempotencyKey string) (string, error) ActivationCode(email, resellerCode string) error SubscriptionPaymentRedirectURL(redirectBody account.PaymentRedirectData) (string, error) - StripeSubscriptionPaymentRedirect(subscriptionType, planID, email string) (string, error) + StripeSubscriptionPaymentRedirect(subscriptionType, planID, email, idempotencyKey string) (string, error) } type SplitTunnel interface { @@ -875,29 +875,47 @@ func (lc *LanternCore) SubscriptionPaymentRedirectURL(redirectBody account.Payme return lc.client.SubscriptionPaymentRedirectURL(lc.ctx, redirectBody) } -func (lc *LanternCore) StripeSubscriptionPaymentRedirect(subscriptionType, planID, email string) (string, error) { +func (lc *LanternCore) StripeSubscriptionPaymentRedirect(subscriptionType, planID, email, idempotencyKey string) (string, error) { + idempotencyKey, err := normalizePaymentRedirectIdempotencyKey(idempotencyKey) + if err != nil { + return "", err + } deviceID := lc.MyDeviceId() redirectBody := account.PaymentRedirectData{ - Provider: "stripe", - Plan: planID, - DeviceName: deviceID, - Email: email, - BillingType: account.SubscriptionType(subscriptionType), + Provider: "stripe", + Plan: planID, + DeviceName: deviceID, + Email: email, + BillingType: account.SubscriptionType(subscriptionType), + IdempotencyKey: idempotencyKey, } return lc.SubscriptionPaymentRedirectURL(redirectBody) } -func (lc *LanternCore) PaymentRedirect(provider, planId, email string) (string, error) { +func (lc *LanternCore) PaymentRedirect(provider, planId, email, idempotencyKey string) (string, error) { + idempotencyKey, err := normalizePaymentRedirectIdempotencyKey(idempotencyKey) + if err != nil { + return "", err + } deviceName := lc.MyDeviceId() body := account.PaymentRedirectData{ - Provider: provider, - Plan: planId, - DeviceName: deviceName, - Email: email, + Provider: provider, + Plan: planId, + DeviceName: deviceName, + Email: email, + IdempotencyKey: idempotencyKey, } return lc.client.PaymentRedirect(lc.ctx, body) } +func normalizePaymentRedirectIdempotencyKey(idempotencyKey string) (string, error) { + idempotencyKey = strings.TrimSpace(idempotencyKey) + if idempotencyKey == "" { + return "", fmt.Errorf("payment redirect idempotency key is required") + } + return idempotencyKey, nil +} + func (lc *LanternCore) ActivationCode(email, resellerCode string) error { purchase, err := lc.client.ActivationCode(lc.ctx, email, resellerCode) if err != nil { diff --git a/lantern-core/ffi/ffi.go b/lantern-core/ffi/ffi.go index a2a45d3282..d10403cb72 100644 --- a/lantern-core/ffi/ffi.go +++ b/lantern-core/ffi/ffi.go @@ -645,16 +645,17 @@ func fetchUserData() *C.char { // Fetch stipe subscription payment redirect link // //export stripeSubscriptionPaymentRedirect -func stripeSubscriptionPaymentRedirect(subType, _planId, _email *C.char) *C.char { +func stripeSubscriptionPaymentRedirect(subType, _planId, _email, _idempotencyKey *C.char) *C.char { subscriptionType := C.GoString(subType) planID := C.GoString(_planId) email := C.GoString(_email) + idempotencyKey := C.GoString(_idempotencyKey) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - redirect, err := c.StripeSubscriptionPaymentRedirect(subscriptionType, planID, email) + redirect, err := c.StripeSubscriptionPaymentRedirect(subscriptionType, planID, email, idempotencyKey) if err != nil { return SendError(err) } @@ -665,16 +666,17 @@ func stripeSubscriptionPaymentRedirect(subType, _planId, _email *C.char) *C.char // Fetch payment redirect link for providers like alipay // //export paymentRedirect -func paymentRedirect(_plan, _provider, _email *C.char) *C.char { +func paymentRedirect(_plan, _provider, _email, _idempotencyKey *C.char) *C.char { plan := C.GoString(_plan) provider := C.GoString(_provider) email := C.GoString(_email) + idempotencyKey := C.GoString(_idempotencyKey) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - redirect, err := c.PaymentRedirect(provider, plan, email) + redirect, err := c.PaymentRedirect(provider, plan, email, idempotencyKey) if err != nil { return SendError(err) } diff --git a/lantern-core/mobile/mobile.go b/lantern-core/mobile/mobile.go index e54e1d882b..dedde5c043 100644 --- a/lantern-core/mobile/mobile.go +++ b/lantern-core/mobile/mobile.go @@ -565,17 +565,18 @@ func AcknowledgeApplePurchase(receipt, planII string) (string, error) { }) } -func PaymentRedirect(provider, planId, email string) (string, error) { - return withCoreR(func(c lanterncore.Core) (string, error) { return c.PaymentRedirect(provider, planId, email) }) - +func PaymentRedirect(provider, planId, email, idempotencyKey string) (string, error) { + return withCoreR(func(c lanterncore.Core) (string, error) { + return c.PaymentRedirect(provider, planId, email, idempotencyKey) + }) } // /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 string) (string, error) { +func StripeSubscriptionPaymentRedirect(subType, planId, email, idempotencyKey string) (string, error) { slog.Debug("stripeSubscriptionPaymentRedirect called") return withCoreR(func(c lanterncore.Core) (string, error) { - return c.StripeSubscriptionPaymentRedirect(subType, planId, email) + return c.StripeSubscriptionPaymentRedirect(subType, planId, email, idempotencyKey) }) } diff --git a/lib/core/common/common.dart b/lib/core/common/common.dart index 6eab9a5459..0527cf5af0 100644 --- a/lib/core/common/common.dart +++ b/lib/core/common/common.dart @@ -83,6 +83,14 @@ String generatePassword() { ).join(); } +String generatePaymentRedirectIdempotencyKey() { + final random = Random.secure(); + return List.generate( + 16, + (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); +} + bool isStoreVersion() { if (!PlatformUtils.isMobile) { return false; diff --git a/lib/features/plans/provider/payment_notifier.dart b/lib/features/plans/provider/payment_notifier.dart index bec5dc5100..8dc931d016 100644 --- a/lib/features/plans/provider/payment_notifier.dart +++ b/lib/features/plans/provider/payment_notifier.dart @@ -54,12 +54,14 @@ class PaymentNotifier extends _$PaymentNotifier { String planId, String email, ) async { + final idempotencyKey = generatePaymentRedirectIdempotencyKey(); return ref .read(lanternServiceProvider) .stipeSubscriptionPaymentRedirect( type: type, planId: planId, email: email, + idempotencyKey: idempotencyKey, ); } @@ -77,9 +79,15 @@ class PaymentNotifier extends _$PaymentNotifier { required String planId, required String email, }) async { + final idempotencyKey = generatePaymentRedirectIdempotencyKey(); return ref .read(lanternServiceProvider) - .paymentRedirect(provider: provider, planId: planId, email: email); + .paymentRedirect( + provider: provider, + planId: planId, + email: email, + idempotencyKey: idempotencyKey, + ); } Future> startUpgradeFlow({ diff --git a/lib/lantern/lantern_core_service.dart b/lib/lantern/lantern_core_service.dart index c78b7e55d6..a0df6af006 100644 --- a/lib/lantern/lantern_core_service.dart +++ b/lib/lantern/lantern_core_service.dart @@ -91,12 +91,14 @@ abstract class LanternCoreService { required BillingType type, required String planId, required String email, + required String idempotencyKey, }); Future> paymentRedirect({ required String provider, required String planId, required String email, + required String idempotencyKey, }); // this is used for stripe subscription diff --git a/lib/lantern/lantern_ffi_service.dart b/lib/lantern/lantern_ffi_service.dart index 3b6549819b..b53d5b0bc3 100644 --- a/lib/lantern/lantern_ffi_service.dart +++ b/lib/lantern/lantern_ffi_service.dart @@ -744,6 +744,7 @@ class LanternFFIService implements LanternCoreService { required BillingType type, required String planId, required String email, + required String idempotencyKey, }) async { try { appLogger.debug('Starting Stripe Subscription Payment Redirect'); @@ -753,6 +754,7 @@ class LanternFFIService implements LanternCoreService { type.name.toCharPtr, planId.toCharPtr, email.toCharPtr, + idempotencyKey.toCharPtr, ) .toDartString(); }); @@ -897,6 +899,7 @@ class LanternFFIService implements LanternCoreService { required String provider, required String planId, required String email, + required String idempotencyKey, }) async { try { final result = await runInBackground(() async { @@ -905,6 +908,7 @@ class LanternFFIService implements LanternCoreService { planId.toCharPtr, provider.toCharPtr, email.toCharPtr, + idempotencyKey.toCharPtr, ) .toDartString(); }); diff --git a/lib/lantern/lantern_generated_bindings.dart b/lib/lantern/lantern_generated_bindings.dart index 9bbce09c93..14cc3944cb 100644 --- a/lib/lantern/lantern_generated_bindings.dart +++ b/lib/lantern/lantern_generated_bindings.dart @@ -5680,8 +5680,14 @@ class LanternBindings { ffi.Pointer subType, ffi.Pointer _planId, ffi.Pointer _email, + ffi.Pointer _idempotencyKey, ) { - return _stripeSubscriptionPaymentRedirect(subType, _planId, _email); + return _stripeSubscriptionPaymentRedirect( + subType, + _planId, + _email, + _idempotencyKey, + ); } late final _stripeSubscriptionPaymentRedirectPtr = @@ -5691,6 +5697,7 @@ class LanternBindings { ffi.Pointer, ffi.Pointer, ffi.Pointer, + ffi.Pointer, ) > >('stripeSubscriptionPaymentRedirect'); @@ -5701,6 +5708,7 @@ class LanternBindings { ffi.Pointer, ffi.Pointer, ffi.Pointer, + ffi.Pointer, ) >(); @@ -5708,8 +5716,9 @@ class LanternBindings { ffi.Pointer _plan, ffi.Pointer _provider, ffi.Pointer _email, + ffi.Pointer _idempotencyKey, ) { - return _paymentRedirect(_plan, _provider, _email); + return _paymentRedirect(_plan, _provider, _email, _idempotencyKey); } late final _paymentRedirectPtr = @@ -5719,6 +5728,7 @@ class LanternBindings { ffi.Pointer, ffi.Pointer, ffi.Pointer, + ffi.Pointer, ) > >('paymentRedirect'); @@ -5728,6 +5738,7 @@ class LanternBindings { ffi.Pointer, ffi.Pointer, ffi.Pointer, + ffi.Pointer, ) >(); diff --git a/lib/lantern/lantern_platform_service.dart b/lib/lantern/lantern_platform_service.dart index 84f48a1d01..b718596d0f 100644 --- a/lib/lantern/lantern_platform_service.dart +++ b/lib/lantern/lantern_platform_service.dart @@ -652,6 +652,7 @@ class LanternPlatformService implements LanternCoreService { required BillingType type, required String planId, required String email, + required String idempotencyKey, }) async { if (!PlatformUtils.isMacOS) { return left( @@ -664,7 +665,12 @@ class LanternPlatformService implements LanternCoreService { try { final redirectUrl = await _methodChannel.invokeMethod( 'stripeSubscriptionPaymentRedirect', - {"type": type.name, "planId": planId, "email": email}, + { + "type": type.name, + "planId": planId, + "email": email, + "idempotencyKey": idempotencyKey, + }, ); return Right(redirectUrl!); } catch (e) { @@ -759,6 +765,7 @@ class LanternPlatformService implements LanternCoreService { required String provider, required String planId, required String email, + required String idempotencyKey, }) async { if (PlatformUtils.isIOS) { throw UnimplementedError("This not supported on IOS"); @@ -766,7 +773,12 @@ class LanternPlatformService implements LanternCoreService { try { final redirectUrl = await _methodChannel.invokeMethod( 'paymentRedirect', - {'provider': provider, 'planId': planId, 'email': email}, + { + 'provider': provider, + 'planId': planId, + 'email': email, + 'idempotencyKey': idempotencyKey, + }, ); return Right(redirectUrl!); } catch (e, stackTrace) { diff --git a/lib/lantern/lantern_service.dart b/lib/lantern/lantern_service.dart index 35492f711d..e8d48a6485 100644 --- a/lib/lantern/lantern_service.dart +++ b/lib/lantern/lantern_service.dart @@ -176,18 +176,21 @@ class LanternService implements LanternCoreService { required BillingType type, required String planId, required String email, + required String idempotencyKey, }) { if (PlatformUtils.isFFISupported) { return _ffiService.stipeSubscriptionPaymentRedirect( type: type, planId: planId, email: email, + idempotencyKey: idempotencyKey, ); } return _platformService.stipeSubscriptionPaymentRedirect( type: type, planId: planId, email: email, + idempotencyKey: idempotencyKey, ); } @@ -358,18 +361,21 @@ class LanternService implements LanternCoreService { required String provider, required String planId, required String email, + required String idempotencyKey, }) { if (PlatformUtils.isFFISupported) { return _ffiService.paymentRedirect( provider: provider, planId: planId, email: email, + idempotencyKey: idempotencyKey, ); } return _platformService.paymentRedirect( provider: provider, planId: planId, email: email, + idempotencyKey: idempotencyKey, ); } diff --git a/macos/Runner/Handlers/MethodHandler.swift b/macos/Runner/Handlers/MethodHandler.swift index e4dcb77968..d41e93e30d 100644 --- a/macos/Runner/Handlers/MethodHandler.swift +++ b/macos/Runner/Handlers/MethodHandler.swift @@ -1246,8 +1246,21 @@ class MethodHandler { let email = data["email"] as? String ?? "" let planId = data["planId"] as? String ?? "" let type = data["type"] as? String ?? "" + let idempotencyKey: String + do { + idempotencyKey = try self.paymentRedirectIdempotencyKey(from: data) + } catch { + await self.handleFlutterError(error, result: result, code: "STRIPE_PAYMENT_REDIRECT_ERROR") + return + } var error: NSError? - let url = MobileStripeSubscriptionPaymentRedirect(type, planId, email, &error) + let url = MobileStripeSubscriptionPaymentRedirect( + type, + planId, + email, + idempotencyKey, + &error + ) if let err = error { await self.handleFlutterError(err, result: result, code: "STRIPE_PAYMENT_REDIRECT_ERROR") return @@ -1263,8 +1276,21 @@ class MethodHandler { let provider = data["provider"] as? String ?? "" let planId = data["planId"] as? String ?? "" let email = data["email"] as? String ?? "" + let idempotencyKey: String + do { + idempotencyKey = try self.paymentRedirectIdempotencyKey(from: data) + } catch { + await self.handleFlutterError(error, result: result, code: "PAYMENT_REDIRECT_ERROR") + return + } var error: NSError? - let url = MobilePaymentRedirect(provider, planId, email, &error) + let url = MobilePaymentRedirect( + provider, + planId, + email, + idempotencyKey, + &error + ) if let err = error { await self.handleFlutterError(err, result: result, code: "PAYMENT_REDIRECT_ERROR") return @@ -1446,6 +1472,19 @@ class MethodHandler { // MARK: - Utils + private func paymentRedirectIdempotencyKey(from data: [String: Any]) throws -> String { + let idempotencyKey = (data["idempotencyKey"] as? String ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + if idempotencyKey.isEmpty { + throw NSError( + domain: "LanternPayment", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Payment redirect idempotency key is required"] + ) + } + return idempotencyKey + } + /// Helper for handling Flutter errors private func handleFlutterError( _ error: Error?,