fix(billing): surface real billing errors, guard portal, gate buttons by plan - #61
Conversation
… 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
There was a problem hiding this comment.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
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/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; | |
| } |
| 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; | ||
| } |
There was a problem hiding this comment.
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;
}
| 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; | ||
| } |
There was a problem hiding this comment.
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;
}
| 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; | ||
| } |
There was a problem hiding this comment.
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;
}
| 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; | ||
| } |
There was a problem hiding this comment.
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]); |
There was a problem hiding this comment.
The 3 cataloged billing UI debt items (UAT 2026-09-20 findings, see shared-dos-billing notes):
/billing/subscribeand/billing/cancelresponses are now gated onresp.ok. Before: any 4xx body ({statusCode, message}) had nourl/portalfields 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.updatePaymentnavigates towindow.location.href = portal- undefined when dos.me answers 409 no_stripe_subscription. Now: shows the error message, no navigation.Also gives
first.billing.component(DOS view, new-layout) the sameresp.okgate + 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
🔍 Changes Walkthrough
apps/frontend/src/components/billing/first.billing.component.tsxuseToasterhook. RefactoredstartDosCheckoutto checkresponse.okbefore 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.tsxupdatePayment,moveToCheckout(reactivate & cancel), and subscription updates to checkresponse.ok. Added toast notifications for failures (e.g., 409 conflicts, missing Stripe subscriptions).2. UI Logic: Introduced
showPortalAndCancelflag 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