Skip to content
Merged
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
2 changes: 2 additions & 0 deletions plugins/braintree-payment/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ dependencies:[Modules.CACHE]
id: 'braintree',
options: {
environment: process.env.BRAINTREE_ENVIRONMENT || (process.env.NODE_ENV !== 'production' ? 'sandbox' : 'production'),
defaultCurrencyCode: "USD",
merchantId: process.env.BRAINTREE_MERCHANT_ID,
publicKey: process.env.BRAINTREE_PUBLIC_KEY,
privateKey: process.env.BRAINTREE_PRIVATE_KEY,
Expand All @@ -75,6 +76,7 @@ dependencies:[Modules.CACHE]
#### Options

- **merchantId**: Your Braintree Merchant ID.
- **defaultCurrencyCode**: An optional field to indicate default currency code
- **publicKey**: Your Braintree Public Key.
- **privateKey**: Your Braintree Private Key.
- **webhookSecret**: Secret for validating Braintree webhooks.
Expand Down
2 changes: 1 addition & 1 deletion plugins/braintree-payment/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lambdacurry/medusa-payment-braintree",
"version": "0.0.15",
"version": "0.0.16",
"description": "Braintree plugin for Medusa",
"author": "Lambda Curry (https://lambdacurry.dev)",
"license": "MIT",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Add the following configuration to the `payment` section of your `medusa-config.
id: 'braintree',
options: {
environment: process.env.NODE_ENV !== 'production' ? 'sandbox' : 'production',
defaultCurrencyCode: "USD",
merchantId: process.env.BRAINTREE_MERCHANT_ID,
publicKey: process.env.BRAINTREE_PUBLIC_KEY,
privateKey: process.env.BRAINTREE_PRIVATE_KEY,
Expand All @@ -56,6 +57,7 @@ Add the following configuration to the `payment` section of your `medusa-config.
- **enable3DSecure**: Enable 3D Secure authentication (`true` or `false`).
- **savePaymentMethod**: Save payment methods for future use (default: `true`).
- **autoCapture**: Automatically capture payments (default: `true`).
- **defaultCurrencyCode**: The default currency to use. This is optional
- **customFields**: Array of Braintree custom field API names permitted to be forwarded from `data.custom_fields`. If empty or omitted, no user-provided custom fields are sent.

## Features
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
import BraintreeProviderService from '../../services/braintree-provider';
import { BraintreeConstructorArgs, BraintreePaymentSessionData } from '../braintree-base';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import BraintreeImportService from '../../services/braintree-import';
import { BraintreeConstructorArgs } from '../braintree-base';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@ import {
MathBN,
MedusaError,
Modules,
Order,
PaymentActions,
PaymentSessionStatus,
isDefined,
} from '@medusajs/framework/utils';
import { formatToTwoDecimalString } from '../../../../utils/format-amount';
import type {
AddressDTO,
AuthorizePaymentInput,
Expand All @@ -18,7 +16,6 @@ import type {
CancelPaymentOutput,
CapturePaymentInput,
CapturePaymentOutput,
CartDTO,
CartLineItemDTO,
CreateAccountHolderInput,
CreateAccountHolderOutput,
Expand All @@ -29,12 +26,9 @@ import type {
GetPaymentStatusInput,
GetPaymentStatusOutput,
ICacheService,
ICartModuleService,
InitiatePaymentInput,
InitiatePaymentOutput,
Logger,
MedusaContainer,
OrderDTO,
PaymentAccountHolderDTO,
PaymentCustomerDTO,
PaymentProviderContext,
Expand All @@ -54,6 +48,7 @@ import type {
import type { Transaction, TransactionNotification, TransactionStatus } from 'braintree';
import Braintree from 'braintree';
import { z } from 'zod';
import { formatToTwoDecimalString } from '../../../../utils/format-amount';
import type { BraintreeOptions, CustomFields } from '../types';

export type BraintreeConstructorArgs = Record<string, unknown> & {
Expand All @@ -62,6 +57,7 @@ export type BraintreeConstructorArgs = Record<string, unknown> & {
};

export interface ExtendedPaymentProviderContext extends PaymentProviderContext {
order_currency_code?: string;
custom_fields?: Record<string, string>;
order_id?: string;
shipping_address?: AddressDTO;
Expand Down Expand Up @@ -109,12 +105,17 @@ const validateOptionalString = (value: unknown, fieldName: string): string | und
};

// Error handling utility that preserves full error context
export const buildBraintreeError = (error: unknown, operation: string, logger: Logger, context?: Record<string, unknown>): MedusaError => {
export const buildBraintreeError = (
error: unknown,
operation: string,
logger: Logger,
context?: Record<string, unknown>,
): MedusaError => {
const errorMessage = error instanceof Error ? error.message : String(error);

// Preserve full error context in logging
logger.error(`Braintree ${operation} failed: ${errorMessage}`, error instanceof Error ? error : undefined);

return new MedusaError(MedusaError.Types.INVALID_DATA, `Failed to ${operation}: ${errorMessage}`);
};

Expand All @@ -124,15 +125,13 @@ class BraintreeBase extends AbstractPaymentProvider<BraintreeOptions> {
protected gateway: Braintree.BraintreeGateway;
logger: Logger;
cache: ICacheService;
cartService: ICartModuleService;

protected constructor(container: BraintreeConstructorArgs, options: BraintreeOptions) {
super(container, options);

this.options_ = options;
this.logger = container[ContainerRegistrationKeys.LOGGER];
this.cache = container[Modules.CACHE];
this.cartService = container[Modules.CART] as ICartModuleService;
this.init();
}

Expand Down Expand Up @@ -216,7 +215,6 @@ class BraintreeBase extends AbstractPaymentProvider<BraintreeOptions> {
}

private formatToTwoDecimalStringIfFinite(amount: unknown): string | undefined {

const n = Number(amount);
if (!Number.isFinite(n)) return undefined;
return formatToTwoDecimalString(n);
Expand Down Expand Up @@ -442,14 +440,7 @@ class BraintreeBase extends AbstractPaymentProvider<BraintreeOptions> {
countryCodeAlpha2: this.sanitizeCountryCodeAlpha2(context.shipping_address?.country_code),
}
: undefined;
let cart: CartDTO | undefined;
if(context.items?.[0]?.cart_id) {
cart = await this.cartService.retrieveCart(context.items?.[0]?.cart_id as string);
if (!cart) {
throw new MedusaError(MedusaError.Types.NOT_FOUND, 'Cart not found');
}
}


const lineItems = context.items
?.slice(0, 249)
.map((item) => {
Expand All @@ -459,15 +450,15 @@ class BraintreeBase extends AbstractPaymentProvider<BraintreeOptions> {
const discount = Number(item.discount_total);
const discountAmount =
Number.isFinite(discount) && discount > 0 ? formatToTwoDecimalString(discount) : undefined;

const currencyCode = context.order_currency_code ?? this.options_.defaultCurrencyCode ?? 'USD';
const li: Braintree.TransactionLineItem = {
kind: 'debit',
name: name.substring(0, 127), // Max 127 characters
productCode: ((item.metadata?.productCode as string) ?? item.product_id).substring(0, 12), // Max 12 characters
commodityCode: ((item.metadata?.commodityCode as string) ?? "placeholder").substring(0, 12), // Max 12 characters
description: (item.product_description ?? "Healthcare").substring(0, 127), // Max 127 characters
commodityCode: ((item.metadata?.commodityCode as string) ?? 'placeholder').substring(0, 12), // Max 12 characters
description: (item.product_description ?? 'Healthcare').substring(0, 127), // Max 127 characters
url: `/${item.product_handle}`.substring(0, 255), // Max 255 characters
unitOfMeasure: ((cart?.currency_code as string)?.toUpperCase() ?? 'USD').substring(0, 12), // Max 12 characters
unitOfMeasure: (currencyCode as string)?.toUpperCase().substring(0, 12), // Max 12 characters
taxAmount: this.formatToTwoDecimalStringIfFinite(Number(item.tax_total)), // Must be decimal string
discountAmount: this.formatToTwoDecimalStringIfFinite(Number(item.discount_total)), // Must be decimal string
quantity: Math.max(1, Math.floor(Number(item.quantity) ?? 1)).toString(), // Must be positive integer
Expand Down Expand Up @@ -552,14 +543,16 @@ class BraintreeBase extends AbstractPaymentProvider<BraintreeOptions> {
const schema = z.object({
paymentMethodNonce: z.string().optional(),
payment_method_nonce: z.string().optional(),
cardDetails:z.object({
cardType: z.string().optional(),
lastFour: z.string().optional(),
lastTwo: z.string().optional(),
expirationMonth: z.string().optional(),
expirationYear: z.string().optional(),
cardholderName: z.string().optional(),
}).optional(),
cardDetails: z
.object({
cardType: z.string().optional(),
lastFour: z.string().optional(),
lastTwo: z.string().optional(),
expirationMonth: z.string().optional(),
expirationYear: z.string().optional(),
cardholderName: z.string().optional(),
})
.optional(),
});

const result = schema.safeParse(data);
Expand Down Expand Up @@ -636,7 +629,9 @@ class BraintreeBase extends AbstractPaymentProvider<BraintreeOptions> {
if (saleResponse.transaction?.id) {
await this.gateway.transaction.void(saleResponse.transaction.id);
}
throw buildBraintreeError(error, 'sync payment session', this.logger, { transactionId: saleResponse.transaction?.id });
throw buildBraintreeError(error, 'sync payment session', this.logger, {
transactionId: saleResponse.transaction?.id,
});
}
} catch (error) {
throw buildBraintreeError(error, 'create Braintree transaction', this.logger);
Expand All @@ -660,7 +655,6 @@ class BraintreeBase extends AbstractPaymentProvider<BraintreeOptions> {
} catch (e) {
throw buildBraintreeError(e, 'delete Braintree payment', this.logger);
}

} else {
return {
data: {
Expand Down Expand Up @@ -734,12 +728,12 @@ class BraintreeBase extends AbstractPaymentProvider<BraintreeOptions> {

let shouldVoid = ['submitted_for_settlement', 'authorized'].includes(transaction.status);

if(process.env.TEST_FORCE_SETTLED === 'true') {
if (process.env.TEST_FORCE_SETTLED === 'true') {
shouldVoid = false;
await this.gateway.testing.settle(transaction.id);
transaction = await this.retrieveTransaction(transaction.id);
}

if (shouldVoid) {
const voidResponse = await this.gateway.transaction.void(transaction.id);

Expand Down Expand Up @@ -785,8 +779,6 @@ class BraintreeBase extends AbstractPaymentProvider<BraintreeOptions> {
`Failed to create Braintree refund: ${refundResponse.message}`,
);



const refundResult: RefundPaymentOutput = {
data: {
...input.data,
Expand Down Expand Up @@ -896,7 +888,6 @@ class BraintreeBase extends AbstractPaymentProvider<BraintreeOptions> {
} catch (e) {
throw buildBraintreeError(e, 'delete account holder', this.logger);
}

}

async getWebhookActionAndData(webhookData: ProviderWebhookPayload['payload']): Promise<WebhookActionResult> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,39 +6,39 @@ import {
PaymentActions,
PaymentSessionStatus,
} from '@medusajs/framework/utils';
import { formatToTwoDecimalString } from '../../../../utils/format-amount';
import { buildBraintreeError } from './braintree-base';
import { z } from 'zod';
import { BraintreeOptions, PaymentProviderKeys } from '../types';
import type { BraintreeConstructorArgs } from './braintree-base';
import Braintree from 'braintree';
import {
CapturePaymentInput,
CapturePaymentOutput,
AuthorizePaymentInput,
AuthorizePaymentOutput,
CancelPaymentInput,
CancelPaymentOutput,
InitiatePaymentInput,
InitiatePaymentOutput,
CapturePaymentInput,
CapturePaymentOutput,
CreateAccountHolderInput,
CreateAccountHolderOutput,
DeleteAccountHolderInput,
DeleteAccountHolderOutput,
DeletePaymentInput,
DeletePaymentOutput,
GetPaymentStatusInput,
GetPaymentStatusOutput,
InitiatePaymentInput,
InitiatePaymentOutput,
Logger,
ProviderWebhookPayload,
RefundPaymentInput,
RefundPaymentOutput,
RetrievePaymentInput,
RetrievePaymentOutput,
UpdatePaymentInput,
UpdatePaymentOutput,
ProviderWebhookPayload,
WebhookActionResult,
DeleteAccountHolderOutput,
CreateAccountHolderInput,
CreateAccountHolderOutput,
DeleteAccountHolderInput,
Logger,
} from '@medusajs/types';
import Braintree from 'braintree';
import { z } from 'zod';
import { formatToTwoDecimalString } from '../../../../utils/format-amount';
import { BraintreeOptions, PaymentProviderKeys } from '../types';
import { buildBraintreeError } from './braintree-base';
import type { BraintreeConstructorArgs } from './braintree-base';

export interface BraintreeImportInitiatePaymentData {
transactionId?: string;
Expand Down Expand Up @@ -106,7 +106,6 @@ class BraintreeImport extends AbstractPaymentProvider<BraintreeOptions> {
return result.data as BraintreeImportPaymentSessionData;
}


private truncate(value: unknown, max: number): string | undefined {
if (value === null || value === undefined) return undefined;
const str = String(value);
Expand Down Expand Up @@ -200,10 +199,7 @@ class BraintreeImport extends AbstractPaymentProvider<BraintreeOptions> {

// Explicit guard to verify transaction and transaction.id exist
if (!transaction || !transaction.id) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Braintree transaction not found: ${transactionId}`
);
throw new MedusaError(MedusaError.Types.NOT_FOUND, `Braintree transaction not found: ${transactionId}`);
}

const shouldVoid = ['submitted_for_settlement', 'authorized'].includes(transaction.status);
Expand All @@ -212,12 +208,9 @@ class BraintreeImport extends AbstractPaymentProvider<BraintreeOptions> {
const cancelResponse = await this.gateway.transaction.void(transaction.id);

if (!cancelResponse.success) {
throw buildBraintreeError(
new Error(cancelResponse.message),
'void Braintree transaction',
this.logger,
{ transactionId: transaction.id }
);
throw buildBraintreeError(new Error(cancelResponse.message), 'void Braintree transaction', this.logger, {
transactionId: transaction.id,
});
}

return {
Expand All @@ -243,12 +236,10 @@ class BraintreeImport extends AbstractPaymentProvider<BraintreeOptions> {
const refundResponse = await this.gateway.transaction.refund(transaction.id, refundAmountDecimal);

if (!refundResponse.success) {
throw buildBraintreeError(
new Error(refundResponse.message),
'create Braintree refund',
this.logger,
{ transactionId: transaction.id, refundAmount: refundAmountDecimal }
);
throw buildBraintreeError(new Error(refundResponse.message), 'create Braintree refund', this.logger, {
transactionId: transaction.id,
refundAmount: refundAmountDecimal,
});
}

return {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ModuleProvider, Modules } from '@medusajs/framework/utils';
import { BraintreeProviderService, BraintreeImportService } from './services';
import { BraintreeImportService, BraintreeProviderService } from './services';

const services = [BraintreeProviderService, BraintreeImportService];

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { BraintreeOptions } from '../types';
import { PaymentProviderKeys } from '../types';
import { BraintreeConstructorArgs } from '../core/braintree-base';
import BraintreeImport from '../core/braintree-import';
import type { BraintreeOptions } from '../types';
import { PaymentProviderKeys } from '../types';

class BraintreeImportService extends BraintreeImport {
static identifier = PaymentProviderKeys.IMPORTED;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type Braintree from 'braintree';

export interface BraintreeOptions extends Braintree.ClientGatewayConfig {
defaultCurrencyCode?: string;
environment: 'production' | 'sandbox' | 'development' | 'qa';
merchantId: string;
publicKey: string;
Expand Down
Loading