Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ public struct CreditsOrderStatusResponse
public const string STATUS_CREDITED = "credited";
public const string STATUS_FAILED = "failed";

// The checkout was retired without a payment — the buyer clicked back on Stripe's page, or the
// session expired. Terminal: no payment can be taken against it any more, so a poll that keeps
// waiting on it waits for something that can never arrive.
public const string STATUS_ABANDONED = "abandoned";

public string status;

// Whole credits granted by the order.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,29 @@ public async Task TransitionToCreditedAndRefreshBalance()
await creditsApiClient.Received(1).GetUserCreditsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}

/// <summary>
/// The server has always reported `abandoned` for a checkout retired without payment, but the
/// client only recognised credited/failed — so a cancelled purchase looked exactly like one still
/// in flight and the modal span until the poll timed out (unity-explorer#9737).
/// </summary>
[Test]
public async Task TransitionToAbandonedWhenOrderIsRetired()
{
// Arrange
creditsApiClient.GetCheckoutOrderAsync(ORDER_ID, Arg.Any<CancellationToken>())
.Returns(Order(CreditsOrderStatusResponse.STATUS_ABANDONED));

// Act
service.StartTopUp(PACK);
await WaitForStageAsync(CreditsTopUpStage.Abandoned);

// Assert
Assert.AreEqual(ORDER_ID, service.CurrentStatus.OrderId);
// Nobody was charged, so there is no balance to re-read and nothing to call an error.
Assert.IsNull(service.CurrentStatus.CheckoutError);
await creditsApiClient.DidNotReceive().GetUserCreditsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}

[Test]
public async Task TransitionToFailedWhenOrderFails()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ private enum PollOutcome
{
Credited,
Failed,
// The ORDER was retired server-side. Distinct from Cancelled below, which means this local
// operation was aborted (the user closed the modal) and whoever aborted it owns the status.
Abandoned,
TimedOut,
Cancelled,
}
Expand Down Expand Up @@ -153,6 +156,9 @@ private async UniTaskVoid RunTopUpAsync(CreditPack pack, CancellationToken ct)
case PollOutcome.Failed:
SetStatus(CreditsTopUpStatus.GrantFailed(pack, orderId, order.error));
break;
case PollOutcome.Abandoned:
SetStatus(CreditsTopUpStatus.Abandoned(pack, orderId));
break;
}
}
catch (OperationCanceledException) { }
Expand Down Expand Up @@ -182,6 +188,8 @@ private async UniTaskVoid RunTopUpAsync(CreditPack pack, CancellationToken ct)
return (PollOutcome.Credited, result.Value);
case CreditsOrderStatusResponse.STATUS_FAILED:
return (PollOutcome.Failed, result.Value);
case CreditsOrderStatusResponse.STATUS_ABANDONED:
return (PollOutcome.Abandoned, result.Value);
}
}
else
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public enum CreditsTopUpStage
WaitingForPayment,
PendingTimeout,
Credited,
Abandoned,
Failed,
}

Expand Down Expand Up @@ -52,5 +53,12 @@ public static CreditsTopUpStatus CheckoutFailed(CreditPack pack, CreditsCheckout

public static CreditsTopUpStatus GrantFailed(CreditPack pack, string orderId, string? errorMessage) =>
new (CreditsTopUpStage.Failed, pack, orderId, errorMessage: errorMessage);

/// <summary>
/// The checkout was retired without a payment. Kept apart from Failed on purpose: nothing went
/// wrong and nobody was charged, so the UI should say "cancelled" rather than raise an error.
/// </summary>
public static CreditsTopUpStatus Abandoned(CreditPack pack, string orderId) =>
new (CreditsTopUpStage.Abandoned, pack, orderId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,12 @@ private void ApplyStatus(in CreditsTopUpStatus status)
viewInstance.SuccessPackImage.enabled = packSprite != null;
}

break;
case CreditsTopUpStage.Abandoned:
// Nobody was charged and nothing broke, so this must not read as an error. Retry is
// offered because starting again is exactly what the buyer is likely to want.
viewInstance.FailedReasonText.text = "Purchase cancelled — you were not charged.";
viewInstance.RetryButton.gameObject.SetActive(true);
break;
case CreditsTopUpStage.Failed:
(string reason, bool allowRetry) = MapFailureCopy(status);
Expand Down Expand Up @@ -356,6 +362,10 @@ private static ModalState MapStage(CreditsTopUpStage stage) =>
CreditsTopUpStage.WaitingForPayment => ModalState.WaitingForBrowser,
CreditsTopUpStage.PendingTimeout => ModalState.Pending,
CreditsTopUpStage.Credited => ModalState.Success,
// Reuses the Failed panel rather than adding a state the prefab has no view for. The
// STAGE stays distinct so nothing downstream has to treat a cancellation as an error;
// only the presentation is shared, and the copy below says what actually happened.
CreditsTopUpStage.Abandoned => ModalState.Failed,
CreditsTopUpStage.Failed => ModalState.Failed,
_ => ModalState.PackSelection,
};
Expand Down
Loading