Repository navigation
fix(billing): surface real billing errors, guard portal, gate buttons by plan #61
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,7 @@ import { | |
| FAQSection, | ||
| } from '@gitroom/frontend/components/billing/faq.component'; | ||
| import { useT } from '@gitroom/react/translation/get.transation.service.client'; | ||
| import { useToaster } from '@gitroom/react/toaster/toaster'; | ||
| import { useUser } from '@gitroom/frontend/components/layout/user.context'; | ||
| import { useDubClickId } from '@gitroom/frontend/components/layout/dubAnalytics'; | ||
| import SafeImage from '@gitroom/react/helpers/safe.image'; | ||
|
|
@@ -57,6 +58,7 @@ export const FirstBillingComponent = () => { | |
| const fetch = useFetch(); | ||
| const modals = useModals(); | ||
| const t = useT(); | ||
| const toaster = useToaster(); | ||
| const [datafast_visitor_id] = useCookie('datafast_visitor_id', ''); | ||
| const [datafast_session_id] = useCookie('datafast_session_id', ''); | ||
| const sharedDosBilling = !!user?.sharedDosBilling; | ||
|
|
@@ -87,15 +89,20 @@ export const FirstBillingComponent = () => { | |
| const startDosCheckout = useCallback(async () => { | ||
| setDosCheckoutLoading(true); | ||
| try { | ||
| const result = await ( | ||
| await fetch('/billing/subscribe', { | ||
| method: 'POST', | ||
| body: JSON.stringify({ | ||
| billing: tier, | ||
| period: 'MONTHLY', | ||
| }), | ||
| }) | ||
| ).json(); | ||
| 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; | ||
| } | ||
| if (result.url) { | ||
| window.location.href = result.url; | ||
| return; | ||
|
|
@@ -106,7 +113,7 @@ export const FirstBillingComponent = () => { | |
| } finally { | ||
| setDosCheckoutLoading(false); | ||
| } | ||
| }, [fetch, tier]); | ||
| }, [fetch, tier, toaster]); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| const showYouTube = () => { | ||
| modals.openModal({ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -264,7 +264,14 @@ export const MainBillingComponent: FC<{ | |
| setSubscription(sub); | ||
| }, [sub]); | ||
| const updatePayment = useCallback(async () => { | ||
| const { portal } = await (await fetch('/billing/portal')).json(); | ||
| // dos.me returns 409 no_stripe_subscription for accounts without a | ||
| // dos-managed plan - never navigate to an undefined portal. | ||
| 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; | ||
| } | ||
|
Comment on lines
+269
to
+274
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| window.location.href = portal; | ||
| }, []); | ||
| const currentPackage = useMemo(() => { | ||
|
|
@@ -281,22 +288,35 @@ export const MainBillingComponent: FC<{ | |
| } | ||
| return subscription?.subscriptionTier; | ||
| }, [subscription, initialChannels, monthlyOrYearly, period, sharedDosBilling]); | ||
| // dos.me owns checkout/portal/cancel: they only exist for a dos-managed | ||
| // paid plan, so hide both buttons for FREE and legacy tiers (e.g. | ||
| // comped ULTIMATE) where the calls can only answer 409. | ||
| const showPortalAndCancel = | ||
| !sharedDosBilling || | ||
| subscription?.subscriptionTier === 'STANDARD' || | ||
| subscription?.subscriptionTier === 'PRO'; | ||
| const moveToCheckout = useCallback( | ||
| (billing: 'STANDARD' | 'PRO' | 'FREE', reactivate = false) => | ||
| async () => { | ||
| if (reactivate) { | ||
| setLoading(true); | ||
| const { cancel_at } = await ( | ||
| await fetch('/billing/cancel', { | ||
| method: 'POST', | ||
| body: JSON.stringify({ | ||
| feedback: '', | ||
| }), | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| }, | ||
| }) | ||
| ).json(); | ||
| 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; | ||
| } | ||
|
Comment on lines
+303
to
+319
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the |
||
| setSubscription((subs) => ({ | ||
| ...subs!, | ||
| cancelAt: cancel_at, | ||
|
|
@@ -365,17 +385,23 @@ export const MainBillingComponent: FC<{ | |
| }); | ||
|
|
||
| setLoading(true); | ||
| const { cancel_at } = await ( | ||
| await fetch('/billing/cancel', { | ||
| method: 'POST', | ||
| body: JSON.stringify({ | ||
| feedback: info, | ||
| }), | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| }, | ||
| }) | ||
| ).json(); | ||
| 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; | ||
| } | ||
|
Comment on lines
+388
to
+404
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the |
||
| setSubscription((subs) => ({ | ||
| ...subs!, | ||
| cancelAt: cancel_at, | ||
|
|
@@ -393,17 +419,26 @@ export const MainBillingComponent: FC<{ | |
| return; | ||
| } | ||
| setLoading(true); | ||
| const { url, portal, blocked } = await ( | ||
| await fetch('/billing/subscribe', { | ||
| method: 'POST', | ||
| body: JSON.stringify({ | ||
| period: monthlyOrYearly === 'on' ? 'YEARLY' : 'MONTHLY', | ||
| utm, | ||
| billing, | ||
| ...(dub ? { dub } : {}), | ||
| }), | ||
| }) | ||
| ).json(); | ||
| 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; | ||
| } | ||
|
Comment on lines
+422
to
+441
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the |
||
| if (blocked) { | ||
| setLoading(false); | ||
| await deleteDialog( | ||
|
|
@@ -604,7 +639,7 @@ export const MainBillingComponent: FC<{ | |
| </div> | ||
| ))} | ||
| </div> | ||
| {!!subscription?.id && ( | ||
| {!!subscription?.id && showPortalAndCancel && ( | ||
| <div className="flex justify-center mt-[20px] gap-[10px]"> | ||
| <Button onClick={updatePayment}> | ||
| {t( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
fetchcall 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 thetfunction.