1 import { useState } from 'react';
3 import { c } from 'ttag';
5 import { useSubscription } from '@proton/account/subscription/hooks';
6 import { useUser } from '@proton/account/user/hooks';
7 import { Button, ButtonLike, Href } from '@proton/atoms';
8 import PrimaryButton from '@proton/components/components/button/PrimaryButton';
9 import Form from '@proton/components/components/form/Form';
10 import useDebounceInput from '@proton/components/components/input/useDebounceInput';
11 import Loader from '@proton/components/components/loader/Loader';
12 import type { ModalProps } from '@proton/components/components/modalTwo/Modal';
13 import ModalTwo from '@proton/components/components/modalTwo/Modal';
14 import ModalTwoContent from '@proton/components/components/modalTwo/ModalContent';
15 import ModalTwoFooter from '@proton/components/components/modalTwo/ModalFooter';
16 import ModalTwoHeader from '@proton/components/components/modalTwo/ModalHeader';
17 import Price from '@proton/components/components/price/Price';
18 import useConfig from '@proton/components/hooks/useConfig';
19 import useEventManager from '@proton/components/hooks/useEventManager';
20 import useNotifications from '@proton/components/hooks/useNotifications';
21 import { useAutomaticCurrency, usePaymentFacade } from '@proton/components/payments/client-extensions';
22 import { useChargebeeContext } from '@proton/components/payments/client-extensions/useChargebeeContext';
23 import { usePollEvents } from '@proton/components/payments/client-extensions/usePollEvents';
24 import type { PaymentProcessorHook } from '@proton/components/payments/react-extensions/interface';
25 import { useLoading } from '@proton/hooks';
32 type PaymentMethodStatusExtended,
33 type PlainPaymentMethodType,
35 } from '@proton/payments';
36 import { getPaymentsVersion } from '@proton/shared/lib/api/payments';
37 import { APPS } from '@proton/shared/lib/constants';
38 import { captureMessage } from '@proton/shared/lib/helpers/sentry';
39 import { getHasSomeVpnPlan } from '@proton/shared/lib/helpers/subscription';
40 import { getKnowledgeBaseUrl } from '@proton/shared/lib/helpers/url';
41 import { ChargebeeEnabled } from '@proton/shared/lib/interfaces';
42 import { getSentryError } from '@proton/shared/lib/keys';
43 import noop from '@proton/utils/noop';
45 import { ChargebeePaypalWrapper } from '../../payments/chargebee/ChargebeeWrapper';
46 import AmountRow from './AmountRow';
47 import PaymentInfo from './PaymentInfo';
48 import PaymentWrapper from './PaymentWrapper';
49 import StyledPayPalButton from './StyledPayPalButton';
51 const getCurrenciesI18N = () => ({
52 EUR: c('Monetary unit').t`Euro`,
53 CHF: c('Monetary unit').t`Swiss franc`,
54 USD: c('Monetary unit').t`US Dollar`,
55 BRL: c('Monetary unit').t`Brazilian real`,
56 GBP: c('Monetary unit').t`British pound`,
57 AUD: c('Monetary unit').t`Australian dollar`,
58 CAD: c('Monetary unit').t`Canadian dollar`,
62 status: PaymentMethodStatusExtended;
65 export const DEFAULT_CREDITS_AMOUNT = 5000;
67 const nonChargeableMethods = new Set<PlainPaymentMethodType | undefined>([
68 PAYMENT_METHOD_TYPES.BITCOIN,
69 PAYMENT_METHOD_TYPES.CHARGEBEE_BITCOIN,
70 PAYMENT_METHOD_TYPES.CASH,
73 const CreditsModal = ({ status, ...props }: Props) => {
74 const { APP_NAME } = useConfig();
75 const { call } = useEventManager();
76 const { createNotification } = useNotifications();
77 const [loading, withLoading] = useLoading();
79 const [preferredCurrency] = useAutomaticCurrency();
80 const [currency, setCurrency] = useState<Currency>(preferredCurrency);
81 const [amount, setAmount] = useState(DEFAULT_CREDITS_AMOUNT);
82 const debouncedAmount = useDebounceInput(amount);
83 const amountLoading = debouncedAmount !== amount;
84 const i18n = getCurrenciesI18N();
85 const i18nCurrency = i18n[currency];
86 const pollEventsMultipleTimes = usePollEvents();
87 const chargebeeContext = useChargebeeContext();
88 const [subscription, loadingSubscription] = useSubscription();
89 const [user, loadingUser] = useUser();
91 const paymentFacade = usePaymentFacade({
92 amount: debouncedAmount,
94 billingPlatform: subscription?.BillingPlatform,
95 chargebeeUserExists: user.ChargebeeUserExists,
96 paymentMethodStatusExtended: status,
97 onChargeable: (operations, data) => {
98 const run = async () => {
99 await operations.buyCredit();
103 if (data.sourceType === 'chargebee-bitcoin') {
106 .t`The transaction is successfully detected. The credits will be added to your account once the transaction is fully confirmed.`,
110 createNotification({ text: c('Success').t`Credits added` });
114 const promise = run();
115 void withLoading(promise);
117 promise.then(() => pollEventsMultipleTimes()).catch(noop);
125 if (loadingSubscription || loadingUser) {
129 const methodValue = paymentFacade.selectedMethodValue;
131 const submit = (() => {
132 const bitcoinAmountInRange = debouncedAmount >= MIN_BITCOIN_AMOUNT && debouncedAmount <= MAX_BITCOIN_AMOUNT;
134 debouncedAmount < MIN_CREDIT_AMOUNT ||
135 ((methodValue === PAYMENT_METHOD_TYPES.BITCOIN || methodValue === PAYMENT_METHOD_TYPES.CHARGEBEE_BITCOIN) &&
136 !bitcoinAmountInRange)
141 if (paymentFacade.methods.isNewPaypal) {
145 paypal={paymentFacade.paypal}
146 amount={debouncedAmount}
147 currency={paymentFacade.currency}
149 disabled={amountLoading}
150 data-testid="paypal-button"
155 if (methodValue === PAYMENT_METHOD_TYPES.CHARGEBEE_PAYPAL) {
158 <ButtonLike disabled loading={true}>
159 {c('Payments').t`Processing payment`}
165 <div className="flex justify-end">
166 <div className="w-1/2 mr-1">
167 <ChargebeePaypalWrapper
168 chargebeePaypal={paymentFacade.chargebeePaypal}
169 iframeHandles={paymentFacade.iframeHandles}
176 const topUpText = c('Action').t`Top up`;
177 if (methodValue === PAYMENT_METHOD_TYPES.BITCOIN || methodValue === PAYMENT_METHOD_TYPES.CHARGEBEE_BITCOIN) {
181 paymentFacade.bitcoinInhouse.bitcoinLoading || paymentFacade.bitcoinChargebee.bitcoinLoading
184 data-testid="top-up-button"
186 {paymentFacade.bitcoinInhouse.awaitingBitcoinPayment ||
187 paymentFacade.bitcoinChargebee.awaitingBitcoinPayment
188 ? c('Info').t`Awaiting transaction`
197 disabled={paymentFacade.methods.loading || !paymentFacade.userCanTriggerSelected || amountLoading}
199 data-testid="top-up-button"
206 const process = async (processor?: PaymentProcessorHook) =>
207 withLoading(async () => {
213 await processor.processPaymentToken();
215 const error = getSentryError(e);
222 processorType: paymentFacade.selectedProcessor?.meta.type,
223 paymentMethod: paymentFacade.selectedMethodType,
224 paymentMethodValue: paymentFacade.selectedMethodValue,
225 paymentsVersion: getPaymentsVersion(),
226 chargebeeEnabled: chargebeeContext.enableChargebeeRef.current,
229 captureMessage('Payments: failed to handle credits', {
231 extra: { error, context },
237 const disableCurrencySelector =
238 chargebeeContext.enableChargebeeRef.current !== ChargebeeEnabled.INHOUSE_FORCED &&
239 !isFreeSubscription(subscription);
241 const amountToCharge =
242 amountLoading || nonChargeableMethods.has(paymentFacade.selectedMethodType) ? null : (
243 <Price currency={currency}>{debouncedAmount}</Price>
248 className="credits-modal"
251 onSubmit={() => process(paymentFacade.selectedProcessor)}
254 <ModalTwoHeader title={c('Title').t`Add credits`} />
256 <PaymentInfo paymentMethodType={paymentFacade.selectedMethodType} />
257 <div className="mb-4">
260 .jt`Top up your account with credits that you can use to subscribe to a new plan or renew your current plan. You get one credit for every ${i18nCurrency} spent.`}
264 APP_NAME === APPS.PROTONVPN_SETTINGS
265 ? 'https://protonvpn.com/support/vpn-credit-proration/'
266 : getKnowledgeBaseUrl('/credit-proration-coupons')
269 {c('Link').t`Learn more`}
274 paymentMethodType={paymentFacade.selectedMethodType}
276 onChangeAmount={setAmount}
278 onChangeCurrency={setCurrency}
279 disableCurrencySelector={disableCurrencySelector}
283 onPaypalCreditClick={() => process(paymentFacade.paypalCredit)}
285 triggersDisabled={amountLoading}
286 hasSomeVpnPlan={getHasSomeVpnPlan(subscription)}
290 className="text-sm text-center color-weak min-h-custom"
292 '--min-h-custom': '1.5rem',
296 ? c('Payments').jt`You will be charged ${amountToCharge} from your selected payment method.`
303 <Button onClick={props.onClose}>{c('Action').t`Close`}</Button>
310 export default CreditsModal;