Files
overleaf-cep/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx
T
Kristina 9ba772b18f [web] handle 3DS challenges for Stripe (#25918)
* handle 3DS challenges on the subscription dashboard
* add `/user/subscription/sync` endpoint
* upgrade `stripe-js` & rm `react-stripe-js`
* group related unit tests together
* add modules `SubscriptionController` unit tests and convert to async/await
* add `StripeClient` unit tests for 3DS failure

GitOrigin-RevId: 9da4758703f6ef4ec08248b328abddbbdd8e44ad
2025-06-02 08:05:16 +00:00

306 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect } from 'react'
import moment from 'moment'
import { useTranslation, Trans } from 'react-i18next'
import {
SubscriptionChangePreview,
AddOnPurchase,
PremiumSubscriptionChange,
} from '../../../../../../types/subscription/subscription-change-preview'
import getMeta from '@/utils/meta'
import { formatCurrency } from '@/shared/utils/currency'
import useAsync from '@/shared/hooks/use-async'
import { useLocation } from '@/shared/hooks/use-location'
import { debugConsole } from '@/utils/debugging'
import { FetchError, postJSON } from '@/infrastructure/fetch-json'
import Notification from '@/shared/components/notification'
import OLCard from '@/features/ui/components/ol/ol-card'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLButton from '@/features/ui/components/ol/ol-button'
import { subscriptionUpdateUrl } from '@/features/subscription/data/subscription-url'
import * as eventTracking from '@/infrastructure/event-tracking'
import sparkleText from '@/shared/svgs/ai-sparkle-text.svg'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import handleStripePaymentAction from '../../util/handle-stripe-payment-action'
function PreviewSubscriptionChange() {
const preview = getMeta(
'ol-subscriptionChangePreview'
) as SubscriptionChangePreview
const purchaseReferrer = getMeta('ol-purchaseReferrer')
const { t } = useTranslation()
const payNowTask = useAsync()
const location = useLocation()
const aiAssistEnabled = useFeatureFlag('overleaf-assist-bundle')
useEffect(() => {
if (preview.change.type === 'add-on-purchase') {
eventTracking.sendMB('preview-subscription-change-view', {
plan: preview.change.addOn.code,
upgradeType: 'add-on',
referrer: purchaseReferrer,
})
}
}, [preview.change, purchaseReferrer])
const handlePayNowClick = useCallback(() => {
if (preview.change.type === 'add-on-purchase') {
eventTracking.sendMB('subscription-change-form-submit', {
plan: preview.change.addOn.code,
upgradeType: 'add-on',
referrer: purchaseReferrer,
})
}
eventTracking.sendMB('assistant-add-on-purchase')
payNowTask
.runAsync(payNow(preview))
.then(() => {
if (preview.change.type === 'add-on-purchase') {
eventTracking.sendMB('subscription-change-form-success', {
plan: preview.change.addOn.code,
upgradeType: 'add-on',
referrer: purchaseReferrer,
})
}
location.replace('/user/subscription/thank-you')
})
.catch(debugConsole.error)
}, [purchaseReferrer, location, payNowTask, preview])
const aiAddOnChange =
preview.change.type === 'add-on-purchase' &&
preview.change.addOn.code === 'assistant'
// the driver of the change, which we can display as the immediate charge
const changeName =
preview.change.type === 'add-on-purchase'
? (preview.change as AddOnPurchase).addOn.name
: (preview.change as PremiumSubscriptionChange).plan.name
return (
<div className="container">
<OLRow>
<OLCol md={{ offset: 2, span: 8 }}>
<OLCard className="p-3">
{preview.change.type === 'add-on-purchase' ? (
<h1>
{t('add_add_on_to_your_plan', {
addOnName: preview.change.addOn.name,
})}
</h1>
) : preview.change.type === 'premium-subscription' ? (
<h1>
{t('subscribe_to_plan', { planName: preview.change.plan.name })}
</h1>
) : null}
{payNowTask.isError && (
<Notification
type="error"
aria-live="polite"
content={
<>
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
{t('generic_if_problem_continues_contact_us')}.
</>
}
/>
)}
{aiAddOnChange && (
<div>
{aiAssistEnabled ? (
<Trans
i18nKey="add_ai_assist_to_your_plan"
components={{
sparkle: (
<img
alt="sparkle"
className="ai-error-assistant-sparkle"
src={sparkleText}
aria-hidden="true"
key="sparkle"
/>
),
}}
/>
) : (
<Trans
i18nKey="add_error_assist_to_your_projects"
components={{
sparkle: (
<img
alt="sparkle"
className="ai-error-assistant-sparkle"
src={sparkleText}
aria-hidden="true"
key="sparkle"
/>
),
}}
/>
)}
</div>
)}
<OLCard className="payment-summary-card mt-5">
<h3>{t('due_today')}:</h3>
<OLRow>
<OLCol xs={9}>{changeName}</OLCol>
<OLCol xs={3} className="text-end">
<strong>
{formatCurrency(
preview.immediateCharge.subtotal,
preview.currency
)}
</strong>
</OLCol>
</OLRow>
{preview.immediateCharge.tax > 0 && (
<OLRow className="mt-1">
<OLCol xs={9}>
{t('vat')} {preview.nextInvoice.tax.rate * 100}%
</OLCol>
<OLCol xs={3} className="text-end">
{formatCurrency(
preview.immediateCharge.tax,
preview.currency
)}
</OLCol>
</OLRow>
)}
<OLRow className="mt-1">
<OLCol xs={9}>{t('total_today')}</OLCol>
<OLCol xs={3} className="text-end">
<strong>
{formatCurrency(
preview.immediateCharge.total,
preview.currency
)}
</strong>
</OLCol>
</OLRow>
</OLCard>
<div className="mt-5">
<Trans
i18nKey="this_total_reflects_the_amount_due_until"
values={{ date: moment(preview.nextInvoice.date).format('LL') }}
components={{ strong: <strong /> }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>{' '}
<Trans
i18nKey="we_will_use_your_existing_payment_method"
values={{ paymentMethod: preview.paymentMethod }}
components={{ strong: <strong /> }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</div>
<div className="mt-5">
<OLButton
variant="primary"
size="lg"
onClick={handlePayNowClick}
disabled={payNowTask.isLoading || payNowTask.isSuccess}
>
{t('pay_now')}
</OLButton>
</div>
<OLCard className="payment-summary-card mt-5">
<h3>{t('future_payments')}:</h3>
<OLRow className="mt-1">
<OLCol xs={9}>{preview.nextInvoice.plan.name}</OLCol>
<OLCol xs={3} className="text-end">
{formatCurrency(
preview.nextInvoice.plan.amount,
preview.currency
)}
</OLCol>
</OLRow>
{preview.nextInvoice.addOns.map(addOn => (
<OLRow className="mt-1" key={addOn.code}>
<OLCol xs={9}>
{addOn.name}
{addOn.quantity > 1 ? ` ×${addOn.quantity}` : ''}
</OLCol>
<OLCol xs={3} className="text-end">
{formatCurrency(addOn.amount, preview.currency)}
</OLCol>
</OLRow>
))}
{preview.nextInvoice.tax.rate > 0 && (
<OLRow className="mt-1">
<OLCol xs={9}>
{t('vat')} {preview.nextInvoice.tax.rate * 100}%
</OLCol>
<OLCol xs={3} className="text-end">
{formatCurrency(
preview.nextInvoice.tax.amount,
preview.currency
)}
</OLCol>
</OLRow>
)}
<OLRow className="mt-1">
<OLCol xs={9}>
{preview.nextPlan.annual
? t('total_per_year')
: t('total_per_month')}
</OLCol>
<OLCol xs={3} className="text-end">
{formatCurrency(preview.nextInvoice.total, preview.currency)}
</OLCol>
</OLRow>
</OLCard>
<div className="mt-5">
<Trans
i18nKey="the_next_payment_will_be_collected_on"
values={{ date: moment(preview.nextInvoice.date).format('LL') }}
components={{ strong: <strong /> }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</div>
</OLCard>
</OLCol>
</OLRow>
</div>
)
}
async function payNow(preview: SubscriptionChangePreview) {
try {
if (preview.change.type === 'add-on-purchase') {
await postJSON(
`/user/subscription/addon/${preview.change.addOn.code}/add`
)
} else if (preview.change.type === 'premium-subscription') {
await postJSON(subscriptionUpdateUrl, {
body: { plan_code: preview.change.plan.code },
})
} else {
throw new Error(
`Unknown subscription change preview type: ${preview.change}`
)
}
} catch (e) {
const { handled } = await handleStripePaymentAction(e as FetchError)
if (!handled) {
throw e
}
}
}
export default PreviewSubscriptionChange