From c859cd1ba38b72c92633d0b0c2bc2d943ae052e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kerti?= <47832952+kkerti@users.noreply.github.com> Date: Tue, 26 Nov 2024 23:23:07 +0100 Subject: [PATCH] feat(payments-plugin): Add multi-currency support to braintree payments --- .../e2e/braintree-dev-server.ts | 132 ++++++++++++++++++ .../braintree-checkout-test.plugin.ts | 116 +++++++++++++++ .../fixtures/stripe-checkout-test.plugin.ts | 4 +- .../payments-plugin/e2e/payment-helpers.ts | 6 + .../payments-plugin/e2e/stripe-dev-server.ts | 2 +- .../src/braintree/braintree-common.ts | 21 ++- .../src/braintree/braintree.handler.ts | 8 +- .../src/braintree/braintree.plugin.ts | 1 + .../src/braintree/braintree.resolver.ts | 17 ++- .../payments-plugin/src/braintree/types.ts | 4 +- 10 files changed, 302 insertions(+), 9 deletions(-) create mode 100644 packages/payments-plugin/e2e/braintree-dev-server.ts create mode 100644 packages/payments-plugin/e2e/fixtures/braintree-checkout-test.plugin.ts diff --git a/packages/payments-plugin/e2e/braintree-dev-server.ts b/packages/payments-plugin/e2e/braintree-dev-server.ts new file mode 100644 index 0000000000..a42b881c69 --- /dev/null +++ b/packages/payments-plugin/e2e/braintree-dev-server.ts @@ -0,0 +1,132 @@ +import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; +import { + ChannelService, + configureDefaultOrderProcess, + DefaultLogger, + LanguageCode, + Logger, + LogLevel, + mergeConfig, + OrderService, + RequestContext, +} from '@vendure/core'; +import { + createTestEnvironment, + registerInitializer, + SimpleGraphQLClient, + SqljsInitializer, + testConfig, +} from '@vendure/testing'; +import gql from 'graphql-tag'; +import path from 'path'; + +import { initialData } from '../../../e2e-common/e2e-initial-data'; +import { BraintreePlugin } from '../src/braintree'; +import { braintreePaymentMethodHandler } from '../src/braintree/braintree.handler'; + +/* eslint-disable */ +import { CREATE_PAYMENT_METHOD } from './graphql/admin-queries'; +import { + CreatePaymentMethodMutation, + CreatePaymentMethodMutationVariables, +} from './graphql/generated-admin-types'; +import { + AddItemToOrderMutation, + AddItemToOrderMutationVariables, + AddPaymentToOrderMutation, + AddPaymentToOrderMutationVariables, +} from './graphql/generated-shop-types'; +import { ADD_ITEM_TO_ORDER, ADD_PAYMENT } from './graphql/shop-queries'; +import { GENERATE_BRAINTREE_CLIENT_TOKEN, proceedToArrangingPayment, setShipping } from './payment-helpers'; +import braintree, { Environment, Test } from 'braintree'; +import { BraintreeTestPlugin } from './fixtures/braintree-checkout-test.plugin'; + +export let clientToken: string; +export let exposedShopClient: SimpleGraphQLClient; + +/** + * The actual starting of the dev server + */ +(async () => { + require('dotenv').config(); + + registerInitializer('sqljs', new SqljsInitializer(path.join(__dirname, '__data__'))); + const config = mergeConfig(testConfig, { + authOptions: { + tokenMethod: ['bearer', 'cookie'], + cookieOptions: { + secret: 'cookie-secret', + }, + }, + plugins: [ + ...testConfig.plugins, + AdminUiPlugin.init({ + route: 'admin', + port: 5001, + }), + BraintreePlugin.init({ + storeCustomersInBraintree: false, + environment: Environment.Sandbox, + merchantAccountIds: { + USD: process.env.BRAINTREE_MERCHANT_ACCOUNT_ID_USD, + EUR: process.env.BRAINTREE_MERCHANT_ACCOUNT_ID_EUR, + }, + }), + BraintreeTestPlugin, + ], + logger: new DefaultLogger({ level: LogLevel.Debug }), + }); + const { server, shopClient, adminClient } = createTestEnvironment(config as any); + exposedShopClient = shopClient; + await server.init({ + initialData, + productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'), + customerCount: 1, + }); + // Create method + await adminClient.asSuperAdmin(); + await adminClient.query( + CREATE_PAYMENT_METHOD, + { + input: { + code: 'braintree-payment-method', + enabled: true, + translations: [ + { + name: 'Braintree', + description: 'This is a Braintree test payment method', + languageCode: LanguageCode.en, + }, + ], + handler: { + code: braintreePaymentMethodHandler.code, + arguments: [ + { name: 'privateKey', value: process.env.BRAINTREE_PRIVATE_KEY! }, + { name: 'publicKey', value: process.env.BRAINTREE_PUBLIC_KEY! }, + { name: 'merchantId', value: process.env.BRAINTREE_MERCHANT_ID! }, + ], + }, + }, + }, + ); + // Prepare order for payment + await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test'); + await shopClient.query(ADD_ITEM_TO_ORDER, { + productVariantId: 'T_1', + quantity: 1, + }); + const ctx = new RequestContext({ + apiType: 'admin', + isAuthorized: true, + authorizedAsOwnerOnly: false, + channel: await server.app.get(ChannelService).getDefaultChannel(), + }); + await server.app.get(OrderService).addSurchargeToOrder(ctx, 1, { + description: 'Negative test surcharge', + listPrice: -20000, + }); + await setShipping(shopClient); + const { generateBraintreeClientToken } = await shopClient.query(GENERATE_BRAINTREE_CLIENT_TOKEN); + clientToken = generateBraintreeClientToken; + Logger.info('http://localhost:3050/checkout', 'Braintree DevServer'); +})(); diff --git a/packages/payments-plugin/e2e/fixtures/braintree-checkout-test.plugin.ts b/packages/payments-plugin/e2e/fixtures/braintree-checkout-test.plugin.ts new file mode 100644 index 0000000000..9996e9ac7d --- /dev/null +++ b/packages/payments-plugin/e2e/fixtures/braintree-checkout-test.plugin.ts @@ -0,0 +1,116 @@ +/* eslint-disable */ +import { Controller, Res, Get, Post, Body } from '@nestjs/common'; +import { PluginCommonModule, VendurePlugin } from '@vendure/core'; +import { Response } from 'express'; + +import { clientToken, exposedShopClient } from '../braintree-dev-server'; +import { proceedToArrangingPayment } from '../payment-helpers'; +import { + AddPaymentToOrderMutation, + AddPaymentToOrderMutationVariables, +} from '../graphql/generated-shop-types'; +import { ADD_PAYMENT } from '../graphql/shop-queries'; +/** + * This test controller returns the Braintree drop-in checkout page + * with the client secret generated by the dev-server + */ +@Controller() +export class BraintreeTestCheckoutController { + @Get('checkout') + async client(@Res() res: Response): Promise { + res.send(` + + Checkout + + + + +
+ +
+ + + + + `); + } + @Post('checkout') + async test(@Body() body: Request, @Res() res: Response): Promise { + await proceedToArrangingPayment(exposedShopClient); + const { addPaymentToOrder } = await exposedShopClient.query< + AddPaymentToOrderMutation, + AddPaymentToOrderMutationVariables + >(ADD_PAYMENT, { + input: { + method: 'braintree-payment-method', + metadata: body, + }, + }); + console.log(addPaymentToOrder); + + res.send(addPaymentToOrder); + } +} + +/** + * Test plugin for serving the Stripe intent checkout page + */ +@VendurePlugin({ + imports: [PluginCommonModule], + controllers: [BraintreeTestCheckoutController], +}) +export class BraintreeTestPlugin {} diff --git a/packages/payments-plugin/e2e/fixtures/stripe-checkout-test.plugin.ts b/packages/payments-plugin/e2e/fixtures/stripe-checkout-test.plugin.ts index ffbfccf4ac..2570f38bd2 100644 --- a/packages/payments-plugin/e2e/fixtures/stripe-checkout-test.plugin.ts +++ b/packages/payments-plugin/e2e/fixtures/stripe-checkout-test.plugin.ts @@ -3,7 +3,7 @@ import { Controller, Res, Get } from '@nestjs/common'; import { PluginCommonModule, VendurePlugin } from '@vendure/core'; import { Response } from 'express'; -import { clientSecret } from './stripe-dev-server'; +import { clientSecret } from '../stripe-dev-server'; /** * This test controller returns the Stripe intent checkout page @@ -18,8 +18,8 @@ export class StripeTestCheckoutController { Checkout - +
diff --git a/packages/payments-plugin/e2e/payment-helpers.ts b/packages/payments-plugin/e2e/payment-helpers.ts index 55687b71b2..14b3692e35 100644 --- a/packages/payments-plugin/e2e/payment-helpers.ts +++ b/packages/payments-plugin/e2e/payment-helpers.ts @@ -229,6 +229,12 @@ export const CREATE_STRIPE_PAYMENT_INTENT = gql` } `; +export const GENERATE_BRAINTREE_CLIENT_TOKEN = gql` + query generateBraintreeClientToken { + generateBraintreeClientToken + } +`; + export const GET_MOLLIE_PAYMENT_METHODS = gql` query molliePaymentMethods($input: MolliePaymentMethodsInput!) { molliePaymentMethods(input: $input) { diff --git a/packages/payments-plugin/e2e/stripe-dev-server.ts b/packages/payments-plugin/e2e/stripe-dev-server.ts index 035b28e7fe..7900c2d6b3 100644 --- a/packages/payments-plugin/e2e/stripe-dev-server.ts +++ b/packages/payments-plugin/e2e/stripe-dev-server.ts @@ -26,7 +26,7 @@ import { import { AddItemToOrderMutation, AddItemToOrderMutationVariables } from './graphql/generated-shop-types'; import { ADD_ITEM_TO_ORDER } from './graphql/shop-queries'; import { CREATE_STRIPE_PAYMENT_INTENT, setShipping } from './payment-helpers'; -import { StripeCheckoutTestPlugin } from './stripe-checkout-test.plugin'; +import { StripeCheckoutTestPlugin } from './fixtures/stripe-checkout-test.plugin'; export let clientSecret: string; diff --git a/packages/payments-plugin/src/braintree/braintree-common.ts b/packages/payments-plugin/src/braintree/braintree-common.ts index 151b9ad3e9..0242ef6018 100644 --- a/packages/payments-plugin/src/braintree/braintree-common.ts +++ b/packages/payments-plugin/src/braintree/braintree-common.ts @@ -1,6 +1,7 @@ +import { CurrencyCode } from '@vendure/core'; import { BraintreeGateway, Environment, Transaction } from 'braintree'; -import { BraintreePluginOptions, PaymentMethodArgsHash } from './types'; +import { BraintreeMerchantAccountIds, BraintreePluginOptions, PaymentMethodArgsHash } from './types'; export function getGateway(args: PaymentMethodArgsHash, options: BraintreePluginOptions): BraintreeGateway { return new BraintreeGateway({ @@ -74,3 +75,21 @@ function decodeAvsCode(code: string): string { return 'Unknown'; } } + +/** + * @description + * Looks up a single mmerchantAccountId from `merchantAccountIds` passed through the plugin options. + * Example: `{NOK: BRAINTREE_MERCHANT_ACCOUNT_ID_NOK}` for Norway. + * Merchant Account IDs have to be setup in the Braintree dashboard, + * see: https://developer.paypal.com/braintree/articles/control-panel/important-gateway-credentials#merchant-account-id + */ +export function lookupMerchantAccountIdByCurrency( + merchantAccountIds: BraintreeMerchantAccountIds | undefined, + currencyCode: CurrencyCode, +): string | undefined { + if (!merchantAccountIds || !currencyCode) { + return undefined; + } + const merchantAccountIdForCurrency = merchantAccountIds[currencyCode]; + return merchantAccountIdForCurrency; +} diff --git a/packages/payments-plugin/src/braintree/braintree.handler.ts b/packages/payments-plugin/src/braintree/braintree.handler.ts index 116a2d340c..a86554cca4 100644 --- a/packages/payments-plugin/src/braintree/braintree.handler.ts +++ b/packages/payments-plugin/src/braintree/braintree.handler.ts @@ -11,7 +11,7 @@ import { } from '@vendure/core'; import { BraintreeGateway } from 'braintree'; -import { defaultExtractMetadataFn, getGateway } from './braintree-common'; +import { defaultExtractMetadataFn, getGateway, lookupMerchantAccountIdByCurrency } from './braintree-common'; import { BRAINTREE_PLUGIN_OPTIONS, loggerCtx } from './constants'; import { BraintreePluginOptions } from './types'; @@ -95,11 +95,17 @@ async function processPayment( customerId: string | undefined, pluginOptions: BraintreePluginOptions, ) { + const merchantAccountId = lookupMerchantAccountIdByCurrency( + options.merchantAccountIds, + order.currencyCode, + ); + const response = await gateway.transaction.sale({ customerId, amount: (amount / 100).toString(10), orderId: order.code, paymentMethodNonce, + merchantAccountId, options: { submitForSettlement: true, storeInVaultOnSuccess: !!customerId, diff --git a/packages/payments-plugin/src/braintree/braintree.plugin.ts b/packages/payments-plugin/src/braintree/braintree.plugin.ts index 374312b056..8990145b9f 100644 --- a/packages/payments-plugin/src/braintree/braintree.plugin.ts +++ b/packages/payments-plugin/src/braintree/braintree.plugin.ts @@ -238,6 +238,7 @@ import { BraintreePluginOptions } from './types'; * @docsCategory core plugins/PaymentsPlugin * @docsPage BraintreePlugin */ + @VendurePlugin({ imports: [PluginCommonModule], providers: [ diff --git a/packages/payments-plugin/src/braintree/braintree.resolver.ts b/packages/payments-plugin/src/braintree/braintree.resolver.ts index 7e976b00fe..a2785326a7 100644 --- a/packages/payments-plugin/src/braintree/braintree.resolver.ts +++ b/packages/payments-plugin/src/braintree/braintree.resolver.ts @@ -13,7 +13,7 @@ import { TransactionalConnection, } from '@vendure/core'; -import { getGateway } from './braintree-common'; +import { getGateway, lookupMerchantAccountIdByCurrency } from './braintree-common'; import { braintreePaymentMethodHandler } from './braintree.handler'; import { BRAINTREE_PLUGIN_OPTIONS, loggerCtx } from './constants'; import { BraintreePluginOptions, PaymentMethodArgsHash } from './types'; @@ -45,13 +45,18 @@ export class BraintreeResolver { } const order = await this.orderService.findOne(ctx, sessionOrder.id); if (order) { - const customerId = order.customer?.customFields.braintreeCustomerId ?? undefined; + const customerId = order.customer?.customFields?.braintreeCustomerId ?? undefined; const args = await this.getPaymentMethodArgs(ctx); const gateway = getGateway(args, this.options); try { let result = await gateway.clientToken.generate({ customerId: includeCustomerId === false ? undefined : customerId, + merchantAccountId: lookupMerchantAccountIdByCurrency( + this.options.merchantAccountIds, + order.currencyCode, + ), }); + if (result.success === true) { return result.clientToken; } else { @@ -65,7 +70,13 @@ export class BraintreeResolver { await this.connection.getRepository(ctx, Customer).save(order.customer); } } - result = await gateway.clientToken.generate({ customerId: undefined }); + result = await gateway.clientToken.generate({ + customerId: undefined, + merchantAccountId: lookupMerchantAccountIdByCurrency( + this.options.merchantAccountIds, + order.currencyCode, + ), + }); if (result.success === true) { return result.clientToken; } diff --git a/packages/payments-plugin/src/braintree/types.ts b/packages/payments-plugin/src/braintree/types.ts index 7976643b2c..688c735c95 100644 --- a/packages/payments-plugin/src/braintree/types.ts +++ b/packages/payments-plugin/src/braintree/types.ts @@ -1,4 +1,4 @@ -import { PaymentMetadata } from '@vendure/core'; +import { CurrencyCode, PaymentMetadata } from '@vendure/core'; import { ConfigArgValues } from '@vendure/core/dist/common/configurable-operation'; import '@vendure/core/dist/entity/custom-entity-fields'; import { Environment, Transaction } from 'braintree'; @@ -6,6 +6,7 @@ import { Environment, Transaction } from 'braintree'; import { braintreePaymentMethodHandler } from './braintree.handler'; export type PaymentMethodArgsHash = ConfigArgValues<(typeof braintreePaymentMethodHandler)['args']>; +export type BraintreeMerchantAccountIds = Partial>; // Note: deep import is necessary here because CustomCustomerFields is also extended in the Stripe // plugin. Reference: https://github.com/microsoft/TypeScript/issues/46617 @@ -96,5 +97,6 @@ export interface BraintreePluginOptions { * * @since 1.7.0 */ + merchantAccountIds?: BraintreeMerchantAccountIds; extractMetadata?: (transaction: Transaction) => PaymentMetadata; }