From 34bfe3e9bad64dddeb0f1728b6c5b4e982ffc40e Mon Sep 17 00:00:00 2001 From: Suejung Shin Date: Mon, 30 Dec 2024 18:58:14 -0800 Subject: [PATCH 1/5] 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/5] 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/5] 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 5e48a4e5eafbbb43142b7267aa55a098ced04b28 Mon Sep 17 00:00:00 2001 From: Suejung Shin Date: Mon, 6 Jan 2025 16:48:31 -0800 Subject: [PATCH 5/5] wip --- src/pages/PlanPage/PlanPage.jsx | 12 +- .../BillingDetails/BillingDetails.tsx | 11 +- .../EditPaymentMethod/EditPaymentMethod.tsx | 137 +++--------------- .../EditPaymentMethodForm.tsx | 93 ++++++++++++ .../PaymentCard/PaymentCard.tsx | 10 +- .../PaymentMethodInformation.tsx | 78 ++++++++++ src/services/account/useStripeSetupIntent.ts | 55 +++++++ .../account/useUpdatePaymentMethod.ts | 63 ++++++++ 8 files changed, 336 insertions(+), 123 deletions(-) create mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethodForm.tsx create mode 100644 src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethodInformation.tsx create mode 100644 src/services/account/useStripeSetupIntent.ts create mode 100644 src/services/account/useUpdatePaymentMethod.ts diff --git a/src/pages/PlanPage/PlanPage.jsx b/src/pages/PlanPage/PlanPage.jsx index 8ae321a08c..107524e6c2 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 { useStripeSetupIntent } from 'services/account/useStripeSetupIntent' import LoadingLogo from 'ui/LoadingLogo' import { PlanProvider } from './context' @@ -37,6 +38,12 @@ function PlanPage() { const { data: ownerData } = useSuspenseQueryV5( PlanPageDataQueryOpts({ owner, provider }) ) + // const { data: setupIntent } = useStripeSetupIntent({ owner, provider }) + + const setupIntent = { + clientSecret: + 'seti_1QfCiSGlVGuVgOrkPhA3FjTZ_secret_RYJLn86FhD6Co4PXYqdSkDYCgMcgZN0', + } if (config.IS_SELF_HOSTED || !ownerData?.isCurrentUserPartOfOrg) { return @@ -45,7 +52,10 @@ 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..10952b5f7a 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 './EditPaymentMethod' +import EditPaymentMethod from './EditPaymentMethod' interface URLParams { provider: string @@ -29,8 +29,6 @@ function BillingDetails() { return null } - console.log('iseditmode', isEditMode) - return (
{/* Billing Details Section */} @@ -68,7 +66,12 @@ function BillingDetails() { )}
{isEditMode ? ( - + ) : ( <> diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethod.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethod.tsx index c7142559c7..23db86d014 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethod.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethod.tsx @@ -1,121 +1,22 @@ -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, - }, - } +import EditPaymentMethodForm from './EditPaymentMethodForm' - return ( - - - - ) +interface EditPaymentMethodProps { + isEditMode: boolean + setEditMode: (isEditMode: boolean) => void + provider: string + owner: string } -interface EditablePaymentMethodProps { - clientSecret: string -} - -const EditPaymentMethod: React.FC = () => { - const clientSecret = MANUALLY_FETCHED_CLIENT_SECRET // TODO - fetch from API - +const EditPaymentMethod = ({ + isEditMode, + setEditMode, + provider, + owner, +}: EditPaymentMethodProps) => { const [activeTab, setActiveTab] = useState<'primary' | 'secondary'>('primary') return ( @@ -143,13 +44,23 @@ const EditPaymentMethod: React.FC = () => {
{activeTab === 'primary' && (
- + {}} provider={''} owner={''} />
)} {activeTab === 'secondary' && (
- + {}} provider={''} owner={''} />
)} diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethodForm.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethodForm.tsx new file mode 100644 index 0000000000..1b878f4bf0 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethod/EditPaymentMethodForm.tsx @@ -0,0 +1,93 @@ +import { PaymentElement, useElements } from '@stripe/react-stripe-js' +import cs from 'classnames' +import { useState } from 'react' + +import { useUpdatePaymentMethod } from 'services/account/useUpdatePaymentMethod' +import { Theme, useThemeContext } from 'shared/ThemeContext' +import Button from 'ui/Button' + +interface PaymentMethodFormProps { + closeForm: () => void + provider: string + owner: string +} + +const EditPaymentMethodForm = ({ + closeForm, + provider, + owner, +}: PaymentMethodFormProps) => { + const [errorState, setErrorState] = useState('') + const { theme } = useThemeContext() + const isDarkMode = theme === Theme.DARK + + const elements = useElements() + const { + mutate: updatePaymentMethod, + isLoading, + error, + reset, + } = useUpdatePaymentMethod({ + provider, + owner, + }) + + function submit(e: React.FormEvent) { + e.preventDefault() + + if (!elements) { + return null + } + + updatePaymentMethod(elements.getElement(PaymentElement), { + onSuccess: closeForm, + }) + } + + const showError = (error && !reset) || errorState + + return ( + +
+
+ +

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

+
+ + +
+
+
+ + ) +} + +export default EditPaymentMethodForm diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.tsx index 539531f4f0..2143d802c8 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.tsx @@ -10,7 +10,7 @@ import A from 'ui/A' import Button from 'ui/Button' import Icon from 'ui/Icon' -import CardInformation from './CardInformation' +import PaymentMethodInformation from '../PaymentMethod/PaymentMethodInformation' import CreditCardForm from './CreditCardForm' import { cn } from 'shared/utils/cn' @@ -29,7 +29,7 @@ function PaymentCard({ owner: string className?: string }) { - const card = subscriptionDetail?.defaultPaymentMethod?.card + const isPaymentMethodSet = !!subscriptionDetail?.defaultPaymentMethod return (
@@ -42,12 +42,12 @@ function PaymentCard({ owner={owner} closeForm={() => setEditMode(false)} /> - ) : card ? ( - + ) : isPaymentMethodSet ? ( + ) : (

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

diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethodInformation.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethodInformation.tsx new file mode 100644 index 0000000000..feda49f00b --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentMethod/PaymentMethodInformation.tsx @@ -0,0 +1,78 @@ +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 { + formatTimestampToCalendarDate, + lastTwoDigits, +} from 'shared/utils/billing' +import CardInformation from '../PaymentCard/CardInformation' + +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 PaymentMethodInformationProps { + subscriptionDetail: z.infer +} +function PaymentMethodInformation({ subscriptionDetail }: PaymentMethodInformationProps) { + const isCard = !!subscriptionDetail?.defaultPaymentMethod?.card + + if (isCard) { + return + } + + return ( +
+
+ + + + +
+ •••• {subscriptionDetail?.defaultPaymentMethod?.us_bank_account?.last4} +
+
+

+ Bank account ending in {subscriptionDetail?.defaultPaymentMethod?.us_bank_account?.last4} +

+
+ ) +} + +export default PaymentMethodInformation diff --git a/src/services/account/useStripeSetupIntent.ts b/src/services/account/useStripeSetupIntent.ts new file mode 100644 index 0000000000..37c07b55ed --- /dev/null +++ b/src/services/account/useStripeSetupIntent.ts @@ -0,0 +1,55 @@ +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/useUpdatePaymentMethod.ts b/src/services/account/useUpdatePaymentMethod.ts new file mode 100644 index 0000000000..9061252071 --- /dev/null +++ b/src/services/account/useUpdatePaymentMethod.ts @@ -0,0 +1,63 @@ +import { useElements, useStripe } from '@stripe/react-stripe-js' +import { StripeCardElement } from '@stripe/stripe-js' +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import Api from 'shared/api' + +interface useUpdatePaymentMethodProps { + provider: string + owner: string +} + +interface useUpdatePaymentMethodReturn { + reset: () => void + error: null | Error + isLoading: boolean + mutate: (variables: any, data: any) => void + data: undefined | unknown +} + +function getPathAccountDetails({ provider, owner }: useUpdatePaymentMethodProps) { + return `/${provider}/${owner}/account-details/` +} + +export function useUpdatePaymentMethod({ + provider, + owner, +}: useUpdatePaymentMethodProps): useUpdatePaymentMethodReturn { + const stripe = useStripe() + const elements = useElements() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => { + return stripe! + .confirmSetup({ + elements: elements!, + confirmParams: { + // eslint-disable-next-line camelcase + return_url: 'http://localhost:3000/plan/github/suejung-sentry', + }, + }) + .then((result) => { + if (result.error) return Promise.reject(result.error) + + const accountPath = getPathAccountDetails({ provider, owner }) + const path = `${accountPath}update_payment` + + return Api.patch({ + provider, + path, + body: { + /* eslint-disable-next-line camelcase */ + payment_method: result.paymentMethod.id, + }, + }) + }) + }, + onSuccess: (data) => { + // update the local cache of account details from what the server returns + queryClient.setQueryData(['accountDetails', provider, owner], data) + }, + }) +}