From 34bfe3e9bad64dddeb0f1728b6c5b4e982ffc40e Mon Sep 17 00:00:00 2001 From: Suejung Shin Date: Mon, 30 Dec 2024 18:58:14 -0800 Subject: [PATCH 1/9] feat: Add ACH payment method --- .env.development | 2 + .../BillingDetails/Address/AddressCard.tsx | 74 ++++---- .../BillingDetails/Address/AddressForm.tsx | 2 +- .../BillingDetails/BillingDetails.tsx | 95 +++++++--- .../EditablePaymentMethod.tsx | 166 ++++++++++++++++++ .../EditablePaymentMethod/index.ts | 1 + .../EmailAddress/EmailAddress.tsx | 12 +- .../PaymentCard/CardInformation.jsx | 82 --------- .../PaymentCard/CardInformation.tsx | 54 ++++++ ...mentCard.test.jsx => PaymentCard.test.tsx} | 0 .../{PaymentCard.jsx => PaymentCard.tsx} | 42 +++-- .../PaymentMethod/PaymentMethod.tsx | 82 +++++++++ .../BillingDetails/PaymentMethod/index.ts | 1 + src/services/account/propTypes.js | 3 + src/services/account/types.ts | 71 ++++++++ .../navigation/useNavLinks/useNavLinks.ts | 21 +++ 16 files changed, 547 insertions(+), 161 deletions(-) create mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditablePaymentMethod/EditablePaymentMethod.tsx create mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditablePaymentMethod/index.ts delete mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.jsx create mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.tsx rename src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/{PaymentCard.test.jsx => PaymentCard.test.tsx} (100%) rename src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/{PaymentCard.jsx => PaymentCard.tsx} (67%) create mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethod.tsx create mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/index.ts create mode 100644 src/services/account/types.ts diff --git a/.env.development b/.env.development index 289e620838..c81e6b0f63 100644 --- a/.env.development +++ b/.env.development @@ -5,3 +5,5 @@ REACT_APP_MARKETING_BASE_URL=https://about.codecov.io # REACT_APP_STRIPE_KEY= # REACT_APP_LAUNCHDARKLY= # REACT_APP_BAREMETRICS_TOKEN= +REACT_APP_STRIPE_KEY=pk_test_514SJTOGlVGuVgOrkRgh7zxp3tQ7bX4CY6pnxxw6zRZZSoDVtUUjArPKC7oXeeIbJNICTqS7H88FRfwZnWMskPKxo00bAnu2i9I +REACT_APP_STRIPE_PUBLIC_KEY=pk_test_514SJTOGlVGuVgOrkRgh7zxp3tQ7bX4CY6pnxxw6zRZZSoDVtUUjArPKC7oXeeIbJNICTqS7H88FRfwZnWMskPKxo00bAnu2i9I diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.tsx index 377a7a3c9e..07c44746a8 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.tsx @@ -10,50 +10,47 @@ import Button from 'ui/Button' import Icon from 'ui/Icon' import AddressForm from './AddressForm' +import { cn } from 'shared/utils/cn' interface AddressCardProps { + isEditMode: boolean + setEditMode: (isEditMode: boolean) => void subscriptionDetail: z.infer provider: string owner: string + className?: string } function AddressCard({ + isEditMode, + setEditMode, subscriptionDetail, provider, owner, + className, }: AddressCardProps) { - const [isFormOpen, setIsFormOpen] = useState(false) const billingDetails = subscriptionDetail?.defaultPaymentMethod?.billingDetails + const isAddressSameAsPrimary = true // TODO + return ( -
- {isFormOpen && ( +
+ {isEditMode && ( setIsFormOpen(false)} + closeForm={() => setEditMode(false)} /> )} - {!isFormOpen && ( + {!isEditMode && ( <> - )} @@ -63,27 +60,36 @@ function AddressCard({ interface BillingInnerProps { billingDetails?: z.infer - setIsFormOpen: (val: boolean) => void + setEditMode: (val: boolean) => void + isAddressSameAsPrimary: boolean } -function BillingInner({ billingDetails, setIsFormOpen }: BillingInnerProps) { +function BillingInner({ + billingDetails, + setEditMode, + isAddressSameAsPrimary, +}: BillingInnerProps) { if (billingDetails) { return (
-

{`${billingDetails.name ?? 'N/A'}`}

-

Billing address

-

{`${billingDetails.address?.line1 ?? ''} ${ - billingDetails.address?.line2 ?? '' - }`}

-

- {billingDetails.address?.city - ? `${billingDetails.address?.city}, ` - : ''} - {`${billingDetails.address?.state ?? ''} ${ - billingDetails.address?.postalCode ?? '' - }`} -

+ {isAddressSameAsPrimary ? ( +

Same as primary address

+ ) : ( + <> +

{`${billingDetails.address?.line1 ?? ''} ${ + billingDetails.address?.line2 ?? '' + }`}

+

+ {billingDetails.address?.city + ? `${billingDetails.address?.city}, ` + : ''} + {`${billingDetails.address?.state ?? ''} ${ + billingDetails.address?.postalCode ?? '' + }`} +

+ + )}
) } @@ -98,7 +104,7 @@ function BillingInner({ billingDetails, setIsFormOpen }: BillingInnerProps) { + ) : ( + + )} +
+ {isEditMode ? ( + + ) : ( + <> + + + + {subscriptionDetail?.taxIds?.length ? ( +
+

Tax ID

+ {subscriptionDetail?.taxIds?.map((val, index) => ( +

{val?.value}

+ ))} +
+ ) : null} + + )}
) } diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditablePaymentMethod/EditablePaymentMethod.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditablePaymentMethod/EditablePaymentMethod.tsx new file mode 100644 index 0000000000..40f2b94684 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditablePaymentMethod/EditablePaymentMethod.tsx @@ -0,0 +1,166 @@ +import React, { useState } from 'react' +import AddressForm from '../Address/AddressForm' +import { + Elements, + CardElement, + IbanElement, + useStripe, + useElements, +} from '@stripe/react-stripe-js' +import { loadStripe } from '@stripe/stripe-js' +import CreditCardForm from '../PaymentCard/CreditCardForm' +import { RadioTileGroup } from 'ui/RadioTileGroup' +import Icon from 'ui/Icon' + +// Load your Stripe public key +const stripePromise = loadStripe('your-publishable-key-here') + +interface PaymentFormProps { + clientSecret: string +} + +const PaymentForm: React.FC = ({ clientSecret }) => { + const stripe = useStripe() + const elements = useElements() + const [paymentMethod, setPaymentMethod] = useState<'card' | 'bank'>('card') + const [isSubmitting, setIsSubmitting] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + setIsSubmitting(true) + setErrorMessage(null) + + if (!stripe || !elements) { + setErrorMessage('Stripe has not loaded yet. Please try again.') + setIsSubmitting(false) + return + } + + const paymentElement = elements.getElement( + paymentMethod === 'card' ? CardElement : IbanElement + ) + + if (!paymentElement) { + setErrorMessage('Payment element is missing.') + setIsSubmitting(false) + return + } + + // Confirm payment based on selected method + const { error } = await stripe.confirmPayment({ + elements, + confirmParams: { + return_url: 'https://your-website.com/order-complete', // Redirect URL + }, + }) + + if (error) { + setErrorMessage(error.message || 'An unexpected error occurred.') + setIsSubmitting(false) + } else { + setIsSubmitting(false) + } + } + + return ( +
+

Choose Payment Method

+ + setPaymentMethod(value)} + className="flex-row" + > + + +
+ + Card +
+
+
+ + +
+ + Bank Account +
+
+
+
+ + {/* Payment Element */} + {paymentMethod === 'card' && ( +
+

Card Details

+ +
+ )} + + {paymentMethod === 'bank' && ( +
+

Bank Account Details

+ +
+ )} + + {errorMessage &&
{errorMessage}
} +
+ ) +} + +// Wrapper Component to provide Stripe Elements +const PaymentPage: React.FC<{ clientSecret: string }> = ({ clientSecret }) => { + // if (!clientSecret) { + // return
Loading...
+ // } + + const options = { + clientSecret, + appearance: { + theme: 'stripe', + }, + } + + return ( + + + + ) +} + +interface EditablePaymentMethodProps { + clientSecret: string +} + +const EditablePaymentMethod: React.FC = ({ + clientSecret, +}) => { + return ( +
+

Edit payment method

+ + +
+ ) +} + +export default EditablePaymentMethod diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditablePaymentMethod/index.ts b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditablePaymentMethod/index.ts new file mode 100644 index 0000000000..6ed3506966 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditablePaymentMethod/index.ts @@ -0,0 +1 @@ +export { default } from './EditablePaymentMethod' diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EmailAddress/EmailAddress.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EmailAddress/EmailAddress.tsx index 8a159c2f13..90c77ad722 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EmailAddress/EmailAddress.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EmailAddress/EmailAddress.tsx @@ -27,7 +27,7 @@ type FormData = z.infer function EmailAddress() { const { provider, owner } = useParams() const { data: accountDetails } = useAccountDetails({ provider, owner }) - const [isFormOpen, setIsFormOpen] = useState(false) + const [isEditMode, setEditMode] = useState(false) const currentCustomerEmail = accountDetails?.subscriptionDetail?.customer?.email || 'No email provided' @@ -50,7 +50,7 @@ function EmailAddress() { { newEmail: data?.newCustomerEmail }, { onSuccess: () => { - setIsFormOpen(false) + setEditMode(false) }, } ) @@ -60,18 +60,18 @@ function EmailAddress() {

Email address

{' '} - {!isFormOpen && ( + {!isEditMode && ( /* @ts-expect-error - A hasn't been typed yet */ setIsFormOpen(true)} + onClick={() => setEditMode(true)} hook="edit-email" > Edit )}
- {isFormOpen ? ( + {isEditMode ? (
setIsFormOpen(false)} + onClick={() => setEditMode(false)} > Cancel diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.jsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.jsx deleted file mode 100644 index b1830d0174..0000000000 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.jsx +++ /dev/null @@ -1,82 +0,0 @@ -import PropTypes from 'prop-types' - -import amexLogo from 'assets/billing/amex.svg' -import discoverLogo from 'assets/billing/discover.svg' -import mastercardLogo from 'assets/billing/mastercard.svg' -import visaLogo from 'assets/billing/visa.svg' -import { subscriptionDetailType } from 'services/account' -import { - formatTimestampToCalendarDate, - lastTwoDigits, -} from 'shared/utils/billing' - -const cardBrand = { - amex: { - logo: amexLogo, - name: 'American Express', - }, - discover: { - logo: discoverLogo, - name: 'Discover', - }, - mastercard: { - logo: mastercardLogo, - name: 'MasterCard', - }, - visa: { - logo: visaLogo, - name: 'Visa', - }, - fallback: { - logo: visaLogo, - name: 'Credit card', - }, -} - -function CardInformation({ subscriptionDetail, card }) { - const typeCard = cardBrand[card?.brand] ?? cardBrand?.fallback - let nextBilling = null - - if (!subscriptionDetail?.cancelAtPeriodEnd) { - nextBilling = formatTimestampToCalendarDate( - subscriptionDetail.currentPeriodEnd - ) - } - - return ( -
-
- credit card logo -
- •••• {card?.last4} -
-
-

- Expires {card?.expMonth}/{lastTwoDigits(card?.expYear)} -

- {nextBilling && ( -

- Your next billing date is{' '} - {nextBilling}. -

- )} -
- ) -} - -CardInformation.propTypes = { - subscriptionDetail: subscriptionDetailType, - card: PropTypes.shape({ - brand: PropTypes.string.isRequired, - last4: PropTypes.string.isRequired, - expMonth: PropTypes.number.isRequired, - expYear: PropTypes.number.isRequired, - }).isRequired, - openForm: PropTypes.func.isRequired, -} - -export default CardInformation diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.tsx new file mode 100644 index 0000000000..ac7b76ada0 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.tsx @@ -0,0 +1,54 @@ +import { z } from 'zod' + +import { SubscriptionDetailSchema } from 'services/account' +import { CardBrand, CardBrands } from 'services/account/types' +import { + formatTimestampToCalendarDate, + lastTwoDigits, +} from 'shared/utils/billing' + +interface CardInformationProps { + subscriptionDetail: z.infer + card: { + brand: string + last4: string + expMonth: number + expYear: number + } +} +function CardInformation({ subscriptionDetail, card }: CardInformationProps) { + const typeCard = CardBrands[card?.brand as CardBrand] ?? CardBrands.fallback + let nextBilling = null + + if (!subscriptionDetail?.cancelAtPeriodEnd) { + nextBilling = formatTimestampToCalendarDate( + subscriptionDetail?.currentPeriodEnd + ) + } + + return ( +
+
+ credit card logo +
+ •••• {card?.last4} +
+
+

+ Expires {card?.expMonth}/{lastTwoDigits(card?.expYear)} +

+ {nextBilling && ( +

+ Your next billing date is{' '} + {nextBilling}. +

+ )} +
+ ) +} + +export default CardInformation diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.test.jsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.test.tsx similarity index 100% rename from src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.test.jsx rename to src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.test.tsx diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.jsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.tsx similarity index 67% rename from src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.jsx rename to src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.tsx index 97433deb8c..539531f4f0 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.jsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.tsx @@ -1,36 +1,46 @@ import PropTypes from 'prop-types' import { useState } from 'react' +import { z } from 'zod' -import { subscriptionDetailType } from 'services/account' +import { + SubscriptionDetailSchema, + subscriptionDetailType, +} from 'services/account' import A from 'ui/A' import Button from 'ui/Button' import Icon from 'ui/Icon' import CardInformation from './CardInformation' import CreditCardForm from './CreditCardForm' -function PaymentCard({ subscriptionDetail, provider, owner }) { - const [isFormOpen, setIsFormOpen] = useState(false) +import { cn } from 'shared/utils/cn' + +function PaymentCard({ + isEditMode, + setEditMode, + subscriptionDetail, + provider, + owner, + className, +}: { + isEditMode: boolean + setEditMode: (isEditMode: boolean) => void + subscriptionDetail: z.infer + provider: string + owner: string + className?: string +}) { const card = subscriptionDetail?.defaultPaymentMethod?.card return ( -
+

Payment method

- {!isFormOpen && ( - setIsFormOpen(true)} - hook="edit-card" - > - Edit - - )}
- {isFormOpen ? ( + {isEditMode ? ( setIsFormOpen(false)} + closeForm={() => setEditMode(false)} /> ) : card ? ( @@ -44,7 +54,7 @@ function PaymentCard({ subscriptionDetail, provider, owner }) { diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethod.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethod.tsx new file mode 100644 index 0000000000..fa3df01b09 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethod.tsx @@ -0,0 +1,82 @@ +import { z } from 'zod' + +import { SubscriptionDetailSchema } from 'services/account' +import { ExpandableSection } from 'ui/ExpandableSection' + +import AddressCard from '../Address/AddressCard' +import PaymentCard from '../PaymentCard' +import Button from 'ui/Button' + +function PaymentMethod({ + heading, + isPrimary, + isEditMode, + setEditMode, + subscriptionDetail, + provider, + owner, +}: { + heading: string + isPrimary?: boolean + isEditMode: boolean + setEditMode: (isEditMode: boolean) => void + subscriptionDetail: z.infer + provider: string + owner: string +}) { + const isAdmin = true // TODO + + const isCreditCard = subscriptionDetail?.defaultPaymentMethod?.card // TODO + + console.log(subscriptionDetail) + + console.log(isEditMode) + return ( + + +

{heading}

+
+ + {!isPrimary ? ( +

+ By default, if the primary payment fails, the secondary will be + charged automatically. +

+ ) : null} +
+ +
+

{isCreditCard ? 'Cardholder name' : 'Full name'}

+

N/A

+
+ +
+ {!isPrimary ? ( + + ) : null} +
+
+ ) +} + +export default PaymentMethod diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/index.ts b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/index.ts new file mode 100644 index 0000000000..34940e48e5 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/index.ts @@ -0,0 +1 @@ +export { default } from './PaymentMethod' diff --git a/src/services/account/propTypes.js b/src/services/account/propTypes.js index 02a7f1e5eb..6eb1cadb96 100644 --- a/src/services/account/propTypes.js +++ b/src/services/account/propTypes.js @@ -1,5 +1,8 @@ import PropType from 'prop-types' +// TODO: These types were duplicated into types.ts, +// delete this file once all usages are migrated to TS + export const invoicePropType = PropType.shape({ created: PropType.number.isRequired, dueDate: PropType.number, diff --git a/src/services/account/types.ts b/src/services/account/types.ts new file mode 100644 index 0000000000..f264646ca4 --- /dev/null +++ b/src/services/account/types.ts @@ -0,0 +1,71 @@ +import amexLogo from 'assets/billing/amex.svg' +import discoverLogo from 'assets/billing/discover.svg' +import mastercardLogo from 'assets/billing/mastercard.svg' +import visaLogo from 'assets/billing/visa.svg' + +export interface Invoice { + created: number + dueDate?: number + total: number + invoicePdf: string +} + +export interface Plan { + marketingName: string + baseUnitPrice: number + benefits: string[] + quantity?: number + value: string + monthlyUploadLimit?: number +} + +export interface Card { + brand: CardBrand + expMonth: number + expYear: number + last4: string +} + +export interface PaymentMethod { + card: Card +} + +export interface SubscriptionDetail { + latestInvoice?: Invoice + defaultPaymentMethod?: PaymentMethod + trialEnd?: number + cancelAtPeriodEnd?: boolean + currentPeriodEnd?: number +} + +export interface AccountDetails { + plan: Plan + activatedUserCount: number + planAutoActivate?: boolean + subscriptionDetail?: SubscriptionDetail +} + +export const CardBrands = { + amex: { + logo: amexLogo, + name: 'American Express', + }, + discover: { + logo: discoverLogo, + name: 'Discover', + }, + mastercard: { + logo: mastercardLogo, + name: 'MasterCard', + }, + visa: { + logo: visaLogo, + name: 'Visa', + }, + fallback: { + logo: visaLogo, + name: 'Credit card', + }, +} + +export type CardBrand = keyof typeof CardBrands diff --git a/src/services/navigation/useNavLinks/useNavLinks.ts b/src/services/navigation/useNavLinks/useNavLinks.ts index 85d998b63f..ede8591126 100644 --- a/src/services/navigation/useNavLinks/useNavLinks.ts +++ b/src/services/navigation/useNavLinks/useNavLinks.ts @@ -967,5 +967,26 @@ export function useNavLinks() { isExternalLink: true, openNewTab: true, }, + billingEditPrimary: { + path: ( + { provider = p, owner = o} = { + provider: p, + owner: o, + } + ) => + `/plan/${provider}/${owner}?tab=primary`, + isExternalLink: false, + text: 'Primary payment method', + }, + billingEditSecondary: { + path: ( + { provider = p, owner = o} = { + provider: p, + owner: o, + } + ) => `/plan/${provider}/${owner}?tab=secondary`, + isExternalLink: false, + text: 'Secondary payment method', + }, } } From 1a8560e476c92488e32ab933a65a4df08494dbf9 Mon Sep 17 00:00:00 2001 From: Suejung Shin Date: Tue, 31 Dec 2024 18:11:20 -0800 Subject: [PATCH 2/9] wip --- .../BillingDetails/Address/AddressCard.tsx | 23 +++-- .../BillingDetails/BillingDetails.tsx | 13 ++- .../EditPaymentMethod.tsx} | 4 +- .../BillingDetails/EditPaymentMethod/index.ts | 1 + .../EditablePaymentMethod/index.ts | 1 - .../PaymentCard/CardInformation.tsx | 8 +- .../PaymentMethod/PaymentMethod.tsx | 96 ++++++++++--------- src/ui/Button/Button.tsx | 3 +- 8 files changed, 85 insertions(+), 64 deletions(-) rename src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/{EditablePaymentMethod/EditablePaymentMethod.tsx => EditPaymentMethod/EditPaymentMethod.tsx} (97%) create mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/index.ts delete mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditablePaymentMethod/index.ts diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.tsx index 07c44746a8..0fe781ee79 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.tsx @@ -32,7 +32,7 @@ function AddressCard({ const billingDetails = subscriptionDetail?.defaultPaymentMethod?.billingDetails - const isAddressSameAsPrimary = true // TODO + const isAddressSameAsPrimary = false // TODO return (
@@ -46,13 +46,11 @@ function AddressCard({ /> )} {!isEditMode && ( - <> - - + )}
) @@ -69,12 +67,21 @@ function BillingInner({ setEditMode, isAddressSameAsPrimary, }: BillingInnerProps) { + const isEmptyAddress = + !billingDetails?.address?.line1 && + !billingDetails?.address?.line2 && + !billingDetails?.address?.city && + !billingDetails?.address?.state && + !billingDetails?.address?.postalCode + if (billingDetails) { return (

Billing address

{isAddressSameAsPrimary ? (

Same as primary address

+ ) : isEmptyAddress ? ( +

-

) : ( <>

{`${billingDetails.address?.line1 ?? ''} ${ diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx index 031d53ca42..6cb32dc984 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx @@ -7,7 +7,7 @@ import PaymentMethod from './PaymentMethod' import Button from 'ui/Button' import { useState } from 'react' import A from 'ui/A' -import EditablePaymentMethod from './EditablePaymentMethod' +import EditablePaymentMethod from './EditPaymentMethod' interface URLParams { provider: string @@ -32,8 +32,9 @@ function BillingDetails() { console.log('iseditmode', isEditMode) return ( -

-
+
+ {/* Billing Details Section */} +

Billing details

@@ -50,6 +51,7 @@ function BillingDetails() { onClick={() => setEditMode(true)} variant="default" disabled={!isAdmin} + className="flex-none" > Edit payment @@ -57,10 +59,11 @@ function BillingDetails() { )}

diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditablePaymentMethod/EditablePaymentMethod.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethod.tsx similarity index 97% rename from src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditablePaymentMethod/EditablePaymentMethod.tsx rename to src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethod.tsx index 40f2b94684..1779b66da3 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditablePaymentMethod/EditablePaymentMethod.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethod.tsx @@ -145,7 +145,7 @@ interface EditablePaymentMethodProps { clientSecret: string } -const EditablePaymentMethod: React.FC = ({ +const EditPaymentMethod: React.FC = ({ clientSecret, }) => { return ( @@ -163,4 +163,4 @@ const EditablePaymentMethod: React.FC = ({ ) } -export default EditablePaymentMethod +export default EditPaymentMethod diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/index.ts b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/index.ts new file mode 100644 index 0000000000..4f454d7d4f --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/index.ts @@ -0,0 +1 @@ +export { default } from './EditPaymentMethod' diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditablePaymentMethod/index.ts b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditablePaymentMethod/index.ts deleted file mode 100644 index 6ed3506966..0000000000 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditablePaymentMethod/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './EditablePaymentMethod' diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.tsx index ac7b76ada0..bd80fe8458 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.tsx @@ -28,21 +28,21 @@ function CardInformation({ subscriptionDetail, card }: CardInformationProps) { return (
-
+
credit card logo
- •••• {card?.last4} + •••• {card?.last4}

Expires {card?.expMonth}/{lastTwoDigits(card?.expYear)}

{nextBilling && ( -

+

Your next billing date is{' '} {nextBilling}.

diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethod.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethod.tsx index fa3df01b09..520909b30e 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethod.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethod.tsx @@ -32,50 +32,60 @@ function PaymentMethod({ console.log(isEditMode) return ( - - -

{heading}

-
- - {!isPrimary ? ( -

- By default, if the primary payment fails, the secondary will be - charged automatically. -

- ) : null} -
- -
-

{isCreditCard ? 'Cardholder name' : 'Full name'}

-

N/A

+
+ + +

{heading}

+
+ +
+ {!isPrimary ? ( +

+ By default, if the primary payment fails, the secondary will be + charged automatically. +

+ ) : null} +
+ {/* Payment method summary */} + + {/* Cardholder name */} +
+

+ {isCreditCard ? 'Cardholder name' : 'Full name'} +

+

N/A

+
+ {/* Address */} + +
+ {!isPrimary ? ( + + ) : null}
- -
- {!isPrimary ? ( - - ) : null} - - + + +
) } diff --git a/src/ui/Button/Button.tsx b/src/ui/Button/Button.tsx index 846fcc52a8..38138b8b0e 100644 --- a/src/ui/Button/Button.tsx +++ b/src/ui/Button/Button.tsx @@ -134,7 +134,8 @@ function Button({ const className = cs( baseClass, { [baseDisabledClasses]: !isLoading }, - pickVariant(variant, isLoading) + pickVariant(variant, isLoading), + props.className ) const content = ( From 0f6d1c3f79000c556650eaa07d748c54aac95ada Mon Sep 17 00:00:00 2001 From: Suejung Shin Date: Wed, 1 Jan 2025 20:48:34 -0800 Subject: [PATCH 3/9] add stripe payment element --- .env.development | 2 - .../EditPaymentMethod/EditPaymentMethod.tsx | 182 +++++++++--------- .../PaymentMethod/PaymentMethod.tsx | 40 ++-- 3 files changed, 107 insertions(+), 117 deletions(-) diff --git a/.env.development b/.env.development index c81e6b0f63..289e620838 100644 --- a/.env.development +++ b/.env.development @@ -5,5 +5,3 @@ REACT_APP_MARKETING_BASE_URL=https://about.codecov.io # REACT_APP_STRIPE_KEY= # REACT_APP_LAUNCHDARKLY= # REACT_APP_BAREMETRICS_TOKEN= -REACT_APP_STRIPE_KEY=pk_test_514SJTOGlVGuVgOrkRgh7zxp3tQ7bX4CY6pnxxw6zRZZSoDVtUUjArPKC7oXeeIbJNICTqS7H88FRfwZnWMskPKxo00bAnu2i9I -REACT_APP_STRIPE_PUBLIC_KEY=pk_test_514SJTOGlVGuVgOrkRgh7zxp3tQ7bX4CY6pnxxw6zRZZSoDVtUUjArPKC7oXeeIbJNICTqS7H88FRfwZnWMskPKxo00bAnu2i9I diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethod.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethod.tsx index 1779b66da3..c7142559c7 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethod.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethod.tsx @@ -1,28 +1,29 @@ -import React, { useState } from 'react' -import AddressForm from '../Address/AddressForm' import { Elements, - CardElement, - IbanElement, - useStripe, + PaymentElement, useElements, + useStripe, } from '@stripe/react-stripe-js' import { loadStripe } from '@stripe/stripe-js' -import CreditCardForm from '../PaymentCard/CreditCardForm' -import { RadioTileGroup } from 'ui/RadioTileGroup' -import Icon from 'ui/Icon' +import React, { useState } from 'react' + +import Button from 'ui/Button' + +import AddressForm from '../Address/AddressForm' -// Load your Stripe public key -const stripePromise = loadStripe('your-publishable-key-here') +// TODO - fetch from API +const STRIPE_PUBLISHABLE_KEY = process.env.STRIPE_PUBLISHABLE_KEY || '' +const MANUALLY_FETCHED_CLIENT_SECRET = process.env.STRIPE_CLIENT_SECRET || '' + +const stripePromise = loadStripe(STRIPE_PUBLISHABLE_KEY) interface PaymentFormProps { clientSecret: string } -const PaymentForm: React.FC = ({ clientSecret }) => { +const PaymentForm: React.FC = () => { const stripe = useStripe() const elements = useElements() - const [paymentMethod, setPaymentMethod] = useState<'card' | 'bank'>('card') const [isSubmitting, setIsSubmitting] = useState(false) const [errorMessage, setErrorMessage] = useState(null) @@ -37,21 +38,11 @@ const PaymentForm: React.FC = ({ clientSecret }) => { return } - const paymentElement = elements.getElement( - paymentMethod === 'card' ? CardElement : IbanElement - ) - - if (!paymentElement) { - setErrorMessage('Payment element is missing.') - setIsSubmitting(false) - return - } - - // Confirm payment based on selected method const { error } = await stripe.confirmPayment({ elements, confirmParams: { - return_url: 'https://your-website.com/order-complete', // Redirect URL + // eslint-disable-next-line camelcase + return_url: 'https://codecov.io', }, }) @@ -64,73 +55,50 @@ const PaymentForm: React.FC = ({ clientSecret }) => { } return ( - -

Choose Payment Method

- - setPaymentMethod(value)} - className="flex-row" - > - - -
- - Card -
-
-
- - -
- - Bank Account -
-
-
-
- - {/* Payment Element */} - {paymentMethod === 'card' && ( -
-

Card Details

- -
- )} - - {paymentMethod === 'bank' && ( -
-

Bank Account Details

- -
- )} +
+ +
+ + +
{errorMessage &&
{errorMessage}
} - +
) } -// Wrapper Component to provide Stripe Elements const PaymentPage: React.FC<{ clientSecret: string }> = ({ clientSecret }) => { - // if (!clientSecret) { - // return
Loading...
- // } - const options = { clientSecret, appearance: { - theme: 'stripe', + theme: 'stripe' as const, }, } @@ -145,20 +113,48 @@ interface EditablePaymentMethodProps { clientSecret: string } -const EditPaymentMethod: React.FC = ({ - clientSecret, -}) => { +const EditPaymentMethod: React.FC = () => { + const clientSecret = MANUALLY_FETCHED_CLIENT_SECRET // TODO - fetch from API + + const [activeTab, setActiveTab] = useState<'primary' | 'secondary'>('primary') + return (

Edit payment method

- - +
+ {/* Tabs for Primary and Secondary Payment Methods */} +
+ {['primary', 'secondary'].map((tab) => ( + + ))} +
+ + {/* Payment Details for the selected tab */} +
+ {activeTab === 'primary' && ( +
+ + {}} provider={''} owner={''} /> +
+ )} + {activeTab === 'secondary' && ( +
+ + {}} provider={''} owner={''} /> +
+ )} +
+
) } diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethod.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethod.tsx index 520909b30e..20da1857bf 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethod.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethod.tsx @@ -1,11 +1,11 @@ import { z } from 'zod' import { SubscriptionDetailSchema } from 'services/account' +import Button from 'ui/Button' import { ExpandableSection } from 'ui/ExpandableSection' import AddressCard from '../Address/AddressCard' import PaymentCard from '../PaymentCard' -import Button from 'ui/Button' function PaymentMethod({ heading, @@ -25,12 +25,8 @@ function PaymentMethod({ owner: string }) { const isAdmin = true // TODO - const isCreditCard = subscriptionDetail?.defaultPaymentMethod?.card // TODO - console.log(subscriptionDetail) - - console.log(isEditMode) return (
@@ -47,14 +43,14 @@ function PaymentMethod({ ) : null}
{/* Payment method summary */} - + {/* Cardholder name */}

@@ -63,20 +59,20 @@ function PaymentMethod({

N/A

{/* Address */} - +
{!isPrimary ? ( diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.tsx index bd80fe8458..b20ff6de5b 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.tsx @@ -1,12 +1,40 @@ import { z } from 'zod' +import amexLogo from 'assets/billing/amex.svg' +import discoverLogo from 'assets/billing/discover.svg' +import mastercardLogo from 'assets/billing/mastercard.svg' +import visaLogo from 'assets/billing/visa.svg' import { SubscriptionDetailSchema } from 'services/account' -import { CardBrand, CardBrands } from 'services/account/types' -import { +import { formatTimestampToCalendarDate, lastTwoDigits, } from 'shared/utils/billing' +const cardBrands = { + amex: { + logo: amexLogo, + name: 'American Express', + }, + discover: { + logo: discoverLogo, + name: 'Discover', + }, + mastercard: { + logo: mastercardLogo, + name: 'MasterCard', + }, + visa: { + logo: visaLogo, + name: 'Visa', + }, + fallback: { + logo: visaLogo, + name: 'Credit card', + }, +} + +type CardBrand = keyof typeof cardBrands + interface CardInformationProps { subscriptionDetail: z.infer card: { @@ -17,7 +45,7 @@ interface CardInformationProps { } } function CardInformation({ subscriptionDetail, card }: CardInformationProps) { - const typeCard = CardBrands[card?.brand as CardBrand] ?? CardBrands.fallback + const typeCard = cardBrands[card?.brand as CardBrand] ?? cardBrands.fallback let nextBilling = null if (!subscriptionDetail?.cancelAtPeriodEnd) { diff --git a/src/services/account/propTypes.js b/src/services/account/propTypes.js index 6eb1cadb96..02a7f1e5eb 100644 --- a/src/services/account/propTypes.js +++ b/src/services/account/propTypes.js @@ -1,8 +1,5 @@ import PropType from 'prop-types' -// TODO: These types were duplicated into types.ts, -// delete this file once all usages are migrated to TS - export const invoicePropType = PropType.shape({ created: PropType.number.isRequired, dueDate: PropType.number, diff --git a/src/services/account/types.ts b/src/services/account/types.ts deleted file mode 100644 index f264646ca4..0000000000 --- a/src/services/account/types.ts +++ /dev/null @@ -1,71 +0,0 @@ -import amexLogo from 'assets/billing/amex.svg' -import discoverLogo from 'assets/billing/discover.svg' -import mastercardLogo from 'assets/billing/mastercard.svg' -import visaLogo from 'assets/billing/visa.svg' - -export interface Invoice { - created: number - dueDate?: number - total: number - invoicePdf: string -} - -export interface Plan { - marketingName: string - baseUnitPrice: number - benefits: string[] - quantity?: number - value: string - monthlyUploadLimit?: number -} - -export interface Card { - brand: CardBrand - expMonth: number - expYear: number - last4: string -} - -export interface PaymentMethod { - card: Card -} - -export interface SubscriptionDetail { - latestInvoice?: Invoice - defaultPaymentMethod?: PaymentMethod - trialEnd?: number - cancelAtPeriodEnd?: boolean - currentPeriodEnd?: number -} - -export interface AccountDetails { - plan: Plan - activatedUserCount: number - planAutoActivate?: boolean - subscriptionDetail?: SubscriptionDetail -} - -export const CardBrands = { - amex: { - logo: amexLogo, - name: 'American Express', - }, - discover: { - logo: discoverLogo, - name: 'Discover', - }, - mastercard: { - logo: mastercardLogo, - name: 'MasterCard', - }, - visa: { - logo: visaLogo, - name: 'Visa', - }, - fallback: { - logo: visaLogo, - name: 'Credit card', - }, -} - -export type CardBrand = keyof typeof CardBrands diff --git a/src/services/navigation/useNavLinks/useNavLinks.ts b/src/services/navigation/useNavLinks/useNavLinks.ts index ede8591126..85d998b63f 100644 --- a/src/services/navigation/useNavLinks/useNavLinks.ts +++ b/src/services/navigation/useNavLinks/useNavLinks.ts @@ -967,26 +967,5 @@ export function useNavLinks() { isExternalLink: true, openNewTab: true, }, - billingEditPrimary: { - path: ( - { provider = p, owner = o} = { - provider: p, - owner: o, - } - ) => - `/plan/${provider}/${owner}?tab=primary`, - isExternalLink: false, - text: 'Primary payment method', - }, - billingEditSecondary: { - path: ( - { provider = p, owner = o} = { - provider: p, - owner: o, - } - ) => `/plan/${provider}/${owner}?tab=secondary`, - isExternalLink: false, - text: 'Secondary payment method', - }, } } From 89128353bb8fca36fc42d8b3db135460186ff5f5 Mon Sep 17 00:00:00 2001 From: Suejung Shin Date: Mon, 6 Jan 2025 16:48:31 -0800 Subject: [PATCH 5/9] wip --- src/assets/billing/bank.svg | 10 ++ src/pages/PlanPage/PlanPage.jsx | 7 +- .../BillingDetails/BillingDetails.tsx | 48 +++--- .../EditPaymentMethod/EditPaymentMethod.tsx | 162 ------------------ .../BillingDetails/EditPaymentMethod/index.ts | 1 - .../Address/AddressForm.tsx | 23 --- .../EditPaymentMethods/EditPaymentMethods.tsx | 96 +++++++++++ .../PaymentMethod/PaymentMethodForm.tsx | 105 ++++++++++++ .../EditPaymentMethods/index.ts | 1 + .../PaymentCard/CreditCardForm.tsx | 100 ----------- .../PaymentCard/PaymentCard.tsx | 74 -------- .../BillingDetails/PaymentCard/index.ts | 1 - .../BillingDetails/PaymentMethod/index.ts | 1 - .../Address/AddressCard.test.tsx | 0 .../Address/AddressCard.tsx | 31 +--- .../PaymentMethod/BankInformation.tsx | 26 +++ .../PaymentMethod}/CardInformation.tsx | 9 +- .../PaymentMethod/PaymentMethod.test.tsx} | 32 ++-- .../PaymentMethod/PaymentMethod.tsx | 54 ++++++ .../ViewPaymentMethod.tsx} | 32 ++-- .../BillingDetails/ViewPaymentMethod/index.ts | 1 + src/services/account/index.ts | 2 +- src/services/account/useAccountDetails.ts | 9 + src/services/account/useStripeSetupIntent.ts | 55 ++++++ src/services/account/useUpdateBillingEmail.ts | 1 + ...st.tsx => useUpdatePaymentMethod.test.tsx} | 14 +- ...pdateCard.ts => useUpdatePaymentMethod.ts} | 38 ++-- src/stripe.ts | 51 ++++++ 28 files changed, 519 insertions(+), 465 deletions(-) create mode 100644 src/assets/billing/bank.svg delete mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethod.tsx delete mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/index.ts rename src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/{ => EditPaymentMethods}/Address/AddressForm.tsx (71%) create mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/EditPaymentMethods.tsx create mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.tsx create mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/index.ts delete mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CreditCardForm.tsx delete mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.tsx delete mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/index.ts delete mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/index.ts rename src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/{ => ViewPaymentMethod}/Address/AddressCard.test.tsx (100%) rename src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/{ => ViewPaymentMethod}/Address/AddressCard.tsx (80%) create mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/BankInformation.tsx rename src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/{PaymentCard => ViewPaymentMethod/PaymentMethod}/CardInformation.tsx (91%) rename src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/{PaymentCard/PaymentCard.test.tsx => ViewPaymentMethod/PaymentMethod/PaymentMethod.test.tsx} (94%) create mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/PaymentMethod.tsx rename src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/{PaymentMethod/PaymentMethod.tsx => ViewPaymentMethod/ViewPaymentMethod.tsx} (78%) create mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/index.ts create mode 100644 src/services/account/useStripeSetupIntent.ts rename src/services/account/{useUpdateCard.test.tsx => useUpdatePaymentMethod.test.tsx} (91%) rename src/services/account/{useUpdateCard.ts => useUpdatePaymentMethod.ts} (56%) create mode 100644 src/stripe.ts diff --git a/src/assets/billing/bank.svg b/src/assets/billing/bank.svg new file mode 100644 index 0000000000..42a10ad51e --- /dev/null +++ b/src/assets/billing/bank.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/pages/PlanPage/PlanPage.jsx b/src/pages/PlanPage/PlanPage.jsx index 8ae321a08c..5544daed4a 100644 --- a/src/pages/PlanPage/PlanPage.jsx +++ b/src/pages/PlanPage/PlanPage.jsx @@ -8,6 +8,7 @@ import config from 'config' import { SentryRoute } from 'sentry' +import { Theme, useThemeContext } from 'shared/ThemeContext' import LoadingLogo from 'ui/LoadingLogo' import { PlanProvider } from './context' @@ -15,6 +16,8 @@ import PlanBreadcrumb from './PlanBreadcrumb' import { PlanPageDataQueryOpts } from './queries/PlanPageDataQueryOpts' import Tabs from './Tabs' +import { StripeAppearance } from '../../stripe' + const CancelPlanPage = lazy(() => import('./subRoutes/CancelPlanPage')) const CurrentOrgPlan = lazy(() => import('./subRoutes/CurrentOrgPlan')) const InvoicesPage = lazy(() => import('./subRoutes/InvoicesPage')) @@ -37,6 +40,8 @@ function PlanPage() { const { data: ownerData } = useSuspenseQueryV5( PlanPageDataQueryOpts({ owner, provider }) ) + const { theme } = useThemeContext() + const isDarkMode = theme !== Theme.LIGHT if (config.IS_SELF_HOSTED || !ownerData?.isCurrentUserPartOfOrg) { return @@ -45,7 +50,7 @@ function PlanPage() { return (
- + }> diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx index 6cb32dc984..22e89d784a 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx @@ -1,13 +1,13 @@ +import { useState } from 'react' import { useParams } from 'react-router-dom' import { useAccountDetails } from 'services/account' +import A from 'ui/A' +import Button from 'ui/Button' +import EditPaymentMethods from './EditPaymentMethods' import EmailAddress from './EmailAddress' -import PaymentMethod from './PaymentMethod' -import Button from 'ui/Button' -import { useState } from 'react' -import A from 'ui/A' -import EditablePaymentMethod from './EditPaymentMethod' +import { ViewPaymentMethod } from './ViewPaymentMethod' interface URLParams { provider: string @@ -22,15 +22,12 @@ function BillingDetails() { }) const subscriptionDetail = accountDetails?.subscriptionDetail const [isEditMode, setEditMode] = useState(false) - - const isAdmin = true // TODO + const secondaryPaymentFeatureEnabled = false if (!subscriptionDetail) { return null } - console.log('iseditmode', isEditMode) - return (
{/* Billing Details Section */} @@ -50,7 +47,6 @@ function BillingDetails() { hook="button" onClick={() => setEditMode(true)} variant="default" - disabled={!isAdmin} className="flex-none" > Edit payment @@ -60,7 +56,6 @@ function BillingDetails() { hook="button" onClick={() => setEditMode(false)} variant="default" - disabled={!isAdmin} className="flex-none" > Back @@ -68,28 +63,35 @@ function BillingDetails() { )}
{isEditMode ? ( - + ) : ( <> - - + {secondaryPaymentFeatureEnabled && ( + + )} {subscriptionDetail?.taxIds?.length ? (

Tax ID

diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethod.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethod.tsx deleted file mode 100644 index c7142559c7..0000000000 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethod.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { - Elements, - PaymentElement, - useElements, - useStripe, -} from '@stripe/react-stripe-js' -import { loadStripe } from '@stripe/stripe-js' -import React, { useState } from 'react' - -import Button from 'ui/Button' - -import AddressForm from '../Address/AddressForm' - -// TODO - fetch from API -const STRIPE_PUBLISHABLE_KEY = process.env.STRIPE_PUBLISHABLE_KEY || '' -const MANUALLY_FETCHED_CLIENT_SECRET = process.env.STRIPE_CLIENT_SECRET || '' - -const stripePromise = loadStripe(STRIPE_PUBLISHABLE_KEY) - -interface PaymentFormProps { - clientSecret: string -} - -const PaymentForm: React.FC = () => { - const stripe = useStripe() - const elements = useElements() - const [isSubmitting, setIsSubmitting] = useState(false) - const [errorMessage, setErrorMessage] = useState(null) - - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault() - setIsSubmitting(true) - setErrorMessage(null) - - if (!stripe || !elements) { - setErrorMessage('Stripe has not loaded yet. Please try again.') - setIsSubmitting(false) - return - } - - const { error } = await stripe.confirmPayment({ - elements, - confirmParams: { - // eslint-disable-next-line camelcase - return_url: 'https://codecov.io', - }, - }) - - if (error) { - setErrorMessage(error.message || 'An unexpected error occurred.') - setIsSubmitting(false) - } else { - setIsSubmitting(false) - } - } - - return ( -
- -
- - -
- - {errorMessage &&
{errorMessage}
} -
- ) -} - -const PaymentPage: React.FC<{ clientSecret: string }> = ({ clientSecret }) => { - const options = { - clientSecret, - appearance: { - theme: 'stripe' as const, - }, - } - - return ( - - - - ) -} - -interface EditablePaymentMethodProps { - clientSecret: string -} - -const EditPaymentMethod: React.FC = () => { - const clientSecret = MANUALLY_FETCHED_CLIENT_SECRET // TODO - fetch from API - - const [activeTab, setActiveTab] = useState<'primary' | 'secondary'>('primary') - - return ( -
-

Edit payment method

-
- {/* Tabs for Primary and Secondary Payment Methods */} -
- {['primary', 'secondary'].map((tab) => ( - - ))} -
- - {/* Payment Details for the selected tab */} -
- {activeTab === 'primary' && ( -
- - {}} provider={''} owner={''} /> -
- )} - {activeTab === 'secondary' && ( -
- - {}} provider={''} owner={''} /> -
- )} -
-
-
- ) -} - -export default EditPaymentMethod diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/index.ts b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/index.ts deleted file mode 100644 index 4f454d7d4f..0000000000 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './EditPaymentMethod' diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressForm.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/Address/AddressForm.tsx similarity index 71% rename from src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressForm.tsx rename to src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/Address/AddressForm.tsx index 045fdd8008..ae237d5333 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressForm.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/Address/AddressForm.tsx @@ -4,7 +4,6 @@ import { z } from 'zod' import { AddressSchema } from 'services/account' import { useUpdateBillingAddress } from 'services/account/useUpdateBillingAddress' -import { Theme, useThemeContext } from 'shared/ThemeContext' import Button from 'ui/Button' interface AddressFormProps { @@ -23,28 +22,6 @@ function AddressForm({ owner, }: AddressFormProps) { const elements = useElements() - const { theme } = useThemeContext() - const isDarkMode = theme === Theme.DARK - - // Note: unfortunately seems Stripe doesn't let us reference like `var(--)` so rgbs are hardcoded in below - elements?.update({ - appearance: { - variables: { - fontFamily: 'Poppins, ui-sans-serif, system-ui, sans-serif', - }, - rules: { - '.Label': { - fontWeight: '600', - color: isDarkMode ? 'rgb(210,212,215)' : 'rgb(14,27,41)', // Same values as --color-app-text-primary. - }, - '.Input': { - backgroundColor: isDarkMode ? 'rgb(22,24,29)' : 'rgb(255,255,255)', // Same values as --color-app-container. - borderColor: isDarkMode ? 'rgb(47,51,60)' : 'rgb(216,220,226)', // Same values as --color-ds-gray-tertiary. - color: isDarkMode ? 'rgb(210,212,215)' : 'rgb(14,27,41)', // Same values as --color-app-text-primary. - }, - }, - }, - }) const { mutate: updateAddress, diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/EditPaymentMethods.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/EditPaymentMethods.tsx new file mode 100644 index 0000000000..8e926a8a45 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/EditPaymentMethods.tsx @@ -0,0 +1,96 @@ +import React, { useState } from 'react' +import { z } from 'zod' + +import { SubscriptionDetailSchema } from 'services/account/useAccountDetails' + +import AddressForm from './Address/AddressForm' +import PaymentMethodForm from './PaymentMethod/PaymentMethodForm' + +interface EditPaymentMethodProps { + setEditMode: (isEditMode: boolean) => void + provider: string + owner: string + existingSubscriptionDetail: z.infer +} + +const EditPaymentMethod = ({ + setEditMode, + provider, + owner, + existingSubscriptionDetail, +}: EditPaymentMethodProps) => { + const [activeTab, setActiveTab] = useState<'primary' | 'secondary'>('primary') + const isSecondaryPaymentMethodFeatureEnabled = false + + return ( +
+

Edit payment method

+
+ {/* Tabs for Primary and Secondary Payment Methods */} +
+ {[ + 'primary', + ...(isSecondaryPaymentMethodFeatureEnabled ? ['secondary'] : []), + ].map((tab) => ( + + ))} +
+ + {/* Payment Details for the selected tab */} +
+ {activeTab === 'primary' && ( +
+ setEditMode(false)} + provider={provider} + owner={owner} + existingSubscriptionDetail={existingSubscriptionDetail} + /> + setEditMode(false)} + provider={provider} + owner={owner} + /> +
+ )} + {activeTab === 'secondary' && + isSecondaryPaymentMethodFeatureEnabled && ( +
+ setEditMode(false)} + provider={provider} + owner={owner} + existingSubscriptionDetail={existingSubscriptionDetail} + /> + setEditMode(false)} + provider={provider} + owner={owner} + /> +
+ )} +
+
+
+ ) +} + +export default EditPaymentMethod diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.tsx new file mode 100644 index 0000000000..1a3fd8aee2 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.tsx @@ -0,0 +1,105 @@ +import { PaymentElement, useElements } from '@stripe/react-stripe-js' +import cs from 'classnames' +import { useState } from 'react' +import { z } from 'zod' + +import { SubscriptionDetailSchema } from 'services/account' +import { useStripeSetupIntent } from 'services/account/useStripeSetupIntent' +import { useUpdatePaymentMethod } from 'services/account/useUpdatePaymentMethod' +import Button from 'ui/Button' + +interface PaymentMethodFormProps { + closeForm: () => void + provider: string + owner: string + existingSubscriptionDetail: z.infer +} + +const PaymentMethodForm = ({ + closeForm, + provider, + owner, + existingSubscriptionDetail, +}: PaymentMethodFormProps) => { + const [errorState, _] = useState('') + const { data: setupIntent } = useStripeSetupIntent({ owner, provider }) + const elements = useElements() + + elements?.update({ + clientSecret: setupIntent?.clientSecret, + }) + + const { + mutate: updatePaymentMethod, + isLoading, + error, + reset, + } = useUpdatePaymentMethod({ + provider, + owner, + email: + existingSubscriptionDetail?.defaultPaymentMethod?.billingDetails?.email, + }) + + async function submit(e: React.FormEvent) { + e.preventDefault() + + if (!elements) { + return null + } + + const paymentElement = elements.getElement(PaymentElement) + + updatePaymentMethod(paymentElement, { + onSuccess: closeForm, + }) + } + + const showError = (error && !reset) || errorState + + return ( + +
+
+ +

+ {showError && (error?.message || errorState)} +

+
+ + +
+
+
+ + ) +} + +export default PaymentMethodForm diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/index.ts b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/index.ts new file mode 100644 index 0000000000..a53f3b83bd --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/index.ts @@ -0,0 +1 @@ +export { default } from './EditPaymentMethods' diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CreditCardForm.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CreditCardForm.tsx deleted file mode 100644 index bfe18a3c7c..0000000000 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CreditCardForm.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { CardElement, useElements } from '@stripe/react-stripe-js' -import cs from 'classnames' -import { useState } from 'react' - -import { useUpdateCard } from 'services/account' -import { Theme, useThemeContext } from 'shared/ThemeContext' -import Button from 'ui/Button' - -interface CreditCardFormProps { - closeForm: () => void - provider: string - owner: string -} - -function CreditCardForm({ closeForm, provider, owner }: CreditCardFormProps) { - const [errorState, setErrorState] = useState('') - const { theme } = useThemeContext() - const isDarkMode = theme === Theme.DARK - - const elements = useElements() - const { - mutate: updateCard, - isLoading, - error, - reset, - } = useUpdateCard({ - provider, - owner, - }) - - function submit(e: React.FormEvent) { - e.preventDefault() - - if (!elements) { - return null - } - - updateCard(elements.getElement(CardElement), { - onSuccess: closeForm, - }) - } - - const showError = (error && !reset) || errorState - - return ( -
-
-
- setErrorState(e.error?.message || '')} - options={{ - disableLink: true, - hidePostalCode: true, - classes: { - empty: - 'rounded border-ds-gray-tertiary border-2 bg-ds-container', - focus: - 'rounded !border-ds-blue-darker border-4 bg-ds-container', - base: 'px-4 py-3 bg-ds-container', - invalid: - 'rounded !border-ds-primary-red border-4 bg-ds-container', - }, - style: { - base: { - color: isDarkMode ? 'rgb(210,212,215)' : 'rgb(14,27,41)', // Same values as --color-app-text-primary. - }, - }, - }} - /> -

- {showError && (error?.message || errorState)} -

-
-
- - -
-
-
- ) -} - -export default CreditCardForm diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.tsx deleted file mode 100644 index 539531f4f0..0000000000 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import PropTypes from 'prop-types' -import { useState } from 'react' -import { z } from 'zod' - -import { - SubscriptionDetailSchema, - subscriptionDetailType, -} from 'services/account' -import A from 'ui/A' -import Button from 'ui/Button' -import Icon from 'ui/Icon' - -import CardInformation from './CardInformation' -import CreditCardForm from './CreditCardForm' -import { cn } from 'shared/utils/cn' - -function PaymentCard({ - isEditMode, - setEditMode, - subscriptionDetail, - provider, - owner, - className, -}: { - isEditMode: boolean - setEditMode: (isEditMode: boolean) => void - subscriptionDetail: z.infer - provider: string - owner: string - className?: string -}) { - const card = subscriptionDetail?.defaultPaymentMethod?.card - - return ( -
-
-

Payment method

-
- {isEditMode ? ( - setEditMode(false)} - /> - ) : card ? ( - - ) : ( -
-

- No credit card set. Please contact support if you think it’s an - error or set it yourself. -

-
- -
-
- )} -
- ) -} - -PaymentCard.propTypes = { - subscriptionDetail: PropTypes.oneOf([subscriptionDetailType, null]), - provider: PropTypes.string.isRequired, - owner: PropTypes.string.isRequired, -} - -export default PaymentCard diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/index.ts b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/index.ts deleted file mode 100644 index 9102b8ff5f..0000000000 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './PaymentCard' diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/index.ts b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/index.ts deleted file mode 100644 index 34940e48e5..0000000000 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './PaymentMethod' diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/AddressCard.test.tsx similarity index 100% rename from src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.test.tsx rename to src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/AddressCard.test.tsx diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/AddressCard.tsx similarity index 80% rename from src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.tsx rename to src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/AddressCard.tsx index 0fe781ee79..7e1d7e2407 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/AddressCard.tsx @@ -1,16 +1,11 @@ -import { useState } from 'react' import { z } from 'zod' import { BillingDetailsSchema, SubscriptionDetailSchema, } from 'services/account' -import A from 'ui/A' -import Button from 'ui/Button' -import Icon from 'ui/Icon' - -import AddressForm from './AddressForm' import { cn } from 'shared/utils/cn' +import Button from 'ui/Button' interface AddressCardProps { isEditMode: boolean @@ -22,11 +17,8 @@ interface AddressCardProps { } function AddressCard({ - isEditMode, setEditMode, subscriptionDetail, - provider, - owner, className, }: AddressCardProps) { const billingDetails = @@ -36,22 +28,11 @@ function AddressCard({ return (
- {isEditMode && ( - setEditMode(false)} - /> - )} - {!isEditMode && ( - - )} +
) } diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/BankInformation.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/BankInformation.tsx new file mode 100644 index 0000000000..f73afae9f8 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/BankInformation.tsx @@ -0,0 +1,26 @@ +import { z } from 'zod' + +import bankLogo from 'assets/billing/bank.svg' +import { SubscriptionDetailSchema } from 'services/account' + +interface BankInformationProps { + subscriptionDetail: z.infer +} +function BankInformation({ subscriptionDetail }: BankInformationProps) { + return ( +
+
+ bank logo +
+ + {subscriptionDetail?.defaultPaymentMethod?.usBankAccount?.bankName} +  ••••  + {subscriptionDetail?.defaultPaymentMethod?.usBankAccount?.last4} + +
+
+
+ ) +} + +export default BankInformation diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/CardInformation.tsx similarity index 91% rename from src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.tsx rename to src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/CardInformation.tsx index b20ff6de5b..9193fdc345 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/CardInformation.tsx @@ -37,14 +37,9 @@ type CardBrand = keyof typeof cardBrands interface CardInformationProps { subscriptionDetail: z.infer - card: { - brand: string - last4: string - expMonth: number - expYear: number - } } -function CardInformation({ subscriptionDetail, card }: CardInformationProps) { +function CardInformation({ subscriptionDetail }: CardInformationProps) { + const card = subscriptionDetail?.defaultPaymentMethod?.card const typeCard = cardBrands[card?.brand as CardBrand] ?? cardBrands.fallback let nextBilling = null diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/PaymentMethod.test.tsx similarity index 94% rename from src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.test.tsx rename to src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/PaymentMethod.test.tsx index b8021a7cb6..b3f7fdd2d6 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.test.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/PaymentMethod.test.tsx @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event' import { ThemeContextProvider } from 'shared/ThemeContext' import { Plans } from 'shared/utils/billing' -import PaymentCard from './PaymentCard' +import PaymentMethod from './PaymentMethod' const mocks = vi.hoisted(() => ({ useUpdateCard: vi.fn(), @@ -66,7 +66,11 @@ describe('PaymentCard', () => { // BillingDetails.tsx if there is no subscriptionDetail it('renders the set card message', () => { render( - + ) expect( @@ -80,7 +84,7 @@ describe('PaymentCard', () => { describe(`when the user doesn't have any card`, () => { it('renders an error message', () => { render( - { it(`doesn't render the card anymore`, async () => { const { user } = setup() render( - { it('renders the form', async () => { const { user } = setup() render( - { describe('when the user have a card', () => { it('renders the card', () => { render( - { it('renders the next billing', () => { render( - { describe('when the subscription is set to expire', () => { it(`doesn't render the next billing`, () => { render( - { }) render( - { isLoading: false, }) render( - { isLoading: false, }) render( - { isLoading: false, }) render( - { error: { message: randomError }, }) render( - { isLoading: true, }) render( - void + subscriptionDetail: z.infer + provider: string + owner: string + className?: string +}) { + const isCardSet = !!subscriptionDetail?.defaultPaymentMethod?.card + const isBankSet = !!subscriptionDetail?.defaultPaymentMethod?.usBankAccount + + return ( +
+
+

Payment method

+
+ {isCardSet ? ( + + ) : isBankSet ? ( + + ) : ( +
+

+ No payment method set. Please contact support if you think it’s an + error or set it yourself. +

+
+ +
+
+ )} +
+ ) +} + +export default PaymentMethod diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethod.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/ViewPaymentMethod.tsx similarity index 78% rename from src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethod.tsx rename to src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/ViewPaymentMethod.tsx index 20da1857bf..0248e2a4ce 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethod.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/ViewPaymentMethod.tsx @@ -4,12 +4,12 @@ import { SubscriptionDetailSchema } from 'services/account' import Button from 'ui/Button' import { ExpandableSection } from 'ui/ExpandableSection' -import AddressCard from '../Address/AddressCard' -import PaymentCard from '../PaymentCard' +import AddressCard from './Address/AddressCard' +import PaymentMethod from './PaymentMethod/PaymentMethod' -function PaymentMethod({ +function ViewPaymentMethod({ heading, - isPrimary, + isPrimaryPaymentMethod, isEditMode, setEditMode, subscriptionDetail, @@ -17,25 +17,27 @@ function PaymentMethod({ owner, }: { heading: string - isPrimary?: boolean + isPrimaryPaymentMethod?: boolean isEditMode: boolean setEditMode: (isEditMode: boolean) => void subscriptionDetail: z.infer provider: string owner: string }) { - const isAdmin = true // TODO const isCreditCard = subscriptionDetail?.defaultPaymentMethod?.card // TODO return (
- +

{heading}

- {!isPrimary ? ( + {!isPrimaryPaymentMethod ? (

By default, if the primary payment fails, the secondary will be charged automatically. @@ -43,7 +45,7 @@ function PaymentMethod({ ) : null}

{/* Payment method summary */} - {isCreditCard ? 'Cardholder name' : 'Full name'} -

N/A

+

+ { + subscriptionDetail?.defaultPaymentMethod?.billingDetails + ?.name + } +

{/* Address */}
- {!isPrimary ? ( + {!isPrimaryPaymentMethod ? (
)} - {activeTab === 'secondary' && - isSecondaryPaymentMethodFeatureEnabled && ( -
- setEditMode(false)} - provider={provider} - owner={owner} - existingSubscriptionDetail={existingSubscriptionDetail} - /> - setEditMode(false)} - provider={provider} - owner={owner} - /> -
- )} + {activeTab === 'secondary' && ( +
+ setEditMode(false)} + provider={provider} + owner={owner} + existingSubscriptionDetail={existingSubscriptionDetail} + /> + setEditMode(false)} + provider={provider} + owner={owner} + /> +
+ )}
diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.tsx index 1a3fd8aee2..5c371b8d0f 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.tsx @@ -4,7 +4,7 @@ import { useState } from 'react' import { z } from 'zod' import { SubscriptionDetailSchema } from 'services/account' -import { useStripeSetupIntent } from 'services/account/useStripeSetupIntent' +import { useCreateStripeSetupIntent } from 'services/account/useCreateStripeSetupIntent' import { useUpdatePaymentMethod } from 'services/account/useUpdatePaymentMethod' import Button from 'ui/Button' @@ -22,10 +22,11 @@ const PaymentMethodForm = ({ existingSubscriptionDetail, }: PaymentMethodFormProps) => { const [errorState, _] = useState('') - const { data: setupIntent } = useStripeSetupIntent({ owner, provider }) + const { data: setupIntent } = useCreateStripeSetupIntent({ owner, provider }) const elements = useElements() elements?.update({ + // @ts-expect-error clientSecret works actually clientSecret: setupIntent?.clientSecret, }) @@ -51,7 +52,9 @@ const PaymentMethodForm = ({ const paymentElement = elements.getElement(PaymentElement) updatePaymentMethod(paymentElement, { - onSuccess: closeForm, + onSuccess: async () => { + closeForm() + }, }) } diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/AddressCard.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/AddressCard.test.tsx index 5142eafd5c..49ca52c423 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/AddressCard.test.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/AddressCard.test.tsx @@ -85,13 +85,18 @@ describe('AddressCard', () => { // BillingDetails.tsx if there is no subscriptionDetail it('renders the set card message', () => { render( - , + {}} + />, { wrapper } ) expect( screen.getByText( - /No address has been set. Please contact support if you think it’s an error or set it yourself./ + /No address has been set. Please contact support if you think it's an error or set it yourself./ ) ).toBeInTheDocument() }) @@ -108,13 +113,14 @@ describe('AddressCard', () => { }} provider="gh" owner="codecov" + setEditMode={() => {}} />, { wrapper } ) expect( screen.getByText( - /No address has been set. Please contact support if you think it’s an error or set it yourself./ + /No address has been set. Please contact support if you think it's an error or set it yourself./ ) ).toBeInTheDocument() }) @@ -131,6 +137,7 @@ describe('AddressCard', () => { }} provider="gh" owner="codecov" + setEditMode={() => {}} />, { wrapper } ) @@ -157,6 +164,7 @@ describe('AddressCard', () => { }} provider="gh" owner="codecov" + setEditMode={() => {}} />, { wrapper } ) @@ -181,6 +189,7 @@ describe('AddressCard', () => { subscriptionDetail={subscriptionDetail} provider="gh" owner="codecov" + setEditMode={() => {}} />, { wrapper } ) @@ -220,6 +229,7 @@ describe('AddressCard', () => { }} provider="gh" owner="codecov" + setEditMode={() => {}} />, { wrapper } ) @@ -237,6 +247,7 @@ describe('AddressCard', () => { subscriptionDetail={subscriptionDetail} provider="gh" owner="codecov" + setEditMode={() => {}} />, { wrapper } ) @@ -260,6 +271,7 @@ describe('AddressCard', () => { subscriptionDetail={subscriptionDetail} provider="gh" owner="codecov" + setEditMode={() => {}} />, { wrapper } ) @@ -286,6 +298,7 @@ describe('AddressCard', () => { subscriptionDetail={subscriptionDetail} provider="gh" owner="codecov" + setEditMode={() => {}} />, { wrapper } ) @@ -309,6 +322,7 @@ describe('AddressCard', () => { subscriptionDetail={subscriptionDetail} provider="gh" owner="codecov" + setEditMode={() => {}} />, { wrapper } ) @@ -331,6 +345,7 @@ describe('AddressCard', () => { subscriptionDetail={subscriptionDetail} provider="gh" owner="codecov" + setEditMode={() => {}} />, { wrapper } ) @@ -358,6 +373,7 @@ describe('AddressCard', () => { subscriptionDetail={subscriptionDetail} provider="gh" owner="codecov" + setEditMode={() => {}} />, { wrapper } ) @@ -380,6 +396,7 @@ describe('AddressCard', () => { subscriptionDetail={subscriptionDetail} provider="gh" owner="codecov" + setEditMode={() => {}} />, { wrapper } ) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/AddressCard.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/AddressCard.tsx index 7e1d7e2407..ba8f159ec8 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/AddressCard.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/AddressCard.tsx @@ -7,8 +7,9 @@ import { import { cn } from 'shared/utils/cn' import Button from 'ui/Button' +import { SECONDARY_PAYMENT_FEATURE_ENABLED } from '../../BillingDetails' + interface AddressCardProps { - isEditMode: boolean setEditMode: (isEditMode: boolean) => void subscriptionDetail: z.infer provider: string @@ -24,7 +25,10 @@ function AddressCard({ const billingDetails = subscriptionDetail?.defaultPaymentMethod?.billingDetails - const isAddressSameAsPrimary = false // TODO + // TODO: Implement this when we have secondary payment method feature + const isAddressSameAsPrimary = SECONDARY_PAYMENT_FEATURE_ENABLED + ? true + : undefined return (
@@ -40,7 +44,7 @@ function AddressCard({ interface BillingInnerProps { billingDetails?: z.infer setEditMode: (val: boolean) => void - isAddressSameAsPrimary: boolean + isAddressSameAsPrimary?: boolean } function BillingInner({ diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/ViewPaymentMethod.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/ViewPaymentMethod.tsx index 0248e2a4ce..812437fa90 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/ViewPaymentMethod.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/ViewPaymentMethod.tsx @@ -10,7 +10,6 @@ import PaymentMethod from './PaymentMethod/PaymentMethod' function ViewPaymentMethod({ heading, isPrimaryPaymentMethod, - isEditMode, setEditMode, subscriptionDetail, provider, @@ -18,13 +17,12 @@ function ViewPaymentMethod({ }: { heading: string isPrimaryPaymentMethod?: boolean - isEditMode: boolean setEditMode: (isEditMode: boolean) => void subscriptionDetail: z.infer provider: string owner: string }) { - const isCreditCard = subscriptionDetail?.defaultPaymentMethod?.card // TODO + const isCreditCard = subscriptionDetail?.defaultPaymentMethod?.card return (
@@ -47,7 +45,6 @@ function ViewPaymentMethod({ {/* Payment method summary */} + createStripeSetupIntent({ provider, owner, signal }).then((res) => { + const parsedRes = CreateStripeSetupIntentSchema.safeParse(res.data) + if (!parsedRes.success) { + return Promise.reject({ + status: 404, + data: {}, + dev: 'useStripeSetupIntent - 404 failed to parse', + } satisfies NetworkErrorObject) + } + + return parsedRes.data.createStripeSetupIntent + }), + ...opts, + }) +} diff --git a/src/services/account/useStripeSetupIntent.ts b/src/services/account/useStripeSetupIntent.ts deleted file mode 100644 index 37c07b55ed..0000000000 --- a/src/services/account/useStripeSetupIntent.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { z } from 'zod' - -import Api from 'shared/api' -import { NetworkErrorObject } from 'shared/api/helpers' - -export const StripeSetupIntentSchema = z.object({ - clientSecret: z.string(), -}) - -export interface UseStripeSetupIntentArgs { - provider: string - owner: string - opts?: { - enabled?: boolean - } -} - -function fetchStripeSetupIntent({ - provider, - owner, - signal, -}: { - provider: string - owner: string - signal?: AbortSignal -}) { - const path = `/${provider}/${owner}/account-details/setup_intent` - return Api.get({ path, provider, signal }) -} - -export function useStripeSetupIntent({ - provider, - owner, - opts = {}, -}: UseStripeSetupIntentArgs) { - return useQuery({ - queryKey: ['setupIntent', provider, owner], - queryFn: ({ signal }) => - fetchStripeSetupIntent({ provider, owner, signal }).then((res) => { - const parsedRes = StripeSetupIntentSchema.safeParse(res) - - if (!parsedRes.success) { - return Promise.reject({ - status: 404, - data: {}, - dev: 'useStripeSetupIntent - 404 failed to parse', - } satisfies NetworkErrorObject) - } - - return parsedRes.data - }), - ...opts, - }) -} diff --git a/src/services/account/useUpdateBillingEmail.test.tsx b/src/services/account/useUpdateBillingEmail.test.tsx index 4151927605..2049ca4024 100644 --- a/src/services/account/useUpdateBillingEmail.test.tsx +++ b/src/services/account/useUpdateBillingEmail.test.tsx @@ -95,6 +95,7 @@ describe('useUpdateBillingEmail', () => { await waitFor(() => expect(mockBody).toHaveBeenCalledWith({ new_email: 'test@gmail.com', + should_propagate_to_payment_methods: true, }) ) }) diff --git a/src/services/account/useUpdatePaymentMethod.test.tsx b/src/services/account/useUpdatePaymentMethod.test.tsx index 239ee0b2b2..4c06082072 100644 --- a/src/services/account/useUpdatePaymentMethod.test.tsx +++ b/src/services/account/useUpdatePaymentMethod.test.tsx @@ -65,7 +65,7 @@ afterAll(() => { server.close() }) -describe('useUpdateCard', () => { +describe('useUpdatePaymentMethod', () => { const card = { last4: '1234', } From 616eaa800d3369b8e76291b56cb0d07dbafbf64a Mon Sep 17 00:00:00 2001 From: Suejung Shin Date: Mon, 13 Jan 2025 23:29:32 -0800 Subject: [PATCH 7/9] fix tests --- src/pages/PlanPage/PlanPage.jsx | 9 +- .../PaymentMethod/PaymentMethodForm.tsx | 9 +- .../ViewPaymentMethod.test.tsx | 79 ++++++++++++++++ .../account/useAccountDetails.test.tsx | 49 ++++++++-- .../useCreateStripeSetupIntent.test.tsx | 90 +++++++++++++++++++ .../account/useUpdatePaymentMethod.test.tsx | 26 +++++- .../account/useUpdatePaymentMethod.ts | 17 +++- src/shared/utils/billing.ts | 2 +- 8 files changed, 261 insertions(+), 20 deletions(-) create mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/ViewPaymentMethod.test.tsx create mode 100644 src/services/account/useCreateStripeSetupIntent.test.tsx diff --git a/src/pages/PlanPage/PlanPage.jsx b/src/pages/PlanPage/PlanPage.jsx index 5544daed4a..2608b9efb9 100644 --- a/src/pages/PlanPage/PlanPage.jsx +++ b/src/pages/PlanPage/PlanPage.jsx @@ -50,7 +50,14 @@ function PlanPage() { return (
- + }> diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.tsx index 5c371b8d0f..46ac35a50d 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.tsx @@ -4,7 +4,6 @@ import { useState } from 'react' import { z } from 'zod' import { SubscriptionDetailSchema } from 'services/account' -import { useCreateStripeSetupIntent } from 'services/account/useCreateStripeSetupIntent' import { useUpdatePaymentMethod } from 'services/account/useUpdatePaymentMethod' import Button from 'ui/Button' @@ -22,14 +21,8 @@ const PaymentMethodForm = ({ existingSubscriptionDetail, }: PaymentMethodFormProps) => { const [errorState, _] = useState('') - const { data: setupIntent } = useCreateStripeSetupIntent({ owner, provider }) const elements = useElements() - elements?.update({ - // @ts-expect-error clientSecret works actually - clientSecret: setupIntent?.clientSecret, - }) - const { mutate: updatePaymentMethod, isLoading, @@ -49,6 +42,8 @@ const PaymentMethodForm = ({ return null } + elements.submit() + const paymentElement = elements.getElement(PaymentElement) updatePaymentMethod(paymentElement, { diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/ViewPaymentMethod.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/ViewPaymentMethod.test.tsx new file mode 100644 index 0000000000..b104102086 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/ViewPaymentMethod.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from '@testing-library/react' +import { z } from 'zod' + +import { SubscriptionDetailSchema } from 'services/account' +import { accountDetailsParsedObj } from 'services/account/mocks' + +import ViewPaymentMethod from './ViewPaymentMethod' + +describe('ViewPaymentMethod', () => { + const mockSetEditMode = vi.fn() + const defaultProps = { + heading: 'Payment Method', + setEditMode: mockSetEditMode, + subscriptionDetail: accountDetailsParsedObj.subscriptionDetail as z.infer< + typeof SubscriptionDetailSchema + >, + provider: 'gh', + owner: 'codecov', + } + + beforeEach(() => { + mockSetEditMode.mockClear() + }) + + describe('when rendered as primary payment method', () => { + it('renders heading', () => { + render() + + expect(screen.getByText('Payment Method')).toBeInTheDocument() + }) + + it('does not show secondary payment method message', () => { + render() + + expect( + screen.queryByText( + 'By default, if the primary payment fails, the secondary will be charged automatically.' + ) + ).not.toBeInTheDocument() + }) + + it('does not show set as primary button', () => { + render() + + expect(screen.queryByText('Set as primary')).not.toBeInTheDocument() + }) + }) + + describe('when payment method is credit card', () => { + it('shows Cardholder name label', () => { + render() + + expect(screen.getByText('Cardholder name')).toBeInTheDocument() + }) + }) + + describe('when payment method is bank account', () => { + beforeEach(() => { + defaultProps.subscriptionDetail = { + ...accountDetailsParsedObj.subscriptionDetail, + defaultPaymentMethod: { + billingDetails: + accountDetailsParsedObj.subscriptionDetail?.defaultPaymentMethod + ?.billingDetails, + usBankAccount: { + bankName: 'Test Bank', + last4: '1234', + }, + }, + } + }) + + it('shows Full name label', () => { + render() + + expect(screen.getByText('Full name')).toBeInTheDocument() + }) + }) +}) diff --git a/src/services/account/useAccountDetails.test.tsx b/src/services/account/useAccountDetails.test.tsx index 18081b27a1..c31b422b71 100644 --- a/src/services/account/useAccountDetails.test.tsx +++ b/src/services/account/useAccountDetails.test.tsx @@ -4,9 +4,10 @@ import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import React from 'react' import { MemoryRouter, Route } from 'react-router-dom' +import { z } from 'zod' -import { accountDetailsObject, accountDetailsParsedObj } from './mocks' -import { useAccountDetails } from './useAccountDetails' +import { accountDetailsParsedObj } from './mocks' +import { AccountDetailsSchema, useAccountDetails } from './useAccountDetails' vi.mock('js-cookie') @@ -45,17 +46,17 @@ afterAll(() => { }) describe('useAccountDetails', () => { - function setup() { + function setup(accountDetails: z.infer) { server.use( http.get(`/internal/${provider}/${owner}/account-details/`, () => { - return HttpResponse.json(accountDetailsObject) + return HttpResponse.json(accountDetails) }) ) } describe('when called', () => { it('returns the data', async () => { - setup() + setup(accountDetailsParsedObj) const { result } = renderHook( () => useAccountDetails({ provider, owner }), { wrapper: wrapper() } @@ -65,5 +66,43 @@ describe('useAccountDetails', () => { expect(result.current.data).toEqual(accountDetailsParsedObj) ) }) + + it('returns data with usBankAccount when enabled', async () => { + const withUSBankAccount = { + ...accountDetailsParsedObj, + subscriptionDetail: { + ...accountDetailsParsedObj.subscriptionDetail, + defaultPaymentMethod: { + billingDetails: null, + usBankAccount: { + bankName: 'Bank of America', + last4: '1234', + }, + }, + }, + } + setup(withUSBankAccount) + + const { result } = renderHook( + () => useAccountDetails({ provider, owner }), + { wrapper: wrapper() } + ) + + await waitFor(() => + expect(result.current.data).toEqual({ + ...accountDetailsParsedObj, + subscriptionDetail: { + ...accountDetailsParsedObj.subscriptionDetail, + defaultPaymentMethod: { + billingDetails: null, + usBankAccount: { + bankName: 'Bank of America', + last4: '1234', + }, + }, + }, + }) + ) + }) }) }) diff --git a/src/services/account/useCreateStripeSetupIntent.test.tsx b/src/services/account/useCreateStripeSetupIntent.test.tsx new file mode 100644 index 0000000000..6c72a1fc60 --- /dev/null +++ b/src/services/account/useCreateStripeSetupIntent.test.tsx @@ -0,0 +1,90 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { graphql, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import React from 'react' +import { MemoryRouter, Route } from 'react-router-dom' + +import { useCreateStripeSetupIntent } from './useCreateStripeSetupIntent' + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}) +const wrapper = + (initialEntries = '/gh'): React.FC => + ({ children }) => ( + + + {children} + + + ) + +const provider = 'gh' +const owner = 'codecov' + +const server = setupServer() +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + queryClient.clear() + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +describe('useCreateStripeSetupIntent', () => { + function setup(hasError = false) { + server.use( + graphql.mutation('CreateStripeSetupIntent', () => { + if (hasError) { + return HttpResponse.json({ data: {} }) + } + + return HttpResponse.json({ + data: { createStripeSetupIntent: { clientSecret: 'test_secret' } }, + }) + }) + ) + } + + describe('when called', () => { + describe('on success', () => { + it('returns the data', async () => { + setup() + const { result } = renderHook( + () => useCreateStripeSetupIntent({ provider, owner }), + { wrapper: wrapper() } + ) + + await waitFor(() => + expect(result.current.data).toEqual({ clientSecret: 'test_secret' }) + ) + }) + }) + + describe('on fail', () => { + beforeAll(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterAll(() => { + vi.restoreAllMocks() + }) + + it('fails to parse if bad data', async () => { + setup(true) + const { result } = renderHook( + () => useCreateStripeSetupIntent({ provider, owner }), + { wrapper: wrapper() } + ) + + await waitFor(() => expect(result.current.error).toBeTruthy()) + }) + }) + }) +}) diff --git a/src/services/account/useUpdatePaymentMethod.test.tsx b/src/services/account/useUpdatePaymentMethod.test.tsx index 4c06082072..e0077cabca 100644 --- a/src/services/account/useUpdatePaymentMethod.test.tsx +++ b/src/services/account/useUpdatePaymentMethod.test.tsx @@ -1,3 +1,5 @@ +import { Elements } from '@stripe/react-stripe-js' +import { loadStripe } from '@stripe/stripe-js' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { renderHook, waitFor } from '@testing-library/react' import { http, HttpResponse } from 'msw' @@ -12,6 +14,7 @@ import { useUpdatePaymentMethod } from './useUpdatePaymentMethod' const mocks = vi.hoisted(() => ({ useStripe: vi.fn(), + useCreateStripeSetupIntent: vi.fn(), })) vi.mock('@stripe/react-stripe-js', async () => { @@ -22,6 +25,12 @@ vi.mock('@stripe/react-stripe-js', async () => { } }) +vi.mock('./useCreateStripeSetupIntent', () => ({ + useCreateStripeSetupIntent: mocks.useCreateStripeSetupIntent, +})) + +const stripePromise = loadStripe('fake-publishable-key') + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }) @@ -30,7 +39,9 @@ const wrapper = ({ children }) => ( - {children} + + {children} + ) @@ -74,6 +85,9 @@ describe('useUpdatePaymentMethod', () => { mocks.useStripe.mockReturnValue({ confirmSetup, }) + mocks.useCreateStripeSetupIntent.mockReturnValue({ + data: { clientSecret: 'test_secret' }, + }) } describe('when called', () => { @@ -83,7 +97,9 @@ describe('useUpdatePaymentMethod', () => { confirmSetup: vi.fn( () => new Promise((resolve) => { - resolve({ paymentMethod: { id: 1 } }) + resolve({ + setupIntent: { payment_method: 'test_payment_method' }, + }) }) ), }) @@ -100,7 +116,8 @@ describe('useUpdatePaymentMethod', () => { it('returns the data from the server', async () => { const { result } = renderHook( - () => useUpdatePaymentMethod({ provider, owner }), + () => + useUpdatePaymentMethod({ provider, owner, email: 'test@test.com' }), { wrapper: wrapper() } ) @@ -140,7 +157,8 @@ describe('useUpdatePaymentMethod', () => { it('does something', async () => { const { result } = renderHook( - () => useUpdatePaymentMethod({ provider, owner }), + () => + useUpdatePaymentMethod({ provider, owner, email: 'test@test.com' }), { wrapper: wrapper() } ) diff --git a/src/services/account/useUpdatePaymentMethod.ts b/src/services/account/useUpdatePaymentMethod.ts index aec3b1ee99..82c335b9c4 100644 --- a/src/services/account/useUpdatePaymentMethod.ts +++ b/src/services/account/useUpdatePaymentMethod.ts @@ -3,6 +3,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import Api from 'shared/api' +import { useCreateStripeSetupIntent } from './useCreateStripeSetupIntent' + interface useUpdatePaymentMethodProps { provider: string owner: string @@ -16,11 +18,13 @@ interface useUpdatePaymentMethodReturn { mutate: (variables: any, data: any) => void data: undefined | unknown } - function getPathAccountDetails({ provider, owner, -}: useUpdatePaymentMethodProps) { +}: { + provider: string + owner: string +}) { return `/${provider}/${owner}/account-details/` } @@ -32,11 +36,18 @@ export function useUpdatePaymentMethod({ const stripe = useStripe() const elements = useElements() const queryClient = useQueryClient() + const { data: setupIntent } = useCreateStripeSetupIntent({ owner, provider }) return useMutation({ mutationFn: () => { + const clientSecret = setupIntent?.clientSecret + if (!clientSecret) { + throw new Error('Client secret not found') + } + return stripe! .confirmSetup({ + clientSecret, elements: elements!, redirect: 'if_required', confirmParams: { @@ -47,6 +58,8 @@ export function useUpdatePaymentMethod({ email: email, }, }, + // eslint-disable-next-line camelcase + return_url: `/plan/${provider}/${owner}`, }, }) .then((result) => { diff --git a/src/shared/utils/billing.ts b/src/shared/utils/billing.ts index f6b0140306..b5e6749738 100644 --- a/src/shared/utils/billing.ts +++ b/src/shared/utils/billing.ts @@ -143,7 +143,7 @@ export function formatTimestampToCalendarDate( return new Intl.DateTimeFormat('en-US', options).format(date) } -export function lastTwoDigits(value: number | string) { +export function lastTwoDigits(value: number | string | undefined) { if (typeof value === 'number' || typeof value === 'string') { return value.toString().slice(-2) } From eaaf86d005bdb6464cd9eb7a065ee7fd0e633b1c Mon Sep 17 00:00:00 2001 From: Suejung Shin Date: Mon, 13 Jan 2025 23:51:35 -0800 Subject: [PATCH 8/9] fix tests --- src/pages/PlanPage/PlanPage.test.jsx | 28 +- .../BillingDetails/BillingDetails.test.tsx | 16 +- .../BillingDetails/BillingDetails.tsx | 2 +- .../Address/AddressForm.test.tsx | 232 ++++++++++ .../Address/AddressForm.tsx | 14 +- .../EditPaymentMethods.test.tsx | 81 ++++ .../EditPaymentMethods/EditPaymentMethods.tsx | 24 +- .../PaymentMethod/PaymentMethodForm.test.tsx | 218 ++++++++++ .../PaymentMethod/PaymentMethodForm.tsx | 9 +- .../Address/Address.test.tsx | 215 +++++++++ .../Address/{AddressCard.tsx => Address.tsx} | 83 ++-- .../Address/AddressCard.test.tsx | 409 ------------------ .../PaymentMethod/PaymentMethod.test.tsx | 243 +++-------- .../PaymentMethod/PaymentMethod.tsx | 1 + .../ViewPaymentMethod/ViewPaymentMethod.tsx | 4 +- .../account/useUpdatePaymentMethod.ts | 2 +- src/shared/ThemeContext/ThemeContext.tsx | 4 +- 17 files changed, 885 insertions(+), 700 deletions(-) create mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/Address/AddressForm.test.tsx create mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/EditPaymentMethods.test.tsx create mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.test.tsx create mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/Address.test.tsx rename src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/{AddressCard.tsx => Address.tsx} (57%) delete mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/AddressCard.test.tsx diff --git a/src/pages/PlanPage/PlanPage.test.jsx b/src/pages/PlanPage/PlanPage.test.jsx index 3cdb714f09..b7bbc12378 100644 --- a/src/pages/PlanPage/PlanPage.test.jsx +++ b/src/pages/PlanPage/PlanPage.test.jsx @@ -11,6 +11,8 @@ import { MemoryRouter, Route } from 'react-router-dom' import config from 'config' +import { ThemeContextProvider } from 'shared/ThemeContext' + import PlanPage from './PlanPage' vi.mock('config') @@ -44,18 +46,20 @@ const wrapper = ({ children }) => ( - - - {children} - { - testLocation = location - return null - }} - /> - - + + + + {children} + { + testLocation = location + return null + }} + /> + + + ) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.test.tsx index c524ccd390..1877e4e717 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.test.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.test.tsx @@ -8,11 +8,15 @@ import { Plans } from 'shared/utils/billing' import BillingDetails from './BillingDetails' -vi.mock('./PaymentCard/PaymentCard', () => ({ default: () => 'Payment Card' })) +vi.mock('./ViewPaymentMethod/PaymentMethod/PaymentMethod', () => ({ + default: () => 'Payment Method', +})) vi.mock('./EmailAddress/EmailAddress', () => ({ default: () => 'Email Address', })) -vi.mock('./Address/AddressCard', () => ({ default: () => 'Address Card' })) +vi.mock('./ViewPaymentMethod/Address/Address', () => ({ + default: () => 'Address Card', +})) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -84,12 +88,12 @@ describe('BillingDetails', () => { } describe('when there is a subscription', () => { - it('renders the payment card', async () => { + it('renders the payment method card', async () => { setup({ hasSubscription: true }) render(, { wrapper }) - const paymentCard = await screen.findByText(/Payment Card/) - expect(paymentCard).toBeInTheDocument() + const paymentCards = await screen.findAllByText(/Payment Method/) + expect(paymentCards.length).toBeGreaterThan(0) }) it('renders the email address component', async () => { @@ -132,7 +136,7 @@ describe('BillingDetails', () => { it('renders the payment card', async () => { render(, { wrapper }) - const paymentCard = screen.queryByText(/Payment Card/) + const paymentCard = screen.queryByText(/Payment Method/) expect(paymentCard).not.toBeInTheDocument() }) }) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx index b9874d0dd4..bc74d38261 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx @@ -71,7 +71,7 @@ function BillingDetails() { setEditMode={setEditMode} provider={provider} owner={owner} - existingSubscriptionDetail={subscriptionDetail} + subscriptionDetail={subscriptionDetail} /> ) : ( <> diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/Address/AddressForm.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/Address/AddressForm.test.tsx new file mode 100644 index 0000000000..3c547763a3 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/Address/AddressForm.test.tsx @@ -0,0 +1,232 @@ +import { Elements } from '@stripe/react-stripe-js' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route } from 'react-router-dom' +import { vi } from 'vitest' +import { z } from 'zod' + +import { SubscriptionDetailSchema } from 'services/account/useAccountDetails' + +import AddressForm from './AddressForm' + +const queryClient = new QueryClient() + +const mockGetElement = vi.fn() +const mockGetValue = vi.fn() + +vi.mock('@stripe/react-stripe-js', async () => { + const actual = await vi.importActual('@stripe/react-stripe-js') + return { + ...actual, + useElements: () => ({ + getElement: mockGetElement.mockReturnValue({ + getValue: mockGetValue.mockResolvedValue({ + complete: true, + value: { + name: 'John Doe', + address: { + line1: '123 Main St', + line2: null, + city: 'San Francisco', + state: 'CA', + postal_code: '94105', + country: 'US', + }, + }, + }), + }), + }), + } +}) + +const wrapper: React.FC = ({ children }) => ( + + + + {children} + + + +) + +const mockSubscriptionDetail: z.infer = { + defaultPaymentMethod: { + billingDetails: { + address: { + line1: '123 Main St', + city: 'San Francisco', + state: 'CA', + postalCode: '94105', + country: 'US', + line2: null, + }, + phone: '1234567890', + name: 'John Doe', + email: 'test@example.com', + }, + card: { + brand: 'visa', + expMonth: 12, + expYear: 2025, + last4: '4242', + }, + }, + currentPeriodEnd: 1706851492, + cancelAtPeriodEnd: false, + customer: { + id: 'cust_123', + email: 'test@example.com', + }, + latestInvoice: null, + taxIds: [], + trialEnd: null, +} + +const mocks = { + useUpdateBillingAddress: vi.fn(), +} + +vi.mock('services/account/useUpdateBillingAddress', () => ({ + useUpdateBillingAddress: () => mocks.useUpdateBillingAddress(), +})) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('AddressForm', () => { + const setup = () => { + return { user: userEvent.setup() } + } + + it('renders the form', () => { + mocks.useUpdateBillingAddress.mockReturnValue({ + mutate: vi.fn(), + isLoading: false, + }) + + render( + {}} + />, + { wrapper } + ) + + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() + }) + + describe('when submitting', () => { + it('calls the service to update the address', async () => { + const user = userEvent.setup() + const updateAddress = vi.fn() + mocks.useUpdateBillingAddress.mockReturnValue({ + mutate: updateAddress, + isLoading: false, + }) + + render( + {}} + />, + { wrapper } + ) + + await user.click(screen.getByTestId('submit-address-update')) + expect(updateAddress).toHaveBeenCalledWith( + { + name: 'John Doe', + address: { + line1: '123 Main St', + line2: null, + city: 'San Francisco', + state: 'CA', + postal_code: '94105', + country: 'US', + }, + }, + expect.any(Object) + ) + }) + }) + + describe('when the user clicks on cancel', () => { + it('calls the closeForm prop', async () => { + const { user } = setup() + const closeForm = vi.fn() + mocks.useUpdateBillingAddress.mockReturnValue({ + mutate: vi.fn(), + isLoading: false, + }) + + render( + , + { wrapper } + ) + + await user.click(screen.getByRole('button', { name: /cancel/i })) + + expect(closeForm).toHaveBeenCalled() + }) + }) + + describe('when the form is loading', () => { + it('has the save and cancel buttons disabled', () => { + mocks.useUpdateBillingAddress.mockReturnValue({ + mutate: vi.fn(), + isLoading: true, + error: null, + reset: vi.fn(), + }) + + render( + {}} + />, + { wrapper } + ) + + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled() + }) + }) +}) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/Address/AddressForm.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/Address/AddressForm.tsx index ba3885707b..027ba1c985 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/Address/AddressForm.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/Address/AddressForm.tsx @@ -8,7 +8,7 @@ import Button from 'ui/Button' interface AddressFormProps { address?: z.infer - name?: string | null | undefined + name?: string closeForm: () => void provider: string owner: string @@ -23,12 +23,7 @@ function AddressForm({ }: AddressFormProps) { const elements = useElements() - const { - mutate: updateAddress, - isLoading, - error, - reset, - } = useUpdateBillingAddress({ + const { mutate: updateAddress, isLoading } = useUpdateBillingAddress({ provider, owner, }) @@ -47,8 +42,6 @@ function AddressForm({ } } - const showError = error && !reset - return (
@@ -72,7 +65,6 @@ function AddressForm({ }, }} /> -

{showError && error}

@@ -90,7 +81,6 @@ function AddressForm({ variant="plain" disabled={isLoading} onClick={closeForm} - to={undefined} > Cancel diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/EditPaymentMethods.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/EditPaymentMethods.test.tsx new file mode 100644 index 0000000000..9cf7e4cba8 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/EditPaymentMethods.test.tsx @@ -0,0 +1,81 @@ +import { Elements } from '@stripe/react-stripe-js' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import { MemoryRouter, Route } from 'react-router-dom' +import { z } from 'zod' + +import { SubscriptionDetailSchema } from 'services/account' + +import EditPaymentMethods from './EditPaymentMethods' + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}) + +vi.mock('./PaymentMethod/PaymentMethodForm', () => ({ + default: () => 'Payment Method Form', +})) + +vi.mock('./Address/AddressForm', () => ({ + default: () => 'Address Form', +})) + +const wrapper: React.FC = ({ children }) => ( + + + + {children} + + + +) + +const mockSubscriptionDetail: z.infer = { + defaultPaymentMethod: { + billingDetails: { + address: { + line1: '123 Main St', + city: 'San Francisco', + state: 'CA', + postalCode: '94105', + country: 'US', + line2: null, + }, + phone: '1234567890', + name: 'John Doe', + email: 'test@example.com', + }, + card: { + brand: 'visa', + expMonth: 12, + expYear: 2025, + last4: '4242', + }, + }, + currentPeriodEnd: 1706851492, + cancelAtPeriodEnd: false, + customer: { + id: 'cust_123', + email: 'test@example.com', + }, + latestInvoice: null, + taxIds: [], + trialEnd: null, +} + +describe('EditPaymentMethod', () => { + it('renders the expected forms', () => { + render( + {}} + provider="gh" + owner="codecov" + subscriptionDetail={mockSubscriptionDetail} + />, + { wrapper } + ) + + expect(screen.getByText(/Payment Method Form/)).toBeInTheDocument() + expect(screen.getByText(/Address Form/)).toBeInTheDocument() + }) +}) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/EditPaymentMethods.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/EditPaymentMethods.tsx index 3b135b829a..ee029ca825 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/EditPaymentMethods.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/EditPaymentMethods.tsx @@ -8,19 +8,19 @@ import PaymentMethodForm from './PaymentMethod/PaymentMethodForm' import { SECONDARY_PAYMENT_FEATURE_ENABLED } from '../BillingDetails' -interface EditPaymentMethodProps { +interface EditPaymentMethodsProps { setEditMode: (isEditMode: boolean) => void provider: string owner: string - existingSubscriptionDetail: z.infer + subscriptionDetail: z.infer } -const EditPaymentMethod = ({ +const EditPaymentMethods = ({ setEditMode, provider, owner, - existingSubscriptionDetail, -}: EditPaymentMethodProps) => { + subscriptionDetail, +}: EditPaymentMethodsProps) => { const [activeTab, setActiveTab] = useState<'primary' | 'secondary'>('primary') const secondaryPaymentMethodFeatureEnabled = SECONDARY_PAYMENT_FEATURE_ENABLED @@ -57,16 +57,16 @@ const EditPaymentMethod = ({ closeForm={() => setEditMode(false)} provider={provider} owner={owner} - existingSubscriptionDetail={existingSubscriptionDetail} + subscriptionDetail={subscriptionDetail} /> setEditMode(false)} provider={provider} @@ -80,7 +80,7 @@ const EditPaymentMethod = ({ closeForm={() => setEditMode(false)} provider={provider} owner={owner} - existingSubscriptionDetail={existingSubscriptionDetail} + subscriptionDetail={subscriptionDetail} /> setEditMode(false)} @@ -95,4 +95,4 @@ const EditPaymentMethod = ({ ) } -export default EditPaymentMethod +export default EditPaymentMethods diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.test.tsx new file mode 100644 index 0000000000..e77927c042 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.test.tsx @@ -0,0 +1,218 @@ +import { Elements } from '@stripe/react-stripe-js' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route } from 'react-router-dom' +import { vi } from 'vitest' +import { z } from 'zod' + +import { SubscriptionDetailSchema } from 'services/account/useAccountDetails' + +import PaymentMethodForm from './PaymentMethodForm' + +const queryClient = new QueryClient() + +const mockElements = { + submit: vi.fn(), + getElement: vi.fn(), +} + +vi.mock('@stripe/react-stripe-js', () => ({ + Elements: ({ children }: { children: React.ReactNode }) => children, + useElements: () => mockElements, + PaymentElement: 'div', +})) + +const wrapper: React.FC = ({ children }) => ( + + + + {children} + + + +) + +const subscriptionDetail: z.infer = { + defaultPaymentMethod: { + billingDetails: { + address: { + line1: '123 Main St', + city: 'San Francisco', + state: 'CA', + postalCode: '94105', + country: 'US', + line2: null, + }, + phone: '1234567890', + name: 'John Doe', + email: 'test@example.com', + }, + card: { + brand: 'visa', + expMonth: 12, + expYear: 2025, + last4: '4242', + }, + }, + currentPeriodEnd: 1706851492, + cancelAtPeriodEnd: false, + customer: { + id: 'cust_123', + email: 'test@example.com', + }, + latestInvoice: null, + taxIds: [], + trialEnd: null, +} + +const mocks = { + useUpdatePaymentMethod: vi.fn(), +} + +vi.mock('services/account/useUpdatePaymentMethod', () => ({ + useUpdatePaymentMethod: () => mocks.useUpdatePaymentMethod(), +})) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('PaymentMethodForm', () => { + describe('when the user clicks on Edit payment method', () => { + it(`doesn't render the payment method anymore`, async () => { + const user = userEvent.setup() + const updatePaymentMethod = vi.fn() + mocks.useUpdatePaymentMethod.mockReturnValue({ + mutate: updatePaymentMethod, + isLoading: false, + }) + + render( + {}} + />, + { wrapper } + ) + await user.click(screen.getByTestId('update-payment-method')) + + expect(screen.queryByText(/Visa/)).not.toBeInTheDocument() + }) + + it('renders the form', async () => { + const user = userEvent.setup() + const updatePaymentMethod = vi.fn() + mocks.useUpdatePaymentMethod.mockReturnValue({ + mutate: updatePaymentMethod, + isLoading: false, + }) + render( + {}} + />, + { wrapper } + ) + await user.click(screen.getByTestId('update-payment-method')) + + expect(screen.getByRole('button', { name: /Save/i })).toBeInTheDocument() + }) + + describe('when submitting', () => { + it('calls the service to update the payment method', async () => { + const user = userEvent.setup() + const updatePaymentMethod = vi.fn() + mocks.useUpdatePaymentMethod.mockReturnValue({ + mutate: updatePaymentMethod, + isLoading: false, + }) + render( + {}} + />, + { wrapper } + ) + await user.click(screen.getByTestId('update-payment-method')) + expect(updatePaymentMethod).toHaveBeenCalled() + }) + }) + + describe('when the user clicks on cancel', () => { + it(`doesn't render the form anymore`, async () => { + const user = userEvent.setup() + const closeForm = vi.fn() + mocks.useUpdatePaymentMethod.mockReturnValue({ + mutate: vi.fn(), + isLoading: false, + }) + render( + , + { wrapper } + ) + + await user.click(screen.getByTestId('update-payment-method')) + await user.click(screen.getByRole('button', { name: /Cancel/ })) + + expect(closeForm).toHaveBeenCalled() + }) + }) + }) + + describe('when there is an error in the form', () => { + it('renders the error', async () => { + const user = userEvent.setup() + const randomError = 'not rich enough' + mocks.useUpdatePaymentMethod.mockReturnValue({ + mutate: vi.fn(), + error: { message: randomError }, + }) + render( + {}} + />, + { wrapper } + ) + + await user.click(screen.getByTestId('update-payment-method')) + + expect(screen.getByText(randomError)).toBeInTheDocument() + }) + }) + + describe('when the form is loading', () => { + it('has the error and save button disabled', async () => { + mocks.useUpdatePaymentMethod.mockReturnValue({ + mutate: vi.fn(), + isLoading: true, + }) + render( + {}} + />, + { wrapper } + ) + + expect(screen.queryByRole('button', { name: /Save/i })).toBeDisabled() + expect(screen.queryByRole('button', { name: /Cancel/i })).toBeDisabled() + }) + }) +}) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.tsx index 46ac35a50d..2deb95111f 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.tsx @@ -11,14 +11,14 @@ interface PaymentMethodFormProps { closeForm: () => void provider: string owner: string - existingSubscriptionDetail: z.infer + subscriptionDetail: z.infer } const PaymentMethodForm = ({ closeForm, provider, owner, - existingSubscriptionDetail, + subscriptionDetail, }: PaymentMethodFormProps) => { const [errorState, _] = useState('') const elements = useElements() @@ -32,7 +32,8 @@ const PaymentMethodForm = ({ provider, owner, email: - existingSubscriptionDetail?.defaultPaymentMethod?.billingDetails?.email, + subscriptionDetail?.defaultPaymentMethod?.billingDetails?.email || + undefined, }) async function submit(e: React.FormEvent) { @@ -75,7 +76,7 @@ const PaymentMethodForm = ({

+
+
+
+ ) + } + + return ( +

Billing address

{isAddressSameAsPrimary ? ( @@ -83,28 +78,8 @@ function BillingInner({ )}
- ) - } - - return ( -
-

- No address has been set. Please contact support if you think it’s an - error or set it yourself. -

-
- -
) } -export default AddressCard +export default Address diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/AddressCard.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/AddressCard.test.tsx deleted file mode 100644 index 49ca52c423..0000000000 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/AddressCard.test.tsx +++ /dev/null @@ -1,409 +0,0 @@ -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { z } from 'zod' - -import { SubscriptionDetailSchema } from 'services/account' -import { ThemeContextProvider } from 'shared/ThemeContext' - -import AddressCard from './AddressCard' - -const mocks = vi.hoisted(() => ({ - useUpdateBillingAddress: vi.fn(), -})) - -vi.mock('services/account/useUpdateBillingAddress', async () => { - const actual = await import('services/account/useUpdateBillingAddress') - return { - ...actual, - useUpdateBillingAddress: mocks.useUpdateBillingAddress, - } -}) - -const subscriptionDetail = { - defaultPaymentMethod: { - card: { - brand: 'visa', - expMonth: 12, - expYear: 2021, - last4: '1234', - }, - billingDetails: { - name: 'Bob Smith', - address: { - line1: '123 Sesame St.', - line2: 'Apt A', - city: 'San Francisco', - country: 'US', - state: 'CA', - postalCode: '12345', - }, - }, - }, - currentPeriodEnd: 1606851492, - cancelAtPeriodEnd: false, -} as z.infer - -const wrapper: React.FC = ({ children }) => ( - {children} -) - -// mocking stripe components -vi.mock('@stripe/react-stripe-js', () => { - function makeFakeComponent(name: string) { - // mocking onReady to be called after a bit of time - return function Component({ _onReady }: { _onReady?: any }) { - return name - } - } - - return { - useElements: () => ({ - getElement: vi.fn().mockReturnValue({ - getValue: vi.fn().mockResolvedValue({ - complete: true, - value: { - address: {}, - }, - }), - }), - update: vi.fn(), - }), - useStripe: () => ({}), - AddressElement: makeFakeComponent('AddressElement'), - } -}) - -describe('AddressCard', () => { - function setup() { - const user = userEvent.setup() - - return { user } - } - - describe(`when the user doesn't have any subscriptionDetail`, () => { - // NOTE: This test is misleading because we hide this component from a higher level in - // BillingDetails.tsx if there is no subscriptionDetail - it('renders the set card message', () => { - render( - {}} - />, - { wrapper } - ) - - expect( - screen.getByText( - /No address has been set. Please contact support if you think it's an error or set it yourself./ - ) - ).toBeInTheDocument() - }) - }) - - describe(`when the user doesn't have billing details`, () => { - it('renders an error message', () => { - render( - {}} - />, - { wrapper } - ) - - expect( - screen.getByText( - /No address has been set. Please contact support if you think it's an error or set it yourself./ - ) - ).toBeInTheDocument() - }) - - describe('when the user clicks on "Set Address"', () => { - it(`doesn't render address info stuff anymore`, async () => { - const { user } = setup() - render( - {}} - />, - { wrapper } - ) - - mocks.useUpdateBillingAddress.mockReturnValue({ - mutate: () => null, - isLoading: false, - }) - await user.click(screen.getByTestId('open-modal')) - - expect( - screen.queryByText(/123 Sesame St. Apt A/) - ).not.toBeInTheDocument() - }) - - it('renders the address form component', async () => { - const { user } = setup() - render( - {}} - />, - { wrapper } - ) - - mocks.useUpdateBillingAddress.mockReturnValue({ - mutate: () => null, - isLoading: false, - }) - await user.click(screen.getByTestId('open-modal')) - - expect( - screen.getByRole('button', { name: /update/i }) - ).toBeInTheDocument() - }) - }) - }) - - describe('when the user has an address', () => { - it('renders the address information', () => { - render( - {}} - />, - { wrapper } - ) - - expect(screen.getByText(/Billing address/)).toBeInTheDocument() - expect(screen.getByText(/123 Sesame St. Apt A/)).toBeInTheDocument() - expect(screen.getByText(/San Francisco, CA 12345/)).toBeInTheDocument() - }) - - it('can render partial information too', () => { - render( - {}} - />, - { wrapper } - ) - - expect(screen.getByText('Cardholder name')).toBeInTheDocument() - expect(screen.getByText('N/A')).toBeInTheDocument() - expect(screen.getByText('Billing address')).toBeInTheDocument() - expect(screen.queryByText(/null/)).not.toBeInTheDocument() - expect(screen.getByText('12345')).toBeInTheDocument() - }) - - it('renders the card holder information', () => { - render( - {}} - />, - { wrapper } - ) - - expect(screen.getByText(/Cardholder name/)).toBeInTheDocument() - expect(screen.getByText(/Bob Smith/)).toBeInTheDocument() - }) - }) - - describe('when the user clicks on Edit', () => { - it(`doesn't render the card anymore`, async () => { - const { user } = setup() - const updateAddress = vi.fn() - mocks.useUpdateBillingAddress.mockReturnValue({ - mutate: updateAddress, - isLoading: false, - }) - - render( - {}} - />, - { wrapper } - ) - await user.click(screen.getByTestId('edit-address')) - - expect(screen.queryByText(/Cardholder name/)).not.toBeInTheDocument() - expect(screen.queryByText(/Bob Smith/)).not.toBeInTheDocument() - expect(screen.queryByText(/Billing address/)).not.toBeInTheDocument() - expect(screen.queryByText(/123 Sesame St. Apt A/)).not.toBeInTheDocument() - expect( - screen.queryByText(/San Francisco, CA 12345/) - ).not.toBeInTheDocument() - }) - - it('renders the form', async () => { - const { user } = setup() - const updateAddress = vi.fn() - mocks.useUpdateBillingAddress.mockReturnValue({ - mutate: updateAddress, - isLoading: false, - }) - render( - {}} - />, - { wrapper } - ) - await user.click(screen.getByTestId('edit-address')) - - expect( - screen.getByRole('button', { name: /update/i }) - ).toBeInTheDocument() - }) - - describe('when submitting', () => { - it('calls the service to update the address', async () => { - const { user } = setup() - const updateAddress = vi.fn() - mocks.useUpdateBillingAddress.mockReturnValue({ - mutate: updateAddress, - isLoading: false, - }) - render( - {}} - />, - { wrapper } - ) - await user.click(screen.getByTestId('edit-address')) - await user.click(screen.queryByRole('button', { name: /update/i })!) - - expect(updateAddress).toHaveBeenCalled() - }) - }) - - describe('when the user clicks on cancel', () => { - it(`doesn't render the form anymore`, async () => { - const { user } = setup() - mocks.useUpdateBillingAddress.mockReturnValue({ - mutate: vi.fn(), - isLoading: false, - }) - render( - {}} - />, - { wrapper } - ) - - await user.click(screen.getByTestId('edit-address')) - await user.click(screen.getByRole('button', { name: /Cancel/ })) - - expect( - screen.queryByRole('button', { name: /save/i }) - ).not.toBeInTheDocument() - }) - }) - }) - - describe('when there is an error in the form', () => { - it('renders the error', async () => { - const { user } = setup() - const randomError = 'not a valid address' - mocks.useUpdateBillingAddress.mockReturnValue({ - mutate: vi.fn(), - error: randomError, - }) - render( - {}} - />, - { wrapper } - ) - - await user.click(screen.getByTestId('edit-address')) - - expect(screen.getByText(randomError)).toBeInTheDocument() - }) - }) - - describe('when the form is loading', () => { - it('has the error and save button disabled', async () => { - const { user } = setup() - mocks.useUpdateBillingAddress.mockReturnValue({ - mutate: vi.fn(), - isLoading: true, - }) - render( - {}} - />, - { wrapper } - ) - await user.click(screen.getByTestId('edit-address')) - - expect(screen.queryByRole('button', { name: /update/i })).toBeDisabled() - expect(screen.queryByRole('button', { name: /cancel/i })).toBeDisabled() - }) - }) -}) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/PaymentMethod.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/PaymentMethod.test.tsx index b3f7fdd2d6..bf8cb4a92e 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/PaymentMethod.test.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/PaymentMethod.test.tsx @@ -2,24 +2,36 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { ThemeContextProvider } from 'shared/ThemeContext' -import { Plans } from 'shared/utils/billing' import PaymentMethod from './PaymentMethod' const mocks = vi.hoisted(() => ({ - useUpdateCard: vi.fn(), + useUpdatePaymentMethod: vi.fn(), })) vi.mock('services/account', async () => { const actual = await vi.importActual('services/account') return { ...actual, - useUpdateCard: mocks.useUpdateCard, + useUpdatePaymentMethod: mocks.useUpdatePaymentMethod, } }) const subscriptionDetail = { defaultPaymentMethod: { + billingDetails: { + name: 'John Doe', + email: 'john.doe@example.com', + address: { + city: 'New York', + country: 'US', + line1: '123 Main St', + line2: 'Apt 4B', + postalCode: '10001', + state: 'NY', + }, + phone: '1234567890', + }, card: { brand: 'visa', expMonth: 12, @@ -27,20 +39,24 @@ const subscriptionDetail = { last4: '1234', }, }, - plan: { - value: Plans.USERS_PR_INAPPY, - }, currentPeriodEnd: 1606851492, cancelAtPeriodEnd: false, + customer: { + id: 'cust_123', + email: 'test@test.com', + }, + latestInvoice: null, + taxIds: [], + trialEnd: null, } -const wrapper = ({ children }) => ( +const wrapper: React.FC = ({ children }) => ( {children} ) // mocking all the stripe components; and trusting the library :) vi.mock('@stripe/react-stripe-js', () => { - function makeFakeComponent(name) { + function makeFakeComponent(name: string) { return function Component() { return name } @@ -54,7 +70,7 @@ vi.mock('@stripe/react-stripe-js', () => { } }) -describe('PaymentCard', () => { +describe('PaymentMethodCard', () => { function setup() { const user = userEvent.setup() @@ -70,85 +86,75 @@ describe('PaymentCard', () => { subscriptionDetail={null} provider="gh" owner="codecov" + setEditMode={() => {}} /> ) expect( screen.getByText( - /No credit card set. Please contact support if you think it’s an error or set it yourself./ + /No payment method set. Please contact support if you think it’s an error or set it yourself./ ) ).toBeInTheDocument() }) }) - describe(`when the user doesn't have any card`, () => { + describe(`when the user doesn't have any payment method`, () => { it('renders an error message', () => { + const subscriptionDetailMissingPaymentMethod = { + ...subscriptionDetail, + defaultPaymentMethod: { + ...subscriptionDetail.defaultPaymentMethod, + card: null, + usBankAccount: null, + }, + } render( {}} />, { wrapper } ) expect( screen.getByText( - /No credit card set. Please contact support if you think it’s an error or set it yourself./ + /No payment method set. Please contact support if you think it’s an error or set it yourself./ ) ).toBeInTheDocument() }) describe('when the user clicks on Set card', () => { - it(`doesn't render the card anymore`, async () => { + it(`doesn't render the card anymore and opens the form`, async () => { const { user } = setup() + const subscriptionDetailMissingPaymentMethod = { + ...subscriptionDetail, + defaultPaymentMethod: { + ...subscriptionDetail.defaultPaymentMethod, + card: null, + usBankAccount: null, + }, + } + const setEditMode = vi.fn() render( , { wrapper } ) - mocks.useUpdateCard.mockReturnValue({ + mocks.useUpdatePaymentMethod.mockReturnValue({ mutate: () => null, isLoading: false, }) - await user.click(screen.getByTestId('open-modal')) + await user.click(screen.getByTestId('open-edit-mode')) expect(screen.queryByText(/Visa/)).not.toBeInTheDocument() - }) - - it('renders the form', async () => { - const { user } = setup() - render( - , - { wrapper } - ) - - mocks.useUpdateCard.mockReturnValue({ - mutate: () => null, - isLoading: false, - }) - await user.click(screen.getByTestId('open-modal')) - - expect( - screen.getByRole('button', { name: /update/i }) - ).toBeInTheDocument() + expect(setEditMode).toHaveBeenCalledWith(true) }) }) }) @@ -160,6 +166,7 @@ describe('PaymentCard', () => { subscriptionDetail={subscriptionDetail} provider="gh" owner="codecov" + setEditMode={() => {}} />, { wrapper } ) @@ -174,6 +181,7 @@ describe('PaymentCard', () => { subscriptionDetail={subscriptionDetail} provider="gh" owner="codecov" + setEditMode={() => {}} />, { wrapper } ) @@ -192,6 +200,7 @@ describe('PaymentCard', () => { }} provider="gh" owner="codecov" + setEditMode={() => {}} />, { wrapper } ) @@ -199,142 +208,4 @@ describe('PaymentCard', () => { expect(screen.queryByText(/1st December, 2020/)).not.toBeInTheDocument() }) }) - - describe('when the user clicks on Edit card', () => { - it(`doesn't render the card anymore`, async () => { - const { user } = setup() - const updateCard = vi.fn() - mocks.useUpdateCard.mockReturnValue({ - mutate: updateCard, - isLoading: false, - }) - - render( - , - { wrapper } - ) - await user.click(screen.getByTestId('edit-card')) - - expect(screen.queryByText(/Visa/)).not.toBeInTheDocument() - }) - - it('renders the form', async () => { - const { user } = setup() - const updateCard = vi.fn() - mocks.useUpdateCard.mockReturnValue({ - mutate: updateCard, - isLoading: false, - }) - render( - , - { wrapper } - ) - await user.click(screen.getByTestId('edit-card')) - - expect( - screen.getByRole('button', { name: /update/i }) - ).toBeInTheDocument() - }) - - describe('when submitting', () => { - it('calls the service to update the card', async () => { - const { user } = setup() - const updateCard = vi.fn() - mocks.useUpdateCard.mockReturnValue({ - mutate: updateCard, - isLoading: false, - }) - render( - , - { wrapper } - ) - await user.click(screen.getByTestId('edit-card')) - await user.click(screen.queryByRole('button', { name: /update/i })) - - expect(updateCard).toHaveBeenCalled() - }) - }) - - describe('when the user clicks on cancel', () => { - it(`doesn't render the form anymore`, async () => { - const { user } = setup() - mocks.useUpdateCard.mockReturnValue({ - mutate: vi.fn(), - isLoading: false, - }) - render( - , - { wrapper } - ) - - await user.click(screen.getByTestId('edit-card')) - await user.click(screen.getByRole('button', { name: /Cancel/ })) - - expect( - screen.queryByRole('button', { name: /save/i }) - ).not.toBeInTheDocument() - }) - }) - }) - - describe('when there is an error in the form', () => { - it('renders the error', async () => { - const { user } = setup() - const randomError = 'not rich enough' - mocks.useUpdateCard.mockReturnValue({ - mutate: vi.fn(), - error: { message: randomError }, - }) - render( - , - { wrapper } - ) - - await user.click(screen.getByTestId('edit-card')) - - expect(screen.getByText(randomError)).toBeInTheDocument() - }) - }) - - describe('when the form is loading', () => { - it('has the error and save button disabled', async () => { - const { user } = setup() - mocks.useUpdateCard.mockReturnValue({ - mutate: vi.fn(), - isLoading: true, - }) - render( - , - { wrapper } - ) - await user.click(screen.getByTestId('edit-card')) - - expect(screen.queryByRole('button', { name: /update/i })).toBeDisabled() - expect(screen.queryByRole('button', { name: /cancel/i })).toBeDisabled() - }) - }) }) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/PaymentMethod.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/PaymentMethod.tsx index c0fe83e00a..812c144055 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/PaymentMethod.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/PaymentMethod.tsx @@ -39,6 +39,7 @@ function PaymentMethod({
{/* Address */} - = ({ children, }) => { - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + const prefersDark = + typeof window !== 'undefined' && + (window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ?? false) let systemTheme = Theme.LIGHT if (prefersDark) { From 2dc8211fc70e59b9885452ace2dd7edf76e30b09 Mon Sep 17 00:00:00 2001 From: Suejung Shin Date: Tue, 14 Jan 2025 13:10:00 -0800 Subject: [PATCH 9/9] fix tests --- src/services/account/useUpdatePaymentMethod.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/services/account/useUpdatePaymentMethod.ts b/src/services/account/useUpdatePaymentMethod.ts index 1ebd7a4384..b6546dec1c 100644 --- a/src/services/account/useUpdatePaymentMethod.ts +++ b/src/services/account/useUpdatePaymentMethod.ts @@ -1,6 +1,8 @@ import { useElements, useStripe } from '@stripe/react-stripe-js' import { useMutation, useQueryClient } from '@tanstack/react-query' +import config from 'config' + import Api from 'shared/api' import { useCreateStripeSetupIntent } from './useCreateStripeSetupIntent' @@ -59,7 +61,7 @@ export function useUpdatePaymentMethod({ }, }, // eslint-disable-next-line camelcase - return_url: `/plan/${provider}/${owner}`, + return_url: `${config.BASE_URL}/plan/${provider}/${owner}`, }, }) .then((result) => {