Skip to content

fix(billing): surface real billing errors, guard portal, gate buttons by plan - #61

Merged
JOY (JOY) merged 1 commit into
devfrom
feat/billing-ui-debt
Sep 25, 2026
Merged

JOY (JOY) merged 1 commit into
devfrom
feat/billing-ui-debt

Conversation

@JOY

@JOY JOY (JOY) commented Sep 25, 2026 •

Copy link
Copy Markdown

The 3 cataloged billing UI debt items (UAT 2026-09-20 findings, see shared-dos-billing notes):

  1. Fake success toast: /billing/subscribe and /billing/cancel responses are now gated on resp.ok. Before: any 4xx body ({statusCode, message}) had no url/portal fields and fell into the success branch - toast 'Subscription updated successfully' + optimistic tier mutation + 'Current Plan' badge, all false. Now: the real error message shows and the tier is untouched. Same gate added to the reactivate call.
  2. Portal /undefined: updatePayment navigates to window.location.href = portal - undefined when dos.me answers 409 no_stripe_subscription. Now: shows the error message, no navigation.
  3. Portal/Cancel for free users: under shared dos billing both buttons are hidden unless the tier is a dos-managed plan (STANDARD/PRO). FREE and legacy comped tiers (ULTIMATE) can only 409 on those endpoints, so the buttons were noise.

Also gives first.billing.component (DOS view, new-layout) the same resp.ok gate + error toast instead of silent failure.

Lint-neutral vs dev baseline (the pre-existing react-compiler 'Compilation Skipped' findings in these files are unchanged; diffed the full finding sets).


📌 TL;DR

This PR enhances the billing components by implementing robust error handling for API responses (specifically for subscription, portal, and cancellation actions) and conditionally hides billing management buttons for users without a valid dos.me-managed plan.

🎯 Type of Change

  • 🚀 New feature
  • 🐛 Bugfix
  • 🧹 Refactor
  • ⚡ Performance
  • 📚 Documentation
  • ⚙️ CI / Configuration

🔍 Changes Walkthrough

File Summary of Changes
apps/frontend/src/components/billing/first.billing.component.tsx Added useToaster hook. Refactored startDosCheckout to check response.ok before processing the JSON payload. If the request fails, it displays a toast with the server-provided error message or a generic fallback, preventing undefined behavior.
apps/frontend/src/components/billing/main.billing.component.tsx 1. Error Handling: Refactored updatePayment, moveToCheckout (reactivate & cancel), and subscription updates to check response.ok. Added toast notifications for failures (e.g., 409 conflicts, missing Stripe subscriptions).
2. UI Logic: Introduced showPortalAndCancel flag to hide "Update Payment" and "Cancel" buttons for FREE or legacy tiers (e.g., comped ULTIMATE) where dos.me does not manage the billing, preventing users from triggering invalid API calls.

📊 Architectural Flow

sequenceDiagram
    participant User
    participant Frontend as Billing Component
    participant API as Billing API
    participant Toaster as Toaster Service

    User->>Frontend: Trigger Billing Action (Subscribe/Cancel/Portal)
    Frontend->>API: POST/GET Request
    API-->>Frontend: Response (Status + JSON)
    
    alt Response is OK (2xx)
        Frontend->>Frontend: Process Success (Redirect/Update State)
    else Response is Error (4xx/5xx)
        Frontend->>Toaster: Show Error Message (from response or fallback)
        Toaster-->>User: Display Toast Notification
        Frontend->>Frontend: Reset Loading State
    end
Loading

… by plan

- /billing/subscribe and /billing/cancel responses are now checked with
  resp.ok: a 4xx (no dos.me plan row, stripe error, no_stripe_subscription)
  used to fall through to the success branch, toast 'Subscription updated
  successfully' and mutate the tier optimistically (fake success)
- Update Payment no longer navigates to /undefined when the portal call
  fails: it shows the error message instead
- Update Payment and Cancel subscription buttons are hidden under shared
  dos billing for FREE and legacy tiers (ULTIMATE) where the endpoints can
  only answer 409 no_stripe_subscription
- first.billing checkout (DOS view) gets the same resp.ok gate with an
  error toast instead of failing silently

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request refactors the billing components to improve error handling for subscription, cancellation, and portal retrieval API requests by checking response status and catching JSON parsing errors. The review feedback highlights critical improvements, including handling potential network errors on fetch calls to prevent unhandled promise rejections and permanent loading states, internationalizing hardcoded error messages, and adding the missing translation function to a useCallback dependency array.

Comment on lines +92 to +105
const response = await fetch('/billing/subscribe', {
method: 'POST',
body: JSON.stringify({
billing: tier,
period: 'MONTHLY',
}),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
toaster.show(
result.message || 'Subscription update failed, please try again later'
);
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.

high

The fetch call can throw a network error, which would result in an unhandled promise rejection. Additionally, the fallback error message is hardcoded and should be internationalized using the t function.

Suggested change
const response = await fetch('/billing/subscribe', {
method: 'POST',
body: JSON.stringify({
billing: tier,
period: 'MONTHLY',
}),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
toaster.show(
result.message || 'Subscription update failed, please try again later'
);
return;
}
const response = await fetch('/billing/subscribe', {
method: 'POST',
body: JSON.stringify({
billing: tier,
period: 'MONTHLY',
}),
}).catch(() => null);
if (!response) {
toaster.show(t('something_went_wrong', 'An unexpected error occurred. Please try again.'));
return;
}
const result = await response.json().catch(() => ({}));
if (!response.ok) {
toaster.show(
result.message || t('billing_subscription_update_failed', 'Subscription update failed, please try again later')
);
return;
}

Comment on lines +269 to +274
const response = await fetch('/billing/portal');
const { portal, message } = await response.json().catch(() => ({}));
if (!response.ok || !portal) {
toast.show(message || 'Payment portal is not available for this account');
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.

high

The fetch call can throw a network error, which would result in an unhandled promise rejection. Additionally, the fallback error message is hardcoded and should be internationalized using the t function.

    const response = await fetch('/billing/portal').catch(() => null);
    if (!response) {
      toast.show(t('something_went_wrong', 'An unexpected error occurred. Please try again.'));
      return;
    }
    const { portal, message } = await response.json().catch(() => ({}));
    if (!response.ok || !portal) {
      toast.show(message || t('payment_portal_not_available', 'Payment portal is not available for this account'));
      return;
    }

Comment on lines +303 to +319
const response = await fetch('/billing/cancel', {
method: 'POST',
body: JSON.stringify({
feedback: '',
}),
headers: {
'Content-Type': 'application/json',
},
});
const { cancel_at, message } = await response
.json()
.catch(() => ({}));
if (!response.ok) {
setLoading(false);
toast.show(message || 'Could not reactivate the subscription');
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.

high

If the fetch call fails due to a network error, the promise will reject, leaving the UI in a permanent loading state since setLoading(false) is only called in the !response.ok branch. Additionally, the fallback error message should be internationalized.

          const response = await fetch('/billing/cancel', {
            method: 'POST',
            body: JSON.stringify({
              feedback: '',
            }),
            headers: {
              'Content-Type': 'application/json',
            },
          }).catch(() => null);
          if (!response) {
            setLoading(false);
            toast.show(t('something_went_wrong', 'An unexpected error occurred. Please try again.'));
            return;
          }
          const { cancel_at, message } = await response
            .json()
            .catch(() => ({}));
          if (!response.ok) {
            setLoading(false);
            toast.show(message || t('could_not_reactivate_subscription', 'Could not reactivate the subscription'));
            return;
          }

Comment on lines +388 to +404
const response = await fetch('/billing/cancel', {
method: 'POST',
body: JSON.stringify({
feedback: info,
}),
headers: {
'Content-Type': 'application/json',
},
});
const { cancel_at, message } = await response
.json()
.catch(() => ({}));
if (!response.ok) {
setLoading(false);
toast.show(message || 'Could not cancel the subscription');
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.

high

If the fetch call fails due to a network error, the promise will reject, leaving the UI in a permanent loading state since setLoading(false) is only called in the !response.ok branch. Additionally, the fallback error message should be internationalized.

            const response = await fetch('/billing/cancel', {
              method: 'POST',
              body: JSON.stringify({
                feedback: info,
              }),
              headers: {
                'Content-Type': 'application/json',
              },
            }).catch(() => null);
            if (!response) {
              setLoading(false);
              toast.show(t('something_went_wrong', 'An unexpected error occurred. Please try again.'));
              return;
            }
            const { cancel_at, message } = await response
              .json()
              .catch(() => ({}));
            if (!response.ok) {
              setLoading(false);
              toast.show(message || t('could_not_cancel_subscription', 'Could not cancel the subscription'));
              return;
            }

Comment on lines +422 to +441
const response = await fetch('/billing/subscribe', {
method: 'POST',
body: JSON.stringify({
period: monthlyOrYearly === 'on' ? 'YEARLY' : 'MONTHLY',
utm,
billing,
...(dub ? { dub } : {}),
}),
});
const { url, portal, blocked, message } = await response
.json()
.catch(() => ({}));
if (!response.ok) {
// A 4xx (no dos.me plan row, stripe error, ...) used to fall through
// to the success branch and toast "Subscription updated" + mutate
// the tier optimistically - surface the real failure instead.
setLoading(false);
toast.show(message || 'Subscription update failed, please try again later');
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.

high

If the fetch call fails due to a network error, the promise will reject, leaving the UI in a permanent loading state since setLoading(false) is only called in the !response.ok branch. Additionally, the fallback error message should be internationalized.

        const response = await fetch('/billing/subscribe', {
          method: 'POST',
          body: JSON.stringify({
            period: monthlyOrYearly === 'on' ? 'YEARLY' : 'MONTHLY',
            utm,
            billing,
            ...(dub ? { dub } : {}),
          }),
        }).catch(() => null);
        if (!response) {
          setLoading(false);
          toast.show(t('something_went_wrong', 'An unexpected error occurred. Please try again.'));
          return;
        }
        const { url, portal, blocked, message } = await response
          .json()
          .catch(() => ({}));
        if (!response.ok) {
          // A 4xx (no dos.me plan row, stripe error, ...) used to fall through
          // to the success branch and toast "Subscription updated" + mutate
          // the tier optimistically - surface the real failure instead.
          setLoading(false);
          toast.show(message || t('subscription_update_failed', 'Subscription update failed, please try again later'));
          return;
        }

setDosCheckoutLoading(false);
}
}, [fetch, tier]);
}, [fetch, tier, toaster]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Since the t translation function is used inside startDosCheckout, it should be included in the dependency array of useCallback to ensure correctness and satisfy React Hook dependency rules.

Suggested change
}, [fetch, tier, toaster]);
}, [fetch, tier, toaster, t]);

@JOY
JOY (JOY) merged commit f400474 into dev Sep 25, 2026
11 checks passed
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