Ver Fonte

feat(payments-plugin): Allow custom params to be passed to Stripe API

Closes #2412
Michael Bromley há 2 anos atrás
pai
commit
1b290972d8

+ 37 - 7
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' },

+ 1 - 0
packages/payments-plugin/e2e/graphql/shop-queries.ts

@@ -59,6 +59,7 @@ export const TEST_ORDER_FRAGMENT = gql`
         }
         customer {
             id
+            emailAddress
             user {
                 id
                 identifier

+ 83 - 0
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<GetActiveOrderQuery>(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<GetActiveOrderQuery>(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;

+ 1 - 0
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.
  *

+ 21 - 1
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;

+ 110 - 1
packages/payments-plugin/src/stripe/types.ts

@@ -11,6 +11,12 @@ declare module '@vendure/core/dist/entity/custom-entity-fields' {
     }
 }
 
+type AdditionalPaymentIntentCreateParams = Partial<
+    Omit<Stripe.PaymentIntentCreateParams, 'amount' | 'currency' | 'customer'>
+>;
+
+type AdditionalCustomerCreateParams = Partial<Omit<Stripe.CustomerCreateParams, 'email'>>;
+
 /**
  * @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<Stripe.MetadataParam>;
+
+    /**
+     * @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<AdditionalPaymentIntentCreateParams>;
+
+    /**
+     * @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<AdditionalCustomerCreateParams>;
 }
 
 export interface RequestWithRawBody extends Request {