Explorar o código

feat(payments-plugin): Add metadata field to StripePluginOptions (#2157)

Closes  #1935. Allow to append extra metadata to Stripe payment intent based on developer needs.
Nhan Bach %!s(int64=2) %!d(string=hai) anos
pai
achega
21baa0a4f7

+ 39 - 0
packages/payments-plugin/e2e/stripe-metadata-sanitize.e2e-spec.ts

@@ -0,0 +1,39 @@
+import { sanitizeMetadata } from '../src/stripe/metadata-sanitize';
+
+describe('Stripe Metadata Sanitize', () => {
+    const metadata = {
+        customerEmail: 'test@gmail.com',
+    };
+    it('should sanitize and create new object metadata', () => {
+        const newMetadata = sanitizeMetadata(metadata);
+        expect(newMetadata).toEqual(metadata);
+        expect(newMetadata).not.toBe(metadata);
+    });
+    it('should omit fields that have key length exceed 40 characters', () => {
+        const newMetadata = sanitizeMetadata({
+            ...metadata,
+            reallylongkey_reallylongkey_reallylongkey_reallylongkey_reallylongkey: 1,
+        });
+        expect(newMetadata).toEqual(metadata);
+    });
+    it('should omit fields that have value length exceed 500 characters', () => {
+        const reallyLongText = Array(501).fill('a').join();
+        const newMetadata = sanitizeMetadata({
+            ...metadata,
+            complexField: reallyLongText,
+        });
+        expect(newMetadata).toEqual(metadata);
+    });
+    it('should truncate metadata that have more than 50 keys', () => {
+        const moreThan50KeysMetadata = Array(51)
+            .fill('a')
+            .reduce((obj, val, idx) => {
+                obj[idx] = val;
+                return obj;
+            }, {});
+        const newMetadata = sanitizeMetadata(moreThan50KeysMetadata);
+        expect(Object.keys(newMetadata).length).toEqual(50);
+        delete moreThan50KeysMetadata['50'];
+        expect(newMetadata).toEqual(moreThan50KeysMetadata);
+    });
+});

+ 35 - 1
packages/payments-plugin/e2e/stripe-payment.e2e-spec.ts

@@ -1,5 +1,5 @@
 /* tslint:disable:no-non-null-assertion */
-import { mergeConfig } from '@vendure/core';
+import { EntityHydrator, mergeConfig } from '@vendure/core';
 import { CreateProduct, CreateProductVariants } from '@vendure/core/e2e/graphql/generated-e2e-admin-types';
 import { CREATE_PRODUCT, CREATE_PRODUCT_VARIANTS } from '@vendure/core/e2e/graphql/shared-definitions';
 import { createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN } from '@vendure/testing';
@@ -162,6 +162,40 @@ describe('Stripe payments', () => {
         expect(createStripePaymentIntent).toEqual('test-client-secret');
     });
 
+    // https://github.com/vendure-ecommerce/vendure/issues/1935
+    it('should attach metadata to stripe payment intent', async () => {
+        StripePlugin.options.metadata = async (injector, ctx, currentOrder) => {
+            const hydrator = await injector.get(EntityHydrator);
+            await hydrator.hydrate(ctx, currentOrder, { relations: ['customer'] });
+            return {
+                customerEmail: currentOrder.customer?.emailAddress ?? 'demo',
+            };
+        };
+        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',
+            'automatic_payment_methods[enabled]': 'true',
+            'metadata[channelToken]': E2E_DEFAULT_CHANNEL_TOKEN,
+            'metadata[orderId]': '1',
+            'metadata[orderCode]': activeOrder?.code,
+            'metadata[customerEmail]': customers[0].emailAddress,
+        });
+        expect(createStripePaymentIntent).toEqual('test-client-secret');
+        StripePlugin.options.metadata = undefined;
+    });
+
     // https://github.com/vendure-ecommerce/vendure/issues/1630
     describe('currencies with no fractional units', () => {
         let japanProductId: string;

+ 35 - 0
packages/payments-plugin/src/stripe/metadata-sanitize.ts

@@ -0,0 +1,35 @@
+import Stripe from 'stripe';
+
+const MAX_KEYS = 50;
+const MAX_KEY_NAME_LENGTH = 40;
+const MAX_VALUE_LENGTH = 500;
+/**
+ * @description
+ * Santitize metadata to ensure it follow Stripe's instructions
+ *
+ * @link
+ * https://stripe.com/docs/api/metadata
+ *
+ * @Restriction
+ * You can specify up to 50 keys, with key names up to 40 characters long and values up to 500 characters long.
+ *
+ */
+export function sanitizeMetadata(metadata: Stripe.MetadataParam) {
+    if (typeof metadata !== 'object' && metadata !== null) return {};
+
+    const keys = Object.keys(metadata)
+        .filter(keyName => keyName.length <= MAX_KEY_NAME_LENGTH)
+        .filter(
+            keyName =>
+                typeof metadata[keyName] !== 'string' ||
+                (metadata[keyName] as string).length <= MAX_VALUE_LENGTH,
+        )
+        .slice(0, MAX_KEYS) as Array<keyof Stripe.MetadataParam>;
+
+    const sanitizedMetadata = keys.reduce((obj, keyName) => {
+        obj[keyName] = metadata[keyName];
+        return obj;
+    }, {} as Stripe.MetadataParam);
+
+    return sanitizedMetadata;
+}

+ 15 - 6
packages/payments-plugin/src/stripe/stripe.service.ts

@@ -1,8 +1,10 @@
 import { Inject, Injectable } from '@nestjs/common';
-import { Customer, Logger, Order, RequestContext, TransactionalConnection } from '@vendure/core';
+import { ModuleRef } from '@nestjs/core';
+import { Customer, Injector, Logger, Order, RequestContext, TransactionalConnection } from '@vendure/core';
 import Stripe from 'stripe';
 
 import { loggerCtx, STRIPE_PLUGIN_OPTIONS } from './constants';
+import { sanitizeMetadata } from './metadata-sanitize';
 import { getAmountInStripeMinorUnits } from './stripe-utils';
 import { StripePluginOptions } from './types';
 
@@ -13,6 +15,7 @@ export class StripeService {
     constructor(
         private connection: TransactionalConnection,
         @Inject(STRIPE_PLUGIN_OPTIONS) private options: StripePluginOptions,
+        private moduleRef: ModuleRef,
     ) {
         this.stripe = new Stripe(this.options.apiKey, {
             apiVersion: '2020-08-27',
@@ -26,6 +29,16 @@ export class StripeService {
             customerId = await this.getStripeCustomerId(ctx, order);
         }
         const amountInMinorUnits = getAmountInStripeMinorUnits(order);
+
+        const metadata = sanitizeMetadata({
+            ...(typeof this.options.metadata === 'function'
+                ? await this.options.metadata(new Injector(this.moduleRef), ctx, order)
+                : {}),
+            channelToken: ctx.channel.token,
+            orderId: order.id,
+            orderCode: order.code,
+        });
+
         const { client_secret } = await this.stripe.paymentIntents.create(
             {
                 amount: amountInMinorUnits,
@@ -34,11 +47,7 @@ export class StripeService {
                 automatic_payment_methods: {
                     enabled: true,
                 },
-                metadata: {
-                    channelToken: ctx.channel.token,
-                    orderId: order.id,
-                    orderCode: order.code,
-                },
+                metadata,
             },
             { idempotencyKey: `${order.code}_${amountInMinorUnits}` },
         );

+ 14 - 0
packages/payments-plugin/src/stripe/types.ts

@@ -1,5 +1,7 @@
+import { Injector, Order, RequestContext } from '@vendure/core';
 import '@vendure/core/dist/entity/custom-entity-fields';
 import { Request } from 'express';
+import Stripe from 'stripe';
 
 // Note: deep import is necessary here because CustomCustomerFields is also extended in the Braintree
 // plugin. Reference: https://github.com/microsoft/TypeScript/issues/46617
@@ -37,6 +39,18 @@ export interface StripePluginOptions {
      * @default false
      */
     storeCustomersInStripe?: boolean;
+
+    /**
+     * @description
+     * Attach extra metadata to Stripe payment intent
+     *
+     * @since 1.9.7
+     */
+    metadata?: (
+        injector: Injector,
+        ctx: RequestContext,
+        order: Order,
+    ) => Stripe.MetadataParam | Promise<Stripe.MetadataParam>;
 }
 
 export interface RequestWithRawBody extends Request {