From bccb91343ef0486ad7716f72963d87f1bb6e7feb Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 2 Oct 2023 16:23:07 +0200 Subject: [PATCH] Move checkout to subscriptions module (#15022) * Move checkout to subscriptions module GitOrigin-RevId: 0ad6587ddd7042aed7f2e18d9d0668e02942eb1e --- .../Subscription/SubscriptionController.js | 163 +------ .../Subscription/SubscriptionRouter.js | 14 - .../web/app/views/subscriptions/new-react.pug | 37 -- .../subscriptions/restricted-country.pug | 11 - .../unconfirmed-primary-email.pug | 11 - .../new/checkout/address-first-line.tsx | 64 --- .../new/checkout/address-second-line.tsx | 53 -- .../components/new/checkout/card-element.tsx | 78 --- .../new/checkout/checkout-panel.tsx | 421 ---------------- .../new/checkout/company-details.tsx | 82 ---- .../new/checkout/country-select.tsx | 68 --- .../components/new/checkout/coupon-code.tsx | 34 -- .../components/new/checkout/first-name.tsx | 46 -- .../components/new/checkout/last-name.tsx | 46 -- .../new/checkout/payment-method-toggle.tsx | 60 --- .../components/new/checkout/postal-code.tsx | 46 -- .../new/checkout/price-switch-header.tsx | 32 -- .../components/new/checkout/submit-button.tsx | 32 -- .../new/checkout/three-d-secure.tsx | 58 --- .../new/checkout/tos-agreement-notice.tsx | 17 - .../new/payment-preview/collaborators.tsx | 38 -- .../new/payment-preview/currency-dropdown.tsx | 40 -- .../new/payment-preview/features-list.tsx | 33 -- .../new/payment-preview/no-discount-price.tsx | 38 -- .../payment-preview/payment-preview-panel.tsx | 29 -- .../price-for-first-x-period.tsx | 49 -- .../new/payment-preview/price-summary.tsx | 84 ---- .../payment-preview/trial-coupon-summary.tsx | 24 - .../new/payment-preview/trial-price.tsx | 37 -- .../subscription/components/new/root.tsx | 37 -- .../subscription/context/payment-context.tsx | 365 -------------- .../context/types/payment-context-value.tsx | 67 --- .../checkout/address-first-line.stories.tsx | 64 --- .../checkout/address-second-line.stories.tsx | 64 --- .../new/checkout/card-element.stories.tsx | 40 -- .../new/checkout/company-details.stories.tsx | 65 --- .../new/checkout/country-select.stories.tsx | 74 --- .../new/checkout/coupon-code.stories.tsx | 47 -- .../new/checkout/first-name.stories.tsx | 61 --- .../new/checkout/last-name.stories.tsx | 61 --- .../payment-method-toggle.stories.tsx | 28 -- .../new/checkout/postal-code.stories.tsx | 64 --- .../checkout/price-switch-header.stories.tsx | 43 -- .../new/checkout/submit-button.stories.tsx | 47 -- .../checkout/tos-agreement-notice.stories.tsx | 17 - .../new/helpers/context-provider.tsx | 13 - .../payment-preview/collaborators.stories.tsx | 40 -- .../payment-preview/features-list.stories.tsx | 33 -- .../no-discount-price.stories.tsx | 93 ---- .../price-for-first-x-period.stories.tsx | 75 --- .../payment-preview/price-summary.stories.tsx | 51 -- .../payment-preview/trial-price.stories.tsx | 33 -- .../components/new/checkout.spec.tsx | 452 ------------------ .../components/new/common.spec.tsx | 68 --- .../components/new/payment-preview.spec.tsx | 310 ------------ .../subscription/fixtures/recurly-mock.ts | 232 --------- .../features/subscription/helpers/payment.ts | 12 - .../SubscriptionControllerTests.js | 296 ------------ 58 files changed, 3 insertions(+), 4594 deletions(-) delete mode 100644 services/web/app/views/subscriptions/new-react.pug delete mode 100644 services/web/app/views/subscriptions/restricted-country.pug delete mode 100644 services/web/app/views/subscriptions/unconfirmed-primary-email.pug delete mode 100644 services/web/frontend/js/features/subscription/components/new/checkout/address-first-line.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/checkout/address-second-line.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/checkout/card-element.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/checkout/company-details.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/checkout/country-select.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/checkout/coupon-code.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/checkout/first-name.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/checkout/last-name.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/checkout/payment-method-toggle.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/checkout/postal-code.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/checkout/price-switch-header.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/checkout/submit-button.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/checkout/three-d-secure.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/checkout/tos-agreement-notice.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/payment-preview/collaborators.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/payment-preview/currency-dropdown.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/payment-preview/features-list.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/payment-preview/no-discount-price.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/payment-preview/payment-preview-panel.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/payment-preview/price-for-first-x-period.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/payment-preview/price-summary.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/payment-preview/trial-coupon-summary.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/payment-preview/trial-price.tsx delete mode 100644 services/web/frontend/js/features/subscription/components/new/root.tsx delete mode 100644 services/web/frontend/js/features/subscription/context/payment-context.tsx delete mode 100644 services/web/frontend/js/features/subscription/context/types/payment-context-value.tsx delete mode 100644 services/web/frontend/stories/subscription/new/checkout/address-first-line.stories.tsx delete mode 100644 services/web/frontend/stories/subscription/new/checkout/address-second-line.stories.tsx delete mode 100644 services/web/frontend/stories/subscription/new/checkout/card-element.stories.tsx delete mode 100644 services/web/frontend/stories/subscription/new/checkout/company-details.stories.tsx delete mode 100644 services/web/frontend/stories/subscription/new/checkout/country-select.stories.tsx delete mode 100644 services/web/frontend/stories/subscription/new/checkout/coupon-code.stories.tsx delete mode 100644 services/web/frontend/stories/subscription/new/checkout/first-name.stories.tsx delete mode 100644 services/web/frontend/stories/subscription/new/checkout/last-name.stories.tsx delete mode 100644 services/web/frontend/stories/subscription/new/checkout/payment-method-toggle.stories.tsx delete mode 100644 services/web/frontend/stories/subscription/new/checkout/postal-code.stories.tsx delete mode 100644 services/web/frontend/stories/subscription/new/checkout/price-switch-header.stories.tsx delete mode 100644 services/web/frontend/stories/subscription/new/checkout/submit-button.stories.tsx delete mode 100644 services/web/frontend/stories/subscription/new/checkout/tos-agreement-notice.stories.tsx delete mode 100644 services/web/frontend/stories/subscription/new/helpers/context-provider.tsx delete mode 100644 services/web/frontend/stories/subscription/new/payment-preview/collaborators.stories.tsx delete mode 100644 services/web/frontend/stories/subscription/new/payment-preview/features-list.stories.tsx delete mode 100644 services/web/frontend/stories/subscription/new/payment-preview/no-discount-price.stories.tsx delete mode 100644 services/web/frontend/stories/subscription/new/payment-preview/price-for-first-x-period.stories.tsx delete mode 100644 services/web/frontend/stories/subscription/new/payment-preview/price-summary.stories.tsx delete mode 100644 services/web/frontend/stories/subscription/new/payment-preview/trial-price.stories.tsx delete mode 100644 services/web/test/frontend/features/subscription/components/new/checkout.spec.tsx delete mode 100644 services/web/test/frontend/features/subscription/components/new/common.spec.tsx delete mode 100644 services/web/test/frontend/features/subscription/components/new/payment-preview.spec.tsx delete mode 100644 services/web/test/frontend/features/subscription/fixtures/recurly-mock.ts delete mode 100644 services/web/test/frontend/features/subscription/helpers/payment.ts diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 1f80f1431f..0c98a253ce 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -1,6 +1,5 @@ const SessionManager = require('../Authentication/SessionManager') const SubscriptionHandler = require('./SubscriptionHandler') -const PlansLocator = require('./PlansLocator') const SubscriptionViewModelBuilder = require('./SubscriptionViewModelBuilder') const LimitationsManager = require('./LimitationsManager') const RecurlyWrapper = require('./RecurlyWrapper') @@ -13,9 +12,6 @@ const plansConfig = require('./plansConfig') const interstitialPaymentConfig = require('./interstitialPaymentConfig') const GroupPlansData = require('./GroupPlansData') const V1SubscriptionManager = require('./V1SubscriptionManager') -const Errors = require('../Errors/Errors') -const HttpErrorHandler = require('../Errors/HttpErrorHandler') -const SubscriptionErrors = require('./Errors') const AnalyticsManager = require('../Analytics/AnalyticsManager') const RecurlyEventHandler = require('./RecurlyEventHandler') const { expressify } = require('../../util/promises') @@ -23,8 +19,6 @@ const OError = require('@overleaf/o-error') const SplitTestHandler = require('../SplitTests/SplitTestHandler') const SubscriptionHelper = require('./SubscriptionHelper') const Features = require('../../infrastructure/Features') -const UserGetter = require('../User/UserGetter') -const Modules = require('../../infrastructure/Modules') const AuthorizationManager = require('../Authorization/AuthorizationManager') const groupPlanModalOptions = Settings.groupPlanModalOptions @@ -188,83 +182,6 @@ async function plansPage(req, res) { }) } -/** - * @param {import('express').Request} req - * @param {import('express').Response} res - * @returns {Promise} - */ -async function paymentPage(req, res) { - const user = SessionManager.getSessionUser(req.session) - const plan = PlansLocator.findLocalPlanInSettings(req.query.planCode) - if (!plan) { - return HttpErrorHandler.unprocessableEntity(req, res, 'Plan not found') - } - const hasSubscription = - await LimitationsManager.promises.userHasV1OrV2Subscription(user) - if (hasSubscription) { - res.redirect('/user/subscription?hasSubscription=true') - } else { - // LimitationsManager.userHasV2Subscription only checks Mongo. Double check with - // Recurly as well at this point (we don't do this most places for speed). - const valid = - await SubscriptionHandler.promises.validateNoSubscriptionInRecurly( - user._id - ) - if (!valid) { - res.redirect('/user/subscription?hasSubscription=true') - } else { - let currency = null - if (req.query.currency) { - const queryCurrency = req.query.currency.toUpperCase() - if (GeoIpLookup.isValidCurrencyParam(queryCurrency)) { - currency = queryCurrency - } - } - const { recommendedCurrency, countryCode } = - await _getRecommendedCurrency(req, res) - if (recommendedCurrency && currency == null) { - currency = recommendedCurrency - } - - // Block web sales to restricted countries - if (Settings.restrictedCountries.includes(countryCode)) { - return res.render('subscriptions/restricted-country', { - title: 'restricted', - }) - } - - res.render('subscriptions/new-react', { - title: 'subscribe', - currency, - countryCode, - plan, - planCode: req.query.planCode, - couponCode: req.query.cc, - showCouponField: !!req.query.scf, - itm_campaign: req.query.itm_campaign, - itm_content: req.query.itm_content, - itm_referrer: req.query.itm_referrer, - }) - } - } -} - -async function requireConfirmedPrimaryEmailAddress(req, res, next) { - const userData = await UserGetter.promises.getUser(req.user._id, { - email: 1, - emails: 1, - }) - const userPrimaryEmail = userData.emails.find( - emailEntry => emailEntry.email === userData.email - ) - if (userPrimaryEmail?.confirmedAt != null) return next() - - res.status(422).render('subscriptions/unconfirmed-primary-email', { - title: 'confirm_email', - email: userData.email, - }) -} - function formatGroupPlansDataForDash() { return { plans: [...groupPlanModalOptions.plan_codes], @@ -493,78 +410,6 @@ async function interstitialPaymentPage(req, res) { } } -async function createSubscription(req, res) { - const user = SessionManager.getSessionUser(req.session) - const recurlyTokenIds = { - billing: req.body.recurly_token_id, - threeDSecureActionResult: - req.body.recurly_three_d_secure_action_result_token_id, - } - const { subscriptionDetails } = req.body - - const hasSubscription = - await LimitationsManager.promises.userHasV1OrV2Subscription(user) - - if (hasSubscription) { - logger.warn({ userId: user._id }, 'user already has subscription') - return res.sendStatus(409) // conflict - } - - const { countryCode } = await _getRecommendedCurrency(req, res) - - // Block web sales to restricted countries - if (Settings.restrictedCountries.includes(countryCode)) { - return HttpErrorHandler.unprocessableEntity( - req, - res, - req.i18n.translate('sorry_detected_sales_restricted_region', { - link: '/contact', - }) - ) - } - - const result = {} - await Modules.promises.hooks.fire( - 'createSubscription', - req, - res, - user, - result - ) - if (result.error) { - return HttpErrorHandler.unprocessableEntity(req, res) - } - - try { - await SubscriptionHandler.promises.createSubscription( - user, - subscriptionDetails, - recurlyTokenIds - ) - - res.sendStatus(201) - } catch (err) { - if ( - err instanceof SubscriptionErrors.RecurlyTransactionError || - err instanceof Errors.InvalidError - ) { - logger.error({ err }, 'recurly transaction error, potential 422') - HttpErrorHandler.unprocessableEntity( - req, - res, - err.message, - OError.getFullInfo(err).public - ) - } else { - logger.warn( - { err, userId: user._id }, - 'something went wrong creating subscription' - ) - throw err - } - } -} - /** * @param {import('express').Request} req * @param {import('express').Response} res @@ -922,10 +767,8 @@ async function _getRecommendedCurrency(req, res) { module.exports = { plansPage: expressify(plansPage), - paymentPage: expressify(paymentPage), userSubscriptionPage: expressify(userSubscriptionPage), interstitialPaymentPage: expressify(interstitialPaymentPage), - createSubscription: expressify(createSubscription), successfulSubscription: expressify(successfulSubscription), cancelSubscription, canceledSubscription, @@ -941,7 +784,7 @@ module.exports = { recurlyNotificationParser, refreshUserFeatures: expressify(refreshUserFeatures), redirectToHostedPage: expressify(redirectToHostedPage), - requireConfirmedPrimaryEmailAddress: expressify( - requireConfirmedPrimaryEmailAddress - ), + promises: { + getRecommendedCurrency: _getRecommendedCurrency, + }, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.js b/services/web/app/src/Features/Subscription/SubscriptionRouter.js index d54e483abc..26a6c0978e 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionRouter.js +++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.js @@ -27,13 +27,6 @@ module.exports = { SubscriptionController.userSubscriptionPage ) - webRouter.get( - '/user/subscription/new', - AuthenticationController.requireLogin(), - SubscriptionController.requireConfirmedPrimaryEmailAddress, - SubscriptionController.paymentPage - ) - webRouter.get( '/user/subscription/choose-your-plan', AuthenticationController.requireLogin(), @@ -90,13 +83,6 @@ module.exports = { ) // user changes their account state - webRouter.post( - '/user/subscription/create', - AuthenticationController.requireLogin(), - PermissionsController.requirePermission('start-subscription'), - SubscriptionController.requireConfirmedPrimaryEmailAddress, - SubscriptionController.createSubscription - ) webRouter.post( '/user/subscription/update', AuthenticationController.requireLogin(), diff --git a/services/web/app/views/subscriptions/new-react.pug b/services/web/app/views/subscriptions/new-react.pug deleted file mode 100644 index fd0036f977..0000000000 --- a/services/web/app/views/subscriptions/new-react.pug +++ /dev/null @@ -1,37 +0,0 @@ -extends ../layout-marketing - -block entrypointVar - - entrypoint = 'pages/user/subscription/new' - -block vars - - var suppressNavbarRight = true - - var suppressFooter = true - - var suppressCookieBanner = true - -block append meta - meta(name="ol-countryCode" content=countryCode) - meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey) - meta(name="ol-recommendedCurrency" content=String(currency).slice(0, 3)) - meta(name="ol-plan" data-type="json" content=plan) - meta(name="ol-planCode" data-type="string" content=planCode) - meta(name="ol-showCouponField" data-type="boolean" content=showCouponField) - meta(name="ol-couponCode" content=couponCode) - meta(name="ol-itm_campaign" content=itm_campaign) - meta(name="ol-itm_content" content=itm_content) - meta(name="ol-itm_referrer" content=itm_referrer) - -block head-scripts - script(type="text/javascript", nonce=scriptNonce, src="https://js.recurly.com/v4/recurly.js") - -block content - main.content.content-alt#subscription-new-root - - script(type="text/javascript", nonce=scriptNonce). - ga('send', 'event', 'pageview', 'payment_form', "#{planCode}") - - script( - type="text/ng-template" - id="cvv-tooltip-tpl.html" - ) - p !{translate("for_visa_mastercard_and_discover", {}, ['strong', 'strong', 'strong'])} - p !{translate("for_american_express", {}, ['strong', 'strong', 'strong'])} diff --git a/services/web/app/views/subscriptions/restricted-country.pug b/services/web/app/views/subscriptions/restricted-country.pug deleted file mode 100644 index 70bdac23cc..0000000000 --- a/services/web/app/views/subscriptions/restricted-country.pug +++ /dev/null @@ -1,11 +0,0 @@ -extends ../layout-marketing - -block content - main.content.content-alt#main-content - .container - .error-container - .error-details - p.error-status #{translate("restricted")} - p.error-description !{translate("sorry_detected_sales_restricted_region", {link: "/contact"})} - p.error-actions - a.error-btn(href="/") #{translate("home")} diff --git a/services/web/app/views/subscriptions/unconfirmed-primary-email.pug b/services/web/app/views/subscriptions/unconfirmed-primary-email.pug deleted file mode 100644 index c7b4fec615..0000000000 --- a/services/web/app/views/subscriptions/unconfirmed-primary-email.pug +++ /dev/null @@ -1,11 +0,0 @@ -extends ../layout-marketing - -block content - main.content.content-alt#main-content - .container - .error-container - .error-details - p.error-status #{translate("confirm_email")} - p.error-description !{translate("please_confirm_email", {emailAddress: email})} - p.error-actions - a.error-btn(href="/user/settings") #{translate("account_settings")} diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/address-first-line.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/address-first-line.tsx deleted file mode 100644 index c4e48f7cf2..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/checkout/address-first-line.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { FormGroup, ControlLabel } from 'react-bootstrap' -import Tooltip from '../../../../../shared/components/tooltip' -import Icon from '../../../../../shared/components/icon' -import useValidateField from '../../../hooks/use-validate-field' -import classnames from 'classnames' -import { callFnsInSequence } from '../../../../../utils/functions' - -type AddressFirstLineProps = { - errorFields: Record | undefined - value: string - onChange: (e: React.ChangeEvent) => void -} - -function AddressFirstLine({ - errorFields, - value, - onChange, -}: AddressFirstLineProps) { - const { t } = useTranslation() - const { validate, isValid } = useValidateField() - - return ( - - - {t('address_line_1')}{' '} - - - - - - {!isValid && ( - - {t('this_field_is_required')} - - )} - - ) -} - -export default AddressFirstLine diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/address-second-line.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/address-second-line.tsx deleted file mode 100644 index 1072d604f7..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/checkout/address-second-line.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useState } from 'react' -import { useTranslation } from 'react-i18next' -import { FormGroup, ControlLabel, Button } from 'react-bootstrap' -import classnames from 'classnames' - -type AddressSecondLineProps = { - errorFields: Record | undefined - value: string - onChange: (e: React.ChangeEvent) => void -} - -function AddressSecondLine({ - errorFields, - value, - onChange, -}: AddressSecondLineProps) { - const { t } = useTranslation() - const [showAddressSecondLine, setShowAddressSecondLine] = useState(false) - - if (showAddressSecondLine) { - return ( - - {t('address_second_line_optional')} - - - ) - } - - return ( - - ) -} - -export default AddressSecondLine diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/card-element.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/card-element.tsx deleted file mode 100644 index 4239ff081c..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/checkout/card-element.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { useState, useEffect, useRef } from 'react' -import { useTranslation } from 'react-i18next' -import { FormGroup, ControlLabel } from 'react-bootstrap' -import { CardElementChangeState } from '../../../../../../../types/recurly/elements' -import { ElementsInstance } from 'recurly__recurly-js' -import classnames from 'classnames' -import getMeta from '../../../../../utils/meta' - -type CardElementProps = { - className?: string - elements: ElementsInstance - onChange: (state: CardElementChangeState) => void -} - -function CardElement({ className, elements, onChange }: CardElementProps) { - const { t } = useTranslation() - const [showCardElementInvalid, setShowCardElementInvalid] = - useState() - const cardRef = useRef(null) - - // Card initialization - useEffect(() => { - if (!cardRef.current) return - - const showNewDesign = - getMeta('ol-splitTestVariants')?.['design-system-updates'] === 'enabled' - - const style = showNewDesign - ? { - fontColor: '#1b222c', - placeholder: { - color: '#677283', - }, - invalid: { - fontColor: '#b83a33', - }, - } - : { - fontColor: '#5d6879', - placeholder: {}, - invalid: { - fontColor: '#a93529', - }, - } - - const card = elements.CardElement({ - displayIcon: true, - inputType: 'mobileSelect', - style, - }) - - card.attach(cardRef.current) - card.on('change', state => { - setShowCardElementInvalid(!state.focus && !state.empty && !state.valid) - onChange(state) - }) - - return () => { - cardRef.current = null - } - }, [elements, onChange]) - - return ( - - {t('card_details')} -
- {showCardElementInvalid && ( - - {t('card_details_are_not_valid')} - - )} - - ) -} - -export default CardElement diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx deleted file mode 100644 index dc2b94c407..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx +++ /dev/null @@ -1,421 +0,0 @@ -import { useRef, useState, useEffect, useCallback } from 'react' -import { useTranslation } from 'react-i18next' -import { usePaymentContext } from '../../../context/payment-context' -import { Row, Col, Alert } from 'react-bootstrap' -import PriceSwitchHeader from './price-switch-header' -import PaymentMethodToggle from './payment-method-toggle' -import CardElement from './card-element' -import FirstName from './first-name' -import LastName from './last-name' -import AddressFirstLine from './address-first-line' -import AddressSecondLine from './address-second-line' -import PostalCode from './postal-code' -import CountrySelect from './country-select' -import CompanyDetails from './company-details' -import CouponCode from './coupon-code' -import TosAgreementNotice from './tos-agreement-notice' -import SubmitButton from './submit-button' -import ThreeDSecure from './three-d-secure' -import getMeta from '../../../../../utils/meta' -import { getSplitTestVariant } from '../../../../../../../frontend/js/utils/splitTestUtils' -import { postJSON } from '../../../../../infrastructure/fetch-json' -import * as eventTracking from '../../../../../infrastructure/event-tracking' -import classnames from 'classnames' -import { - TokenPayload, - RecurlyError, - ElementsInstance, - PayPalInstance, -} from 'recurly__recurly-js' -import { PricingFormState } from '../../../context/types/payment-context-value' -import { CreateError } from '../../../../../../../types/subscription/api' -import { CardElementChangeState } from '../../../../../../../types/recurly/elements' -import { useLocation } from '../../../../../shared/hooks/use-location' - -function CheckoutPanel() { - const { t } = useTranslation() - const { - couponError, - currencyCode, - planCode, - planName, - pricingFormState, - pricing, - recurlyLoadError, - setPricingFormState, - trialLength, - taxes, - } = usePaymentContext() - const showCouponField: boolean = getMeta('ol-showCouponField') - const ITMCampaign: string = getMeta('ol-itm_campaign', '') - const ITMContent: string = getMeta('ol-itm_content', '') - const ITMReferrer: string = getMeta('ol-itm_referrer', '') - const formRef = useRef(null) - const cachedRecurlyBillingToken = useRef() - const elements = useRef(recurly?.Elements()) - const [isProcessing, setIsProcessing] = useState(false) - const [errorFields, setErrorFields] = useState>() - const [genericError, setGenericError] = useState('') - const [paymentMethod, setPaymentMethod] = useState('credit_card') - const [cardIsValid, setCardIsValid] = useState() - const [formIsValid, setFormIsValid] = useState() - const [threeDSecureActionTokenId, setThreeDSecureActionTokenId] = - useState() - const location = useLocation() - - const isCreditCardPaymentMethod = paymentMethod === 'credit_card' - const isPayPalPaymentMethod = paymentMethod === 'paypal' - const isAddCompanyDetailsChecked = Boolean( - formRef.current?.querySelector( - '#add-company-details-checkbox' - )?.checked - ) - const designSystemUpdatesVariant = getSplitTestVariant( - 'design-system-updates', - 'default' - ) - - const completeSubscription = useCallback( - async ( - err?: RecurlyError | null, - recurlyBillingToken?: TokenPayload, - threeDResultToken?: TokenPayload - ) => { - if (recurlyBillingToken) { - // temporary store the billing token as it might be needed when - // re-sending the request after SCA authentication - cachedRecurlyBillingToken.current = recurlyBillingToken - } - - setErrorFields(undefined) - - if (err) { - eventTracking.sendMB('payment-page-form-error', err) - eventTracking.send('subscription-funnel', 'subscription-error') - - setIsProcessing(false) - setGenericError(err.message) - - const errFields = err.fields?.reduce( - (prev, cur) => { - return { ...prev, [cur]: true } - }, - {} - ) - setErrorFields(errFields) - } else { - const billingFields = ['company', 'vat_number'] as const - const billingInfo = billingFields.reduce((prev, cur) => { - if (isPayPalPaymentMethod && isAddCompanyDetailsChecked) { - prev[cur] = pricingFormState[cur] - } - - return prev - }, {} as Partial>) - - const postData = { - _csrf: getMeta('ol-csrfToken'), - recurly_token_id: cachedRecurlyBillingToken.current?.id, - recurly_three_d_secure_action_result_token_id: threeDResultToken?.id, - subscriptionDetails: { - currencyCode: pricing.current?.items.currency, - plan_code: pricing.current?.items.plan?.code, - coupon_code: pricing.current?.items.coupon?.code ?? '', - first_name: pricingFormState.first_name, - last_name: pricingFormState.last_name, - isPaypal: isPayPalPaymentMethod, - address: { - address1: pricingFormState.address1, - address2: pricingFormState.address2, - country: pricingFormState.country, - state: pricingFormState.state, - zip: pricingFormState.postal_code, - }, - ITMCampaign, - ITMContent, - ITMReferrer, - ...(Object.keys(billingInfo).length && { - billing_info: billingInfo, - }), - }, - } - - eventTracking.sendMB('payment-page-form-submit', { - currencyCode: postData.subscriptionDetails.currencyCode, - plan_code: postData.subscriptionDetails.plan_code, - coupon_code: postData.subscriptionDetails.coupon_code, - isPaypal: postData.subscriptionDetails.isPaypal, - 'split-test-design-system-updates': designSystemUpdatesVariant, - }) - eventTracking.send( - 'subscription-funnel', - 'subscription-form-submitted', - postData.subscriptionDetails.plan_code - ) - - try { - await postJSON(`/user/subscription/create`, { body: postData }) - - eventTracking.sendMB('payment-page-form-success') - eventTracking.send( - 'subscription-funnel', - 'subscription-submission-success', - planCode - ) - location.assign('/user/subscription/thank-you') - } catch (error) { - setIsProcessing(false) - - const { data } = error as CreateError - const errorMessage: string = - data.message || t('something_went_wrong_processing_the_request') - setGenericError(errorMessage) - - if (data.threeDSecureActionTokenId) { - setThreeDSecureActionTokenId(data.threeDSecureActionTokenId) - } - } - } - }, - [ - designSystemUpdatesVariant, - ITMCampaign, - ITMContent, - ITMReferrer, - isAddCompanyDetailsChecked, - isPayPalPaymentMethod, - location, - planCode, - pricing, - pricingFormState, - t, - ] - ) - - const payPal = useRef() - - useEffect(() => { - if (!recurly) return - - payPal.current = recurly.PayPal({ - display: { displayName: planName }, - }) - - payPal.current.on('token', token => { - completeSubscription(null, token) - }) - payPal.current.on('error', err => { - completeSubscription(err) - }) - payPal.current.on('cancel', () => { - setIsProcessing(false) - }) - - const payPalCopy = payPal.current - - return () => { - payPalCopy.destroy() - } - }, [completeSubscription, planName]) - - const handleCardChange = useCallback((state: CardElementChangeState) => { - setCardIsValid(state.valid) - }, []) - - const hidePaypal = ['INR', 'COP', 'CLP', 'PEN'].includes(currencyCode) - if (hidePaypal && paymentMethod !== 'credit_card') { - setPaymentMethod('credit_card') - } - - if (recurlyLoadError) { - return ( - - {t('payment_provider_unreachable_error')} - - ) - } - - const handleThreeDToken = (token: TokenPayload) => { - // on SCA verification success: show payment UI in processing mode and - // resubmit the payment with the new token final success or error will be - // handled by `completeSubscription` - completeSubscription(null, undefined, token) - setGenericError('') - setThreeDSecureActionTokenId(undefined) - setIsProcessing(true) - } - - const handleThreeDError = (error: RecurlyError) => { - // on SCA verification error: show payment UI with the error message - setGenericError(`Error: ${error.message}`) - setThreeDSecureActionTokenId(undefined) - } - - const handlePaymentMethod = (e: React.ChangeEvent) => { - setPaymentMethod(e.target.value) - } - - const handleChange = ( - e: React.ChangeEvent, - name: keyof PricingFormState - ) => { - setPricingFormState(s => ({ ...s, [name]: e.target.value })) - } - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - - if (!recurly || !elements.current) return - - setIsProcessing(true) - - if (isPayPalPaymentMethod) { - payPal.current?.start() - return - } - - const { - company: _1, - vat_number: _2, - ...tokenDataWithoutCompanyDetails - } = pricingFormState - - const tokenData = isAddCompanyDetailsChecked - ? { ...pricingFormState } - : tokenDataWithoutCompanyDetails - - recurly.token(elements.current, tokenData, completeSubscription) - } - - const handleFormValidation = () => { - setFormIsValid(Boolean(formRef.current?.checkValidity())) - } - - const isFormValid = (): boolean => { - if (isPayPalPaymentMethod) { - return pricingFormState.country !== '' - } else { - return Boolean(formIsValid && cardIsValid) - } - } - - return ( - <> - {threeDSecureActionTokenId && ( - - )} -
- -
- {genericError && ( - - {genericError} - - )} - {couponError && ( - - {couponError} - - )} - {hidePaypal ? null : ( - - )} - {elements.current && ( - - )} - {isCreditCardPaymentMethod && ( - - - handleChange(e, 'first_name')} - /> - - - handleChange(e, 'last_name')} - /> - - - )} - handleChange(e, 'address1')} - /> - handleChange(e, 'address2')} - /> - - - handleChange(e, 'postal_code')} - /> - - - handleChange(e, 'country')} - /> - - - - {showCouponField && ( - handleChange(e, 'coupon')} - /> - )} - {isPayPalPaymentMethod && - t('proceeding_to_paypal_takes_you_to_the_paypal_site_to_pay')} -
-
- - {isCreditCardPaymentMethod && - (trialLength ? t('upgrade_cc_btn') : t('upgrade_now'))} - {isPayPalPaymentMethod && t('proceed_to_paypal')} - -
- - -
- - ) -} - -export default CheckoutPanel diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/company-details.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/company-details.tsx deleted file mode 100644 index 067476ad38..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/checkout/company-details.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { useState } from 'react' -import { useTranslation } from 'react-i18next' -import { FormGroup, ControlLabel } from 'react-bootstrap' -import { usePaymentContext } from '../../../context/payment-context' -import { PricingFormState } from '../../../context/types/payment-context-value' - -type CompanyDetailsProps = { - taxesCount: number -} - -function CompanyDetails(props: CompanyDetailsProps) { - const { t } = useTranslation() - const [addCompanyDetailsChecked, setAddCompanyDetailsChecked] = - useState(false) - const { pricingFormState, setPricingFormState, applyVatNumber } = - usePaymentContext() - - const handleAddCompanyDetails = (e: React.ChangeEvent) => { - setAddCompanyDetailsChecked(e.target.checked) - } - - const handleChange = ( - e: React.ChangeEvent, - name: keyof PricingFormState - ) => { - setPricingFormState(s => ({ ...s, [name]: e.target.value })) - } - - const handleApplyVatNumber = (e: React.FocusEvent) => { - applyVatNumber(e.target.value) - } - - return ( - <> - -
- - - {t('add_company_details')} - -
-
- {addCompanyDetailsChecked && ( - <> - - {t('company_name')} - handleChange(e, 'company')} - value={pricingFormState.company} - /> - - {props.taxesCount > 0 && ( - - {t('vat_number')} - handleChange(e, 'vat_number')} - onBlur={handleApplyVatNumber} - value={pricingFormState.vat_number} - /> - - )} - - )} - - ) -} - -export default CompanyDetails diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/country-select.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/country-select.tsx deleted file mode 100644 index 358983e4fa..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/checkout/country-select.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { FormGroup, ControlLabel } from 'react-bootstrap' -import classnames from 'classnames' -import useValidateField from '../../../hooks/use-validate-field' -import countries from '../../../data/countries' -import { callFnsInSequence } from '../../../../../utils/functions' -import { PricingFormState } from '../../../context/types/payment-context-value' -import { usePaymentContext } from '../../../context/payment-context' - -type CountrySelectProps = { - errorFields: Record | undefined - value: PricingFormState['country'] - onChange: (e: React.ChangeEvent) => void -} - -function CountrySelect(props: CountrySelectProps) { - const { t } = useTranslation() - const { validate, isValid } = useValidateField() - const { updateCountry } = usePaymentContext() - - const handleUpdateCountry = (e: React.ChangeEvent) => { - updateCountry(e.target.value as PricingFormState['country']) - } - - return ( - - {t('country')} - - {!isValid && ( - - {t('this_field_is_required')} - - )} - - ) -} - -export default CountrySelect diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/coupon-code.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/coupon-code.tsx deleted file mode 100644 index 401bd1c404..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/checkout/coupon-code.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { FormGroup, ControlLabel } from 'react-bootstrap' -import { usePaymentContext } from '../../../context/payment-context' - -type CouponCodeProps = { - value: string - onChange: (e: React.ChangeEvent) => void -} - -function CouponCode(props: CouponCodeProps) { - const { t } = useTranslation() - const { addCoupon } = usePaymentContext() - - const handleApplyCoupon = (e: React.FocusEvent) => { - addCoupon(e.target.value) - } - - return ( - - {t('coupon_code')} - - - ) -} - -export default CouponCode diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/first-name.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/first-name.tsx deleted file mode 100644 index 7f2b7a5fb1..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/checkout/first-name.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { FormGroup, ControlLabel } from 'react-bootstrap' -import useValidateField from '../../../hooks/use-validate-field' -import classnames from 'classnames' -import { callFnsInSequence } from '../../../../../utils/functions' - -type FirstNameProps = { - errorFields: Record | undefined - value: string - onChange: (e: React.ChangeEvent) => void -} - -function FirstName(props: FirstNameProps) { - const { t } = useTranslation() - const { validate, isValid } = useValidateField() - - return ( - - {t('first_name')} - - {!isValid && ( - - {t('this_field_is_required')} - - )} - - ) -} - -export default FirstName diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/last-name.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/last-name.tsx deleted file mode 100644 index 38f7f2237c..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/checkout/last-name.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { FormGroup, ControlLabel } from 'react-bootstrap' -import useValidateField from '../../../hooks/use-validate-field' -import classnames from 'classnames' -import { callFnsInSequence } from '../../../../../utils/functions' - -type LastNameProps = { - errorFields: Record | undefined - value: string - onChange: (e: React.ChangeEvent) => void -} - -function LastName(props: LastNameProps) { - const { t } = useTranslation() - const { validate, isValid } = useValidateField() - - return ( - - {t('last_name')} - - {!isValid && ( - - {t('this_field_is_required')} - - )} - - ) -} - -export default LastName diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/payment-method-toggle.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/payment-method-toggle.tsx deleted file mode 100644 index 3da177d301..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/checkout/payment-method-toggle.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { FormGroup, ControlLabel, Col } from 'react-bootstrap' -import Icon from '../../../../../shared/components/icon' - -type PaymentMethodToggleProps = { - onChange: (e: React.ChangeEvent) => void - paymentMethod: string -} - -function PaymentMethodToggle(props: PaymentMethodToggleProps) { - const { t } = useTranslation() - - return ( - -
-
- - - - - {t('card_payment')}  - - {' '} - - - - - - - - - - PayPal  - - - - - - -
-
- ) -} - -export default PaymentMethodToggle diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/postal-code.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/postal-code.tsx deleted file mode 100644 index a37a4e96d6..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/checkout/postal-code.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { FormGroup, ControlLabel } from 'react-bootstrap' -import useValidateField from '../../../hooks/use-validate-field' -import classnames from 'classnames' -import { callFnsInSequence } from '../../../../../utils/functions' - -type PostalCodeProps = { - errorFields: Record | undefined - value: string - onChange: (e: React.ChangeEvent) => void -} - -function PostalCode(props: PostalCodeProps) { - const { t } = useTranslation() - const { validate, isValid } = useValidateField() - - return ( - - {t('postal_code')} - - {!isValid && ( - - {t('this_field_is_required')} - - )} - - ) -} - -export default PostalCode diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/price-switch-header.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/price-switch-header.tsx deleted file mode 100644 index 0513b84b6c..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/checkout/price-switch-header.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { Row, Col } from 'react-bootstrap' -import { Plan } from '../../../../../../../types/subscription/plan' - -type PriceSwitchHeaderProps = { - planCode: Plan['planCode'] - planCodes: Array -} - -function PriceSwitchHeader({ planCode, planCodes }: PriceSwitchHeaderProps) { - const { t } = useTranslation() - const showStudentDisclaimer = planCodes.includes(planCode) - - return ( -
- - -

{t('select_a_payment_method')}

- -
- {showStudentDisclaimer && ( - - -

{t('student_disclaimer')}

- -
- )} -
- ) -} - -export default PriceSwitchHeader diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/submit-button.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/submit-button.tsx deleted file mode 100644 index e2e28bf685..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/checkout/submit-button.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { Button } from 'react-bootstrap' -import Icon from '../../../../../shared/components/icon' - -type SubmitButtonProps = { - isProcessing: boolean - isFormValid: boolean - children: React.ReactNode -} - -function SubmitButton(props: SubmitButtonProps) { - const { t } = useTranslation() - - return ( - - ) -} - -export default SubmitButton diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/three-d-secure.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/three-d-secure.tsx deleted file mode 100644 index c349310783..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/checkout/three-d-secure.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useEffect, useRef } from 'react' -import { useTranslation } from 'react-i18next' -import { Alert } from 'react-bootstrap' -import { RecurlyError, TokenPayload } from 'recurly__recurly-js' - -type ThreeDSecureProps = { - actionTokenId: string - onToken: (token: TokenPayload) => void - onError: (error: RecurlyError) => void -} - -function ThreeDSecure({ actionTokenId, onToken, onError }: ThreeDSecureProps) { - const { t } = useTranslation() - const container = useRef(null) - const recurlyContainer = useRef(null) - - useEffect(() => { - // scroll the UI into view (timeout needed to make sure the element is - // visible) - const timeout = setTimeout(() => { - container.current?.scrollIntoView() - }, 0) - - return () => { - clearTimeout(timeout) - } - }, []) - - useEffect(() => { - if (!recurly || !recurlyContainer.current) return - - // instanciate and configure Recurly 3DSecure flow - const risk = recurly.Risk() - const threeDSecure = risk.ThreeDSecure({ actionTokenId }) - - threeDSecure.on('token', onToken) - threeDSecure.on('error', onError) - threeDSecure.attach(recurlyContainer.current) - - return () => { - recurlyContainer.current = null - } - }, [actionTokenId, onToken, onError]) - - return ( -
- - {t('card_must_be_authenticated_by_3dsecure')} - -
-
- ) -} - -export default ThreeDSecure diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/tos-agreement-notice.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/tos-agreement-notice.tsx deleted file mode 100644 index dd9ad38ecb..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/checkout/tos-agreement-notice.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Trans } from 'react-i18next' - -function TosAgreementNotice() { - return ( -

- , - ]} - /> -

- ) -} - -export default TosAgreementNotice diff --git a/services/web/frontend/js/features/subscription/components/new/payment-preview/collaborators.tsx b/services/web/frontend/js/features/subscription/components/new/payment-preview/collaborators.tsx deleted file mode 100644 index ff9ea1d113..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/payment-preview/collaborators.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { Plan } from '../../../../../../../types/subscription/plan' - -function CollaboratorsWrapper({ children }: { children: React.ReactNode }) { - return
{children}
-} - -type CollaboratorsProps = { - count: NonNullable['collaborators'] -} - -function Collaborators({ count }: CollaboratorsProps) { - const { t } = useTranslation() - - if (count === 1) { - return ( - - {t('collabs_per_proj_single', { collabcount: 1 })} - - ) - } - - if (count > 1) { - return ( - - {t('collabs_per_proj', { collabcount: count })} - - ) - } - - if (count === -1) { - return {t('unlimited_collabs')} - } - - return null -} - -export default Collaborators diff --git a/services/web/frontend/js/features/subscription/components/new/payment-preview/currency-dropdown.tsx b/services/web/frontend/js/features/subscription/components/new/payment-preview/currency-dropdown.tsx deleted file mode 100644 index 3ff8883e8c..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/payment-preview/currency-dropdown.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { Dropdown, DropdownProps, MenuItem } from 'react-bootstrap' -import Icon from '../../../../../shared/components/icon' -import { usePaymentContext } from '../../../context/payment-context' - -function CurrencyDropdown(props: DropdownProps) { - const { t } = useTranslation() - const { currencyCode, limitedCurrencies, changeCurrency } = - usePaymentContext() - - return ( - - - {t('change_currency')} - - - {Object.entries(limitedCurrencies).map(([currency, symbol]) => ( - changeCurrency(eventKey)} - > - {currency === currencyCode && ( - - - - )} - {currency} ({symbol}) - - ))} - - - ) -} - -export default CurrencyDropdown diff --git a/services/web/frontend/js/features/subscription/components/new/payment-preview/features-list.tsx b/services/web/frontend/js/features/subscription/components/new/payment-preview/features-list.tsx deleted file mode 100644 index 393f8941ab..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/payment-preview/features-list.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { Plan } from '../../../../../../../types/subscription/plan' - -type FeaturesListProps = { - features: NonNullable -} - -function FeaturesList({ features }: FeaturesListProps) { - const { t } = useTranslation() - - return ( - <> -
{t('all_premium_features_including')}
-
    - {features.compileTimeout > 1 && ( -
  • {t('increased_compile_timeout')}
  • - )} - {features.dropbox && features.github && ( -
  • {t('sync_dropbox_github')}
  • - )} - {features.versioning &&
  • {t('full_doc_history')}
  • } - {features.trackChanges &&
  • {t('track_changes')}
  • } - {features.references &&
  • {t('reference_search')}
  • } - {(features.mendeley || features.zotero) && ( -
  • {t('reference_sync')}
  • - )} - {features.symbolPalette &&
  • {t('symbol_palette')}
  • } -
- - ) -} - -export default FeaturesList diff --git a/services/web/frontend/js/features/subscription/components/new/payment-preview/no-discount-price.tsx b/services/web/frontend/js/features/subscription/components/new/payment-preview/no-discount-price.tsx deleted file mode 100644 index 964d5b8165..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/payment-preview/no-discount-price.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { usePaymentContext } from '../../../context/payment-context' - -function NoDiscountPrice() { - const { t } = useTranslation() - const { currencySymbol, monthlyBilling, coupon } = usePaymentContext() - - if (coupon?.normalPrice === undefined) { - return null - } - - const price = `${currencySymbol}${coupon.normalPrice.toFixed(2)}` - - return ( -
- {!coupon.singleUse && - coupon.discountMonths && - coupon.discountMonths > 0 && - monthlyBilling && - t('then_x_price_per_month', { price })} - {!coupon.singleUse && - !coupon.discountMonths && - monthlyBilling && - t('normally_x_price_per_month', { price })} - {!coupon.singleUse && - !monthlyBilling && - t('normally_x_price_per_year', { price })} - {coupon.singleUse && - monthlyBilling && - t('then_x_price_per_month', { price })} - {coupon.singleUse && - !monthlyBilling && - t('then_x_price_per_year', { price })} -
- ) -} - -export default NoDiscountPrice diff --git a/services/web/frontend/js/features/subscription/components/new/payment-preview/payment-preview-panel.tsx b/services/web/frontend/js/features/subscription/components/new/payment-preview/payment-preview-panel.tsx deleted file mode 100644 index 64200635a6..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/payment-preview/payment-preview-panel.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useTranslation } from 'react-i18next' -import Collaborators from './collaborators' -import FeaturesList from './features-list' -import PriceSummary from './price-summary' -import TrialCouponSummary from './trial-coupon-summary' -import { usePaymentContext } from '../../../context/payment-context' - -function PaymentPreviewPanel() { - const { t } = useTranslation() - const { plan, planName } = usePaymentContext() - - return ( -
-

{planName}

- {plan.features && ( - <> - - - - )} - - -
-

{t('cancel_anytime')}

-
- ) -} - -export default PaymentPreviewPanel diff --git a/services/web/frontend/js/features/subscription/components/new/payment-preview/price-for-first-x-period.tsx b/services/web/frontend/js/features/subscription/components/new/payment-preview/price-for-first-x-period.tsx deleted file mode 100644 index c32d830f39..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/payment-preview/price-for-first-x-period.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Trans } from 'react-i18next' -import { usePaymentContext } from '../../../context/payment-context' - -function PriceForFirstXPeriod() { - const { currencySymbol, monthlyBilling, coupon, recurlyPrice } = - usePaymentContext() - - if (!recurlyPrice || !coupon) { - return null - } - - const price = `${currencySymbol}${recurlyPrice.total}` - - return ( -
- {coupon.discountMonths && - coupon.discountMonths > 0 && - !coupon.singleUse && - monthlyBilling && ( - ]} // eslint-disable-line react/jsx-key - values={{ - discountMonths: coupon.discountMonths, - price, - }} - /> - )} - {coupon.singleUse && monthlyBilling && ( - ]} // eslint-disable-line react/jsx-key - values={{ price }} - /> - )} - {coupon.singleUse && !monthlyBilling && ( -
- ]} // eslint-disable-line react/jsx-key - values={{ price }} - /> -
- )} -
- ) -} - -export default PriceForFirstXPeriod diff --git a/services/web/frontend/js/features/subscription/components/new/payment-preview/price-summary.tsx b/services/web/frontend/js/features/subscription/components/new/payment-preview/price-summary.tsx deleted file mode 100644 index bad13d4ece..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/payment-preview/price-summary.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { usePaymentContext } from '../../../context/payment-context' -import CurrencyDropdown from './currency-dropdown' - -function PriceSummary() { - const { t } = useTranslation() - const { - coupon, - currencySymbol, - recurlyPrice, - planName, - taxes, - monthlyBilling, - } = usePaymentContext() - - if (!recurlyPrice) { - return null - } - - const rate = parseFloat(taxes?.[0]?.rate) - const subtotal = - coupon?.normalPriceWithoutTax.toFixed(2) ?? recurlyPrice.subtotal - - return ( - <> -
-
-

{t('payment_summary')}

-
-
- {planName} - - {currencySymbol} - {subtotal} - -
- {coupon && ( -
- {coupon.name} - - –{currencySymbol} - {recurlyPrice.discount} - - - {t('discount_of', { - amount: `${currencySymbol}${recurlyPrice.discount}`, - })} - -
- )} - {rate > 0 && ( -
- - {t('vat')} {rate * 100}% - - - {currencySymbol} - {recurlyPrice.tax} - -
- )} -
- {monthlyBilling ? t('total_per_month') : t('total_per_year')} - - {currencySymbol} - {recurlyPrice.total} - -
-
-
- -
-
- - ) -} - -export default PriceSummary diff --git a/services/web/frontend/js/features/subscription/components/new/payment-preview/trial-coupon-summary.tsx b/services/web/frontend/js/features/subscription/components/new/payment-preview/trial-coupon-summary.tsx deleted file mode 100644 index cb72e01745..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/payment-preview/trial-coupon-summary.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import TrialPrice from './trial-price' -import NoDiscountPrice from './no-discount-price' -import PriceForFirstXPeriod from './price-for-first-x-period' - -function TrialCouponSummary() { - const children = [TrialPrice, NoDiscountPrice, PriceForFirstXPeriod].map( - (Component, index) => - ) - - const showChildren = children.some(child => child.type() != null) - - if (!showChildren) return null - - return ( - <> -
-
- {children} -
- - ) -} - -export default TrialCouponSummary diff --git a/services/web/frontend/js/features/subscription/components/new/payment-preview/trial-price.tsx b/services/web/frontend/js/features/subscription/components/new/payment-preview/trial-price.tsx deleted file mode 100644 index 91872a2df4..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/payment-preview/trial-price.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Trans } from 'react-i18next' -import { usePaymentContext } from '../../../context/payment-context' - -function TrialPrice() { - const { currencySymbol, plan, recurlyPrice, trialLength } = - usePaymentContext() - - if (!trialLength || !recurlyPrice) { - return null - } - - return ( -
- {plan.annual ? ( - , ]} // eslint-disable-line react/jsx-key - values={{ - trialLen: trialLength, - price: `${currencySymbol}${recurlyPrice.total}`, - }} - /> - ) : ( - , ]} // eslint-disable-line react/jsx-key - values={{ - trialLen: trialLength, - price: `${currencySymbol}${recurlyPrice.total}`, - }} - /> - )} -
- ) -} - -export default TrialPrice diff --git a/services/web/frontend/js/features/subscription/components/new/root.tsx b/services/web/frontend/js/features/subscription/components/new/root.tsx deleted file mode 100644 index 95aca17ce5..0000000000 --- a/services/web/frontend/js/features/subscription/components/new/root.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n' -import PaymentPreviewPanel from './payment-preview/payment-preview-panel' -import CheckoutPanel from './checkout/checkout-panel' -import { Col, Row } from 'react-bootstrap' -import { PaymentProvider } from '../../context/payment-context' -import getMeta from '../../../../utils/meta' - -function Root() { - const { isReady } = useWaitForI18n() - - if (!isReady) { - return null - } - - const publicKey = getMeta('ol-recurlyApiKey') - - return ( - -
- - -
- -
- - -
- -
- -
-
-
- ) -} - -export default Root diff --git a/services/web/frontend/js/features/subscription/context/payment-context.tsx b/services/web/frontend/js/features/subscription/context/payment-context.tsx deleted file mode 100644 index ad6b0adf1b..0000000000 --- a/services/web/frontend/js/features/subscription/context/payment-context.tsx +++ /dev/null @@ -1,365 +0,0 @@ -import { - useState, - useEffect, - useLayoutEffect, - useMemo, - useCallback, - useRef, - useContext, - createContext, -} from 'react' -import { currencies, CurrencyCode, CurrencySymbol } from '../data/currency' -import { useTranslation } from 'react-i18next' -import getMeta from '../../../utils/meta' -import { getSplitTestVariant } from '../../../../../frontend/js/utils/splitTestUtils' -import * as eventTracking from '../../../infrastructure/event-tracking' -import { - PaymentContextValue, - PricingFormState, -} from './types/payment-context-value' -import { Plan } from '../../../../../types/subscription/plan' -import { - RecurlyOptions, - SubscriptionPricingStateTax, -} from 'recurly__recurly-js' -import { SubscriptionPricingInstanceCustom } from '../../../../../types/recurly/pricing/subscription' - -function usePayment({ publicKey }: RecurlyOptions) { - const { t } = useTranslation() - const plan: Plan = getMeta('ol-plan') - const initialCountry: PricingFormState['country'] = getMeta( - 'ol-countryCode', - '' - ) - const initialCouponCode: PricingFormState['coupon'] = getMeta( - 'ol-couponCode', - '' - ) - const initiallySelectedCurrencyCode: CurrencyCode = getMeta( - 'ol-recommendedCurrency' - ) - const planCode: string = getMeta('ol-planCode') - const designSystemUpdatesVariant = getSplitTestVariant( - 'design-system-updates', - 'default' - ) - - const [planName, setPlanName] = useState(plan.name) - const [recurlyLoading, setRecurlyLoading] = useState(true) - const [recurlyLoadError, setRecurlyLoadError] = useState(false) - const [recurlyPrice, setRecurlyPrice] = useState<{ - subtotal: string - plan: string - addons: string - setup_fee: string - discount: string - tax: string - total: string - }>() - const [monthlyBilling, setMonthlyBilling] = useState() - const [taxes, setTaxes] = useState([]) - const [coupon, setCoupon] = useState<{ - discountMonths?: number - discountRate?: number - singleUse: boolean - normalPrice: number - name: string - normalPriceWithoutTax: number - }>() - const [couponError, setCouponError] = useState('') - const [trialLength, setTrialLength] = useState() - const [currencyCode, setCurrencyCode] = useState( - initiallySelectedCurrencyCode - ) - const [pricingFormState, setPricingFormState] = useState({ - first_name: '', - last_name: '', - postal_code: '', - address1: '', - address2: '', - state: '', - city: '', - company: '', - vat_number: '', - country: initialCountry, - coupon: initialCouponCode, - }) - const pricing = useRef() - - const limitedCurrencyCodes = Array.from( - new Set([initiallySelectedCurrencyCode, 'USD', 'EUR', 'GBP']) - ) - const limitedCurrencies = limitedCurrencyCodes.reduce((prev, cur) => { - return { ...prev, [cur]: currencies[cur] } - }, {} as Partial) - const currencySymbol = limitedCurrencies[currencyCode] as CurrencySymbol - - useLayoutEffect(() => { - if (typeof recurly === 'undefined' || !recurly) { - setRecurlyLoadError(true) - return - } - - eventTracking.sendMB('payment-page-view', { - plan: planCode, - currency: currencyCode, - 'split-test-design-system-updates': designSystemUpdatesVariant, - }) - eventTracking.send( - 'subscription-funnel', - 'subscription-form-viewed', - planCode - ) - - recurly.configure({ publicKey }) - pricing.current = - recurly.Pricing.Subscription() as SubscriptionPricingInstanceCustom - - const setupPricing = () => { - setRecurlyLoading(true) - - pricing.current - ?.plan(planCode, { quantity: 1 }) - .address({ - first_name: '', - last_name: '', - country: initialCountry, - }) - .tax({ tax_code: 'digital', vat_number: '' }) - .currency(currencyCode) - .coupon(initialCouponCode) - .catch(function (err) { - if (currencyCode !== 'USD' && err.name === 'invalid-currency') { - setCurrencyCode('USD') - setupPricing() - } else if (err.name === 'api-error' && err.code === 'not-found') { - // not-found here should refer to the coupon code, plan_code should be valid - setCouponError(t('coupon_code_is_not_valid_for_selected_plan')) - } else { - // Bail out on other errors, form state will not be correct - setRecurlyLoadError(true) - throw err - } - }) - .done(() => { - setRecurlyLoading(false) - }) - } - - setupPricing() - }, [ - designSystemUpdatesVariant, - initialCountry, - initialCouponCode, - initiallySelectedCurrencyCode, - planCode, - publicKey, - currencyCode, - t, - ]) - - useEffect(() => { - pricing.current?.on('change', function () { - if (!pricing.current) return - - const planName = pricing.current.items.plan?.name - if (planName) { - setPlanName(planName) - } - - const trialLength = pricing.current.items.plan?.trial?.length - setTrialLength(trialLength) - - const recurlyPrice = trialLength - ? pricing.current.price.next - : pricing.current.price.now - setRecurlyPrice(recurlyPrice) - - const monthlyBilling = pricing.current.items.plan?.period.length === 1 - setMonthlyBilling(monthlyBilling) - - setTaxes(pricing.current.price.taxes) - - const couponData = (() => { - if (pricing.current.items.coupon?.discount.type === 'percent') { - const coupon = pricing.current.items.coupon - const basePrice = parseInt(pricing.current.price.base.plan.unit, 10) - const discountData = - coupon.applies_for_months > 0 && coupon.discount.rate - ? { - discountMonths: coupon.applies_for_months, - discountRate: coupon.discount.rate * 100, - } - : {} - - const couponData = { - singleUse: coupon.single_use, - normalPrice: basePrice, - name: coupon.name, - normalPriceWithoutTax: basePrice, - ...discountData, - } - - if (pricing.current.price.taxes[0]?.rate) { - couponData.normalPrice += - basePrice * parseFloat(pricing.current.price.taxes[0].rate) - } - - return couponData - } - })() - setCoupon(couponData) - }) - }, [currencyCode]) - - const addCoupon = useCallback( - (coupon: PricingFormState['coupon']) => { - setRecurlyLoading(true) - setCouponError('') - - pricing.current - ?.coupon(coupon) - .catch(function (err) { - if (err.name === 'api-error' && err.code === 'not-found') { - setCouponError(t('coupon_code_is_not_valid_for_selected_plan')) - } else { - setCouponError( - t('an_error_occurred_when_verifying_the_coupon_code') - ) - throw err - } - }) - .done(() => { - setRecurlyLoading(false) - }) - }, - [t] - ) - - const updateCountry = useCallback( - (country: PricingFormState['country']) => { - setRecurlyLoading(true) - - pricing.current - ?.address({ - country, - first_name: pricingFormState.first_name, - last_name: pricingFormState.last_name, - }) - .done(() => { - setRecurlyLoading(false) - }) - }, - [pricingFormState.first_name, pricingFormState.last_name] - ) - - const applyVatNumber = useCallback( - (vatNumber: PricingFormState['vat_number']) => { - setRecurlyLoading(true) - - pricing.current - ?.tax({ tax_code: 'digital', vat_number: vatNumber }) - .done(() => { - setRecurlyLoading(false) - }) - }, - [] - ) - - const changeCurrency = useCallback( - (newCurrency: CurrencyCode) => { - setRecurlyLoading(true) - setCurrencyCode(newCurrency) - - pricing.current - ?.currency(newCurrency) - .catch(function (err) { - if (currencyCode !== 'USD' && err.name === 'invalid-currency') { - setCurrencyCode('USD') - } else { - throw err - } - }) - .done(() => { - setRecurlyLoading(false) - }) - }, - [currencyCode] - ) - - const value = useMemo( - () => ({ - currencyCode, - setCurrencyCode, - currencySymbol, - limitedCurrencies, - pricingFormState, - setPricingFormState, - plan, - planCode, - planName, - pricing, - recurlyLoading, - recurlyLoadError, - recurlyPrice, - monthlyBilling, - taxes, - coupon, - couponError, - trialLength, - addCoupon, - applyVatNumber, - changeCurrency, - updateCountry, - }), - [ - currencyCode, - setCurrencyCode, - currencySymbol, - limitedCurrencies, - pricingFormState, - setPricingFormState, - plan, - planCode, - planName, - pricing, - recurlyLoading, - recurlyLoadError, - recurlyPrice, - monthlyBilling, - taxes, - coupon, - couponError, - trialLength, - addCoupon, - applyVatNumber, - changeCurrency, - updateCountry, - ] - ) - - return { value } -} - -export const PaymentContext = createContext( - undefined -) - -type PaymentProviderProps = { - publicKey: string - children?: React.ReactNode -} - -export function PaymentProvider({ publicKey, ...props }: PaymentProviderProps) { - const { value } = usePayment({ publicKey }) - - return -} - -export function usePaymentContext() { - const context = useContext(PaymentContext) - if (!context) { - throw new Error('PaymentContext is only available inside PaymentProvider') - } - return context -} diff --git a/services/web/frontend/js/features/subscription/context/types/payment-context-value.tsx b/services/web/frontend/js/features/subscription/context/types/payment-context-value.tsx deleted file mode 100644 index 28e94a4c57..0000000000 --- a/services/web/frontend/js/features/subscription/context/types/payment-context-value.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import countries from '../../data/countries' -import { Plan } from '../../../../../../types/subscription/plan' -import { SubscriptionPricingStateTax } from 'recurly__recurly-js' -import { SubscriptionPricingInstanceCustom } from '../../../../../../types/recurly/pricing/subscription' -import { currencies, CurrencyCode, CurrencySymbol } from '../../data/currency' - -export type PricingFormState = { - first_name: string - last_name: string - postal_code: string - address1: string - address2: string - state: string - city: string - company: string - vat_number: string - country: typeof countries[number]['code'] | '' - coupon: string -} - -export type PaymentContextValue = { - currencyCode: CurrencyCode - setCurrencyCode: React.Dispatch< - React.SetStateAction - > - currencySymbol: CurrencySymbol - limitedCurrencies: Partial - pricingFormState: PricingFormState - setPricingFormState: React.Dispatch< - React.SetStateAction - > - plan: Plan - planCode: string - planName: string - pricing: React.MutableRefObject - recurlyLoading: boolean - recurlyLoadError: boolean - recurlyPrice: - | { - subtotal: string - plan: string - addons: string - setup_fee: string - discount: string - tax: string - total: string - } - | undefined - monthlyBilling: boolean | undefined - taxes: SubscriptionPricingStateTax[] - coupon: - | { - discountMonths?: number - discountRate?: number - singleUse: boolean - normalPrice: number - name: string - normalPriceWithoutTax: number - } - | undefined - couponError: string - trialLength: number | undefined - applyVatNumber: (vatNumber: PricingFormState['vat_number']) => void - addCoupon: (coupon: PricingFormState['coupon']) => void - changeCurrency: (newCurrency: CurrencyCode) => void - updateCountry: (country: PricingFormState['country']) => void -} diff --git a/services/web/frontend/stories/subscription/new/checkout/address-first-line.stories.tsx b/services/web/frontend/stories/subscription/new/checkout/address-first-line.stories.tsx deleted file mode 100644 index 86907d72e7..0000000000 --- a/services/web/frontend/stories/subscription/new/checkout/address-first-line.stories.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useState } from 'react' -import AddressFirstLineComponent from '../../../../js/features/subscription/components/new/checkout/address-first-line' - -type Args = Pick< - React.ComponentProps, - 'errorFields' -> - -const Template = ({ errorFields }: Args) => { - const [value, setValue] = useState('') - - return ( - setValue(e.target.value)} - /> - ) -} - -export const AddressFirstLineDefault = Template.bind({}) as typeof Template & { - args: Args -} -AddressFirstLineDefault.args = { - errorFields: { - address1: false, - }, -} - -export const AddressFirstLineError = Template.bind({}) as typeof Template & { - args: Args -} -AddressFirstLineError.args = { - errorFields: { - address1: true, - }, -} - -export default { - title: 'Subscription / New / Checkout / Form Fields', - component: AddressFirstLineComponent, - argTypes: { - value: { - table: { - disable: true, - }, - }, - onChange: { - table: { - disable: true, - }, - }, - }, - decorators: [ - (Story: React.ComponentType) => ( -
- -
- ), - ], -} diff --git a/services/web/frontend/stories/subscription/new/checkout/address-second-line.stories.tsx b/services/web/frontend/stories/subscription/new/checkout/address-second-line.stories.tsx deleted file mode 100644 index 6c281d0fe0..0000000000 --- a/services/web/frontend/stories/subscription/new/checkout/address-second-line.stories.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useState } from 'react' -import AddressSecondLineComponent from '../../../../js/features/subscription/components/new/checkout/address-second-line' - -type Args = Pick< - React.ComponentProps, - 'errorFields' -> - -const Template = ({ errorFields }: Args) => { - const [value, setValue] = useState('') - - return ( - setValue(e.target.value)} - /> - ) -} - -export const AddressSecondLineDefault = Template.bind({}) as typeof Template & { - args: Args -} -AddressSecondLineDefault.args = { - errorFields: { - address2: false, - }, -} - -export const AddressSecondLineError = Template.bind({}) as typeof Template & { - args: Args -} -AddressSecondLineError.args = { - errorFields: { - address2: true, - }, -} - -export default { - title: 'Subscription / New / Checkout / Form Fields', - component: AddressSecondLineComponent, - argTypes: { - value: { - table: { - disable: true, - }, - }, - onChange: { - table: { - disable: true, - }, - }, - }, - decorators: [ - (Story: React.ComponentType) => ( -
- -
- ), - ], -} diff --git a/services/web/frontend/stories/subscription/new/checkout/card-element.stories.tsx b/services/web/frontend/stories/subscription/new/checkout/card-element.stories.tsx deleted file mode 100644 index 2d4be68294..0000000000 --- a/services/web/frontend/stories/subscription/new/checkout/card-element.stories.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useRef } from 'react' -import CardElementComponent from '../../../../js/features/subscription/components/new/checkout/card-element' -import ExternalScriptLoader from '../../../../js/shared/utils/external-script-loader' - -export const CardElement = () => { - const elements = useRef(recurly.Elements()) - - return ( - {}} /> - ) -} - -export default { - title: 'Subscription / New / Checkout / Form Fields', - component: CardElementComponent, - argTypes: { - elements: { - table: { - disable: true, - }, - }, - onChange: { - table: { - disable: true, - }, - }, - }, - decorators: [ - (Story: React.ComponentType) => ( -
- - - -
- ), - ], -} diff --git a/services/web/frontend/stories/subscription/new/checkout/company-details.stories.tsx b/services/web/frontend/stories/subscription/new/checkout/company-details.stories.tsx deleted file mode 100644 index 731fc49449..0000000000 --- a/services/web/frontend/stories/subscription/new/checkout/company-details.stories.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useState } from 'react' -import CompanyDetailsComponent from '../../../../js/features/subscription/components/new/checkout/company-details' -import { PaymentProvider } from '../helpers/context-provider' -import { - PaymentContextValue, - PricingFormState, -} from '../../../../js/features/subscription/context/types/payment-context-value' - -type Args = Pick< - React.ComponentProps, - 'taxesCount' -> - -export const CompanyDetails = (args: Args) => { - const [pricingFormState, setPricingFormState] = useState({ - first_name: '', - last_name: '', - postal_code: '', - address1: '', - address2: '', - state: '', - city: '', - company: '', - vat_number: '', - country: 'GB', - coupon: '', - }) - - const providerValue = { - applyVatNumber: () => {}, - pricingFormState, - setPricingFormState, - } as unknown as PaymentContextValue - - return ( - - - - ) -} - -export default { - title: 'Subscription / New / Checkout / Form Fields', - component: CompanyDetailsComponent, - argTypes: { - taxesCount: { - control: { - type: 'number', - }, - }, - }, - args: { - taxesCount: 1, - }, - decorators: [ - (Story: React.ComponentType) => ( -
- -
- ), - ], -} diff --git a/services/web/frontend/stories/subscription/new/checkout/country-select.stories.tsx b/services/web/frontend/stories/subscription/new/checkout/country-select.stories.tsx deleted file mode 100644 index ad54c290e3..0000000000 --- a/services/web/frontend/stories/subscription/new/checkout/country-select.stories.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useState } from 'react' -import countries from '../../../../js/features/subscription/data/countries' -import CountrySelectComponent from '../../../../js/features/subscription/components/new/checkout/country-select' -import { PaymentProvider } from '../helpers/context-provider' -import { PaymentContextValue } from '../../../../js/features/subscription/context/types/payment-context-value' - -type Args = Pick< - React.ComponentProps, - 'errorFields' -> - -const Template = ({ errorFields }: Args) => { - const [value, setValue] = useState('GB') - const providerValue = { - updateCountry: () => {}, - } as unknown as PaymentContextValue - - return ( - - - setValue(e.target.value as typeof countries[number]['code']) - } - /> - - ) -} - -export const CountrySelectDefault = Template.bind({}) as typeof Template & { - args: Args -} -CountrySelectDefault.args = { - errorFields: { - country: false, - }, -} - -export const CountrySelectError = Template.bind({}) as typeof Template & { - args: Args -} -CountrySelectError.args = { - errorFields: { - country: true, - }, -} - -export default { - title: 'Subscription / New / Checkout / Form Fields', - component: CountrySelectComponent, - argTypes: { - value: { - table: { - disable: true, - }, - }, - onChange: { - table: { - disable: true, - }, - }, - }, - decorators: [ - (Story: React.ComponentType) => ( -
- -
- ), - ], -} diff --git a/services/web/frontend/stories/subscription/new/checkout/coupon-code.stories.tsx b/services/web/frontend/stories/subscription/new/checkout/coupon-code.stories.tsx deleted file mode 100644 index f424d4c543..0000000000 --- a/services/web/frontend/stories/subscription/new/checkout/coupon-code.stories.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useState } from 'react' -import CouponCodeComponent from '../../../../js/features/subscription/components/new/checkout/coupon-code' -import { PaymentProvider } from '../helpers/context-provider' -import { PaymentContextValue } from '../../../../js/features/subscription/context/types/payment-context-value' - -export const CouponCode = () => { - const [value, setValue] = useState('') - const providerValue = { - addCoupon: () => {}, - } as unknown as PaymentContextValue - - return ( - - setValue(e.target.value)} - /> - - ) -} - -export default { - title: 'Subscription / New / Checkout / Form Fields', - component: CouponCodeComponent, - argTypes: { - value: { - table: { - disable: true, - }, - }, - onChange: { - table: { - disable: true, - }, - }, - }, - decorators: [ - (Story: React.ComponentType) => ( -
- -
- ), - ], -} diff --git a/services/web/frontend/stories/subscription/new/checkout/first-name.stories.tsx b/services/web/frontend/stories/subscription/new/checkout/first-name.stories.tsx deleted file mode 100644 index 717713b66e..0000000000 --- a/services/web/frontend/stories/subscription/new/checkout/first-name.stories.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useState } from 'react' -import FirstNameComponent from '../../../../js/features/subscription/components/new/checkout/first-name' - -type Args = Pick, 'errorFields'> - -const Template = ({ errorFields }: Args) => { - const [value, setValue] = useState('') - - return ( - setValue(e.target.value)} - /> - ) -} - -export const FirstNameDefault = Template.bind({}) as typeof Template & { - args: Args -} -FirstNameDefault.args = { - errorFields: { - first_name: false, - }, -} - -export const FirstNameError = Template.bind({}) as typeof Template & { - args: Args -} -FirstNameError.args = { - errorFields: { - first_name: true, - }, -} - -export default { - title: 'Subscription / New / Checkout / Form Fields', - component: FirstNameComponent, - argTypes: { - value: { - table: { - disable: true, - }, - }, - onChange: { - table: { - disable: true, - }, - }, - }, - decorators: [ - (Story: React.ComponentType) => ( -
- -
- ), - ], -} diff --git a/services/web/frontend/stories/subscription/new/checkout/last-name.stories.tsx b/services/web/frontend/stories/subscription/new/checkout/last-name.stories.tsx deleted file mode 100644 index c33fc97ff6..0000000000 --- a/services/web/frontend/stories/subscription/new/checkout/last-name.stories.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useState } from 'react' -import LastNameComponent from '../../../../js/features/subscription/components/new/checkout/last-name' - -type Args = Pick, 'errorFields'> - -const Template = ({ errorFields }: Args) => { - const [value, setValue] = useState('') - - return ( - setValue(e.target.value)} - /> - ) -} - -export const LastNameDefault = Template.bind({}) as typeof Template & { - args: Args -} -LastNameDefault.args = { - errorFields: { - last_name: false, - }, -} - -export const LastNameError = Template.bind({}) as typeof Template & { - args: Args -} -LastNameError.args = { - errorFields: { - last_name: true, - }, -} - -export default { - title: 'Subscription / New / Checkout / Form Fields', - component: LastNameComponent, - argTypes: { - value: { - table: { - disable: true, - }, - }, - onChange: { - table: { - disable: true, - }, - }, - }, - decorators: [ - (Story: React.ComponentType) => ( -
- -
- ), - ], -} diff --git a/services/web/frontend/stories/subscription/new/checkout/payment-method-toggle.stories.tsx b/services/web/frontend/stories/subscription/new/checkout/payment-method-toggle.stories.tsx deleted file mode 100644 index 498ba513d0..0000000000 --- a/services/web/frontend/stories/subscription/new/checkout/payment-method-toggle.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useState } from 'react' -import PaymentMethodToggleComponent from '../../../../js/features/subscription/components/new/checkout/payment-method-toggle' - -export const PaymentMethodToggle = () => { - const [paymentMethod, setPaymentMethod] = useState('credit_card') - - return ( - setPaymentMethod(e.target.value)} - /> - ) -} - -export default { - title: 'Subscription / New / Checkout', - component: PaymentMethodToggleComponent, - decorators: [ - (Story: React.ComponentType) => ( -
- -
- ), - ], -} diff --git a/services/web/frontend/stories/subscription/new/checkout/postal-code.stories.tsx b/services/web/frontend/stories/subscription/new/checkout/postal-code.stories.tsx deleted file mode 100644 index e7b483b24f..0000000000 --- a/services/web/frontend/stories/subscription/new/checkout/postal-code.stories.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useState } from 'react' -import PostalCodeComponent from '../../../../js/features/subscription/components/new/checkout/postal-code' - -type Args = Pick< - React.ComponentProps, - 'errorFields' -> - -const Template = ({ errorFields }: Args) => { - const [value, setValue] = useState('') - - return ( - setValue(e.target.value)} - /> - ) -} - -export const PostalCodeDefault = Template.bind({}) as typeof Template & { - args: Args -} -PostalCodeDefault.args = { - errorFields: { - postal_code: false, - }, -} - -export const PostalCodeError = Template.bind({}) as typeof Template & { - args: Args -} -PostalCodeError.args = { - errorFields: { - postal_code: true, - }, -} - -export default { - title: 'Subscription / New / Checkout / Form Fields', - component: PostalCodeComponent, - argTypes: { - value: { - table: { - disable: true, - }, - }, - onChange: { - table: { - disable: true, - }, - }, - }, - decorators: [ - (Story: React.ComponentType) => ( -
- -
- ), - ], -} diff --git a/services/web/frontend/stories/subscription/new/checkout/price-switch-header.stories.tsx b/services/web/frontend/stories/subscription/new/checkout/price-switch-header.stories.tsx deleted file mode 100644 index 2e560b862c..0000000000 --- a/services/web/frontend/stories/subscription/new/checkout/price-switch-header.stories.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import PriceSwitchHeaderComponent from '../../../../js/features/subscription/components/new/checkout/price-switch-header' - -type Args = React.ComponentProps - -export const PriceSwitchHeader = (args: Args) => ( - -) - -const options = { - current: 'fake_plan', - other: 'fake_plan_new', -} - -export default { - title: 'Subscription / New / Checkout', - component: PriceSwitchHeaderComponent, - argTypes: { - planCode: { - options: Object.values(options), - control: { - type: 'select', - labels: Object.entries(options).reduce( - (prev, [key, value]) => ({ ...prev, [String(value)]: key }), - {} - ), - }, - }, - }, - args: { - planCode: 'fake_plan', - planCodes: ['fake_plan'], - }, - decorators: [ - (Story: React.ComponentType) => ( -
- -
- ), - ], -} diff --git a/services/web/frontend/stories/subscription/new/checkout/submit-button.stories.tsx b/services/web/frontend/stories/subscription/new/checkout/submit-button.stories.tsx deleted file mode 100644 index 91c9867a68..0000000000 --- a/services/web/frontend/stories/subscription/new/checkout/submit-button.stories.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import SubmitButtonComponent from '../../../../js/features/subscription/components/new/checkout/submit-button' - -type Args = React.ComponentProps - -const Template = (args: Args) => - -export const SubmitButton = Template.bind({}) as typeof Template & { - args: Args -} -SubmitButton.args = { - isProcessing: false, - isFormValid: true, - children: 'Submit', -} - -export const SubmitButtonDisabled = Template.bind({}) as typeof Template & { - args: Args -} -SubmitButtonDisabled.args = { - isProcessing: false, - isFormValid: false, - children: 'Submit', -} - -export const SubmitButtonProcessing = Template.bind({}) as typeof Template & { - args: Args -} -SubmitButtonProcessing.args = { - isProcessing: true, - isFormValid: true, - children: 'Submit', -} - -export default { - title: 'Subscription / New / Checkout / Submit Button', - component: SubmitButtonComponent, - decorators: [ - (Story: React.ComponentType) => ( -
- -
- ), - ], -} diff --git a/services/web/frontend/stories/subscription/new/checkout/tos-agreement-notice.stories.tsx b/services/web/frontend/stories/subscription/new/checkout/tos-agreement-notice.stories.tsx deleted file mode 100644 index 8c90c8b67a..0000000000 --- a/services/web/frontend/stories/subscription/new/checkout/tos-agreement-notice.stories.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import TosAgreementNoticeComponent from '../../../../js/features/subscription/components/new/checkout/tos-agreement-notice' - -export const TosAgreementNotice = () => - -export default { - title: 'Subscription / New / Checkout', - decorators: [ - (Story: React.ComponentType) => ( -
- -
- ), - ], -} diff --git a/services/web/frontend/stories/subscription/new/helpers/context-provider.tsx b/services/web/frontend/stories/subscription/new/helpers/context-provider.tsx deleted file mode 100644 index 59fbff8c96..0000000000 --- a/services/web/frontend/stories/subscription/new/helpers/context-provider.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { PaymentContext } from '../../../../js/features/subscription/context/payment-context' -import { PaymentContextValue } from '../../../../js/features/subscription/context/types/payment-context-value' - -type PaymentProviderProps = { - children: React.ReactNode - value: PaymentContextValue -} - -export function PaymentProvider({ value, children }: PaymentProviderProps) { - return ( - {children} - ) -} diff --git a/services/web/frontend/stories/subscription/new/payment-preview/collaborators.stories.tsx b/services/web/frontend/stories/subscription/new/payment-preview/collaborators.stories.tsx deleted file mode 100644 index aace78dd5c..0000000000 --- a/services/web/frontend/stories/subscription/new/payment-preview/collaborators.stories.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import CollaboratorsComponent from '../../../../js/features/subscription/components/new/payment-preview/collaborators' - -type Args = React.ComponentProps - -export const Collaborators = (args: Args) => ( - -) - -const options = { - unlimited: -1, - single: 1, - multiple: 2, -} - -export default { - title: 'Subscription / New / Payment Preview', - component: CollaboratorsComponent, - argTypes: { - count: { - options: Object.values(options), - control: { - type: 'select', - labels: Object.entries(options).reduce( - (prev, [key, value]) => ({ ...prev, [String(value)]: key }), - {} - ), - }, - }, - }, - args: { - count: options.single, // default - }, - decorators: [ - (Story: React.ComponentType) => ( -
- -
- ), - ], -} diff --git a/services/web/frontend/stories/subscription/new/payment-preview/features-list.stories.tsx b/services/web/frontend/stories/subscription/new/payment-preview/features-list.stories.tsx deleted file mode 100644 index 0380a1f3f3..0000000000 --- a/services/web/frontend/stories/subscription/new/payment-preview/features-list.stories.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import FeaturesListComponent from '../../../../js/features/subscription/components/new/payment-preview/features-list' -import { Plan } from '../../../../../types/subscription/plan' - -type Args = React.ComponentProps - -export const FeaturesList = (args: Args) => - -const features = { - compileTimeout: 2, - dropbox: true, - github: true, - versioning: true, - trackChanges: true, - references: true, - mendeley: true, - zotero: true, - symbolPalette: true, -} as unknown as Plan['features'] - -export default { - title: 'Subscription / New / Payment Preview', - component: FeaturesListComponent, - args: { - features, - }, - decorators: [ - (Story: React.ComponentType) => ( -
- -
- ), - ], -} diff --git a/services/web/frontend/stories/subscription/new/payment-preview/no-discount-price.stories.tsx b/services/web/frontend/stories/subscription/new/payment-preview/no-discount-price.stories.tsx deleted file mode 100644 index 8bd10ccb9e..0000000000 --- a/services/web/frontend/stories/subscription/new/payment-preview/no-discount-price.stories.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import NoDiscountPriceComponent from '../../../../js/features/subscription/components/new/payment-preview/no-discount-price' -import { PaymentProvider } from '../helpers/context-provider' -import { PaymentContextValue } from '../../../../js/features/subscription/context/types/payment-context-value' - -type Args = Pick, 'value'> - -const Template = (args: Args) => { - return ( - - - - ) -} - -const commonValues = { - currencySymbol: '$', - coupon: { - normalPrice: 2, - }, -} - -export const ThenXPricePerMonth = Template.bind({}) as typeof Template & { - args: Args -} -const thenXPricePerMonthValue = { - ...commonValues, - monthlyBilling: true, - coupon: { - ...commonValues.coupon, - discountMonths: 2, - singleUse: false, - }, -} as PaymentContextValue -ThenXPricePerMonth.args = { - value: thenXPricePerMonthValue, -} - -export const ThenXPricePerYear = Template.bind({}) as typeof Template & { - args: Args -} -const thenXPricePerYearValue = { - ...commonValues, - monthlyBilling: false, - coupon: { - ...commonValues.coupon, - singleUse: true, - }, -} as PaymentContextValue -ThenXPricePerYear.args = { - value: thenXPricePerYearValue, -} - -export const NormallyXPricePerMonth = Template.bind({}) as typeof Template & { - args: Args -} -const normallyXPricePerMonthValue = { - ...commonValues, - monthlyBilling: true, - coupon: { - ...commonValues.coupon, - singleUse: false, - }, -} as PaymentContextValue -NormallyXPricePerMonth.args = { - value: normallyXPricePerMonthValue, -} - -export const NormallyXPricePerYear = Template.bind({}) as typeof Template & { - args: Args -} -const normallyXPricePerYearValue = { - ...commonValues, - monthlyBilling: false, - coupon: { - ...commonValues.coupon, - singleUse: false, - }, -} as PaymentContextValue -NormallyXPricePerYear.args = { - value: normallyXPricePerYearValue, -} - -export default { - title: 'Subscription / New / Payment Preview / No Discount Price', - component: NoDiscountPriceComponent, - decorators: [ - (Story: React.ComponentType) => ( -
- -
- ), - ], -} diff --git a/services/web/frontend/stories/subscription/new/payment-preview/price-for-first-x-period.stories.tsx b/services/web/frontend/stories/subscription/new/payment-preview/price-for-first-x-period.stories.tsx deleted file mode 100644 index d94350d073..0000000000 --- a/services/web/frontend/stories/subscription/new/payment-preview/price-for-first-x-period.stories.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import PriceForFirstXPeriod from '../../../../js/features/subscription/components/new/payment-preview/price-for-first-x-period' -import { PaymentProvider } from '../helpers/context-provider' -import { PaymentContextValue } from '../../../../js/features/subscription/context/types/payment-context-value' - -type Args = Pick, 'value'> - -const Template = (args: Args) => { - return ( - - - - ) -} - -const commonValues = { - currencySymbol: '$', - recurlyPrice: { - total: '10.00', - }, -} - -export const XPriceForYMonths = Template.bind({}) as typeof Template & { - args: Args -} -const xPriceForYMonthsValue = { - ...commonValues, - monthlyBilling: true, - coupon: { - discountMonths: 2, - singleUse: false, - }, -} as PaymentContextValue -XPriceForYMonths.args = { - value: xPriceForYMonthsValue, -} - -export const XPriceForFirstMonth = Template.bind({}) as typeof Template & { - args: Args -} -const xPriceForFirstMonthValue = { - ...commonValues, - monthlyBilling: true, - coupon: { - singleUse: true, - }, -} as PaymentContextValue -XPriceForFirstMonth.args = { - value: xPriceForFirstMonthValue, -} - -export const XPriceForFirstYear = Template.bind({}) as typeof Template & { - args: Args -} -const xPriceForFirstYearValue = { - ...commonValues, - monthlyBilling: false, - coupon: { - singleUse: true, - }, -} as PaymentContextValue -XPriceForFirstYear.args = { - value: xPriceForFirstYearValue, -} - -export default { - title: 'Subscription / New / Payment Preview / Price For First X Period', - component: PriceForFirstXPeriod, - decorators: [ - (Story: React.ComponentType) => ( -
- -
- ), - ], -} diff --git a/services/web/frontend/stories/subscription/new/payment-preview/price-summary.stories.tsx b/services/web/frontend/stories/subscription/new/payment-preview/price-summary.stories.tsx deleted file mode 100644 index bc06733f87..0000000000 --- a/services/web/frontend/stories/subscription/new/payment-preview/price-summary.stories.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import PriceSummaryComponent from '../../../../js/features/subscription/components/new/payment-preview/price-summary' -import { PaymentProvider } from '../helpers/context-provider' - -type Args = Pick, 'value'> - -export const PriceSummary = (args: Args) => { - return ( - - - - ) -} - -export default { - title: 'Subscription / New / Payment Preview', - component: PriceSummaryComponent, - args: { - value: { - currencyCode: 'USD', - currencySymbol: '$', - coupon: { - name: 'react', - normalPriceWithoutTax: 15, - }, - changeCurrency: (_eventKey: string) => {}, - limitedCurrencies: { - USD: '$', - EUR: '€', - }, - monthlyBilling: true, - planName: 'Test plan', - recurlyPrice: { - discount: '3', - tax: '5.00', - total: '10.00', - }, - taxes: [ - { - rate: '1', - }, - ], - }, - }, - decorators: [ - (Story: React.ComponentType) => ( -
- -
- ), - ], -} diff --git a/services/web/frontend/stories/subscription/new/payment-preview/trial-price.stories.tsx b/services/web/frontend/stories/subscription/new/payment-preview/trial-price.stories.tsx deleted file mode 100644 index 65edf0aa1b..0000000000 --- a/services/web/frontend/stories/subscription/new/payment-preview/trial-price.stories.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import TrialPriceComponent from '../../../../js/features/subscription/components/new/payment-preview/trial-price' -import { PaymentProvider } from '../helpers/context-provider' - -type Args = Pick, 'value'> - -export const TrialPrice = (args: Args) => { - return ( - - - - ) -} - -export default { - title: 'Subscription / New / Payment Preview', - component: TrialPriceComponent, - args: { - value: { - currencySymbol: '$', - recurlyPrice: { - total: '10.00', - }, - trialLength: 7, - }, - }, - decorators: [ - (Story: React.ComponentType) => ( -
- -
- ), - ], -} diff --git a/services/web/test/frontend/features/subscription/components/new/checkout.spec.tsx b/services/web/test/frontend/features/subscription/components/new/checkout.spec.tsx deleted file mode 100644 index 1ea49ad20a..0000000000 --- a/services/web/test/frontend/features/subscription/components/new/checkout.spec.tsx +++ /dev/null @@ -1,452 +0,0 @@ -import CheckoutPanel from '../../../../../../frontend/js/features/subscription/components/new/checkout/checkout-panel' -import { PaymentProvider } from '../../../../../../frontend/js/features/subscription/context/payment-context' -import { plans } from '../../fixtures/plans' -import { - createFakeRecurly, - defaultSubscription, - ElementsBase, -} from '../../fixtures/recurly-mock' -import { fillForm } from '../../helpers/payment' -import { cloneDeep } from 'lodash' -import { TokenHandler, RecurlyError } from 'recurly__recurly-js' - -function CheckoutPanelWithPaymentProvider() { - return ( - - - - ) -} - -describe('checkout panel', function () { - const itmCampaign = 'fake_itm_campaign' - const itmContent = 'fake_itm_content' - const itmReferrer = 'fake_itm_referrer' - - beforeEach(function () { - const plan = plans.find(({ planCode }) => planCode === 'student-annual') - - if (!plan) { - throw new Error('No plan was found while running the test!') - } - - cy.window().then(win => { - win.metaAttributesCache = new Map() - win.metaAttributesCache.set('ol-countryCode', '') - win.metaAttributesCache.set('ol-recurlyApiKey', '0000') - win.metaAttributesCache.set('ol-recommendedCurrency', 'USD') - win.metaAttributesCache.set('ol-plan', plan) - win.metaAttributesCache.set('ol-planCode', plan.planCode) - win.metaAttributesCache.set('ol-showCouponField', true) - win.metaAttributesCache.set('ol-itm_campaign', itmCampaign) - win.metaAttributesCache.set('ol-itm_content', itmContent) - win.metaAttributesCache.set('ol-itm_referrer', itmReferrer) - - cy.wrap(plan).as('plan') - - // init default recurly - win.recurly = createFakeRecurly(defaultSubscription) - - cy.interceptEvents() - }) - - cy.mount() - cy.findByTestId('checkout-form').as('form') - }) - - it('renders heading', function () { - cy.contains(/select a payment method/i) - }) - - it('renders student disclaimer', function () { - cy.contains( - 'The educational discount applies to all students at secondary and postsecondary institutions ' + - '(schools and universities). We may contact you to confirm that you’re eligible for the discount.' - ) - }) - - it('renders payment method toggle', function () { - cy.findByTestId('payment-method-toggle').within(() => { - cy.findByLabelText(/card payment/i) - cy.findByLabelText(/paypal/i) - }) - }) - - it('renders address first line input', function () { - cy.get('@form').within(() => { - cy.findByLabelText('Address') - cy.findByLabelText(/this address will be shown on the invoice/i) - }) - }) - - it('renders address second line input', function () { - cy.get('@form').within(() => { - cy.findByLabelText(/address second line/i).should('not.exist') - cy.findByRole('button', { name: /add another address line/i }).click() - cy.findByLabelText(/address second line/i) - }) - }) - - it('renders postal code input', function () { - cy.get('@form').within(() => { - cy.findByLabelText(/postal code/i) - }) - }) - - it('renders country dropdown', function () { - cy.get('@form').within(() => { - cy.findByLabelText(/country/i) - }) - }) - - it('renders company details', function () { - cy.get('@form').within(() => { - cy.findByLabelText(/add company details/i).as('checkbox') - cy.get('@checkbox').should('not.be.checked') - cy.findByLabelText(/company name/i).should('not.exist') - cy.findByLabelText(/vat number/i).should('not.exist') - cy.get('@checkbox').click() - cy.findByLabelText(/company name/i) - cy.findByLabelText(/vat number/i) - }) - }) - - it('renders coupon field', function () { - cy.get('@form').within(() => { - cy.findByLabelText(/coupon code/i) - }) - }) - - it('renders tos agreement notice', function () { - cy.contains(/by subscribing, you agree to our terms of service/i) - }) - - it('renders recurly error', function () { - cy.window().then(win => { - win.recurly = undefined! - }) - cy.mount() - cy.contains( - /sorry, there was an error talking to our payment provider. Please try again in a few moments/i - ) - cy.contains( - /if you are using any ad or script blocking extensions in your browser, you may need to temporarily disable them/i - ) - }) - - it('calls recurly.token on submit', function () { - cy.window().then(win => { - cy.stub(win.recurly, 'token') - }) - cy.mount() - cy.get('@form').within(() => fillForm()) - cy.findByRole('button', { name: /upgrade now/i }).click() - - cy.window().then(win => { - expect(win.recurly.token).to.be.calledOnceWith( - Cypress.sinon.match.instanceOf(ElementsBase), - { - first_name: '1', - last_name: '1', - postal_code: '1', - address1: '1', - address2: '', - state: '', - city: '', - country: 'BG', - coupon: '', - }, - Cypress.sinon.match.func - ) - }) - }) - - it('renders generic error', function () { - const errorMessage = 'generic error' - cy.window().then(win => { - win.recurly = createFakeRecurly(defaultSubscription, { - token: (_1: unknown, _2: unknown, handler: TokenHandler) => { - const err = new Error(errorMessage) as RecurlyError - setTimeout(() => handler(err, { id: '1', type: 'abc' }), 100) - }, - }) - }) - cy.mount() - cy.get('@form').within(() => fillForm()) - - cy.findByRole('button', { name: /upgrade now/i }).as('button') - cy.get('@button').click() - cy.get('@button').within(() => { - cy.contains(/processing/i) - }) - cy.findByRole('alert').should('have.text', errorMessage) - cy.get('@button').within(() => { - cy.findByText(/processing/i).should('not.exist') - }) - }) - - it('renders prefilled coupon input', function () { - const couponCode = 'promo_code' - cy.window().then(win => { - win.metaAttributesCache.set('ol-couponCode', couponCode) - }) - cy.mount() - cy.findByLabelText(/coupon code/i).should('have.value', couponCode) - }) - - it('calls coupon method when entering coupon code', function () { - const couponCode = 'promo_code' - cy.window().then(win => { - const couponStub = cy.stub().as('coupon') - couponStub.returnsThis() - win.recurly = createFakeRecurly({ - ...defaultSubscription, - coupon: couponStub, - }) - }) - cy.mount() - cy.get('@coupon').should('have.been.calledOnce') - cy.findByTestId('checkout-form').within(() => { - cy.findByLabelText(/coupon code/i).type(couponCode, { delay: 0 }) - cy.findByLabelText(/coupon code/i).blur() - }) - cy.get('@coupon') - .should('have.been.calledTwice') - .and('have.been.calledWith', couponCode) - }) - - it('enters invalid coupon code', function () { - cy.window().then(win => { - const catchStub = cy.stub().as('catch') - catchStub.onFirstCall().returnsThis() - catchStub - .onSecondCall() - .callsFake(function (this: unknown, cb: (err: RecurlyError) => void) { - const err = { - name: 'api-error', - code: 'not-found', - } as RecurlyError - - cb(err) - return this - }) - - win.recurly = createFakeRecurly({ - ...defaultSubscription, - catch: catchStub, - }) - }) - cy.mount() - cy.findByTestId('checkout-form').within(() => { - cy.findByLabelText(/coupon code/i).type('promo_code', { delay: 0 }) - cy.findByLabelText(/coupon code/i).blur() - }) - cy.findByRole('alert').within(() => { - cy.contains(/coupon code is not valid for selected plan/i) - }) - }) - - it('fails coupon verification', function () { - cy.window().then(win => { - const catchStub = cy.stub().as('catch') - // call original method on change event - catchStub.onFirstCall().returnsThis() - catchStub - .onSecondCall() - .callsFake(function (this: unknown, cb: (err: RecurlyError) => void) { - const err = {} as RecurlyError - - try { - cb(err) - } catch (e) {} - - return this - }) - - win.recurly = createFakeRecurly({ - ...defaultSubscription, - catch: catchStub, - }) - }) - cy.mount() - cy.get('@catch').should('have.been.calledOnce') - cy.findByTestId('checkout-form').within(() => { - cy.findByLabelText(/coupon code/i).type('promo_code', { delay: 0 }) - cy.findByLabelText(/coupon code/i).blur() - }) - cy.get('@catch').should('have.been.calledTwice') - cy.findByRole('alert').within(() => { - cy.contains(/an error occurred when verifying the coupon code/i) - }) - }) - - /* The test is disabled due to https://github.com/overleaf/internal/issues/12004 - it.skip('creates a new subscription', function () { - cy.stub(locationModule, 'assign').as('assign') - cy.intercept('POST', 'user/subscription/create', { - statusCode: 201, - }).as('create') - - cy.mount() - cy.findByTestId('checkout-form').within(() => fillForm()) - cy.findByRole('button', { name: /upgrade now/i }).click() - // verify itm params are also passed - cy.get('@create') - .its('request.body.subscriptionDetails') - .should('contain', { - ITMCampaign: itmCampaign, - ITMContent: itmContent, - ITMReferrer: itmReferrer, - }) - cy.get('@assign') - .should('have.been.calledOnce') - .and('have.been.calledWith', '/user/subscription/thank-you') - }) - */ - - it('fails to create a new subscription', function () { - cy.intercept('POST', 'user/subscription/create', { - statusCode: 404, - }) - - cy.mount() - cy.findByTestId('checkout-form').within(() => fillForm()) - cy.findByRole('button', { name: /upgrade now/i }).click() - cy.findByRole('alert').within(() => { - cy.contains(/something went wrong processing the request/i) - }) - }) - - describe('3DS challenge', function () { - it('shows three d secure challenge', function () { - cy.intercept('POST', 'user/subscription/create', { - statusCode: 404, - body: { - threeDSecureActionTokenId: '123', - }, - }) - - cy.mount() - cy.findByTestId('checkout-form').within(() => fillForm()) - cy.findByRole('button', { name: /upgrade now/i }).click() - cy.findByRole('alert').within(() => { - cy.contains( - /your card must be authenticated with 3D Secure before continuing/i - ) - }) - cy.contains('3D challenge content') - }) - }) - - describe('card payments', function () { - beforeEach(function () { - cy.findByLabelText(/card payment/i).click() - }) - - it('renders card element', function () { - cy.get('@form').within(() => { - cy.findByText(/card details/i, { selector: 'label' }) - cy.findByTestId('test-card-element') - }) - }) - - it('verifies the card element does not disappear when switching between payment methods', function () { - cy.get('@form').within(() => { - cy.findByText(/card details/i, { selector: 'label' }) - cy.findByTestId('test-card-element') - cy.findByLabelText(/paypal/i).click() - cy.findByLabelText(/card payment/i).click() - cy.findByText(/card details/i, { selector: 'label' }) - cy.findByTestId('test-card-element') - }) - }) - - it('renders first name input', function () { - cy.get('@form').within(() => { - cy.findByLabelText(/first name/i) - }) - }) - - it('renders last name input', function () { - cy.get('@form').within(() => { - cy.findByLabelText(/last name/i) - }) - }) - - describe('submit button', function () { - it('renders trial button', function () { - cy.get('@form').within(() => { - cy.findByRole('button', { name: /upgrade now, pay after \d+ days/i }) - }) - }) - - it('renders non-trial button', function () { - cy.window().then(win => { - const clone = cloneDeep(defaultSubscription) - clone.items.plan!.trial = undefined - win.recurly = createFakeRecurly(clone) - }) - cy.mount() - cy.findByTestId('checkout-form').within(() => { - cy.findByRole('button', { name: 'Upgrade Now' }) - }) - }) - - it('handles the disabled state of submit button', function () { - cy.get('@form').within(() => { - cy.findByRole('button', { name: /upgrade now/i }).should( - 'be.disabled' - ) - fillForm() - cy.findByRole('button', { name: /upgrade now/i }).should( - 'not.be.disabled' - ) - }) - }) - }) - }) - - describe('paypal payments', function () { - beforeEach(function () { - cy.findByLabelText(/paypal/i).click() - }) - - it('should not render card element', function () { - cy.get('@form').within(() => { - cy.findByLabelText(/card details/i).should('not.exist') - }) - }) - - it('should not render first name input', function () { - cy.get('@form').within(() => { - cy.findByLabelText(/first name/i).should('not.exist') - }) - }) - - it('should not render last name input', function () { - cy.get('@form').within(() => { - cy.findByLabelText(/last name/i).should('not.exist') - }) - }) - - it('renders proceeding to PayPal notice', function () { - cy.get('@form').within(() => { - cy.contains( - /proceeding to PayPal will take you to the PayPal site to pay for your subscription/i - ) - }) - }) - - it('handles the disabled state of submit button', function () { - cy.get('@form').within(() => { - cy.findByRole('button', { name: /proceed to paypal/i }).should( - 'be.disabled' - ) - cy.findByLabelText(/country/i).select('Bulgaria') - cy.findByRole('button', { name: /proceed to paypal/i }).should( - 'not.be.disabled' - ) - }) - }) - }) -}) diff --git a/services/web/test/frontend/features/subscription/components/new/common.spec.tsx b/services/web/test/frontend/features/subscription/components/new/common.spec.tsx deleted file mode 100644 index 6728642b9c..0000000000 --- a/services/web/test/frontend/features/subscription/components/new/common.spec.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { - createFakeRecurly, - defaultSubscription, -} from '../../fixtures/recurly-mock' -import { PaymentProvider } from '../../../../../../frontend/js/features/subscription/context/payment-context' -import { plans } from '../../fixtures/plans' -import PaymentPreviewPanel from '../../../../../../frontend/js/features/subscription/components/new/payment-preview/payment-preview-panel' -import CheckoutPanel from '../../../../../../frontend/js/features/subscription/components/new/checkout/checkout-panel' -import { fillForm } from '../../helpers/payment' - -describe('common recurly validations', function () { - beforeEach(function () { - const plan = plans.find(({ planCode }) => planCode === 'collaborator') - - if (!plan) { - throw new Error('No plan was found while running the test!') - } - - cy.window().then(win => { - win.metaAttributesCache = new Map() - win.metaAttributesCache.set('ol-countryCode', '') - win.metaAttributesCache.set('ol-recurlyApiKey', '1234') - win.metaAttributesCache.set('ol-recommendedCurrency', 'USD') - win.metaAttributesCache.set('ol-plan', plan) - win.metaAttributesCache.set('ol-planCode', plan.planCode) - win.metaAttributesCache.set('ol-showCouponField', true) - win.recurly = createFakeRecurly(defaultSubscription) - cy.interceptEvents() - }) - }) - - it('initializes recurly', function () { - cy.window().then(win => { - cy.spy(win.recurly, 'configure') - cy.spy(win.recurly.Pricing, 'Subscription') - }) - - cy.mount() - - cy.window().then(win => { - expect(win.recurly.configure).to.be.calledOnce - expect(win.recurly.Pricing.Subscription).to.be.calledOnce - }) - }) - - it('shows three d secure challenge content only once when changing currency', function () { - cy.intercept('POST', 'user/subscription/create', { - statusCode: 404, - body: { - threeDSecureActionTokenId: '123', - }, - }) - - cy.mount( - - - - - ) - cy.findByTestId('checkout-form').within(() => fillForm()) - cy.findByRole('button', { name: /upgrade now/i }).click() - cy.findByRole('button', { name: /change currency/i }).click() - cy.findByRole('menu').within(() => { - cy.findByRole('menuitem', { name: /gbp/i }).click() - }) - cy.findAllByText('3D challenge content').should('have.length', 1) - }) -}) diff --git a/services/web/test/frontend/features/subscription/components/new/payment-preview.spec.tsx b/services/web/test/frontend/features/subscription/components/new/payment-preview.spec.tsx deleted file mode 100644 index 64a931a369..0000000000 --- a/services/web/test/frontend/features/subscription/components/new/payment-preview.spec.tsx +++ /dev/null @@ -1,310 +0,0 @@ -import PaymentPreviewPanel from '../../../../../../frontend/js/features/subscription/components/new/payment-preview/payment-preview-panel' -import { PaymentProvider } from '../../../../../../frontend/js/features/subscription/context/payment-context' -import { plans } from '../../fixtures/plans' -import { - createFakeRecurly, - defaultSubscription, -} from '../../fixtures/recurly-mock' -import { cloneDeep } from 'lodash' -import { Plan } from '../../../../../../types/subscription/plan' - -function PaymentPreviewPanelWithPaymentProvider() { - return ( - - - - ) -} - -describe('payment preview panel', function () { - beforeEach(function () { - const plan = plans.find(({ planCode }) => planCode === 'collaborator') - - if (!plan) { - throw new Error('No plan was found while running the test!') - } - - cy.window().then(win => { - win.metaAttributesCache = new Map() - win.metaAttributesCache.set('ol-countryCode', '') - win.metaAttributesCache.set('ol-recurlyApiKey', '0000') - win.metaAttributesCache.set('ol-recommendedCurrency', 'USD') - win.metaAttributesCache.set('ol-plan', plan) - win.metaAttributesCache.set('ol-planCode', plan.planCode) - cy.wrap(plan).as('plan') - - // init default recurly - win.recurly = createFakeRecurly(defaultSubscription) - cy.interceptEvents() - }) - }) - - it('renders plan name', function () { - cy.mount() - - cy.contains(defaultSubscription.items.plan!.name) - }) - - it('renders collaborators per project', function () { - cy.mount() - - cy.get('@plan').then(plan => { - cy.contains(`${plan.features?.collaborators} collaborators per project`) - }) - }) - - it('renders features list', function () { - cy.mount() - - cy.contains(/all premium features/i) - cy.findByTestId('features-list').within(() => { - cy.get(':nth-child(1)').contains(/increased compile timeout/i) - cy.get(':nth-child(2)').contains(/sync with dropbox and github/i) - cy.get(':nth-child(3)').contains(/full document history/i) - cy.get(':nth-child(4)').contains(/track changes/i) - cy.get(':nth-child(5)').contains(/advanced reference search/i) - cy.get(':nth-child(6)').contains(/reference manager sync/i) - cy.get(':nth-child(7)').contains(/symbol palette/i) - }) - }) - - it('renders no features list', function () { - cy.window().then(win => { - cy.get('@plan').then(plan => { - const { features: _, ...noFeaturesPlan } = plan - win.metaAttributesCache.set('ol-plan', noFeaturesPlan) - }) - }) - - cy.mount() - - cy.findByTestId('features-list').should('not.exist') - }) - - describe('price summary', function () { - beforeEach(function () { - cy.mount() - cy.findByTestId('price-summary').as('priceSummary') - cy.findByTestId('price-summary-plan').as('priceSummaryPlan') - cy.findByTestId('price-summary-coupon').as('priceSummaryCoupon') - cy.findByTestId('price-summary-vat').as('priceSummaryVat') - cy.findByTestId('price-summary-total').as('priceSummaryTotal') - }) - - it('renders title', function () { - cy.get('@priceSummary').contains(/payment summary/i) - }) - - it('renders plan info', function () { - cy.get('@priceSummaryPlan').contains(defaultSubscription.items.plan!.name) - cy.get('@priceSummaryPlan').contains( - `$${defaultSubscription.price.base.plan.unit}` - ) - }) - - it('renders coupon info', function () { - cy.get('@priceSummaryCoupon').contains( - defaultSubscription.items.coupon!.name - ) - cy.get('@priceSummaryCoupon').contains( - `Discount of $${defaultSubscription.price.now.discount}` - ) - }) - - it('does not render coupon info when there is no coupon', function () { - cy.window().then(win => { - const { coupon: _, ...items } = defaultSubscription.items - win.recurly = createFakeRecurly({ ...defaultSubscription, items }) - }) - cy.mount() - cy.findByTestId('price-summary-coupon').should('not.exist') - }) - - it('renders VAT', function () { - cy.get('@priceSummaryVat').contains( - `VAT ${parseFloat(defaultSubscription.price.taxes[0].rate) * 100}%` - ) - cy.get('@priceSummaryVat').contains( - `$${defaultSubscription.price.now.tax}` - ) - }) - - describe('total amount', function () { - it('renders "total per month" text', function () { - cy.window().then(win => { - const clone = cloneDeep(defaultSubscription) - clone.items.plan!.period.length = 1 - win.recurly = createFakeRecurly(clone) - }) - cy.mount() - cy.findByTestId('price-summary-total').contains(/total per month/i) - }) - - it('renders "total per year" text', function () { - cy.window().then(win => { - const clone = cloneDeep(defaultSubscription) - clone.items.plan!.period.length = 2 - win.recurly = createFakeRecurly(clone) - }) - cy.mount() - cy.findByTestId('price-summary-total').contains(/total per year/i) - }) - - it('renders total amount', function () { - cy.get('@priceSummaryTotal').contains( - `$${defaultSubscription.price.now.total}` - ) - }) - }) - - it('renders "change currency" dropdown and changes currency', function () { - cy.get('@priceSummary').within(() => { - cy.get('@priceSummary') - .findByRole('button', { name: /change currency/i }) - .as('button') - cy.findByRole('menu').should('not.exist') - cy.get('@button').click() - cy.findByRole('menu').within(() => { - cy.findByRole('menuitem', { name: /usd \(\$\)/i }).contains( - /selected/i - ) - cy.findByRole('menuitem', { name: /eur \(€\)/i }) - cy.findByRole('menuitem', { name: /gbp \(£\)/i }).click() - - cy.get('@priceSummaryPlan').contains( - `£${defaultSubscription.price.base.plan.unit}` - ) - cy.get('@priceSummaryCoupon').contains( - `Discount of £${defaultSubscription.price.now.discount}` - ) - cy.get('@priceSummaryVat').contains( - `£${defaultSubscription.price.now.tax}` - ) - cy.get('@priceSummaryTotal').contains( - `£${defaultSubscription.price.now.total}` - ) - }) - }) - cy.findByTestId('trial-coupon-summary') - .should('not.contain.text', '$') - .should('contain.text', '£') - }) - }) - - describe('trial coupon summary', function () { - it('renders trial price', function () { - cy.mount() - cy.findByTestId('trial-coupon-summary').contains( - `First ${defaultSubscription.items.plan!.trial!.length} days free, ` + - `after that $${defaultSubscription.price.now.total} per month` - ) - }) - - it('renders "X price for Y months"', function () { - cy.window() - .then(win => { - const clone = cloneDeep(defaultSubscription) - clone.items.coupon!.applies_for_months = 6 - clone.items.coupon!.single_use = false - clone.items.plan!.period.length = 1 - win.recurly = createFakeRecurly(clone) - return clone - }) - .then((clone: typeof defaultSubscription) => { - cy.mount() - cy.findByTestId('trial-coupon-summary').contains( - `$${clone.price.now.total} for your first ${ - clone.items.coupon!.applies_for_months - } months` - ) - }) - }) - - it('renders "X price for first month"', function () { - cy.window() - .then(win => { - const clone = cloneDeep(defaultSubscription) - clone.items.plan!.period.length = 1 - win.recurly = createFakeRecurly(clone) - }) - .then(() => { - cy.mount() - cy.findByTestId('trial-coupon-summary').contains( - `$${defaultSubscription.price.now.total} for your first month` - ) - }) - }) - - it('renders "X price for first year"', function () { - cy.mount() - cy.findByTestId('trial-coupon-summary').contains( - `$${defaultSubscription.price.now.total} for your first year` - ) - }) - - it('renders "then X price per month"', function () { - cy.window() - .then(win => { - const clone = cloneDeep(defaultSubscription) - clone.items.coupon!.applies_for_months = 6 - clone.items.coupon!.single_use = false - clone.items.plan!.period.length = 1 - win.recurly = createFakeRecurly(clone) - }) - .then(() => { - cy.mount() - cy.findByTestId('trial-coupon-summary').contains( - `Then $26.00 per month` - ) - }) - }) - - it('renders "then X price per year"', function () { - cy.mount() - cy.findByTestId('trial-coupon-summary').contains(`Then $26.00 per year`) - }) - - it('renders "normally X price per month"', function () { - cy.window() - .then(win => { - const clone = cloneDeep(defaultSubscription) - clone.items.coupon!.applies_for_months = 0 - clone.items.coupon!.single_use = false - clone.items.plan!.period.length = 1 - win.recurly = createFakeRecurly(clone) - }) - .then(() => { - cy.mount() - cy.findByTestId('trial-coupon-summary').contains( - `Normally $26.00 per month` - ) - }) - }) - - it('renders "normally X price per year"', function () { - cy.window() - .then(win => { - const clone = cloneDeep(defaultSubscription) - clone.items.coupon!.single_use = false - clone.items.plan!.period.length = 2 - win.recurly = createFakeRecurly(clone) - }) - .then(() => { - cy.mount() - cy.findByTestId('trial-coupon-summary').contains( - `Normally $26.00 per year` - ) - }) - }) - }) - - it('renders "cancel anytime" content', function () { - cy.mount() - - cy.contains( - /we’re confident that you’ll love Overleaf, but if not you can cancel anytime/i - ).contains( - /we’ll give you your money back, no questions asked, if you let us know within 30 days/i - ) - }) -}) diff --git a/services/web/test/frontend/features/subscription/fixtures/recurly-mock.ts b/services/web/test/frontend/features/subscription/fixtures/recurly-mock.ts deleted file mode 100644 index 44ad2f18ba..0000000000 --- a/services/web/test/frontend/features/subscription/fixtures/recurly-mock.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { - Address, - CardElementOptions, - ElementsInstance, - PayPalConfig, - PlanOptions, - Recurly, - RecurlyError, - RecurlyOptions, - RiskOptions, - Tax, - TokenPayload, -} from 'recurly__recurly-js' -import { SubscriptionPricingInstanceCustom } from '../../../../../types/recurly/pricing/subscription' - -export const defaultSubscription = { - id: '123', - price: { - base: { - plan: { - unit: '20.00', - setup_fee: '0.00', - }, - }, - next: { - addons: '0.00', - discount: '1.00', - plan: '10.00', - setup_fee: '0.00', - subtotal: '9.00', - tax: '1.20', - total: '12.00', - }, - now: { - addons: '0.00', - discount: '1.00', - plan: '10.00', - setup_fee: '0.00', - subtotal: '9.00', - tax: '1.20', - total: '12.00', - }, - taxes: [ - { - tax_type: 'tax_type_1', - region: 'EU', - rate: '0.3', - }, - ], - }, - items: { - coupon: { - applies_for_months: 2, - code: 'react', - discount: { - type: 'percent', - rate: 0.2, - }, - name: 'fake coupon', - single_use: true, - }, - currency: 'USD', - plan: { - code: 'asd', - name: 'Standard (Collaborator)', - period: { - interval: '2', - length: 5, - }, - price: { - '15': { - unit_amount: 5, - symbol: '$', - setup_fee: 2, - }, - }, - quantity: 1, - tax_code: 'digital', - tax_exempt: false, - trial: { - interval: 'weekly', - length: 7, - }, - }, - }, -} as unknown as SubscriptionPricingInstanceCustom - -class PayPalBase { - protected constructor(config?: PayPalConfig) { - Object.assign(this, config) - } - - static PayPal = () => new this() - - destroy() {} - - on(_eventName: string, _callback: () => void) {} -} - -class Card { - protected fakeCardEl - - constructor() { - this.fakeCardEl = document.createElement('div') - } - - attach(el: HTMLElement) { - this.fakeCardEl.dataset.testid = 'test-card-element' - const input = document.createElement('input') - input.style.border = '1px solid black' - input.style.width = '50px' - const cardNumberInput = input.cloneNode(true) as HTMLInputElement - cardNumberInput.style.width = '200px' - cardNumberInput.placeholder = 'XXXX-XXXX-XXXX-XXXX' - - this.fakeCardEl.appendChild(cardNumberInput) - this.fakeCardEl.appendChild(input.cloneNode(true)) - this.fakeCardEl.appendChild(input.cloneNode(true)) - this.fakeCardEl.appendChild(input.cloneNode(true)) - el.appendChild(this.fakeCardEl) - } - - on(eventName = 'change', callback: (state: Record) => void) { - this.fakeCardEl.querySelectorAll('input').forEach(node => { - node.addEventListener(eventName, () => { - const state = { - valid: true, - } - callback(state) - }) - }) - } -} - -export class ElementsBase { - protected constructor(config?: unknown) { - Object.assign(this, config) - } - - static Elements = () => new this() - - CardElement(_cardElementOptions?: CardElementOptions) { - return new Card() - } -} - -export class ThreeDSecureBase { - protected constructor(riskOptions?: RiskOptions) { - Object.assign(this, riskOptions) - } - - static ThreeDSecure = (_riskOptions: RiskOptions) => new this() - - on(_eventName = 'change', _callback: () => void) {} - - attach(el: HTMLElement) { - const div = document.createElement('div') - div.appendChild(document.createTextNode('3D challenge content')) - el.appendChild(div) - } -} - -abstract class PricingBase { - plan(_planCode: string, _options: PlanOptions) { - return this - } - - address(_address: Address) { - return this - } - - tax(_tax: Tax) { - return this - } - - currency(_currency: string) { - return this - } - - coupon(_coupon: string) { - return this - } - - catch(_callback?: (reason?: RecurlyError) => void) { - return this - } - - done(callback?: () => unknown) { - callback?.() - return this - } - - on(_eventName: string, callback: () => void) { - callback() - } -} - -const createSubscriptionClass = (classProps: unknown) => { - return class extends PricingBase { - protected constructor() { - super() - Object.assign(this, classProps) - } - - static Subscription = () => new this() - } -} - -// Using `overrides` as currently can't stub/spy external files with cypress -export const createFakeRecurly = (classProps: unknown, overrides = {}) => { - return { - configure: (_options: RecurlyOptions) => {}, - token: ( - _elements: ElementsInstance, - _second: unknown, - handler: ( - err?: RecurlyError | null, - recurlyBillingToken?: TokenPayload, - threeDResultToken?: TokenPayload - ) => void - ) => { - handler(undefined, undefined, { id: '123', type: '456' }) - }, - Elements: ElementsBase.Elements, - PayPal: PayPalBase.PayPal, - Pricing: createSubscriptionClass(classProps), - Risk: () => ({ - ThreeDSecure: ThreeDSecureBase.ThreeDSecure, - }), - ...overrides, - } as unknown as Recurly -} diff --git a/services/web/test/frontend/features/subscription/helpers/payment.ts b/services/web/test/frontend/features/subscription/helpers/payment.ts deleted file mode 100644 index e467a13ac2..0000000000 --- a/services/web/test/frontend/features/subscription/helpers/payment.ts +++ /dev/null @@ -1,12 +0,0 @@ -export function fillForm() { - cy.findByTestId('test-card-element').within(() => { - cy.get('input').each(el => { - cy.wrap(el).type('1', { delay: 0 }) - }) - }) - cy.findByLabelText(/first name/i).type('1', { delay: 0 }) - cy.findByLabelText(/last name/i).type('1', { delay: 0 }) - cy.findByLabelText('Address').type('1', { delay: 0 }) - cy.findByLabelText(/postal code/i).type('1', { delay: 0 }) - cy.findByLabelText(/country/i).select('Bulgaria') -} diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js index 1aae4e42b0..9c60011a60 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js @@ -5,7 +5,6 @@ const MockRequest = require('../helpers/MockRequest') const MockResponse = require('../helpers/MockResponse') const modulePath = '../../../../app/src/Features/Subscription/SubscriptionController' -const Errors = require('../../../../app/src/Features/Errors/Errors') const SubscriptionErrors = require('../../../../app/src/Features/Subscription/Errors') const mockSubscriptions = { @@ -85,7 +84,6 @@ describe('SubscriptionController', function () { .returns({ plans: [], planCodesChangingAtTermEnd: [] }), } this.settings = { - restrictedCountries: ['KP'], coupon_codes: { upgradeToAnnualPromo: { student: 'STUDENTCODEHERE', @@ -305,154 +303,6 @@ describe('SubscriptionController', function () { }) }) - describe('paymentPage', function () { - beforeEach(function () { - this.req.headers = {} - this.SubscriptionHandler.promises.validateNoSubscriptionInRecurly = sinon - .stub() - .resolves(true) - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - currencyCode: this.stubbedCurrencyCode, - }) - }) - - describe('with a user without a subscription', function () { - beforeEach(function () { - this.LimitationsManager.promises.userHasV1OrV2Subscription.resolves( - false - ) - this.PlansLocator.findLocalPlanInSettings.returns({}) - }) - - describe('with a valid plan code', function () { - it('should render the new subscription page', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/new-react') - done() - } - this.SubscriptionController.paymentPage(this.req, this.res, done) - }) - }) - }) - - describe('with a user with subscription', function () { - it('should redirect to the subscription dashboard', function (done) { - this.PlansLocator.findLocalPlanInSettings.returns({}) - this.LimitationsManager.promises.userHasV1OrV2Subscription.resolves( - true - ) - this.res.redirect = url => { - url.should.equal('/user/subscription?hasSubscription=true') - done() - } - this.SubscriptionController.paymentPage(this.req, this.res) - }) - }) - - describe('with an invalid plan code', function () { - it('should return 422 error - Unprocessable Entity', function (done) { - this.LimitationsManager.promises.userHasV1OrV2Subscription.resolves( - false - ) - this.PlansLocator.findLocalPlanInSettings.returns(null) - this.HttpErrorHandler.unprocessableEntity = sinon.spy( - (req, res, message) => { - expect(req).to.exist - expect(res).to.exist - expect(message).to.deep.equal('Plan not found') - done() - } - ) - this.SubscriptionController.paymentPage(this.req, this.res) - }) - }) - - describe('which currency to use', function () { - beforeEach(function () { - this.LimitationsManager.promises.userHasV1OrV2Subscription.resolves( - false - ) - this.PlansLocator.findLocalPlanInSettings.returns({}) - }) - - it('should use the set currency from the query string', function (done) { - this.req.query.currency = 'EUR' - this.res.render = (page, opts) => { - opts.currency.should.equal('EUR') - opts.currency.should.not.equal(this.stubbedCurrencyCode) - done() - } - this.SubscriptionController.paymentPage(this.req, this.res) - }) - - it('should upercase the currency code', function (done) { - this.req.query.currency = 'eur' - this.res.render = (page, opts) => { - opts.currency.should.equal('EUR') - done() - } - this.SubscriptionController.paymentPage(this.req, this.res) - }) - - it('should use the geo ip currency if non is provided', function (done) { - this.req.query.currency = null - this.res.render = (page, opts) => { - opts.currency.should.equal(this.stubbedCurrencyCode) - done() - } - this.SubscriptionController.paymentPage(this.req, this.res) - }) - - it('should use the geo ip currency if not valid', function (done) { - this.req.query.currency = 'WAT' - this.GeoIpLookup.isValidCurrencyParam.returns(false) - this.res.render = (page, opts) => { - opts.currency.should.equal(this.stubbedCurrencyCode) - done() - } - this.SubscriptionController.paymentPage(this.req, this.res) - }) - }) - - describe('with a recurly subscription already', function () { - it('should redirect to the subscription dashboard', function (done) { - this.PlansLocator.findLocalPlanInSettings.returns({}) - this.LimitationsManager.promises.userHasV1OrV2Subscription.resolves( - false - ) - this.SubscriptionHandler.promises.validateNoSubscriptionInRecurly.resolves( - false - ) - this.res.redirect = url => { - url.should.equal('/user/subscription?hasSubscription=true') - done() - } - this.SubscriptionController.paymentPage(this.req, this.res) - }) - }) - - describe('with a user from a restricted country', function () { - beforeEach(function () { - this.LimitationsManager.promises.userHasV1OrV2Subscription.resolves( - false - ) - this.PlansLocator.findLocalPlanInSettings.returns({}) - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - currencyCode: this.stubbedCurrencyCode, - countryCode: 'KP', - }) - }) - - it('should render the restricted country page', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/restricted-country') - done() - } - this.SubscriptionController.paymentPage(this.req, this.res, done) - }) - }) - }) - describe('successfulSubscription', function () { it('without a personal subscription', function (done) { this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves( @@ -550,128 +400,6 @@ describe('SubscriptionController', function () { }) }) - describe('createSubscription', function () { - beforeEach(function (done) { - this.res = { - sendStatus() { - done() - }, - } - sinon.spy(this.res, 'sendStatus') - this.subscriptionDetails = { - card: '1234', - cvv: '123', - } - this.recurlyTokenIds = { - billing: '1234', - threeDSecureActionResult: '5678', - } - this.req.body.recurly_token_id = this.recurlyTokenIds.billing - this.req.body.recurly_three_d_secure_action_result_token_id = - this.recurlyTokenIds.threeDSecureActionResult - this.req.body.subscriptionDetails = this.subscriptionDetails - this.LimitationsManager.userHasV1OrV2Subscription.yields(null, false) - this.SubscriptionController.createSubscription(this.req, this.res) - }) - - it('should send the user and subscriptionId to the handler', function (done) { - this.SubscriptionHandler.promises.createSubscription - .calledWithMatch( - this.user, - this.subscriptionDetails, - this.recurlyTokenIds - ) - .should.equal(true) - done() - }) - - it('should redirect to the subscription page', function (done) { - this.res.sendStatus.calledWith(201).should.equal(true) - done() - }) - }) - - describe('createSubscription with errors', function () { - it('should handle users with subscription', function (done) { - this.LimitationsManager.promises.userHasV1OrV2Subscription.resolves(true) - this.SubscriptionController.createSubscription(this.req, { - sendStatus: status => { - expect(status).to.equal(409) - this.SubscriptionHandler.promises.createSubscription.called.should.equal( - false - ) - - done() - }, - }) - }) - - it('should handle restricted country', function (done) { - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'KP', - }) - this.res.callback = () => { - expect(this.res.statusCode).to.equal(422) - expect(this.res.body).to.include( - 'sorry_detected_sales_restricted_region' - ) - this.SubscriptionHandler.promises.createSubscription.called.should.equal( - false - ) - done() - } - this.SubscriptionController.createSubscription(this.req, this.res) - }) - - it('should handle 3DSecure errors/recurly transaction errors', function (done) { - this.LimitationsManager.promises.userHasV1OrV2Subscription.resolves(false) - this.SubscriptionHandler.promises.createSubscription.rejects( - new SubscriptionErrors.RecurlyTransactionError({}) - ) - this.HttpErrorHandler.unprocessableEntity = sinon.spy( - (req, res, message) => { - expect(req).to.exist - expect(res).to.exist - expect(message).to.deep.equal('Unknown transaction error') - done() - } - ) - this.SubscriptionController.createSubscription(this.req, this.res) - }) - - it('should handle validation errors', function (done) { - this.LimitationsManager.promises.userHasV1OrV2Subscription.resolves(false) - this.SubscriptionHandler.promises.createSubscription.rejects( - new Errors.InvalidError('invalid error test') - ) - this.HttpErrorHandler.unprocessableEntity = sinon.spy( - (req, res, message) => { - expect(req).to.exist - expect(res).to.exist - expect(message).to.deep.equal('invalid error test') - done() - } - ) - this.SubscriptionController.createSubscription(this.req, this.res) - }) - - it('should throw errors from createSubscription that are not handled', function (done) { - const genericError = new Error('generic error') - this.LimitationsManager.promises.userHasV1OrV2Subscription.resolves(false) - this.SubscriptionHandler.promises.createSubscription.rejects(genericError) - - this.SubscriptionController.createSubscription( - this.req, - this.res, - error => { - expect(error).to.be.instanceof(Error) - expect(error.message).to.equal(genericError.message) - done() - } - ) - }) - }) - describe('updateSubscription via post', function () { beforeEach(function (done) { this.res = { @@ -1035,28 +763,4 @@ describe('SubscriptionController', function () { this.SubscriptionController.processUpgradeToAnnualPlan(this.req, this.res) }) }) - - describe('requireConfirmedPrimaryEmailAddress', function () { - describe('when user does not have confirmed email address', function () { - beforeEach(function () { - this.req.user = { _id: 'testing' } - this.UserGetter.promises.getUser.resolves({ - email: 'test@example.com', - emails: [{ email: 'test@example.com' }], - }) - }) - - it('should show unconfirmed primary email page', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/unconfirmed-primary-email') - opts.email.should.equal('test@example.com') - done() - } - this.SubscriptionController.requireConfirmedPrimaryEmailAddress( - this.req, - this.res - ) - }) - }) - }) })