From 1b290972d80486e7b88afa4b4143cf16b8c9d390 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Tue, 26 Sep 2023 11:11:49 +0200 Subject: [PATCH] feat(payments-plugin): Allow custom params to be passed to Stripe API Closes #2412 --- .../e2e/graphql/generated-shop-types.ts | 44 +++++-- .../e2e/graphql/shop-queries.ts | 1 + .../e2e/stripe-payment.e2e-spec.ts | 83 +++++++++++++ .../src/stripe/stripe.plugin.ts | 1 + .../src/stripe/stripe.service.ts | 22 +++- packages/payments-plugin/src/stripe/types.ts | 111 +++++++++++++++++- 6 files changed, 253 insertions(+), 9 deletions(-) diff --git a/packages/payments-plugin/e2e/graphql/generated-shop-types.ts b/packages/payments-plugin/e2e/graphql/generated-shop-types.ts index 187f943d1c..dc3d471726 100644 --- a/packages/payments-plugin/e2e/graphql/generated-shop-types.ts +++ b/packages/payments-plugin/e2e/graphql/generated-shop-types.ts @@ -3280,7 +3280,7 @@ export type TestOrderFragmentFragment = { }>; }>; shippingLines: Array<{ shippingMethod: { id: string; code: string; description: string } }>; - customer?: { id: string; user?: { id: string; identifier: string } | null } | null; + customer?: { id: string; emailAddress: string; user?: { id: string; identifier: string } | null } | null; history: { items: Array<{ id: string; type: HistoryEntryType; data: any }> }; }; @@ -3340,7 +3340,11 @@ export type AddPaymentToOrderMutation = { }>; }>; shippingLines: Array<{ shippingMethod: { id: string; code: string; description: string } }>; - customer?: { id: string; user?: { id: string; identifier: string } | null } | null; + customer?: { + id: string; + emailAddress: string; + user?: { id: string; identifier: string } | null; + } | null; history: { items: Array<{ id: string; type: HistoryEntryType; data: any }> }; } | { errorCode: ErrorCode; message: string } @@ -3456,7 +3460,11 @@ export type SetShippingMethodMutation = { }>; }>; shippingLines: Array<{ shippingMethod: { id: string; code: string; description: string } }>; - customer?: { id: string; user?: { id: string; identifier: string } | null } | null; + customer?: { + id: string; + emailAddress: string; + user?: { id: string; identifier: string } | null; + } | null; history: { items: Array<{ id: string; type: HistoryEntryType; data: any }> }; } | { errorCode: ErrorCode; message: string }; @@ -3521,7 +3529,11 @@ export type AddItemToOrderMutation = { }>; }>; shippingLines: Array<{ shippingMethod: { id: string; code: string; description: string } }>; - customer?: { id: string; user?: { id: string; identifier: string } | null } | null; + customer?: { + id: string; + emailAddress: string; + user?: { id: string; identifier: string } | null; + } | null; history: { items: Array<{ id: string; type: HistoryEntryType; data: any }> }; }; } @@ -3574,7 +3586,11 @@ export type AddItemToOrderMutation = { }>; }>; shippingLines: Array<{ shippingMethod: { id: string; code: string; description: string } }>; - customer?: { id: string; user?: { id: string; identifier: string } | null } | null; + customer?: { + id: string; + emailAddress: string; + user?: { id: string; identifier: string } | null; + } | null; history: { items: Array<{ id: string; type: HistoryEntryType; data: any }> }; } | { errorCode: ErrorCode; message: string } @@ -3634,7 +3650,11 @@ export type GetOrderByCodeQuery = { }>; }>; shippingLines: Array<{ shippingMethod: { id: string; code: string; description: string } }>; - customer?: { id: string; user?: { id: string; identifier: string } | null } | null; + customer?: { + id: string; + emailAddress: string; + user?: { id: string; identifier: string } | null; + } | null; history: { items: Array<{ id: string; type: HistoryEntryType; data: any }> }; } | null; }; @@ -3690,7 +3710,11 @@ export type GetActiveOrderQuery = { }>; }>; shippingLines: Array<{ shippingMethod: { id: string; code: string; description: string } }>; - customer?: { id: string; user?: { id: string; identifier: string } | null } | null; + customer?: { + id: string; + emailAddress: string; + user?: { id: string; identifier: string } | null; + } | null; history: { items: Array<{ id: string; type: HistoryEntryType; data: any }> }; } | null; }; @@ -3820,6 +3844,7 @@ export const TestOrderFragmentFragmentDoc = { kind: 'SelectionSet', selections: [ { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'emailAddress' } }, { kind: 'Field', name: { kind: 'Name', value: 'user' }, @@ -4103,6 +4128,7 @@ export const AddPaymentToOrderDocument = { kind: 'SelectionSet', selections: [ { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'emailAddress' } }, { kind: 'Field', name: { kind: 'Name', value: 'user' }, @@ -4541,6 +4567,7 @@ export const SetShippingMethodDocument = { kind: 'SelectionSet', selections: [ { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'emailAddress' } }, { kind: 'Field', name: { kind: 'Name', value: 'user' }, @@ -4808,6 +4835,7 @@ export const AddItemToOrderDocument = { kind: 'SelectionSet', selections: [ { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'emailAddress' } }, { kind: 'Field', name: { kind: 'Name', value: 'user' }, @@ -5013,6 +5041,7 @@ export const GetOrderByCodeDocument = { kind: 'SelectionSet', selections: [ { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'emailAddress' } }, { kind: 'Field', name: { kind: 'Name', value: 'user' }, @@ -5201,6 +5230,7 @@ export const GetActiveOrderDocument = { kind: 'SelectionSet', selections: [ { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'emailAddress' } }, { kind: 'Field', name: { kind: 'Name', value: 'user' }, diff --git a/packages/payments-plugin/e2e/graphql/shop-queries.ts b/packages/payments-plugin/e2e/graphql/shop-queries.ts index a31898dba9..adf61b965a 100644 --- a/packages/payments-plugin/e2e/graphql/shop-queries.ts +++ b/packages/payments-plugin/e2e/graphql/shop-queries.ts @@ -59,6 +59,7 @@ export const TEST_ORDER_FRAGMENT = gql` } customer { id + emailAddress user { id identifier diff --git a/packages/payments-plugin/e2e/stripe-payment.e2e-spec.ts b/packages/payments-plugin/e2e/stripe-payment.e2e-spec.ts index d88464ea77..7bebbfcef7 100644 --- a/packages/payments-plugin/e2e/stripe-payment.e2e-spec.ts +++ b/packages/payments-plugin/e2e/stripe-payment.e2e-spec.ts @@ -210,6 +210,89 @@ describe('Stripe payments', () => { StripePlugin.options.metadata = undefined; }); + // https://github.com/vendure-ecommerce/vendure/issues/2412 + it('should attach additional params to payment intent using paymentIntentCreateParams', async () => { + StripePlugin.options.paymentIntentCreateParams = async (injector, ctx, currentOrder) => { + const hydrator = injector.get(EntityHydrator); + await hydrator.hydrate(ctx, currentOrder, { relations: ['customer'] }); + return { + description: `Order #${currentOrder.code} for ${currentOrder.customer!.emailAddress}`, + }; + }; + let createPaymentIntentPayload: any; + const { activeOrder } = await shopClient.query(GET_ACTIVE_ORDER); + nock('https://api.stripe.com/') + .post('/v1/payment_intents', body => { + createPaymentIntentPayload = body; + return true; + }) + .reply(200, { + client_secret: 'test-client-secret', + }); + const { createStripePaymentIntent } = await shopClient.query(CREATE_STRIPE_PAYMENT_INTENT); + expect(createPaymentIntentPayload).toEqual({ + amount: activeOrder?.totalWithTax.toString(), + currency: activeOrder?.currencyCode?.toLowerCase(), + customer: 'new-customer-id', + description: `Order #${activeOrder!.code} for ${activeOrder!.customer!.emailAddress}`, + 'automatic_payment_methods[enabled]': 'true', + 'metadata[channelToken]': E2E_DEFAULT_CHANNEL_TOKEN, + 'metadata[orderId]': '1', + 'metadata[orderCode]': activeOrder?.code, + }); + expect(createStripePaymentIntent).toEqual('test-client-secret'); + StripePlugin.options.paymentIntentCreateParams = undefined; + }); + + // https://github.com/vendure-ecommerce/vendure/issues/2412 + it('should attach additional params to customer using customerCreateParams', async () => { + StripePlugin.options.customerCreateParams = async (injector, ctx, currentOrder) => { + const hydrator = injector.get(EntityHydrator); + await hydrator.hydrate(ctx, currentOrder, { relations: ['customer'] }); + return { + description: `Description for ${currentOrder.customer!.emailAddress}`, + phone: '12345', + }; + }; + + await shopClient.asUserWithCredentials(customers[1].emailAddress, 'test'); + const { addItemToOrder } = await shopClient.query< + AddItemToOrderMutation, + AddItemToOrderMutationVariables + >(ADD_ITEM_TO_ORDER, { + productVariantId: 'T_1', + quantity: 2, + }); + order = addItemToOrder as TestOrderFragmentFragment; + + let createCustomerPayload: { name: string; email: string } | undefined; + const emptyList = { data: [] }; + nock('https://api.stripe.com/') + .get(/\/v1\/customers.*/) + .reply(200, emptyList); + nock('https://api.stripe.com/') + .post('/v1/customers', body => { + createCustomerPayload = body; + return true; + }) + .reply(201, { + id: 'new-customer-id', + }); + nock('https://api.stripe.com/').post('/v1/payment_intents').reply(200, { + client_secret: 'test-client-secret', + }); + + const { activeOrder } = await shopClient.query(GET_ACTIVE_ORDER); + + await shopClient.query(CREATE_STRIPE_PAYMENT_INTENT); + expect(createCustomerPayload).toEqual({ + email: 'trevor_donnelly96@hotmail.com', + name: 'Trevor Donnelly', + description: `Description for ${activeOrder!.customer!.emailAddress}`, + phone: '12345', + }); + }); + // https://github.com/vendure-ecommerce/vendure/issues/1630 describe('currencies with no fractional units', () => { let japanProductId: string; diff --git a/packages/payments-plugin/src/stripe/stripe.plugin.ts b/packages/payments-plugin/src/stripe/stripe.plugin.ts index 1f12454cb6..8fca91c5c2 100644 --- a/packages/payments-plugin/src/stripe/stripe.plugin.ts +++ b/packages/payments-plugin/src/stripe/stripe.plugin.ts @@ -45,6 +45,7 @@ import { StripePluginOptions } from './types'; * }), * ] * ```` + * For all the plugin options, see the {@link StripePluginOptions} type. * 2. Create a new PaymentMethod in the Admin UI, and select "Stripe payments" as the handler. * 3. Set the webhook secret and API key in the PaymentMethod form. * diff --git a/packages/payments-plugin/src/stripe/stripe.service.ts b/packages/payments-plugin/src/stripe/stripe.service.ts index 7c3342a076..6a36d2e202 100644 --- a/packages/payments-plugin/src/stripe/stripe.service.ts +++ b/packages/payments-plugin/src/stripe/stripe.service.ts @@ -40,6 +40,11 @@ export class StripeService { } const amountInMinorUnits = getAmountInStripeMinorUnits(order); + const additionalParams = await this.options.paymentIntentCreateParams?.( + new Injector(this.moduleRef), + ctx, + order, + ); const metadata = sanitizeMetadata({ ...(typeof this.options.metadata === 'function' ? await this.options.metadata(new Injector(this.moduleRef), ctx, order) @@ -49,6 +54,11 @@ export class StripeService { orderCode: order.code, }); + const allMetadata = { + ...metadata, + ...sanitizeMetadata(additionalParams?.metadata ?? {}), + }; + const { client_secret } = await stripe.paymentIntents.create( { amount: amountInMinorUnits, @@ -57,7 +67,8 @@ export class StripeService { automatic_payment_methods: { enabled: true, }, - metadata, + ...(additionalParams ?? {}), + metadata: allMetadata, }, { idempotencyKey: `${order.code}_${amountInMinorUnits}` }, ); @@ -164,9 +175,18 @@ export class StripeService { if (stripeCustomers.data.length > 0) { stripeCustomerId = stripeCustomers.data[0].id; } else { + const additionalParams = await this.options.customerCreateParams?.( + new Injector(this.moduleRef), + ctx, + order, + ); const newStripeCustomer = await stripe.customers.create({ email: customer.emailAddress, name: `${customer.firstName} ${customer.lastName}`, + ...(additionalParams ?? {}), + ...(additionalParams?.metadata + ? { metadata: sanitizeMetadata(additionalParams.metadata) } + : {}), }); stripeCustomerId = newStripeCustomer.id; diff --git a/packages/payments-plugin/src/stripe/types.ts b/packages/payments-plugin/src/stripe/types.ts index 998783f243..715dcf5719 100644 --- a/packages/payments-plugin/src/stripe/types.ts +++ b/packages/payments-plugin/src/stripe/types.ts @@ -11,6 +11,12 @@ declare module '@vendure/core/dist/entity/custom-entity-fields' { } } +type AdditionalPaymentIntentCreateParams = Partial< + Omit +>; + +type AdditionalCustomerCreateParams = Partial>; + /** * @description * Configuration options for the Stripe payments plugin. @@ -32,7 +38,30 @@ export interface StripePluginOptions { /** * @description - * Attach extra metadata to Stripe payment intent + * Attach extra metadata to Stripe payment intent creation call. + * + * @example + * ```ts + * import { EntityHydrator, VendureConfig } from '\@vendure/core'; + * import { StripePlugin } from '\@vendure/payments-plugin/package/stripe'; + * + * export const config: VendureConfig = { + * // ... + * plugins: [ + * StripePlugin.init({ + * metadata: async (injector, ctx, order) => { + * const hydrator = injector.get(EntityHydrator); + * await hydrator.hydrate(ctx, order, { relations: ['customer'] }); + * return { + * description: `Order #${order.code} for ${order.customer!.emailAddress}` + * }, + * } + * }), + * ], + * }; + * + * Note: If the `paymentIntentCreateParams` is also used and returns a `metadata` key, then the values + * returned by both functions will be merged. * * @since 1.9.7 */ @@ -41,6 +70,86 @@ export interface StripePluginOptions { ctx: RequestContext, order: Order, ) => Stripe.MetadataParam | Promise; + + /** + * @description + * Provide additional parameters to the Stripe payment intent creation. By default, + * the plugin will already pass the `amount`, `currency`, `customer` and `automatic_payment_methods: { enabled: true }` parameters. + * + * For example, if you want to provide a `description` for the payment intent, you can do so like this: + * + * @example + * ```ts + * import { VendureConfig } from '\@vendure/core'; + * import { StripePlugin } from '\@vendure/payments-plugin/package/stripe'; + * + * export const config: VendureConfig = { + * // ... + * plugins: [ + * StripePlugin.init({ + * paymentIntentCreateParams: (injector, ctx, order) => { + * return { + * description: `Order #${order.code} for ${order.customer?.emailAddress}` + * }, + * } + * }), + * ], + * }; + * ``` + * + * @since 2.1.0 + * + */ + paymentIntentCreateParams?: ( + injector: Injector, + ctx: RequestContext, + order: Order, + ) => AdditionalPaymentIntentCreateParams | Promise; + + /** + * @description + * Provide additional parameters to the Stripe customer creation. By default, + * the plugin will already pass the `email` and `name` parameters. + * + * For example, if you want to provide an address for the customer: + * + * @example + * ```ts + * import { EntityHydrator, VendureConfig } from '\@vendure/core'; + * import { StripePlugin } from '\@vendure/payments-plugin/package/stripe'; + * + * export const config: VendureConfig = { + * // ... + * plugins: [ + * StripePlugin.init({ + * storeCustomersInStripe: true, + * customerCreateParams: async (injector, ctx, order) => { + * const entityHydrator = injector.get(EntityHydrator); + * const customer = order.customer; + * await entityHydrator.hydrate(ctx, customer, { relations: ['addresses'] }); + * const defaultBillingAddress = customer.addresses.find(a => a.defaultBillingAddress) ?? customer.addresses[0]; + * return { + * address: { + * line1: defaultBillingAddress.streetLine1 || order.shippingAddress?.streetLine1, + * postal_code: defaultBillingAddress.postalCode || order.shippingAddress?.postalCode, + * city: defaultBillingAddress.city || order.shippingAddress?.city, + * state: defaultBillingAddress.province || order.shippingAddress?.province, + * country: defaultBillingAddress.country.code || order.shippingAddress?.countryCode, + * }, + * }, + * } + * }), + * ], + * }; + * ``` + * + * @since 2.1.0 + */ + customerCreateParams?: ( + injector: Injector, + ctx: RequestContext, + order: Order, + ) => AdditionalCustomerCreateParams | Promise; } export interface RequestWithRawBody extends Request {