Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions apps/frontend/src/components/billing/first.billing.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Comment on lines +92 to +105

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;
}

if (result.url) {
window.location.href = result.url;
return;
Expand All @@ -106,7 +113,7 @@ export const FirstBillingComponent = () => {
} finally {
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]);


const showYouTube = () => {
modals.openModal({
Expand Down
105 changes: 70 additions & 35 deletions apps/frontend/src/components/billing/main.billing.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

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;
    }

window.location.href = portal;
}, []);
const currentPackage = useMemo(() => {
Expand All @@ -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

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;
          }

setSubscription((subs) => ({
...subs!,
cancelAt: cancel_at,
Expand Down Expand Up @@ -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

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;
            }

setSubscription((subs) => ({
...subs!,
cancelAt: cancel_at,
Expand All @@ -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

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;
        }

if (blocked) {
setLoading(false);
await deleteDialog(
Expand Down Expand Up @@ -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(
Expand Down
Loading