Skip to content

fix: stop waiting when a credits checkout is cancelled - #9767

Closed
juanmahidalgo wants to merge 1 commit into
devfrom
fix/credits-detect-cancelled-order
Closed

fix: stop waiting when a credits checkout is cancelled#9767
juanmahidalgo wants to merge 1 commit into
devfrom
fix/credits-detect-cancelled-order

Conversation

@juanmahidalgo

Copy link
Copy Markdown
Contributor

Closes #9737 (the client half).

What was wrong

CreditsOrderStatusResponse declared three statuses — processing, credited, failed — and the poll only breaks on the last two. The server has always been able to answer abandoned for a checkout retired without payment; its own comment says "a poll that saw it as 'processing' would wait forever".

So a cancelled purchase was indistinguishable from one still in flight, and the modal span for the full 60s foreground + 10min background window before giving up.

The change

  • STATUS_ABANDONED added to the wire contract.
  • A PollOutcome.Abandoned, kept separate from the existing Cancelled — that one means this local operation was aborted (the user closed the modal) and carries the rule that "whoever cancelled owns the status". Reusing it would have silently swallowed the new case.
  • A CreditsTopUpStage.Abandoned with its own factory, kept apart from Failed: nobody was charged and nothing broke, so it must not be reported as an error.
  • The modal renders it through the existing Failed panel — no new prefab state — with cancelled copy and Retry enabled: "Purchase cancelled — you were not charged."

Needs the server change to be useful

This reads a status that, today, nothing sets in time: the order only becomes abandoned when the timed sweep retires it ~26h later. decentraland/credits-server#587 makes Stripe's cancel_url record it immediately, so clicking back flips the status within one poll (~1.5s).

Merging this alone is harmless but changes nothing observable.

Deliberately not done

  • No auto-cancel on timeout. Cancelling expires the Stripe session, so a client that gave up after 60s while the buyer was still typing their card would destroy a live purchase.
  • No analytics event. BuyCreditsFailed would file a cancellation as a failure and skew the funnel; a cancelled step deserves its own event, which is a separate decision.
  • Closing the tab still is not detected — no click, no redirect, no webhook. Nothing can observe that instantly.

Test plan

@juanmahidalgo
juanmahidalgo requested review from a team as code owners August 17, 2026 10:07
@github-actions
github-actions Bot requested a review from DafGreco August 17, 2026 10:07
@decentraland-bot decentraland-bot added the ext-contribution Identifies a contribution which was not initiated by a Unity Developer label Aug 17, 2026
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

Build skipped — no changes detected under Explorer/.

Lint

Warnings not reduced: 13156 => 13165 — remove at least 10 warnings to merge.

Warnings/errors in files changed by this PR (4)
Assets/DCL/MarketplaceCredits/MarketplaceCreditsAPIService/CreditsOrderStatusResponse.cs:21  InconsistentNaming  Name 'creditsGranted' does not match rule 'members_should_be_pascal_case'. Suggested name is 'CreditsGranted'.
Assets/DCL/MarketplaceCredits/MarketplaceCreditsAPIService/CreditsOrderStatusResponse.cs:26  InconsistentNaming  Name 'error' does not match rule 'members_should_be_pascal_case'. Suggested name is 'Error'.
Assets/DCL/MarketplaceCredits/MarketplaceCreditsAPIService/CreditsOrderStatusResponse.cs:24  InconsistentNaming  Name 'newBalance' does not match rule 'members_should_be_pascal_case'. Suggested name is 'NewBalance'.
Assets/DCL/MarketplaceCredits/MarketplaceCreditsAPIService/CreditsOrderStatusResponse.cs:18  InconsistentNaming  Name 'status' does not match rule 'members_should_be_pascal_case'. Suggested name is 'Status'.

Tests

⚠️ EditMode produced no results — the run likely crashed or timed out before finishing. Check the Unity Test / Test (editmode) job.

TESTS SUITE Result Passed Failed Skipped
EditMode ⚠️ No results
PlayMode ✅ Passed 236 0 37

@github-actions

Copy link
Copy Markdown
Contributor

Slack notification sent to #explorer-ext-contributions for external review.
To re-send, delete this comment and re-add the ext-contribution label.

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review — fix: stop waiting when a credits checkout is cancelled

STEP 2 — Root-cause check

PASS. The PR correctly identifies the root cause: the polling loop in PollOrderAsync only broke on credited or failed, so a server-side abandoned status (checkout expired or buyer clicked back on Stripe) was indistinguishable from processing. The poll would spin for the entire 60s foreground + 10min background window before giving up. Adding STATUS_ABANDONED to the wire contract and mapping it through the state machine is the right fix.

STEP 3 — Design & integration

PASS. The design decisions are well-justified:

  • Abandoned vs Failed: correct separation. Abandoned means nobody was charged and nothing broke; Failed means something went wrong (e.g. card declined). Different semantics → different stage → different UI copy. The factory correctly sets no error and skips the balance re-read.
  • Abandoned vs Cancelled: correct separation. Cancelled means the local user closed the modal (CancelTopUp owns the state transition); Abandoned means the server retired the checkout session. Reusing Cancelled would have conflated server-side status with client-side intent.
  • Reusing ModalState.Failed panel: pragmatic. Avoids a prefab change for what amounts to different copy in the same layout. The STAGE stays distinct downstream.

No new long-lived units, systems, or controllers are introduced. The changes extend existing types with a new enum value and its handling — no lifecycle or owner-search concerns.

STEP 4 — Member audit

New members introduced:

  • STATUS_ABANDONED (const) — consumed by PollOrderAsync switch. ✅
  • PollOutcome.Abandoned (enum) — consumed by RunTopUpAsync switch. ✅
  • CreditsTopUpStage.Abandoned (enum) — consumed by MapStage, ApplyStatus. ✅
  • CreditsTopUpStatus.Abandoned(pack, orderId) (factory) — consumed by RunTopUpAsync. ✅

All new members have exactly the consumers they need. No single-use or redundant predicates.

STEP 5 — Line-level review

[P1] Bug: AcknowledgeTerminalState() does not handle the new Abandoned stage

📍 CreditsTopUpService.cs line 98

Abandoned is a terminal state (the checkout is permanently retired — no payment can ever arrive). But AcknowledgeTerminalState() only resets for Credited or Failed:

// Current (line 98):
if (CurrentStatus.Stage is CreditsTopUpStage.Credited or CreditsTopUpStage.Failed)

So the service gets stuck in Abandoned forever.

Consequences:

  1. Retry breaks. The modal maps AbandonedModalState.Failed, so OnRetryClicked passes its guard and calls AcknowledgeTerminalState() — which silently does nothing. The UI stays on the Failed panel; the user is stuck.
  2. Close-and-reopen breaks. OnViewClose calls AcknowledgeTerminalState() for ModalState.Failed, which again does nothing. Next open re-shows the Abandoned panel.

Fix:

if (CurrentStatus.Stage is CreditsTopUpStage.Credited or CreditsTopUpStage.Failed or CreditsTopUpStage.Abandoned)

The existing ResetTerminalStateToIdleOnAcknowledge test covers Failed; a parallel test for Abandoned should be added to lock this down.


No other issues found. No security vulnerabilities, no resource leaks, no nullability violations, no naming or style issues. The test is well-structured (AAA pattern, NUnit + NSubstitute), and the comments are informative without being excessive.

STEP 6 — Complexity

SIMPLE — 5 files, +54 −0 lines, pure additions of a new enum value and its handling. No ECS, async, or architectural changes.

STEP 7 — QA

QA_REQUIRED: YES — changes affect runtime UI behavior (modal state machine, user-visible copy).

STEP 8 — Non-blocking warnings

None. Main scene not modified.

Security review

No security issues found. The abandoned status string comes from the existing authenticated API. No injection vectors (compared against a const, not interpolated). No auth/authz changes. No sensitive data exposure.

REVIEW_RESULT: FAIL ❌
COMPLEXITY: SIMPLE
COMPLEXITY_REASON: Pure addition of a new status enum value through the existing credits top-up state machine, no structural or architectural changes.
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by unknown via Slack

@decentraland-bot

Copy link
Copy Markdown
Contributor

PR #9767, run #32021753857

Builds: Windows change, Windows baseline, macOS change, macOS baseline

How to read this table
  • Each build is measured 3 times. The values are the median, and (min–max) is the lowest and highest of those runs — a wide range means the metric is noisy and small differences are not trustworthy.
  • Δ is Change minus Baseline (a negative Δ means Change is faster).
  • 🟢 faster / 🔴 slower — a real difference: larger than both 3% and the run-to-run range.
  • ⚪ within noise — the difference is smaller than how much the build varies between its own runs, so it cannot be told apart from random variation. Treat it as no change.
  • Exceptions per run — the average number of exceptions in a run's log; more than the baseline is flagged 🔴 even when frame times look fine. The Exception breakdown under each table groups them by the explorer's report category and exception type (as totals across the runs).
  • A run that logged unusually many exceptions (at least 10 and 5× the median of its build's runs — e.g. a service was down during it) is excluded from all numbers and called out under the table.

Intel Core i5

Metric Baseline Change Δ Result
Samples 2701 (×3) 2318 (×3)
CPU average 33.2 ms (33.2–34.6) 38.4 ms (36.1–38.8) 5.2 ms 🔴 16% slower
CPU 1% worst 34.3 ms (33.5–184.5) 336.0 ms (317.8–338.0) 301.7 ms 🔴 881% slower
CPU 0.1% worst 41.4 ms (33.7–332.0) 360.2 ms (334.3–406.1) 318.7 ms 🔴 770% slower
GPU average 9.3 ms (9.2–9.4) 9.3 ms (9.1–9.6) 0.1 ms ⚪ within noise
GPU 1% worst 20.7 ms (19.8–26.9) 34.6 ms (33.9–37.6) 13.9 ms 🔴 67% slower
GPU 0.1% worst 36.3 ms (31.6–37.7) 44.5 ms (40.6–48.9) 8.2 ms ⚪ within noise
Exceptions per run 66 66 0 ⚪ none new
Exception breakdown
Exception Baseline (3 runs) Change (3 runs)
[UI] DllNotFoundException 192 192
[ENGINE] NullReferenceException 3 3
[ENGINE] ObjectDisposedException 3 3

Apple M1

Metric Baseline Change Δ Result
Samples 4368 (×3) 4041 (×3)
CPU average 20.5 ms (20.3–21.6) 22.1 ms (21.4–23.3) 1.6 ms ⚪ within noise
CPU 1% worst 34.7 ms (33.9–34.7) 231.8 ms (180.5–233.1) 197.1 ms 🔴 568% slower
CPU 0.1% worst 34.9 ms (34.9–35.3) 241.8 ms (229.0–243.8) 206.9 ms 🔴 592% slower
GPU average 1.0 ms (0.1–1.6) 7.1 ms (2.5–9.5) 6.1 ms ⚪ within noise
GPU 1% worst 34.2 ms (7.7–34.8) 35.0 ms (34.3–36.9) 0.8 ms ⚪ within noise
GPU 0.1% worst 35.9 ms (35.1–37.2) 36.1 ms (35.9–37.9) 0.2 ms ⚪ within noise
Exceptions per run 0 0 0 ⚪ none new

@davidejensen

Copy link
Copy Markdown
Member

Closing in favor of #9768

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ext-contribution Identifies a contribution which was not initiated by a Unity Developer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Credits top-up: Explorer stays stuck on checkout spinner when browser tab is closed or purchase is cancelled

3 participants