From fa628bbb55294cef9c7fe432572cbf0880b0cd6a Mon Sep 17 00:00:00 2001 From: elizabeth-ilina Date: Tue, 19 Aug 2025 17:22:25 -0400 Subject: [PATCH] feat(payments-next): Enable Amazon Pay and Google Pay Because: * Amazon and Google Pay are not enabled. This commit: * Enables Amazon and Google Pay. Closes #PAY-2076 --- .../checkout/[cartId]/error/page.tsx | 16 ++++++ .../checkout/[cartId]/processing/page.tsx | 21 +++++++- .../checkout/[cartId]/success/page.tsx | 23 +++++++++ .../[cartId]/(mainLayout)/error/page.tsx | 16 ++++++ .../[cartId]/(mainLayout)/success/page.tsx | 25 ++++++++- .../[cartId]/(startLayout)/start/page.tsx | 7 +++ apps/payments/next/app/[locale]/en.ftl | 1 + .../[locale]/subscriptions/manage/page.tsx | 26 ++++++++++ libs/payments/cart/src/lib/cart.factories.ts | 1 + libs/payments/cart/src/lib/cart.service.ts | 51 +++++++++++++++++++ .../payments/cart/src/lib/checkout.service.ts | 13 +++++ .../metrics/src/lib/glean/glean.types.ts | 1 + .../lib/factories/payment-intent.factory.ts | 2 +- .../src/lib/actions/handleStripeRedirect.ts | 19 +++++++ libs/payments/ui/src/lib/actions/index.ts | 1 + .../src/lib/nestapp/nextjs-actions.service.ts | 7 +++ .../nestapp/validators/GetCartActionResult.ts | 1 + libs/payments/ui/src/lib/utils/getCardIcon.ts | 6 +++ .../ui/src/lib/utils/terms-and-privacy.ts | 2 + .../src/images/payment-methods/amazon-pay.svg | 19 +++++++ libs/shared/l10n/src/lib/branding.ftl | 1 + 21 files changed, 255 insertions(+), 4 deletions(-) create mode 100644 libs/payments/ui/src/lib/actions/handleStripeRedirect.ts create mode 100644 libs/shared/assets/src/images/payment-methods/amazon-pay.svg diff --git a/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/error/page.tsx b/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/error/page.tsx index f5ea805b8f7..ca78d89b732 100644 --- a/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/error/page.tsx +++ b/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/error/page.tsx @@ -8,6 +8,8 @@ import Link from 'next/link'; import errorIcon from '@fxa/shared/assets/images/error.svg'; import checkIcon from '@fxa/shared/assets/images/check.svg'; +import { redirect } from 'next/navigation'; +import { URLSearchParams } from 'url'; import { getApp, CheckoutParams, @@ -51,6 +53,20 @@ export default async function CheckoutError({ params: CheckoutParams; searchParams: Record; }) { + if ( + searchParams?.payment_intent || + searchParams?.payment_intent_client_secret || + searchParams?.redirect_status + ) { + const cleanedParams = new URLSearchParams(searchParams); + cleanedParams.delete('payment_intent'); + cleanedParams.delete('payment_intent_client_secret'); + cleanedParams.delete('redirect_status'); + const queryParamString = `?${cleanedParams.toString()}`; + redirect( + `/${params.locale}/${params.offeringId}/${params.interval}/checkout/${params.cartId}/error${queryParamString}` + ); + } const { locale } = params; const acceptLanguage = headers().get('accept-language'); diff --git a/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/processing/page.tsx b/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/processing/page.tsx index 503a07fe41f..c73f8650be9 100644 --- a/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/processing/page.tsx +++ b/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/processing/page.tsx @@ -13,9 +13,13 @@ import { buildPageMetadata, } from '@fxa/payments/ui/server'; import { headers } from 'next/headers'; -import { validateCartStateAndRedirectAction } from '@fxa/payments/ui/actions'; +import { + validateCartStateAndRedirectAction, + handleStripeRedirect +} from '@fxa/payments/ui/actions'; import type { Metadata } from 'next'; import { config } from 'apps/payments/next/config'; +import { revalidatePath } from 'next/cache'; export async function generateMetadata({ params, @@ -41,9 +45,22 @@ export default async function ProcessingPage({ params: CheckoutParams; searchParams: Record | undefined; }) { - const { locale } = params; + const { locale, cartId, offeringId, interval } = params; const acceptLanguage = headers().get('accept-language'); const l10n = getApp().getL10n(acceptLanguage, locale); + + const paymentIntentId = typeof searchParams?.payment_intent === 'string' ? searchParams.payment_intent : null; + const clientSecret = typeof searchParams?.payment_intent_client_secret === 'string' ? searchParams.payment_intent_client_secret : null; + const redirectStatus = typeof searchParams?.redirect_status === 'string' ? searchParams.redirect_status : null; + + if (paymentIntentId && clientSecret && redirectStatus) { + try { + await handleStripeRedirect(cartId, paymentIntentId, redirectStatus); + } catch { + revalidatePath(`/${locale}/${offeringId}/${interval}/checkout/${cartId}/processing`); + } + } + await validateCartStateAndRedirectAction( params.cartId, SupportedPages.PROCESSING, diff --git a/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/success/page.tsx b/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/success/page.tsx index f363d74caf3..b1885794115 100644 --- a/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/success/page.tsx +++ b/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/success/page.tsx @@ -4,6 +4,7 @@ import type { Metadata } from 'next'; import { headers } from 'next/headers'; +import { URLSearchParams } from 'url'; import Image from 'next/image'; import { auth } from 'apps/payments/next/auth'; import { getCardIcon } from '@fxa/payments/ui'; @@ -19,6 +20,7 @@ import { buildPageMetadata, } from '@fxa/payments/ui/server'; import { config } from 'apps/payments/next/config'; +import { redirect } from 'next/navigation'; export const dynamic = 'force-dynamic'; @@ -46,6 +48,20 @@ export default async function CheckoutSuccess({ params: CheckoutParams; searchParams: Record; }) { + if ( + searchParams?.payment_intent || + searchParams?.payment_intent_client_secret || + searchParams?.redirect_status + ) { + const cleanedParams = new URLSearchParams(searchParams); + cleanedParams.delete('payment_intent'); + cleanedParams.delete('payment_intent_client_secret'); + cleanedParams.delete('redirect_status'); + const queryParamString = `?${cleanedParams.toString()}`; + redirect( + `/${params.locale}/${params.offeringId}/${params.interval}/checkout/${params.cartId}/success${queryParamString}` + ); + } const { locale } = params; const acceptLanguage = headers().get('accept-language'); @@ -162,6 +178,13 @@ export default async function CheckoutSuccess({ width={70} height={24} /> + ) : cart.paymentInfo.type === 'amazon_pay' ? ( + {l10n.getString('amazon-pay-logo-alt-text', ) : ( {cart.paymentInfo.brand && ( diff --git a/apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/(mainLayout)/error/page.tsx b/apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/(mainLayout)/error/page.tsx index 944fb9ce977..deb595b87cb 100644 --- a/apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/(mainLayout)/error/page.tsx +++ b/apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/(mainLayout)/error/page.tsx @@ -5,6 +5,8 @@ import { headers } from 'next/headers'; import Image from 'next/image'; import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import { URLSearchParams } from 'url'; import errorIcon from '@fxa/shared/assets/images/error.svg'; import { @@ -49,6 +51,20 @@ export default async function UpgradeError({ params: CheckoutParams; searchParams: Record; }) { + if ( + searchParams?.payment_intent || + searchParams?.payment_intent_client_secret || + searchParams?.redirect_status + ) { + const cleanedParams = new URLSearchParams(searchParams); + cleanedParams.delete('payment_intent'); + cleanedParams.delete('payment_intent_client_secret'); + cleanedParams.delete('redirect_status'); + const queryParamString = `?${cleanedParams.toString()}`; + redirect( + `/${params.locale}/${params.offeringId}/${params.interval}/upgrade/${params.cartId}/error${queryParamString}` + ); + } const { locale } = params; const acceptLanguage = headers().get('accept-language'); diff --git a/apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/(mainLayout)/success/page.tsx b/apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/(mainLayout)/success/page.tsx index 7f00d3728ae..b0c667d20a0 100644 --- a/apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/(mainLayout)/success/page.tsx +++ b/apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/(mainLayout)/success/page.tsx @@ -19,6 +19,8 @@ import { CheckoutParams } from '@fxa/payments/ui/server'; import Image from 'next/image'; import { Metadata } from 'next'; import { config } from 'apps/payments/next/config'; +import { redirect } from 'next/navigation'; +import { URLSearchParams } from 'url'; export const dynamic = 'force-dynamic'; @@ -46,6 +48,20 @@ export default async function UpgradeSuccess({ params: CheckoutParams; searchParams: Record; }) { + if ( + searchParams?.payment_intent || + searchParams?.payment_intent_client_secret || + searchParams?.redirect_status + ) { + const cleanedParams = new URLSearchParams(searchParams); + cleanedParams.delete('payment_intent'); + cleanedParams.delete('payment_intent_client_secret'); + cleanedParams.delete('redirect_status'); + const queryParamString = `?${cleanedParams.toString()}`; + redirect( + `/${params.locale}/${params.offeringId}/${params.interval}/upgrade/${params.cartId}/success${queryParamString}` + ); + } const { locale } = params; const acceptLanguage = headers().get('accept-language'); @@ -163,7 +179,14 @@ export default async function UpgradeSuccess({ width={70} height={24} /> - ) :( + ) : cart.paymentInfo.type === 'amazon_pay' ? ( + {l10n.getString('amazon-pay-logo-alt-text', + ) : ( {cart.paymentInfo.brand && ( + ) : cart.paymentInfo.type === 'amazon_pay' ? ( + {l10n.getString('amazon-pay-logo-alt-text', ) : ( {cart.paymentInfo.brand && ( diff --git a/apps/payments/next/app/[locale]/en.ftl b/apps/payments/next/app/[locale]/en.ftl index 753d3f60c3b..d89ff469b2c 100644 --- a/apps/payments/next/app/[locale]/en.ftl +++ b/apps/payments/next/app/[locale]/en.ftl @@ -11,6 +11,7 @@ visa-logo-alt-text = { -brand-visa } logo # Alt text for generic payment card logo unbranded-logo-alt-text = Unbranded logo link-logo-alt-text = { -brand-link } logo +amazon-pay-logo-alt-text = { -brand-amazon-pay } logo ## Error pages - /checkout and /upgrade ## Common strings used in multiple pages diff --git a/apps/payments/next/app/[locale]/subscriptions/manage/page.tsx b/apps/payments/next/app/[locale]/subscriptions/manage/page.tsx index c7302188b24..604dc4362e3 100644 --- a/apps/payments/next/app/[locale]/subscriptions/manage/page.tsx +++ b/apps/payments/next/app/[locale]/subscriptions/manage/page.tsx @@ -222,6 +222,32 @@ export default async function Manage({ )} + {type === 'amazon_pay' && ( +
+
+ {l10n.getString('amazon-pay-logo-alt-text', +
+ + {l10n.getString( + 'subscription-management-button-change-payment-method', + 'Change' + )} + +
+ )} + {type === 'external_paypal' && brand && (
diff --git a/libs/payments/cart/src/lib/cart.factories.ts b/libs/payments/cart/src/lib/cart.factories.ts index 2ed41702d88..2062c4c04d6 100644 --- a/libs/payments/cart/src/lib/cart.factories.ts +++ b/libs/payments/cart/src/lib/cart.factories.ts @@ -108,6 +108,7 @@ export const PaymentInfoFactory = ( 'apple_iap', 'external_paypal', 'link', + 'amazon_pay', ]), ...override, }); diff --git a/libs/payments/cart/src/lib/cart.service.ts b/libs/payments/cart/src/lib/cart.service.ts index 69d43571a6c..396cfb51467 100644 --- a/libs/payments/cart/src/lib/cart.service.ts +++ b/libs/payments/cart/src/lib/cart.service.ts @@ -1083,4 +1083,55 @@ export class CartService { } }); } + + @SanitizeExceptions() + async handleStripeRedirect( + cartId: string, + paymentIntentId: string, + redirectStatus: string + ) { + return this.wrapWithCartCatch(cartId, async () => { + const cart = await this.cartManager.fetchCartById(cartId); + + if (cart.stripeIntentId !== paymentIntentId) { + await this.cartManager.updateFreshCart(cartId, cart.version, { + stripeIntentId: paymentIntentId, + }); + } + + const isPayIntentId = isPaymentIntentId(paymentIntentId); + + for (let i = 0; i < 10; i++) { + const intent = isPayIntentId + ? await this.paymentIntentManager.retrieve(paymentIntentId) + : await this.setupIntentManager.retrieve(paymentIntentId); + + if (redirectStatus === 'succeeded'|| intent.status === 'succeeded') { + await this.submitNeedsInput(cartId); + return; + } + + if (redirectStatus === 'failed' || intent.status === 'requires_payment_method') { + const errorCode = isPaymentIntent(intent) + ? intent.last_payment_error?.code + : intent.last_setup_error?.code; + const declineCode = isPaymentIntent(intent) + ? intent.last_payment_error?.decline_code + : intent.last_setup_error?.decline_code; + + throwIntentFailedError( + errorCode, + declineCode, + cart.id, + intent.id, + isPaymentIntentId(intent.id) ? 'PaymentIntent' : 'SetupIntent' + ); + } + + await new Promise((r) => setTimeout(r, 500)); + } + + throw new SubmitNeedsInputFailedError(cartId); + }); + } } diff --git a/libs/payments/cart/src/lib/checkout.service.ts b/libs/payments/cart/src/lib/checkout.service.ts index 7309203ddea..c36277e5c07 100644 --- a/libs/payments/cart/src/lib/checkout.service.ts +++ b/libs/payments/cart/src/lib/checkout.service.ts @@ -76,6 +76,7 @@ import { throwIntentFailedError } from './util/throwIntentFailedError'; import type { AsyncLocalStorage } from 'async_hooks'; import { AsyncLocalStorageCart } from './cart-als.provider'; import type { CartStore } from './cart-als.types'; +import { CartEligibilityStatus } from '@fxa/shared/db/mysql/account'; @Injectable() export class CheckoutService { @@ -397,11 +398,23 @@ export class CheckoutService { ); if (invoice.payment_intent && invoice.amount_due !== 0) { + const pageType = cart.eligibilityStatus === CartEligibilityStatus.UPGRADE + ? 'upgrade' + : 'checkout'; + + const baseUrl = process.env['PAYMENTS_NEXT_HOSTED_URL']; + intent = await this.paymentIntentManager.confirm( invoice.payment_intent, { confirmation_token: confirmationTokenId, off_session: false, + ...(baseUrl && { + return_url: + `${baseUrl}` + + `/${customerData.locale}/${cart.offeringConfigId}/${cart.interval}` + + `/${pageType}/${cart.id}/processing`, + }), } ); } else { diff --git a/libs/payments/metrics/src/lib/glean/glean.types.ts b/libs/payments/metrics/src/lib/glean/glean.types.ts index 1666dc1d7be..5f2b1ec394f 100644 --- a/libs/payments/metrics/src/lib/glean/glean.types.ts +++ b/libs/payments/metrics/src/lib/glean/glean.types.ts @@ -13,6 +13,7 @@ export const PaymentProvidersTypePartial = [ 'apple_iap', 'external_paypal', 'link', + 'amazon_pay', ] as const; export type PaymentProvidersType = | Stripe.PaymentMethod.Type diff --git a/libs/payments/stripe/src/lib/factories/payment-intent.factory.ts b/libs/payments/stripe/src/lib/factories/payment-intent.factory.ts index ec2a513d657..0dfc8dcc80c 100644 --- a/libs/payments/stripe/src/lib/factories/payment-intent.factory.ts +++ b/libs/payments/stripe/src/lib/factories/payment-intent.factory.ts @@ -50,7 +50,7 @@ export const StripePaymentIntentFactory = ( persistent_token: null, }, }, - payment_method_types: ['card', 'link'], + payment_method_types: ['card', 'link', 'amazon_pay'], processing: null, receipt_email: null, review: null, diff --git a/libs/payments/ui/src/lib/actions/handleStripeRedirect.ts b/libs/payments/ui/src/lib/actions/handleStripeRedirect.ts new file mode 100644 index 00000000000..6a8d707fa82 --- /dev/null +++ b/libs/payments/ui/src/lib/actions/handleStripeRedirect.ts @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use server'; + +import { getApp } from '../nestapp/app'; + +export async function handleStripeRedirect( + cartId: string, + paymentIntentId: string, + redirectStatus: string, +) { + await getApp().getActionsService().handleStripeRedirect({ + cartId, + paymentIntentId, + redirectStatus, + }); +} diff --git a/libs/payments/ui/src/lib/actions/index.ts b/libs/payments/ui/src/lib/actions/index.ts index e8a606ee387..bc17b02a7ca 100644 --- a/libs/payments/ui/src/lib/actions/index.ts +++ b/libs/payments/ui/src/lib/actions/index.ts @@ -32,3 +32,4 @@ export { serverLogAction } from './serverLog'; export { getStripeClientSession } from './getStripeClientSession'; export { updateStripePaymentDetails } from './updateStripePaymentDetails'; export { setDefaultStripePaymentDetails } from './setDefaultStripePaymentDetails'; +export { handleStripeRedirect } from './handleStripeRedirect'; diff --git a/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts b/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts index bc80e80d452..8c33ab4f4a1 100644 --- a/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts +++ b/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts @@ -258,6 +258,13 @@ export class NextJSActionsService { return cart; } + @SanitizeExceptions() + @WithTypeCachableAsyncLocalStorage() + @CaptureTimingWithStatsD() + async handleStripeRedirect(args: { cartId: string; paymentIntentId: string; redirectStatus: string }) { + await this.cartService.handleStripeRedirect(args.cartId, args.paymentIntentId, args.redirectStatus); + } + @SanitizeExceptions() @NextIOValidator(FinalizeCartWithErrorArgs, undefined) @WithTypeCachableAsyncLocalStorage() diff --git a/libs/payments/ui/src/lib/nestapp/validators/GetCartActionResult.ts b/libs/payments/ui/src/lib/nestapp/validators/GetCartActionResult.ts index d2005a35c52..5741f802583 100644 --- a/libs/payments/ui/src/lib/nestapp/validators/GetCartActionResult.ts +++ b/libs/payments/ui/src/lib/nestapp/validators/GetCartActionResult.ts @@ -66,6 +66,7 @@ class PaymentInfo { 'apple_iap', 'external_paypal', 'link', + 'amazon_pay', ] satisfies PaymentProvidersType[]) type!: PaymentProvidersType; diff --git a/libs/payments/ui/src/lib/utils/getCardIcon.ts b/libs/payments/ui/src/lib/utils/getCardIcon.ts index 99b3ca17b7f..e07be25569c 100644 --- a/libs/payments/ui/src/lib/utils/getCardIcon.ts +++ b/libs/payments/ui/src/lib/utils/getCardIcon.ts @@ -2,6 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import AmazonPay from '@fxa/shared/assets/images/payment-methods/amazon-pay.svg'; import Amex from '@fxa/shared/assets/images/payment-methods/amex.svg'; import Diners from '@fxa/shared/assets/images/payment-methods/diners.svg'; import Discover from '@fxa/shared/assets/images/payment-methods/discover.svg'; @@ -16,6 +17,11 @@ import { LocalizerRsc } from '@fxa/shared/l10n/server'; export function getCardIcon(cardBrand: string, l10n: LocalizerRsc) { switch (cardBrand) { + case 'amazon_pay': + return { + img: AmazonPay, + altText: l10n.getString('amazon-pay-logo-alt-text', 'Amazon Pay logo'), + }; case 'amex': return { img: Amex, diff --git a/libs/payments/ui/src/lib/utils/terms-and-privacy.ts b/libs/payments/ui/src/lib/utils/terms-and-privacy.ts index 32212a1f72f..d4bb1c04640 100644 --- a/libs/payments/ui/src/lib/utils/terms-and-privacy.ts +++ b/libs/payments/ui/src/lib/utils/terms-and-privacy.ts @@ -25,6 +25,7 @@ export const PaymentProviders = { google_iap: 'google_iap', apple_iap: 'apple_iap', link: 'link', + amazon_pay: 'amazon_pay', } as const; export type PaymentProvidersType = typeof PaymentProviders; @@ -41,6 +42,7 @@ export function buildPaymentTerms( const items: GenericTermsListItem[] = []; switch (providerType) { case PaymentProviders.link: + case PaymentProviders.amazon_pay: case PaymentProviders.stripe: providerString = 'Stripe'; titleLocalizationId = 'terms-and-privacy-stripe-label'; diff --git a/libs/shared/assets/src/images/payment-methods/amazon-pay.svg b/libs/shared/assets/src/images/payment-methods/amazon-pay.svg new file mode 100644 index 00000000000..e6760779696 --- /dev/null +++ b/libs/shared/assets/src/images/payment-methods/amazon-pay.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/libs/shared/l10n/src/lib/branding.ftl b/libs/shared/l10n/src/lib/branding.ftl index b5d60975448..78af93c7b68 100644 --- a/libs/shared/l10n/src/lib/branding.ftl +++ b/libs/shared/l10n/src/lib/branding.ftl @@ -50,6 +50,7 @@ -product-firefox-relay-short = Relay -product-pocket = Pocket +-brand-amazon-pay = Amazon Pay -brand-apple = Apple -brand-google = Google -brand-paypal = PayPal