Skip to content

fix: billing page confirms real paid state after checkout (no stale success banner)#2398

Merged
vipulnsward merged 2 commits into
developfrom
fix/billing-post-checkout-polling
Jul 23, 2026
Merged

fix: billing page confirms real paid state after checkout (no stale success banner)#2398
vipulnsward merged 2 commits into
developfrom
fix/billing-post-checkout-polling

Conversation

@vipulnsward

@vipulnsward vipulnsward commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Conversion-funnel audit, plan 020 (see plans/). Depends on #2397 (merged).

Problem

Stripe redirects the buyer back to /settings/billing?billing=success the instant checkout completes — usually before the checkout.session.completed webhook is processed. The page fetched its summary once on mount and unconditionally showed a green "subscription updated" banner, while the plan card still read "Pro Trial"/"No plan" and both "Upgrade with Stripe" buttons stayed visible — inviting a second purchase (the double-charge #2397 guards server-side). It only self-healed on manual refresh or the daily 01:00 reconcile job.

Fix

On ?billing=success, poll the subscription summary every 2s up to ~20s:

  • Show a "Finalizing your subscription…" state and hide both upgrade buttons until plan_tier flips to paid.
  • When paid → the real success banner.
  • On timeout with a loaded summary → a softer "Payment received — your Pro access will appear shortly" message.
  • On all-poll-failure (no summary ever loaded) → the error state ("Unable to load billing details"), not an endless skeleton.
  • Strip the ?billing=success query param on read so a refresh doesn't re-run the cycle.
  • Poll timer cleared on unmount; every setState guarded against setState-after-unmount.

Verification

Browser-verified end to end with Playwright (zero console errors):

  • Finalizing: "Finalizing…" shows, both upgrade buttons hidden.
  • Paid: flipping the company to paid mid-poll shows the real success banner, upgrade button hidden. Screenshot captured.
  • Delayed: after the poll cap, the payment-received fallback appears.
  • Refresh: URL is stripped to /settings/billing; a reload shows the normal page, no re-trigger.
  • bin/vite build clean, ESLint clean.

Two defects were caught only because I verified in the browser + adversarial review, and fixed in this PR: a second unguarded upgrade button (the plan-table CTA), and a stuck-loading-skeleton when every poll fails during the webhook window. Dual-model adversarial review: CONFIRMED clean.

Deferred (documented in plan 020)

The attempt cap only advances when the request settles; the repo's axios client has no default timeout, so a hung first request is a pre-existing low-probability edge — noted as a follow-up, not blocking (per review).

Summary by CodeRabbit

  • New Features

    • Added a subscription finalization state after successful billing.
    • The billing page now checks for plan activation and updates the subscription status automatically.
    • Added messaging for delayed Pro access when payment has been received.
  • Bug Fixes

    • Improved post-checkout handling to prevent premature billing status updates.
    • Upgrade actions are temporarily hidden while subscription changes are being finalized.

…banner

Stripe redirects the buyer back to /settings/billing?billing=success
before the checkout.session.completed webhook lands, so the page showed
a green subscription-updated banner while the plan still read Pro Trial
and both Upgrade with Stripe buttons stayed visible (double-charge
invite). On ?billing=success, poll the subscription summary every 2s up
to ~20s: show a Finalizing your subscription state and hide both upgrade
buttons until plan_tier flips to paid, then show the real success
banner. On timeout or poll error, fall back to a softer payment-received
message. The poll timer is cleared on unmount and guarded against
setState-after-unmount.
…lling never loads

Adversarial review: if every poll of the subscription summary failed
(the endpoint erroring during the post-checkout webhook window), status
stayed LOADING forever, leaving the membership card on a skeleton while
the banner claimed payment received. Track whether any poll loaded a
summary; on the cap, show the delayed banner only when a summary did
load, otherwise set the error state so the card renders the
unable-to-load message. Also strip the ?billing=success query param on
read so a refresh no longer re-runs the whole polling cycle.
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The billing page now polls subscription status after checkout success, tracks finalization and delayed outcomes, updates success alerts, hides checkout CTAs during finalization, and adds English messages for finalizing and delayed subscription states.

Changes

Billing subscription finalization

Layer / File(s) Summary
Post-checkout subscription polling
app/javascript/src/components/Profile/Organization/Billing/index.tsx
The billing page clears the billing query, polls subscription details after successful checkout, updates the summary while waiting for a paid plan, and handles delayed or error outcomes.
Finalization alerts and checkout gating
app/javascript/src/components/Profile/Organization/Billing/index.tsx, app/javascript/src/i18n/locales/en.ts
Success alerts use finalization-specific text, descriptions are suppressed during finalization, Stripe checkout buttons are hidden, and new English alert messages are added.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BillingPage
  participant BrowserHistory
  participant subscriptionsApi
  BillingPage->>BrowserHistory: Read and clear billing query
  BillingPage->>subscriptionsApi: Poll subscription details
  subscriptionsApi-->>BillingPage: Return plan summary and status
  BillingPage->>BillingPage: Update finalizing or billing result state
Loading

Suggested reviewers: amolmjoshi93

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: verifying the real paid state after checkout and avoiding a stale success banner.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/billing-post-checkout-polling

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
app/javascript/src/components/Profile/Organization/Billing/index.tsx (2)

162-195: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate fetch/summary logic between pollSummary and fetchSummary.

pollSummary re-implements the fetch + setSummary + setSeatEstimate (with the same used_team_seats || 3 fallback) already in fetchSummary (Lines 73-83). Extracting a shared helper would avoid the two copies drifting out of sync.

♻️ Proposed refactor
+  const loadBillingSummary = async () => {
+    const response = await subscriptionsApi.show();
+    setSummary(response.data);
+    setSeatEstimate(Math.max(response.data.used_team_seats || 3, 3));
+    return response.data;
+  };
+
   const fetchSummary = async () => {
     try {
       setStatus(ApiStatus.LOADING);
-      const response = await subscriptionsApi.show();
-      setSummary(response.data);
-      setSeatEstimate(Math.max(response.data.used_team_seats || 3, 3));
+      await loadBillingSummary();
       setStatus(ApiStatus.SUCCESS);
     } catch {
       setStatus(ApiStatus.ERROR);
     }
   };

And inside pollSummary, replace the fetch/setSummary/setSeatEstimate lines with const data = await loadBillingSummary(); (then use data.plan_tier).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/javascript/src/components/Profile/Organization/Billing/index.tsx` around
lines 162 - 195, Extract the shared subscription fetch, summary state update,
and seat-estimate fallback from fetchSummary and pollSummary into a
loadBillingSummary helper. Update both callers to await this helper, use its
returned data for plan_tier checks, and preserve the existing used_team_seats ||
3 behavior.

139-203: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

No test coverage for the polling/finalization flow.

This is a timer-driven, multi-branch (paid/delayed/error/unmount-guarded) effect on a critical billing path, but no accompanying test changes are included in this PR's file set.

Want me to draft tests (using fake timers) covering the paid, delayed, and all-attempts-failed branches?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/javascript/src/components/Profile/Organization/Billing/index.tsx` around
lines 139 - 203, Add tests for the billing useEffect polling flow covering
paid-plan completion, delayed completion after exhausting attempts with a loaded
summary, and all-attempts-failed error handling. Use fake timers to advance the
2-second polling schedule and verify finalization, status, billing-result
updates, API calls, and unmount protection around pollSummary.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/javascript/src/components/Profile/Organization/Billing/index.tsx`:
- Around line 183-192: Update the polling exhaustion handling around the
attempts threshold and the success-banner condition near the billing result
alert so a failed run that never loads a summary cannot retain or display the
`"success"` billing result. Ensure the error path sets a non-success result
while preserving `"delayed"` when a summary was loaded, and make the top alert
render success only for an actually successful status.

---

Nitpick comments:
In `@app/javascript/src/components/Profile/Organization/Billing/index.tsx`:
- Around line 162-195: Extract the shared subscription fetch, summary state
update, and seat-estimate fallback from fetchSummary and pollSummary into a
loadBillingSummary helper. Update both callers to await this helper, use its
returned data for plan_tier checks, and preserve the existing used_team_seats ||
3 behavior.
- Around line 139-203: Add tests for the billing useEffect polling flow covering
paid-plan completion, delayed completion after exhausting attempts with a loaded
summary, and all-attempts-failed error handling. Use fake timers to advance the
2-second polling schedule and verify finalization, status, billing-result
updates, API calls, and unmount protection around pollSummary.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 05c7a845-827d-4e79-8eb3-d3687626a746

📥 Commits

Reviewing files that changed from the base of the PR and between 971957a and 26e6093.

📒 Files selected for processing (2)
  • app/javascript/src/components/Profile/Organization/Billing/index.tsx
  • app/javascript/src/i18n/locales/en.ts

Comment on lines +183 to +192
if (attempts >= 10) {
setFinalizing(false);
if (loadedSummary) {
setBillingResult("delayed");
} else {
setStatus(ApiStatus.ERROR);
}

return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Misleading "success" banner still shows when polling fully fails.

When all 10 attempts fail without ever loading a summary, setStatus(ApiStatus.ERROR) is called but billingResult is left as "success". Since the top alert at Line 392 only checks billingResult === "success", it will render "Subscription updated / Your plan was updated in Stripe successfully" at the same time the card below shows "Unable to load billing details" (Line 442-451). This directly contradicts the PR's goal of accurate post-checkout messaging — the user is told the update succeeded and failed simultaneously.

🐛 Proposed fix
       if (attempts >= 10) {
         setFinalizing(false);
         if (loadedSummary) {
           setBillingResult("delayed");
         } else {
+          setBillingResult(null);
           setStatus(ApiStatus.ERROR);
         }
 
         return;
       }

Also applies to: 395-407

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/javascript/src/components/Profile/Organization/Billing/index.tsx` around
lines 183 - 192, Update the polling exhaustion handling around the attempts
threshold and the success-banner condition near the billing result alert so a
failed run that never loads a summary cannot retain or display the `"success"`
billing result. Ensure the error path sets a non-success result while preserving
`"delayed"` when a summary was loaded, and make the top alert render success
only for an actually successful status.

@vipulnsward
vipulnsward merged commit c1f6c4e into develop Jul 23, 2026
12 checks passed
@vipulnsward
vipulnsward deleted the fix/billing-post-checkout-polling branch July 23, 2026 17:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant