Skip to content
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! },
],
Comment on lines +104 to +107
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add validation for required environment variables.

The required Braintree credentials are used without validation. Consider adding checks to ensure they're properly configured.

                    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! },
+                        { name: 'privateKey', value: process.env.BRAINTREE_PRIVATE_KEY || (() => { throw new Error('BRAINTREE_PRIVATE_KEY is required'); })() },
+                        { name: 'publicKey', value: process.env.BRAINTREE_PUBLIC_KEY || (() => { throw new Error('BRAINTREE_PUBLIC_KEY is required'); })() },
+                        { name: 'merchantId', value: process.env.BRAINTREE_MERCHANT_ID || (() => { throw new Error('BRAINTREE_MERCHANT_ID is required'); })() },
                    ],
🤖 Prompt for AI Agents
In packages/payments-plugin/e2e/braintree-dev-server.ts around lines 104 to 107,
the Braintree environment variables are used without validation. Add checks
before this code to verify that process.env.BRAINTREE_PRIVATE_KEY,
process.env.BRAINTREE_PUBLIC_KEY, and process.env.BRAINTREE_MERCHANT_ID are
defined and not empty. If any are missing, throw an error or log a clear message
to prevent proceeding with invalid configuration.

},
},
},
);
// 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,114 @@
/* eslint-disable */
import { Body, Controller, Get, Post, Res } from '@nestjs/common';
import { PluginCommonModule, VendurePlugin } from '@vendure/core';
import { Response } from 'express';

import { clientToken, exposedShopClient } from '../braintree-dev-server';
import {
AddPaymentToOrderMutation,
AddPaymentToOrderMutationVariables,
} from '../graphql/generated-shop-types';
import { ADD_PAYMENT } from '../graphql/shop-queries';
import { proceedToArrangingPayment } from '../payment-helpers';
/**
* 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,
},
});
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 @@ -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 CREATE_CUSTOM_STRIPE_PAYMENT_INTENT = gql`
mutation createCustomStripePaymentIntent {
createCustomStripePaymentIntent
Expand Down
1 change: 1 addition & 0 deletions packages/payments-plugin/e2e/stripe-dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from './graphql/generated-admin-types';
import { AddItemToOrderMutation, AddItemToOrderMutationVariables } from './graphql/generated-shop-types';
import { ADD_ITEM_TO_ORDER } from './graphql/shop-queries';
import { StripeCheckoutTestPlugin } from './fixtures/stripe-checkout-test.plugin';
import {
CREATE_CUSTOM_STRIPE_PAYMENT_INTENT,
CREATE_STRIPE_PAYMENT_INTENT,
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 @@ -20,7 +20,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 @@ -60,4 +61,4 @@
"stripe": "^13.3.0",
"typescript": "5.8.2"
}
}
}
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 merchantAccountId 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
1 change: 1 addition & 0 deletions packages/payments-plugin/src/braintree/braintree.plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ import { BraintreePluginOptions } from './types';
* @docsCategory core plugins/PaymentsPlugin
* @docsPage BraintreePlugin
*/

@VendurePlugin({
imports: [PluginCommonModule],
providers: [
Expand Down
Loading
Loading