Skip to content

Commit 36187a0

Browse files
fix: auto-cancel credits top-up spinner when user returns from browser without paying
When the Stripe checkout tab was closed without completing a purchase, the Explorer modal remained stuck on the "Continue the purchase in your browser..." spinner until the foreground poll timed out (60 s) or the user manually pressed X. Fix: subscribe to application focus events while the modal is in WaitingForBrowser state. When the Explorer regains focus (the user returned from the browser), a 10-second grace period begins. If the poll receives a credited/failed status during the grace period the normal success/failure flow continues; otherwise the top-up is auto-cancelled and the modal resets to pack selection. - Add IApplicationFocusSource interface and UnityApplicationFocusSource wrapper for Unity's Application.focusChanged — keeps CreditsTopUpModalController testable. - Inject IApplicationFocusSource into CreditsTopUpModalController; subscribe on OnViewShow, unsubscribe on OnViewClose. - WaitAndAutoCancelAsync: waits the grace period then calls CancelTopUp() if still in WaitingForBrowser state, also fires the BuyCreditsCancelled analytics event. - Wire UnityApplicationFocusSource in CreditPurchasePlugin. - New tests: CancelTopUpAfterGracePeriodWhenFocusReturnedWhileWaitingForBrowser, NotCancelTopUpWhenPaymentArrivesWithinGracePeriod. Closes #9737
1 parent 77f9c1e commit 36187a0

5 files changed

Lines changed: 163 additions & 5 deletions

File tree

Explorer/Assets/DCL/MarketplaceCredits/Purchase/Tests/CreditsTopUpModalControllerShould.cs

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using NUnit.Framework;
66
using System;
77
using System.Collections.Generic;
8+
using System.Threading.Tasks;
89

910
namespace DCL.MarketplaceCredits.Purchase.Tests
1011
{
@@ -17,6 +18,7 @@ public class CreditsTopUpModalControllerShould
1718
private static readonly CreditPack PACK = new ("pack_25", 24.99f, 235, true, string.Empty);
1819

1920
private ICreditsTopUpService topUpService = null!;
21+
private IApplicationFocusSource applicationFocusSource = null!;
2022
private TestableController controller = null!;
2123

2224
private readonly List<(string orderId, CreditPack pack)> redirected = new ();
@@ -39,11 +41,13 @@ public void SetUp()
3941
packsLoadFailed.Clear();
4042

4143
topUpService = Substitute.For<ICreditsTopUpService>();
44+
applicationFocusSource = Substitute.For<IApplicationFocusSource>();
4245

4346
controller = new TestableController(
4447
topUpService,
4548
Substitute.For<MarketplaceCreditsAPIClient>(null, null),
46-
Substitute.For<IWeb3IdentityCache>());
49+
Substitute.For<IWeb3IdentityCache>(),
50+
applicationFocusSource);
4751

4852
controller.RedirectedToStripe += (orderId, pack) => redirected.Add((orderId, pack));
4953
controller.BuyCreditsCompleted += (orderId, pack) => completed.Add((orderId, pack));
@@ -189,6 +193,69 @@ public void StartFromPackSelectionWhenReopenedAfterCancellingClose()
189193
Assert.AreEqual(1, cancelled.Count);
190194
}
191195

196+
[Test]
197+
public async Task CancelTopUpAfterGracePeriodWhenFocusReturnedWhileWaitingForBrowser()
198+
{
199+
// Arrange: start with the service in WaitingForPayment so the controller
200+
// enters WaitingForBrowser UI state; use a near-zero grace period.
201+
topUpService.CurrentStatus.Returns(CreditsTopUpStatus.WaitingForPayment(PACK, ORDER_ID));
202+
var fastController = new TestableController(
203+
topUpService,
204+
Substitute.For<MarketplaceCreditsAPIClient>(null, null),
205+
Substitute.For<IWeb3IdentityCache>(),
206+
applicationFocusSource,
207+
gracePeriod: TimeSpan.FromMilliseconds(30));
208+
209+
fastController.BuyCreditsCancelled += (orderId, pack) => cancelled.Add((orderId, pack));
210+
211+
fastController.Show();
212+
RaiseStatus(CreditsTopUpStatus.WaitingForPayment(PACK, ORDER_ID));
213+
214+
// Act: simulate the user returning focus to the Explorer (browser tab closed).
215+
applicationFocusSource.FocusChanged += Raise.Event<Action<bool>>(true);
216+
217+
// Assert: after the grace period the service must have been cancelled.
218+
await Task.Delay(200);
219+
topUpService.Received(1).CancelTopUp();
220+
Assert.AreEqual(1, cancelled.Count);
221+
Assert.AreEqual(ORDER_ID, cancelled[0].orderId);
222+
223+
fastController.Dispose();
224+
}
225+
226+
[Test]
227+
public async Task NotCancelTopUpWhenPaymentArrivesWithinGracePeriod()
228+
{
229+
// Arrange: service in WaitingForPayment; grace period shorter than payment arrival simulation.
230+
topUpService.CurrentStatus.Returns(CreditsTopUpStatus.WaitingForPayment(PACK, ORDER_ID));
231+
var fastController = new TestableController(
232+
topUpService,
233+
Substitute.For<MarketplaceCreditsAPIClient>(null, null),
234+
Substitute.For<IWeb3IdentityCache>(),
235+
applicationFocusSource,
236+
gracePeriod: TimeSpan.FromMilliseconds(100));
237+
238+
fastController.BuyCreditsCompleted += (orderId, pack) => completed.Add((orderId, pack));
239+
240+
fastController.Show();
241+
RaiseStatus(CreditsTopUpStatus.WaitingForPayment(PACK, ORDER_ID));
242+
243+
// Act: user returns focus, but payment arrives before grace period expires.
244+
applicationFocusSource.FocusChanged += Raise.Event<Action<bool>>(true);
245+
246+
// Simulate payment arriving within the grace period (immediately after focus).
247+
topUpService.CurrentStatus.Returns(CreditsTopUpStatus.Credited(PACK, ORDER_ID, 250, 300));
248+
RaiseStatus(CreditsTopUpStatus.Credited(PACK, ORDER_ID, 250, 300));
249+
250+
await Task.Delay(300);
251+
252+
// Assert: CancelTopUp must NOT have been called — success won the race.
253+
topUpService.DidNotReceive().CancelTopUp();
254+
Assert.AreEqual(1, completed.Count);
255+
256+
fastController.Dispose();
257+
}
258+
192259
private void RaiseStatus(CreditsTopUpStatus status) =>
193260
topUpService.StatusChanged += Raise.Event<Action<CreditsTopUpStatus>>(status);
194261

@@ -198,8 +265,17 @@ private class TestableController : CreditsTopUpModalController
198265
public TestableController(
199266
ICreditsTopUpService topUpService,
200267
MarketplaceCreditsAPIClient creditsApiClient,
201-
IWeb3IdentityCache identityCache)
202-
: base(() => null!, topUpService, creditsApiClient, identityCache, null!) { }
268+
IWeb3IdentityCache identityCache,
269+
IApplicationFocusSource? applicationFocusSource = null,
270+
TimeSpan? gracePeriod = null)
271+
: base(
272+
() => null!,
273+
topUpService,
274+
creditsApiClient,
275+
identityCache,
276+
null!,
277+
applicationFocusSource ?? Substitute.For<IApplicationFocusSource>(),
278+
gracePeriod) { }
203279

204280
public void Show() =>
205281
OnViewShow();
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using System;
2+
3+
namespace DCL.MarketplaceCredits.Purchase.TopUp
4+
{
5+
/// <summary>
6+
/// Abstracts Unity's <c>Application.focusChanged</c> for testability.
7+
/// </summary>
8+
public interface IApplicationFocusSource
9+
{
10+
/// <summary>
11+
/// Raised when the application gains or loses OS focus.
12+
/// <c>true</c> = focus gained, <c>false</c> = focus lost.
13+
/// </summary>
14+
event Action<bool> FocusChanged;
15+
}
16+
}

Explorer/Assets/DCL/MarketplaceCredits/Purchase/TopUp/UI/CreditsTopUpModalController.cs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,14 @@ private enum ModalState
2929
private const string PACKS_LOAD_FAILED_REQUEST = "request_failed";
3030
private const string PACKS_LOAD_FAILED_EMPTY = "empty_response";
3131

32+
private static readonly TimeSpan DEFAULT_FOCUS_RETURN_GRACE_PERIOD = TimeSpan.FromSeconds(10);
33+
3234
private readonly ICreditsTopUpService topUpService;
3335
private readonly MarketplaceCreditsAPIClient creditsApiClient;
3436
private readonly IWeb3IdentityCache identityCache;
3537
private readonly ImageControllerProvider imageControllerProvider;
38+
private readonly IApplicationFocusSource applicationFocusSource;
39+
private readonly TimeSpan focusReturnGracePeriod;
3640

3741
private ModalState currentState;
3842
private CreditsTopUpStage lastStage = CreditsTopUpStage.Idle;
@@ -57,13 +61,17 @@ public CreditsTopUpModalController(
5761
ICreditsTopUpService topUpService,
5862
MarketplaceCreditsAPIClient creditsApiClient,
5963
IWeb3IdentityCache identityCache,
60-
ImageControllerProvider imageControllerProvider)
64+
ImageControllerProvider imageControllerProvider,
65+
IApplicationFocusSource applicationFocusSource,
66+
TimeSpan? focusReturnGracePeriod = null)
6167
: base(viewFactory)
6268
{
6369
this.topUpService = topUpService;
6470
this.creditsApiClient = creditsApiClient;
6571
this.identityCache = identityCache;
6672
this.imageControllerProvider = imageControllerProvider;
73+
this.applicationFocusSource = applicationFocusSource;
74+
this.focusReturnGracePeriod = focusReturnGracePeriod ?? DEFAULT_FOCUS_RETURN_GRACE_PERIOD;
6775
topUpService.StatusChanged += OnServiceStatusChanged;
6876
}
6977

@@ -85,11 +93,13 @@ protected override void OnViewShow()
8593
LoadAndBindPacksAsync(lifeCts.Token).Forget();
8694
LoadBalanceAsync(lifeCts.Token).Forget();
8795

96+
applicationFocusSource.FocusChanged += OnApplicationFocusChanged;
8897
ModalOpened?.Invoke(inputData.Source);
8998
}
9099

91100
protected override void OnViewClose()
92101
{
102+
applicationFocusSource.FocusChanged -= OnApplicationFocusChanged;
93103
isViewShown = false;
94104
purchasedPackItem = null;
95105

@@ -242,6 +252,31 @@ private void OnRetryClicked()
242252
topUpService.AcknowledgeTerminalState();
243253
}
244254

255+
private void OnApplicationFocusChanged(bool hasFocus)
256+
{
257+
if (!hasFocus || currentState != ModalState.WaitingForBrowser)
258+
return;
259+
260+
// The user has returned to the Explorer while the Stripe checkout was open.
261+
// Wait a short grace period to let a completed payment arrive via the poll cycle;
262+
// if the state is still WaitingForBrowser after the grace period, the user
263+
// likely cancelled in the browser — auto-cancel the top-up on their behalf.
264+
WaitAndAutoCancelAsync(lifeCts!.Token).Forget();
265+
}
266+
267+
private async UniTaskVoid WaitAndAutoCancelAsync(CancellationToken ct)
268+
{
269+
bool wasCancelled = await UniTask.Delay(focusReturnGracePeriod, cancellationToken: ct)
270+
.SuppressCancellationThrow();
271+
272+
if (wasCancelled || currentState != ModalState.WaitingForBrowser)
273+
return;
274+
275+
CreditsTopUpStatus status = topUpService.CurrentStatus;
276+
BuyCreditsCancelled?.Invoke(status.OrderId!, status.Pack);
277+
topUpService.CancelTopUp();
278+
}
279+
245280
private void OnServiceStatusChanged(CreditsTopUpStatus status)
246281
{
247282
if (status.Stage != lastStage)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using System;
2+
using UnityEngine;
3+
4+
namespace DCL.MarketplaceCredits.Purchase.TopUp
5+
{
6+
/// <summary>
7+
/// Forwards Unity's <c>Application.focusChanged</c> event through <see cref="IApplicationFocusSource"/>.
8+
/// Subscribe via the interface; dispose to unsubscribe from the Unity event.
9+
/// </summary>
10+
public class UnityApplicationFocusSource : IApplicationFocusSource, IDisposable
11+
{
12+
public event Action<bool>? FocusChanged;
13+
14+
public UnityApplicationFocusSource()
15+
{
16+
Application.focusChanged += OnFocusChanged;
17+
}
18+
19+
public void Dispose()
20+
{
21+
Application.focusChanged -= OnFocusChanged;
22+
}
23+
24+
private void OnFocusChanged(bool hasFocus) =>
25+
FocusChanged?.Invoke(hasFocus);
26+
}
27+
}

Explorer/Assets/DCL/PluginSystem/Global/CreditPurchasePlugin.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ public class CreditPurchasePlugin : IDCLGlobalPlugin<CreditPurchasePlugin.Credit
3232
private CreditPurchaseModalController? creditPurchaseModalController;
3333
private ICreditsTopUpService? creditsTopUpService;
3434
private CreditsTopUpModalController? creditsTopUpModalController;
35+
private UnityApplicationFocusSource? applicationFocusSource;
3536

3637
public CreditPurchasePlugin(
3738
IAssetsProvisioner assetsProvisioner,
@@ -56,6 +57,7 @@ public void Dispose()
5657
creditPurchaseModalController?.Dispose();
5758
creditsTopUpModalController?.Dispose();
5859
creditsTopUpService?.Dispose();
60+
applicationFocusSource?.Dispose();
5961
}
6062

6163
public void InjectToWorld(ref ArchSystemsWorldBuilder<Arch.Core.World> builder, in GlobalPluginArguments arguments) { }
@@ -76,6 +78,7 @@ public async UniTask InitializeAsync(CreditPurchaseSettings settings, Cancellati
7678
mvcManager.RegisterController(creditPurchaseModalController);
7779

7880
creditsTopUpService = new CreditsTopUpService(marketplaceCreditsAPIClient, web3IdentityCache, webBrowser);
81+
applicationFocusSource = new UnityApplicationFocusSource();
7982

8083
CreditsTopUpModalView topUpViewAsset = (await assetsProvisioner.ProvideMainAssetValueAsync(settings.CreditsTopUpPopupPrefab, ct: ct)).GetComponent<CreditsTopUpModalView>();
8184

@@ -84,7 +87,8 @@ public async UniTask InitializeAsync(CreditPurchaseSettings settings, Cancellati
8487
creditsTopUpService,
8588
marketplaceCreditsAPIClient,
8689
web3IdentityCache,
87-
imageControllerProvider);
90+
imageControllerProvider,
91+
applicationFocusSource);
8892

8993
mvcManager.RegisterController(creditsTopUpModalController);
9094
}

0 commit comments

Comments
 (0)