Browse Source

feat(payments-plugin): Add support for Braintree vault to store cc data

Michael Bromley 4 years ago
parent
commit
1d93db847d

+ 94 - 23
packages/payments-plugin/src/braintree/braintree.handler.ts

@@ -1,11 +1,25 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
-import { Injector, Logger, PaymentMethodHandler } from '@vendure/core';
+import {
+    Customer,
+    EntityHydrator,
+    Injector,
+    InternalServerError,
+    Logger,
+    Order,
+    PaymentMethodHandler,
+    RequestContext,
+    TransactionalConnection,
+} from '@vendure/core';
+import { ConfigArgValues } from '@vendure/core/src/common/configurable-operation';
+import { BraintreeGateway } from 'braintree';
 
 import { extractMetadataFromTransaction, getGateway } from './braintree-common';
 import { BRAINTREE_PLUGIN_OPTIONS, loggerCtx } from './constants';
 import { BraintreePluginOptions } from './types';
 
 let options: BraintreePluginOptions;
+let connection: TransactionalConnection;
+let entityHydrator: EntityHydrator;
 /**
  * The handler for Braintree payments.
  */
@@ -19,33 +33,19 @@ export const braintreePaymentMethodHandler = new PaymentMethodHandler({
     },
     init(injector: Injector) {
         options = injector.get<BraintreePluginOptions>(BRAINTREE_PLUGIN_OPTIONS);
+        connection = injector.get(TransactionalConnection);
+        entityHydrator = injector.get(EntityHydrator);
     },
     async createPayment(ctx, order, amount, args, metadata) {
         const gateway = getGateway(args, options);
+        let customerId: string | undefined;
         try {
-            const response = await gateway.transaction.sale({
-                amount: (amount / 100).toString(10),
-                orderId: order.code,
-                paymentMethodNonce: metadata.nonce,
-                options: {
-                    submitForSettlement: true,
-                },
-            });
-            if (!response.success) {
-                return {
-                    amount,
-                    state: 'Declined' as const,
-                    transactionId: response.transaction.id,
-                    errorMessage: response.message,
-                    metadata: extractMetadataFromTransaction(response.transaction),
-                };
+            await entityHydrator.hydrate(ctx, order, { relations: ['customer'] });
+            const customer = order.customer;
+            if (options.storeCustomersInBraintree && ctx.activeUserId && customer) {
+                customerId = await getBraintreeCustomerId(ctx, gateway, customer);
             }
-            return {
-                amount,
-                state: 'Settled' as const,
-                transactionId: response.transaction.id,
-                metadata: extractMetadataFromTransaction(response.transaction),
-            };
+            return processPayment(ctx, gateway, order, amount, metadata.nonce, customerId);
         } catch (e) {
             Logger.error(e, loggerCtx);
             return {
@@ -81,3 +81,74 @@ export const braintreePaymentMethodHandler = new PaymentMethodHandler({
         };
     },
 });
+
+async function processPayment(
+    ctx: RequestContext,
+    gateway: BraintreeGateway,
+    order: Order,
+    amount: number,
+    paymentMethodNonce: any,
+    customerId: string | undefined,
+) {
+    const response = await gateway.transaction.sale({
+        customerId,
+        amount: (amount / 100).toString(10),
+        orderId: order.code,
+        paymentMethodNonce,
+        options: {
+            submitForSettlement: true,
+            storeInVaultOnSuccess: !!customerId,
+        },
+    });
+    if (!response.success) {
+        return {
+            amount,
+            state: 'Declined' as const,
+            transactionId: response.transaction.id,
+            errorMessage: response.message,
+            metadata: extractMetadataFromTransaction(response.transaction),
+        };
+    }
+    return {
+        amount,
+        state: 'Settled' as const,
+        transactionId: response.transaction.id,
+        metadata: extractMetadataFromTransaction(response.transaction),
+    };
+}
+
+/**
+ * If the Customer has no braintreeCustomerId, create one, else return the existing braintreeCustomerId.
+ */
+async function getBraintreeCustomerId(
+    ctx: RequestContext,
+    gateway: BraintreeGateway,
+    customer: Customer,
+): Promise<string | undefined> {
+    if (!customer.customFields.braintreeCustomerId) {
+        try {
+            const result = await gateway.customer.create({
+                firstName: customer.firstName,
+                lastName: customer.lastName,
+                email: customer.emailAddress,
+            });
+            if (result.success) {
+                const customerId = result.customer.id;
+                Logger.verbose(`Created Braintree Customer record for customerId ${customer.id}`, loggerCtx);
+                customer.customFields.braintreeCustomerId = customerId;
+                await connection.getRepository(ctx, Customer).save(customer, { reload: false });
+                return customerId;
+            } else {
+                Logger.error(
+                    `Failed to create Braintree Customer record for customerId ${customer.id}. View Debug level logs for details.`,
+                    loggerCtx,
+                );
+                Logger.debug(JSON.stringify(result.errors, null, 2), loggerCtx);
+            }
+        } catch (e) {
+            Logger.error(e.message, loggerCtx, e.stack);
+        }
+    } else {
+        return customer.customFields.braintreeCustomerId;
+    }
+}

+ 26 - 3
packages/payments-plugin/src/braintree/braintree.plugin.ts

@@ -1,4 +1,4 @@
-import { PluginCommonModule, VendurePlugin } from '@vendure/core';
+import { LanguageCode, PluginCommonModule, Type, VendurePlugin } from '@vendure/core';
 import { gql } from 'apollo-server-core';
 
 import { braintreePaymentMethodHandler } from './braintree.handler';
@@ -34,7 +34,12 @@ import { BraintreePluginOptions } from './types';
  *     // ...
  *
  *     plugins: [
- *       BraintreePlugin.init({ environment: Environment.Sandbox }),
+ *       BraintreePlugin.init({
+ *         environment: Environment.Sandbox,
+ *         // This allows saving customer payment
+ *         // methods with Braintree
+ *         storeCustomersInBraintree: true,
+ *       }),
  *     ]
  *     ```
  * 2. Create a new PaymentMethod in the Admin UI, and select "Braintree payments" as the handler.
@@ -101,6 +106,14 @@ import { BraintreePluginOptions } from './types';
  *     }),
  *   );
  *
+ *   // If you are using the `storeCustomersInBraintree` option, then the
+ *   // customer might already have a stored payment method selected as
+ *   // soon as the dropin script loads. In this case, show the submit
+ *   // button immediately.
+ *   if (dropin.isPaymentMethodRequestable()) {
+ *     showSubmitButton = true;
+ *   }
+ *
  *   dropin.on('paymentMethodRequestable', (payload) => {
  *     if (payload.type === 'CreditCard') {
  *       showSubmitButton = true;
@@ -187,6 +200,16 @@ import { BraintreePluginOptions } from './types';
     ],
     configuration: config => {
         config.paymentOptions.paymentMethodHandlers.push(braintreePaymentMethodHandler);
+        if (BraintreePlugin.options.storeCustomersInBraintree === true) {
+            config.customFields.Customer.push({
+                name: 'braintreeCustomerId',
+                type: 'string',
+                label: [{ languageCode: LanguageCode.en, value: 'Braintree Customer ID' }],
+                nullable: true,
+                public: false,
+                readonly: true,
+            });
+        }
         return config;
     },
     shopApiExtensions: {
@@ -200,7 +223,7 @@ import { BraintreePluginOptions } from './types';
 })
 export class BraintreePlugin {
     static options: BraintreePluginOptions = {};
-    static init(options: BraintreePluginOptions): BraintreePlugin {
+    static init(options: BraintreePluginOptions): Type<BraintreePlugin> {
         this.options = options;
         return BraintreePlugin;
     }

+ 2 - 2
packages/payments-plugin/src/braintree/braintree.resolver.ts

@@ -28,11 +28,11 @@ export class BraintreeResolver {
     async generateBraintreeClientToken(@Ctx() ctx: RequestContext, @Args() { orderId }: { orderId: ID }) {
         const order = await this.orderService.findOne(ctx, orderId);
         if (order && order.customer) {
-            const customerId = order.customer.id.toString();
+            const customerId = order.customer.customFields.braintreeCustomerId ?? undefined;
             const args = await this.getPaymentMethodArgs(ctx);
             const gateway = getGateway(args, this.options);
             try {
-                const result = await gateway.clientToken.generate({});
+                const result = await gateway.clientToken.generate({ customerId });
                 return result.clientToken;
             } catch (e) {
                 Logger.error(

+ 18 - 0
packages/payments-plugin/src/braintree/types.ts

@@ -5,6 +5,12 @@ import { braintreePaymentMethodHandler } from './braintree.handler';
 
 export type PaymentMethodArgsHash = ConfigArgValues<typeof braintreePaymentMethodHandler['args']>;
 
+declare module '@vendure/core' {
+    interface CustomCustomerFields {
+        braintreeCustomerId?: string;
+    }
+}
+
 /**
  * @description
  * Options for the Braintree plugin.
@@ -16,6 +22,18 @@ export interface BraintreePluginOptions {
     /**
      * @description
      * The Braintree environment being targeted, e.g. sandbox or production.
+     *
+     * @default Environment.Sandbox
      */
     environment?: Environment;
+    /**
+     * @description
+     * If set to `true`, a [Customer](https://developer.paypal.com/braintree/docs/guides/customers) object
+     * will be created in Braintree, which allows the secure storage of previously-used payment methods.
+     * This is done by adding a custom field to the Customer entity to store the Braintree customer ID,
+     * so switching this on will require a database migration / synchronization.
+     *
+     * @default false
+     */
+    storeCustomersInBraintree?: boolean;
 }