Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(payments-plugin): Add multi currency support for braintree plugin #3239

Open
wants to merge 2 commits into
base: minor
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/payments-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,26 @@ STRIPE_PUBLISHABLE_KEY=pk_test_xxxx
3. Watch the logs for the link or go to `http://localhost:3050/checkout` to test the checkout.

After checkout completion you can see your payment in https://dashboard.stripe.com/test/payments/

### Braintree local development

For testing out changes to the Braintree plugin locally, with a real Braintree account, follow the steps below. These steps
will create an order, set Braintree as payment method, and create a clientToken, which will be used with a drop-in UI on the test checkout page.

1. Get the test keys from your Braintree dashboard, under menu settings, API section: https://sandbox.braintreegateway.com
2. Create a `.env` file in the `packages/payments-plugin` directory with the following content:
3. Optionally, if you want to add multi currency support, on Braintree dashboard, under menu settings, Business section, get Merchant Account ID keys.

```sh
BRAINTREE_PRIVATE_KEY=asdf...
BRAINTREE_PUBLIC_KEY=ghjk...
BRAINTREE_MERCHANT_ID=12ly...
BRAINTREE_ENVIRONMENT=sandbox
BRAINTREE_MERCHANT_ACCOUNT_ID_EUR=account_for_eur #optional, can be used a different naming scheme as well
BRAINTREE_MERCHANT_ACCOUNT_ID_USD=account_for_usd #optional, can be used a different naming scheme as well
```

1. `cd packages/payments-plugin`
2. `npm run dev-server:braintree`
3. Watch the logs for the link or go to `http://localhost:3050/checkout` to test the checkout.
4. If you have added `BRAINTREE_MERCHANT_ACCOUNT_ID_XXX`, those have to be added to BraintreePlugin options in `e2e/braintree-dev-server.ts`
132 changes: 132 additions & 0 deletions packages/payments-plugin/e2e/braintree-dev-server.ts
Original file line number Diff line number Diff line change
@@ -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<CreatePaymentMethodMutation, CreatePaymentMethodMutationVariables>(
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('[email protected]', 'test');
await shopClient.query<AddItemToOrderMutation, AddItemToOrderMutationVariables>(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');
})();
Original file line number Diff line number Diff line change
@@ -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<void> {
res.send(`
<head>
<title>Checkout</title>
<script src="https://js.braintreegateway.com/web/dropin/1.33.3/js/dropin.min.js"></script>
</head>
<html>

<div id="dropin-container"></div>
<button id="submit-button">Purchase</button>
<div id="result"/>

<script>
var submitButton = document.querySelector('#submit-button');
braintree.dropin.create({
authorization: "${clientToken}",
container: '#dropin-container',
dataCollector: true,
paypal: {
flow: 'checkout',
amount: 100,
currency: 'GBP',
},
}, function (err, dropinInstance) {

submitButton.addEventListener('click', function () {
dropinInstance.requestPaymentMethod(async function (err, payload) {
sendPayloadToServer(payload)
});
});

if (dropinInstance.isPaymentMethodRequestable()) {
// This will be true if you generated the client token
// with a customer ID and there is a saved payment method
// available to tokenize with that customer.
submitButton.removeAttribute('disabled');
}

dropinInstance.on('paymentMethodRequestable', function (event) {
console.log(event.type); // The type of Payment Method, e.g 'CreditCard', 'PayPalAccount'.
console.log(event.paymentMethodIsSelected); // true if a customer has selected a payment method when paymentMethodRequestable fires
submitButton.removeAttribute('disabled');
});

dropinInstance.on('noPaymentMethodRequestable', function () {
submitButton.setAttribute('disabled', true);
});
});

async function sendPayloadToServer(payload) {
const response = await fetch('checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Credentials': 'include',
},
body: JSON.stringify(payload)
})
.then(res => res.json())
.catch(err => console.error(err))

document.querySelector('#result').innerHTML = JSON.stringify(response)
console.log(response)

}
</script>

</html>
`);
}
@Post('checkout')
async test(@Body() body: Request, @Res() res: Response): Promise<void> {
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 {}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,8 +18,8 @@ export class StripeTestCheckoutController {
<title>Checkout</title>
<script src="https://js.stripe.com/v3/"></script>
</head>
<html>

<html>
<form id="payment-form">
<div id="payment-element">
<!-- Elements will create form elements here -->
Expand Down
6 changes: 6 additions & 0 deletions packages/payments-plugin/e2e/payment-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion packages/payments-plugin/e2e/stripe-dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
5 changes: 3 additions & 2 deletions packages/payments-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"lint": "eslint --fix .",
"ci": "npm run build",
"dev-server:mollie": "npm run build && DB=sqlite node -r ts-node/register e2e/mollie-dev-server.ts",
"dev-server:stripe": "npm run build && DB=sqlite node -r ts-node/register e2e/stripe-dev-server.ts"
"dev-server:stripe": "npm run build && DB=sqlite node -r ts-node/register e2e/stripe-dev-server.ts",
"dev-server:braintree": "npm run build && DB=sqlite node -r ts-node/register e2e/braintree-dev-server.ts"
},
"homepage": "https://www.vendure.io/",
"funding": "https://github.com/sponsors/michaelbromley",
Expand Down Expand Up @@ -56,4 +57,4 @@
"stripe": "^13.3.0",
"typescript": "5.1.6"
}
}
}
21 changes: 20 additions & 1 deletion packages/payments-plugin/src/braintree/braintree-common.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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;
}
8 changes: 7 additions & 1 deletion packages/payments-plugin/src/braintree/braintree.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading