Browse Source

Merge branch 'minor' into major

Michael Bromley 3 years ago
parent
commit
44e5fda351
20 changed files with 308 additions and 82 deletions
  1. 4 2
      packages/admin-ui/src/lib/core/src/shared/components/facet-value-selector/facet-value-selector.component.ts
  2. 1 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/facet-value-form-input/facet-value-form-input.component.html
  3. 10 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/facet-value-form-input/facet-value-form-input.component.ts
  4. 83 65
      packages/core/e2e/custom-field-relations.e2e-spec.ts
  5. 2 0
      packages/core/src/api/api-internal-modules.ts
  6. 5 0
      packages/core/src/api/resolvers/entity/collection-entity.resolver.ts
  7. 5 0
      packages/core/src/api/resolvers/entity/country-entity.resolver.ts
  8. 5 0
      packages/core/src/api/resolvers/entity/facet-entity.resolver.ts
  9. 5 0
      packages/core/src/api/resolvers/entity/facet-value-entity.resolver.ts
  10. 5 0
      packages/core/src/api/resolvers/entity/product-entity.resolver.ts
  11. 5 0
      packages/core/src/api/resolvers/entity/product-option-entity.resolver.ts
  12. 5 0
      packages/core/src/api/resolvers/entity/product-option-group-entity.resolver.ts
  13. 8 0
      packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts
  14. 26 0
      packages/core/src/api/resolvers/entity/shipping-method-entity.resolver.ts
  15. 1 0
      packages/core/src/api/schema/common/shipping-method.type.graphql
  16. 9 3
      packages/core/src/service/helpers/locale-string-hydrator/locale-string-hydrator.ts
  17. 9 3
      packages/payments-plugin/src/braintree/braintree-common.ts
  18. 21 7
      packages/payments-plugin/src/braintree/braintree.handler.ts
  19. 43 1
      packages/payments-plugin/src/braintree/braintree.plugin.ts
  20. 56 1
      packages/payments-plugin/src/braintree/types.ts

+ 4 - 2
packages/admin-ui/src/lib/core/src/shared/components/facet-value-selector/facet-value-selector.component.ts

@@ -60,6 +60,7 @@ export class FacetValueSelectorComponent implements OnInit, ControlValueAccessor
     @Output() selectedValuesChange = new EventEmitter<FacetValueFragment[]>();
     @Input() facets: FacetWithValuesFragment[];
     @Input() readonly = false;
+    @Input() transformControlValueAccessorValue: (value: FacetValueSeletorItem[]) => any[] = value => value;
 
     @ViewChild(NgSelectComponent) private ngSelect: NgSelectComponent;
 
@@ -67,7 +68,7 @@ export class FacetValueSelectorComponent implements OnInit, ControlValueAccessor
     onChangeFn: (val: any) => void;
     onTouchFn: () => void;
     disabled = false;
-    value: string[];
+    value: Array<string | FacetValueFragment>;
     constructor(private dataService: DataService) {}
 
     ngOnInit() {
@@ -80,7 +81,8 @@ export class FacetValueSelectorComponent implements OnInit, ControlValueAccessor
         }
         this.selectedValuesChange.emit(selected.map(s => s.value));
         if (this.onChangeFn) {
-            this.onChangeFn(JSON.stringify(selected.map(s => s.id)));
+            const transformedValue = this.transformControlValueAccessorValue(selected);
+            this.onChangeFn(transformedValue);
         }
     }
 

+ 1 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/facet-value-form-input/facet-value-form-input.component.html

@@ -3,4 +3,5 @@
     [readonly]="readonly"
     [facets]="facets"
     [formControl]="formControl"
+    [transformControlValueAccessorValue]="valueTransformFn"
 ></vdr-facet-value-selector>

+ 10 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/facet-value-form-input/facet-value-form-input.component.ts

@@ -7,6 +7,7 @@ import { shareReplay } from 'rxjs/operators';
 import { FormInputComponent, InputComponentConfig } from '../../../common/component-registry-types';
 import { FacetWithValuesFragment } from '../../../common/generated-types';
 import { DataService } from '../../../data/providers/data.service';
+import { FacetValueSeletorItem } from '../../components/facet-value-selector/facet-value-selector.component';
 
 /**
  * @description
@@ -37,4 +38,13 @@ export class FacetValueFormInputComponent implements FormInputComponent, OnInit
             .mapSingle(data => data.facets.items)
             .pipe(shareReplay(1));
     }
+
+    valueTransformFn = (values: FacetValueSeletorItem[]) => {
+        const isUsedInConfigArg = this.config.__typename === 'ConfigArgDefinition';
+        if (isUsedInConfigArg) {
+            return JSON.stringify(values.map(s => s.id));
+        } else {
+            return values;
+        }
+    };
 }

+ 83 - 65
packages/core/e2e/custom-field-relations.e2e-spec.ts

@@ -93,6 +93,7 @@ customFieldConfig.Product?.push(
 );
 
 const testResolverSpy = jest.fn();
+
 @Resolver()
 class TestResolver1636 {
     constructor(private connection: TransactionalConnection) {}
@@ -185,6 +186,76 @@ describe('Custom field relations', () => {
 
     describe('special data resolution', () => {
         let productId: string;
+        const productCustomFieldRelationsSelection = `
+        id
+        customFields {
+            cfCollection {
+                languageCode
+                name
+            }
+            cfCountry {
+                languageCode
+                name
+            }
+            cfFacetValue {
+                languageCode
+                name
+            }
+            cfFacet {
+                languageCode
+                name
+            }
+            cfProductOptionGroup {
+                languageCode
+                name
+            }
+            cfProductOption {
+                languageCode
+                name
+            }
+            cfProductVariant {
+                languageCode
+                name
+            }
+            cfProduct {
+                languageCode
+                name
+            }
+            cfShippingMethod {
+                languageCode
+                name
+            }
+        }`;
+
+        function assertTranslatableCustomFieldValues(product: { customFields: any }) {
+            expect(product.customFields.cfCollection).toEqual({
+                languageCode: 'en',
+                name: '__root_collection__',
+            });
+            expect(product.customFields.cfCountry).toEqual({ languageCode: 'en', name: 'Australia' });
+            expect(product.customFields.cfFacetValue).toEqual({
+                languageCode: 'en',
+                name: 'electronics',
+            });
+            expect(product.customFields.cfFacet).toEqual({ languageCode: 'en', name: 'category' });
+            expect(product.customFields.cfProductOptionGroup).toEqual({
+                languageCode: 'en',
+                name: 'screen size',
+            });
+            expect(product.customFields.cfProductOption).toEqual({
+                languageCode: 'en',
+                name: '13 inch',
+            });
+            expect(product.customFields.cfProductVariant).toEqual({
+                languageCode: 'en',
+                name: 'Laptop 13 inch 8GB',
+            });
+            expect(product.customFields.cfProduct).toEqual({ languageCode: 'en', name: 'Laptop' });
+            expect(product.customFields.cfShippingMethod).toEqual({
+                languageCode: 'en',
+                name: 'Standard Shipping',
+            });
+        }
 
         it('translatable entities get translated', async () => {
             const { createProduct } = await adminClient.query(gql`
@@ -211,75 +282,22 @@ describe('Custom field relations', () => {
                                 cfShippingMethodId: "T_1"
                             }
                         }
-                    ) {
-                        id
-                        customFields {
-                            cfCollection {
-                                languageCode
-                                name
-                            }
-                            cfCountry {
-                                languageCode
-                                name
-                            }
-                            cfFacetValue {
-                                languageCode
-                                name
-                            }
-                            cfFacet {
-                                languageCode
-                                name
-                            }
-                            cfProductOptionGroup {
-                                languageCode
-                                name
-                            }
-                            cfProductOption {
-                                languageCode
-                                name
-                            }
-                            cfProductVariant {
-                                languageCode
-                                name
-                            }
-                            cfProduct {
-                                languageCode
-                                name
-                            }
-                            cfShippingMethod {
-                                name
-                            }
-                        }
-                    }
+                    ) { ${productCustomFieldRelationsSelection} }
                 }
             `);
 
             productId = createProduct.id;
+            assertTranslatableCustomFieldValues(createProduct);
+        });
 
-            expect(createProduct.customFields.cfCollection).toEqual({
-                languageCode: 'en',
-                name: '__root_collection__',
-            });
-            expect(createProduct.customFields.cfCountry).toEqual({ languageCode: 'en', name: 'Australia' });
-            expect(createProduct.customFields.cfFacetValue).toEqual({
-                languageCode: 'en',
-                name: 'electronics',
-            });
-            expect(createProduct.customFields.cfFacet).toEqual({ languageCode: 'en', name: 'category' });
-            expect(createProduct.customFields.cfProductOptionGroup).toEqual({
-                languageCode: 'en',
-                name: 'screen size',
-            });
-            expect(createProduct.customFields.cfProductOption).toEqual({
-                languageCode: 'en',
-                name: '13 inch',
-            });
-            expect(createProduct.customFields.cfProductVariant).toEqual({
-                languageCode: 'en',
-                name: 'Laptop 13 inch 8GB',
-            });
-            expect(createProduct.customFields.cfProduct).toEqual({ languageCode: 'en', name: 'Laptop' });
-            expect(createProduct.customFields.cfShippingMethod).toEqual({ name: 'Standard Shipping' });
+        it('translatable entities get translated on findOneInChannel', async () => {
+            const { product } = await adminClient.query(gql`
+                query {
+                    product(id: "${productId}") { ${productCustomFieldRelationsSelection} }
+                }
+            `);
+
+            assertTranslatableCustomFieldValues(product);
         });
 
         it('ProductVariant prices get resolved', async () => {
@@ -979,7 +997,7 @@ describe('Custom field relations', () => {
                             ${customFieldsSelection}
                         }
                     }
-                            `);
+                `);
 
                 assertCustomFieldIds(updateAsset.customFields, 'T_2', ['T_3', 'T_4']);
             });

+ 2 - 0
packages/core/src/api/api-internal-modules.ts

@@ -72,6 +72,7 @@ import {
 import { RefundEntityResolver } from './resolvers/entity/refund-entity.resolver';
 import { RoleEntityResolver } from './resolvers/entity/role-entity.resolver';
 import { ShippingLineEntityResolver } from './resolvers/entity/shipping-line-entity.resolver';
+import { ShippingMethodEntityResolver } from './resolvers/entity/shipping-method-entity.resolver';
 import { TaxRateEntityResolver } from './resolvers/entity/tax-rate-entity.resolver';
 import { UserEntityResolver } from './resolvers/entity/user-entity.resolver';
 import { ShopAuthResolver } from './resolvers/shop/shop-auth.resolver';
@@ -136,6 +137,7 @@ export const entityResolvers = [
     ShippingLineEntityResolver,
     UserEntityResolver,
     TaxRateEntityResolver,
+    ShippingMethodEntityResolver,
 ];
 
 export const adminEntityResolvers = [

+ 5 - 0
packages/core/src/api/resolvers/entity/collection-entity.resolver.ts

@@ -39,6 +39,11 @@ export class CollectionEntityResolver {
         return this.localeStringHydrator.hydrateLocaleStringField(ctx, collection, 'description');
     }
 
+    @ResolveField()
+    languageCode(@Ctx() ctx: RequestContext, @Parent() collection: Collection): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, collection, 'languageCode');
+    }
+
     @ResolveField()
     async productVariants(
         @Ctx() ctx: RequestContext,

+ 5 - 0
packages/core/src/api/resolvers/entity/country-entity.resolver.ts

@@ -13,4 +13,9 @@ export class CountryEntityResolver {
     name(@Ctx() ctx: RequestContext, @Parent() country: Country): Promise<string> {
         return this.localeStringHydrator.hydrateLocaleStringField(ctx, country, 'name');
     }
+
+    @ResolveField()
+    languageCode(@Ctx() ctx: RequestContext, @Parent() country: Country): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, country, 'languageCode');
+    }
 }

+ 5 - 0
packages/core/src/api/resolvers/entity/facet-entity.resolver.ts

@@ -21,6 +21,11 @@ export class FacetEntityResolver {
         return this.localeStringHydrator.hydrateLocaleStringField(ctx, facetValue, 'name');
     }
 
+    @ResolveField()
+    languageCode(@Ctx() ctx: RequestContext, @Parent() facetValue: FacetValue): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, facetValue, 'languageCode');
+    }
+
     @ResolveField()
     async values(@Ctx() ctx: RequestContext, @Parent() facet: Facet): Promise<FacetValue[]> {
         if (facet.values) {

+ 5 - 0
packages/core/src/api/resolvers/entity/facet-value-entity.resolver.ts

@@ -21,6 +21,11 @@ export class FacetValueEntityResolver {
         return this.localeStringHydrator.hydrateLocaleStringField(ctx, facetValue, 'name');
     }
 
+    @ResolveField()
+    languageCode(@Ctx() ctx: RequestContext, @Parent() facetValue: FacetValue): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, facetValue, 'languageCode');
+    }
+
     @ResolveField()
     async facet(@Ctx() ctx: RequestContext, @Parent() facetValue: FacetValue): Promise<Facet | undefined> {
         if (facetValue.facet) {

+ 5 - 0
packages/core/src/api/resolvers/entity/product-entity.resolver.ts

@@ -50,6 +50,11 @@ export class ProductEntityResolver {
         return this.localeStringHydrator.hydrateLocaleStringField(ctx, product, 'description');
     }
 
+    @ResolveField()
+    languageCode(@Ctx() ctx: RequestContext, @Parent() product: Product): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, product, 'languageCode');
+    }
+
     @ResolveField()
     async variants(
         @Ctx() ctx: RequestContext,

+ 5 - 0
packages/core/src/api/resolvers/entity/product-option-entity.resolver.ts

@@ -25,6 +25,11 @@ export class ProductOptionEntityResolver {
         return this.localeStringHydrator.hydrateLocaleStringField(ctx, productOption, 'name');
     }
 
+    @ResolveField()
+    languageCode(@Ctx() ctx: RequestContext, @Parent() productOption: ProductOption): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, productOption, 'languageCode');
+    }
+
     @ResolveField()
     @Allow(Permission.ReadCatalog, Permission.Public, Permission.ReadProduct)
     async group(

+ 5 - 0
packages/core/src/api/resolvers/entity/product-option-group-entity.resolver.ts

@@ -22,6 +22,11 @@ export class ProductOptionGroupEntityResolver {
         return this.localeStringHydrator.hydrateLocaleStringField(ctx, optionGroup, 'name');
     }
 
+    @ResolveField()
+    languageCode(@Ctx() ctx: RequestContext, @Parent() optionGroup: ProductOptionGroup): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, optionGroup, 'languageCode');
+    }
+
     @ResolveField()
     @Allow(Permission.ReadCatalog, Permission.Public, Permission.ReadProduct)
     async options(

+ 8 - 0
packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts

@@ -32,6 +32,14 @@ export class ProductVariantEntityResolver {
         return this.localeStringHydrator.hydrateLocaleStringField(ctx, productVariant, 'name');
     }
 
+    @ResolveField()
+    async languageCode(
+        @Ctx() ctx: RequestContext,
+        @Parent() productVariant: ProductVariant,
+    ): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, productVariant, 'languageCode');
+    }
+
     @ResolveField()
     async price(@Ctx() ctx: RequestContext, @Parent() productVariant: ProductVariant): Promise<number> {
         return this.productVariantService.hydratePriceFields(ctx, productVariant, 'price');

+ 26 - 0
packages/core/src/api/resolvers/entity/shipping-method-entity.resolver.ts

@@ -0,0 +1,26 @@
+import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
+
+import { ShippingMethod } from '../../../entity/index';
+import { LocaleStringHydrator } from '../../../service/helpers/locale-string-hydrator/locale-string-hydrator';
+import { RequestContext } from '../../common/request-context';
+import { Ctx } from '../../decorators/request-context.decorator';
+
+@Resolver('ShippingMethod')
+export class ShippingMethodEntityResolver {
+    constructor(private localeStringHydrator: LocaleStringHydrator) {}
+
+    @ResolveField()
+    name(@Ctx() ctx: RequestContext, @Parent() shippingMethod: ShippingMethod): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, shippingMethod, 'name');
+    }
+
+    @ResolveField()
+    description(@Ctx() ctx: RequestContext, @Parent() shippingMethod: ShippingMethod): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, shippingMethod, 'description');
+    }
+
+    @ResolveField()
+    languageCode(@Ctx() ctx: RequestContext, @Parent() shippingMethod: ShippingMethod): Promise<string> {
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, shippingMethod, 'languageCode');
+    }
+}

+ 1 - 0
packages/core/src/api/schema/common/shipping-method.type.graphql

@@ -2,6 +2,7 @@ type ShippingMethod implements Node {
     id: ID!
     createdAt: DateTime!
     updatedAt: DateTime!
+    languageCode: LanguageCode!
     code: String!
     name: String!
     description: String!

+ 9 - 3
packages/core/src/service/helpers/locale-string-hydrator/locale-string-hydrator.ts

@@ -1,4 +1,5 @@
 import { Injectable } from '@nestjs/common';
+import { LanguageCode } from '@vendure/common/lib/generated-types';
 
 import { RequestContext } from '../../../api/common/request-context';
 import { RequestContextCacheService } from '../../../cache/request-context-cache.service';
@@ -19,10 +20,10 @@ export class LocaleStringHydrator {
         private requestCache: RequestContextCacheService,
     ) {}
 
-    async hydrateLocaleStringField<T extends VendureEntity & Translatable>(
+    async hydrateLocaleStringField<T extends VendureEntity & Translatable & { languageCode?: LanguageCode }>(
         ctx: RequestContext,
         entity: T,
-        fieldName: TranslatableKeys<T>,
+        fieldName: TranslatableKeys<T> | 'languageCode',
     ): Promise<string> {
         if (entity[fieldName]) {
             // Already hydrated, so return the value
@@ -61,7 +62,12 @@ export class LocaleStringHydrator {
         if (entity.translations.length) {
             const translated = translateDeep(entity, ctx.languageCode);
             for (const localeStringProp of Object.keys(entity.translations[0])) {
-                if (localeStringProp === 'base' || localeStringProp === 'languageCode') {
+                if (
+                    localeStringProp === 'base' ||
+                    localeStringProp === 'id' ||
+                    localeStringProp === 'createdAt' ||
+                    localeStringProp === 'updatedAt'
+                ) {
                     continue;
                 }
                 if (localeStringProp === 'customFields') {

+ 9 - 3
packages/payments-plugin/src/braintree/braintree-common.ts

@@ -12,9 +12,11 @@ export function getGateway(args: PaymentMethodArgsHash, options: BraintreePlugin
 }
 
 /**
- * Returns a subset of the Transaction object of interest to the Administrator.
+ * @description
+ * Returns a subset of the Transaction object of interest to the Administrator, plus some
+ * public data which may be useful to display in the storefront account area.
  */
-export function extractMetadataFromTransaction(transaction: Transaction): { [key: string]: any } {
+export function defaultExtractMetadataFn(transaction: Transaction): { [key: string]: any } {
     const metadata: { [key: string]: any } = {
         status: transaction.status,
         currencyIsoCode: transaction.currencyIsoCode,
@@ -25,13 +27,16 @@ export function extractMetadataFromTransaction(transaction: Transaction): { [key
         processorAuthorizationCode: transaction.processorAuthorizationCode,
         processorResponseText: transaction.processorResponseText,
         paymentMethod: transaction.paymentInstrumentType,
+        public: {},
     };
     if (transaction.creditCard && transaction.creditCard.cardType) {
-        metadata.cardData = {
+        const cardData = {
             cardType: transaction.creditCard.cardType,
             last4: transaction.creditCard.last4,
             expirationDate: transaction.creditCard.expirationDate,
         };
+        metadata.cardData = cardData;
+        metadata.public.cardData = cardData;
     }
     if (transaction.paypalAccount && transaction.paypalAccount.authorizationId) {
         metadata.paypalData = {
@@ -42,6 +47,7 @@ export function extractMetadataFromTransaction(transaction: Transaction): { [key
             sellerProtectionStatus: transaction.paypalAccount.sellerProtectionStatus,
             transactionFeeAmount: transaction.paypalAccount.transactionFeeAmount,
         };
+        metadata.public.paypalData = { authorizationId: transaction.paypalAccount.authorizationId };
     }
     return metadata;
 }

+ 21 - 7
packages/payments-plugin/src/braintree/braintree.handler.ts

@@ -11,7 +11,7 @@ import {
 } from '@vendure/core';
 import { BraintreeGateway } from 'braintree';
 
-import { extractMetadataFromTransaction, getGateway } from './braintree-common';
+import { defaultExtractMetadataFn, getGateway } from './braintree-common';
 import { BRAINTREE_PLUGIN_OPTIONS, loggerCtx } from './constants';
 import { BraintreePluginOptions } from './types';
 
@@ -38,17 +38,27 @@ export const braintreePaymentMethodHandler = new PaymentMethodHandler({
     async createPayment(ctx, order, amount, args, metadata) {
         const gateway = getGateway(args, options);
         let customerId: string | undefined;
+        const { nonce, storeCardInVault } = metadata;
+        if (!nonce) {
+            return {
+                amount,
+                state: 'Error' as const,
+                transactionId: '',
+                errorMessage: `No "nonce" value was specified in the metadata`,
+                metadata,
+            };
+        }
         try {
             await entityHydrator.hydrate(ctx, order, { relations: ['customer'] });
             const customer = order.customer;
             if (options.storeCustomersInBraintree && ctx.activeUserId && customer) {
                 customerId = await getBraintreeCustomerId(ctx, gateway, customer);
             }
-            return processPayment(ctx, gateway, order, amount, metadata.nonce, customerId);
+            return processPayment(ctx, gateway, order, amount, nonce, customerId, options, storeCardInVault);
         } catch (e: any) {
             Logger.error(e, loggerCtx);
             return {
-                amount: order.total,
+                amount,
                 state: 'Error' as const,
                 transactionId: '',
                 errorMessage: e.toString(),
@@ -69,7 +79,7 @@ export const braintreePaymentMethodHandler = new PaymentMethodHandler({
         if (!response.success) {
             return {
                 state: 'Failed' as const,
-                transactionId: response.transaction.id,
+                transactionId: response.transaction?.id,
                 metadata: response,
             };
         }
@@ -88,6 +98,8 @@ async function processPayment(
     amount: number,
     paymentMethodNonce: any,
     customerId: string | undefined,
+    pluginOptions: BraintreePluginOptions,
+    storeCardInVault = true,
 ) {
     const response = await gateway.transaction.sale({
         customerId,
@@ -96,23 +108,25 @@ async function processPayment(
         paymentMethodNonce,
         options: {
             submitForSettlement: true,
-            storeInVaultOnSuccess: !!customerId,
+            storeInVaultOnSuccess: !!customerId && storeCardInVault,
         },
     });
+    const extractMetadataFn = pluginOptions.extractMetadata ?? defaultExtractMetadataFn;
+    const metadata = extractMetadataFn(response.transaction);
     if (!response.success) {
         return {
             amount,
             state: 'Declined' as const,
             transactionId: response.transaction.id,
             errorMessage: response.message,
-            metadata: extractMetadataFromTransaction(response.transaction),
+            metadata,
         };
     }
     return {
         amount,
         state: 'Settled' as const,
         transactionId: response.transaction.id,
-        metadata: extractMetadataFromTransaction(response.transaction),
+        metadata,
     };
 }
 

+ 43 - 1
packages/payments-plugin/src/braintree/braintree.plugin.ts

@@ -62,7 +62,7 @@ import { BraintreePluginOptions } from './types';
  * 2. Use this client token to instantiate the Braintree Dropin UI.
  * 3. Listen for the `"paymentMethodRequestable"` event which emitted by the Dropin.
  * 4. Use the Dropin's `requestPaymentMethod()` method to get the required payment metadata.
- * 5. Pass that metadata to the `addPaymentToOrder` mutation.
+ * 5. Pass that metadata to the `addPaymentToOrder` mutation. The metadata should be an object of type `{ nonce: string; }`
  *
  * Here is an example of how your storefront code will look. Note that this example is attempting to
  * be framework-agnostic, so you'll need to adapt it to fit to your framework of choice.
@@ -186,7 +186,49 @@ import { BraintreePluginOptions } from './types';
  *         dropin.clearSelectedPaymentMethod();
  *   }
  * }
+ *
+ * ## Storing cards in the vault
+ *
+ * Braintree has a "vault" mechanism which allows it to store the credit card information for a Customer, so that on the next purchase
+ * the stored details do not need to be re-entered.
+ *
+ * To enable this feature, you need to ensure that the {@link BraintreePluginOptions} `storeCustomersInBraintree` option is set to
+ * `true`. This will allow Braintree to associate a unique ID to each of your Customers.
+ *
+ * From v1.7.0, you can then specify on a per-payment basis whether a card should be stored in the vault. By default, all cards will
+ * be automatically stored in the vault. But you can opt out of this behavior by specifying the `storeCardInVault` property in the `metadata` object
+ * supplied to the `addPaymentToOrder` mutation:
+ *
+ * @example
+ * ```TypeScript {hl_lines=[21]}
+ * const { addPaymentToOrder } = await graphQlClient.query(gql`
+ *   mutation AddPayment($input: PaymentInput!) {
+ *     addPaymentToOrder(input: $input) {
+ *       ... on Order {
+ *         id
+ *         payments {
+ *           id
+ *           # ... etc
+ *         }
+ *       }
+ *       ... on ErrorResult {
+ *         errorCode
+ *         message
+ *       }
+ *     }
+ *   }`, {
+ *     input: {
+ *       method: 'braintree',
+ *       metadata: {
+ *         nonce: paymentResult.nonce,
+ *         storeCardInVault: false,
+ *       },
+ *     },
+ *   },
+ * );
  * ```
+ *
+ *
  * @docsCategory payments-plugin
  * @docsPage BraintreePlugin
  */

+ 56 - 1
packages/payments-plugin/src/braintree/types.ts

@@ -1,6 +1,7 @@
+import { PaymentMetadata } from '@vendure/core';
 import { ConfigArgValues } from '@vendure/core/dist/common/configurable-operation';
 import '@vendure/core/dist/entity/custom-entity-fields';
-import { Environment } from 'braintree';
+import { Environment, Transaction } from 'braintree';
 
 import { braintreePaymentMethodHandler } from './braintree.handler';
 
@@ -39,4 +40,58 @@ export interface BraintreePluginOptions {
      * @default false
      */
     storeCustomersInBraintree?: boolean;
+    /**
+     * @description
+     * Allows you to configure exactly what information from the Braintree
+     * [Transaction object](https://developer.paypal.com/braintree/docs/reference/response/transaction#result-object] (which is returned by the
+     * `transaction.sale()` method of the SDK) should be persisted to the resulting Payment entity metadata.
+     *
+     * By default, the built-in extraction function will return a metadata object that looks like this:
+     *
+     * @example
+     * ```TypeScript
+     * const metadata = {
+     *   "status": "settling",
+     *   "currencyIsoCode": "GBP",
+     *   "merchantAccountId": "my_account_id",
+     *   "cvvCheck": "Not Applicable",
+     *   "avsPostCodeCheck": "Not Applicable",
+     *   "avsStreetAddressCheck": "Not Applicable",
+     *   "processorAuthorizationCode": null,
+     *   "processorResponseText": "Approved",
+     *   // for Paypal payments
+     *   "paymentMethod": "paypal_account",
+     *   "paypalData": {
+     *     "payerEmail": "michael-buyer@paypalsandbox.com",
+     *     "paymentId": "PAYID-MLCXYNI74301746XK8807043",
+     *     "authorizationId": "3BU93594D85624939",
+     *     "payerStatus": "VERIFIED",
+     *     "sellerProtectionStatus": "ELIGIBLE",
+     *     "transactionFeeAmount": "0.54"
+     *   },
+     *   // for credit card payments
+     *   "paymentMethod": "credit_card",
+     *   "cardData": {
+     *     "cardType": "MasterCard",
+     *     "last4": "5454",
+     *     "expirationDate": "02/2023"
+     *   }
+     *   // publicly-available metadata that will be
+     *   // readable from the Shop API
+     *   "public": {
+     *     "cardData": {
+     *       "cardType": "MasterCard",
+     *       "last4": "5454",
+     *       "expirationDate": "02/2023"
+     *     },
+     *     "paypalData": {
+     *       "authorizationId": "3BU93594D85624939",
+     *     }
+     *   }
+     * }
+     * ```
+     *
+     * @since 1.7.0
+     */
+    extractMetadata?: (transaction: Transaction) => PaymentMetadata;
 }