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
6 changes: 6 additions & 0 deletions plugins/braintree-payment/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ dependencies:[Modules.CACHE]
- **savePaymentMethod**: Save payment methods for future use (default: `true`).
- **autoCapture**: Automatically capture payments (default: `true`).
- **allowRefundOnRefunded**: Allow refund attempts on already-refunded imported transactions (default: `false`).
- **customHttpAgent**: Optional pre-configured Node `http.Agent` or `https.Agent` used for all Braintree API requests. If set, this takes highest precedence.
- **proxyUrl**: Optional proxy URL (for example `http://user:pass@proxy.example.com:8080`). When provided, the provider will try to create an HTTPS proxy agent using `https-proxy-agent`.
- **httpAgent**: Optional HTTPS agent configuration object used to create a standard `https.Agent` when `customHttpAgent` and `proxyUrl` are not provided.
- **logging**: When `true`, logs important operations (initiate, authorize, capture, refund, etc.) to the console for debugging (default: `false`).
- **disableVoidTransactions**: When `true`, refunds never void; only `settled`/`settling` transactions may be refunded. Late requirement so future partial order refunds and order edits can be supported (void cancels the full authorization). Default: `false`. With this enabled, `authorized`/`submitted_for_settlement` refunds fail with `INVALID_DATA` (“cannot be refunded right now because it's in status …”); other non-refundable statuses fail with `NOT_FOUND` (“cannot be refunded because it's in status …”).
- **logging**: Enable verbose plugin debug logging (`true` or `false`, default: `false`). When `true`, the provider logs operation details (initiate, authorize, capture, refund, etc.) and expanded Braintree error context via Medusa's logger with a `[Braintree]` prefix. Set via `BRAINTREE_LOGGING=true` in `.env` or pass `logging: true` directly in provider options. Disable in production unless actively debugging.
- **testForceSettled**: **Sandbox only.** When `true` **and** `environment` is `sandbox`, the refund flow settles the Braintree transaction via the sandbox testing API before attempting a refund. Use this to exercise the **refund** path (settled/settling) instead of the **void** path (authorized/submitted_for_settlement). Defaults to `false`. Ignored (with a warning) outside sandbox. Set via `TEST_FORCE_SETTLED=true` in `.env` wired to this option, or pass `testForceSettled: true` directly. Do not enable in production.
Expand Down Expand Up @@ -152,6 +156,8 @@ Earlier README examples used `logging: process.env.NODE_ENV !== 'production'` (a
> - `autoCapture`: If set to `true`, payments are captured automatically after authorization.
> - `savePaymentMethod`: If set to `true`, customer payment methods are saved for future use.
> - `allowRefundOnRefunded`: If set to `true`, the imported payment provider will gracefully handle refund attempts on transactions that have already been refunded in Braintree. Instead of throwing an error, it will log a warning and record the refund locally only. This is useful when orders are imported and later refunded directly in Braintree.
> - HTTP agent precedence is: `customHttpAgent` -> `proxyUrl` -> `httpAgent`.
> - If `proxyUrl` is set, install `https-proxy-agent` in your project so proxy agent creation succeeds.

### Upgrading to 0.2.5

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@ import type {
} from '@medusajs/types';
import type { Transaction, TransactionNotification, TransactionStatus } from 'braintree';
import Braintree from 'braintree';
import http from 'http';
import https from 'https';
import { z } from 'zod';
import { formatToTwoDecimalString } from '../../../../utils/format-amount';
import type { BraintreeOptions, CustomFields } from '../types';
import type { BraintreeOptions, CustomFields, HttpAgentConfig } from '../types';

/** Medusa DI container fields required by {@link BraintreeBase}. */
export type BraintreeConstructorArgs = Record<string, unknown> & {
Expand Down Expand Up @@ -462,6 +464,69 @@ class BraintreeBase extends AbstractPaymentProvider<BraintreeOptions> {
return result.data as BraintreePaymentSessionData;
}

private createHttpAgent(): http.Agent | https.Agent | undefined {
// Backward compatibility: if customHttpAgent is directly provided, use it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

backwards compatibility? We didn't have this before right? Who would be providing options.customHttpAgent?

if (this.options_.customHttpAgent) {
return this.options_.customHttpAgent;
}

// If proxy URL is provided, try to create a proxy agent
if (this.options_.proxyUrl) {
try {
// Try to use https-proxy-agent (most common for HTTPS proxies)
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { HttpsProxyAgent } = require('https-proxy-agent');

// Create proxy agent with URL string
// Proxy URL format: http://[username:password@]proxy.example.com:8080
return new HttpsProxyAgent(this.options_.proxyUrl);
} catch (error) {
const isRequireMissingProxyAgent =
error instanceof Error && /Cannot find module ['"]https-proxy-agent['"]/.test(error.message);
const isModuleNotFoundProxyAgent =
typeof error === 'object' &&
error !== null &&
('code' in error ? (error as { code?: string }).code === 'MODULE_NOT_FOUND' : false) &&
error instanceof Error &&
error.message.includes('https-proxy-agent');

if (isRequireMissingProxyAgent || isModuleNotFoundProxyAgent) {
this.logger.warn(
'https-proxy-agent package not found. Install it with: npm install https-proxy-agent. Falling back to regular agent.',
);
// Fall through to create regular agent only when the module is missing
} else {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to initialize proxy agent from proxyUrl: ${message}`);
throw error;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// If httpAgent config is provided, create a regular HTTPS agent
if (this.options_.httpAgent) {
const agentOptions: https.AgentOptions = {
keepAlive: this.options_.httpAgent.keepAlive ?? true,
keepAliveMsecs: this.options_.httpAgent.keepAliveMsecs ?? 1000,
maxSockets: this.options_.httpAgent.maxSockets,
maxFreeSockets: this.options_.httpAgent.maxFreeSockets,
timeout: this.options_.httpAgent.timeout,
rejectUnauthorized: this.options_.httpAgent.rejectUnauthorized ?? true,
};

// Remove undefined values
Object.keys(agentOptions).forEach((key) => {
if (agentOptions[key as keyof https.AgentOptions] === undefined) {
delete agentOptions[key as keyof https.AgentOptions];
}
});

return new https.Agent(agentOptions);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return undefined;
}

/**
* Creates (or reuses) the Braintree SDK gateway from plugin options.
* @returns Configured {@link Braintree.BraintreeGateway}
Expand All @@ -476,17 +541,24 @@ class BraintreeBase extends AbstractPaymentProvider<BraintreeOptions> {
};
const environment = envMap[envKey] ?? Braintree.Environment.Sandbox;

const gateway =
this.gateway ||
new Braintree.BraintreeGateway({
environment,
merchantId: this.options_.merchantId!,
publicKey: this.options_.publicKey!,
privateKey: this.options_.privateKey!,
});
const gatewayConfig: Braintree.GatewayConfig = {
environment,
merchantId: this.options_.merchantId!,
publicKey: this.options_.publicKey!,
privateKey: this.options_.privateKey!,
};

// Create and add HTTP agent if configured
// Using 'as any' because TypeScript definitions don't include customHttpAgent,
// but the runtime Braintree library supports it
const httpAgent = this.createHttpAgent();
if (httpAgent) {
(gatewayConfig as any).customHttpAgent = httpAgent;
}

this.gateway = this.gateway || new Braintree.BraintreeGateway(gatewayConfig);
this.logDebug(`Gateway initialized (environment: ${envKey})`);
return gateway;
return this.gateway;
}

/**
Expand Down Expand Up @@ -546,6 +618,80 @@ class BraintreeBase extends AbstractPaymentProvider<BraintreeOptions> {
);
}
}

if (isDefined(options.proxyUrl)) {
if (typeof options.proxyUrl !== 'string' || !options.proxyUrl.trim()) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
'Option "proxyUrl" must be a non-empty string in Braintree plugin',
);
}

try {
new URL(options.proxyUrl);
} catch {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
`Option "proxyUrl" must be a valid URL in Braintree plugin: "${options.proxyUrl}"`,
);
Comment thread
lcmohsen marked this conversation as resolved.
}
}

if (isDefined(options.httpAgent)) {
if (typeof options.httpAgent !== 'object' || options.httpAgent === null || Array.isArray(options.httpAgent)) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
'Option "httpAgent" must be an object in Braintree plugin',
);
}

const { keepAlive, keepAliveMsecs, maxSockets, maxFreeSockets, timeout, rejectUnauthorized } = options.httpAgent;

const isNonNegativeNumber = (value: unknown): value is number =>
typeof value === 'number' && !Number.isNaN(value) && Number.isFinite(value) && value >= 0;

if (isDefined(keepAlive) && typeof keepAlive !== 'boolean') {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
'Option "httpAgent.keepAlive" must be a boolean in Braintree plugin',
);
}

if (isDefined(keepAliveMsecs) && !isNonNegativeNumber(keepAliveMsecs)) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
'Option "httpAgent.keepAliveMsecs" must be a number greater than or equal to 0 in Braintree plugin',
);
}

if (isDefined(maxSockets) && !isNonNegativeNumber(maxSockets)) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
'Option "httpAgent.maxSockets" must be a number greater than or equal to 0 in Braintree plugin',
);
}

if (isDefined(maxFreeSockets) && !isNonNegativeNumber(maxFreeSockets)) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
'Option "httpAgent.maxFreeSockets" must be a number greater than or equal to 0 in Braintree plugin',
);
}

if (isDefined(timeout) && !isNonNegativeNumber(timeout)) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
'Option "httpAgent.timeout" must be a number greater than or equal to 0 in Braintree plugin',
);
}

if (isDefined(rejectUnauthorized) && typeof rejectUnauthorized !== 'boolean') {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
'Option "httpAgent.rejectUnauthorized" must be a boolean in Braintree plugin',
);
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import type Braintree from 'braintree';
import type { Agent } from 'http';
import type { Agent as HttpsAgent } from 'https';

export interface HttpAgentConfig {
keepAlive?: boolean;
keepAliveMsecs?: number;
maxSockets?: number;
maxFreeSockets?: number;
timeout?: number;
rejectUnauthorized?: boolean;
}

export interface BraintreeOptions extends Braintree.ClientGatewayConfig {
defaultCurrencyCode?: string;
Expand All @@ -11,6 +22,12 @@ export interface BraintreeOptions extends Braintree.ClientGatewayConfig {
webhookSecret: string;
autoCapture: boolean;
allowRefundOnRefunded?: boolean;
/** Lowest precedence agent config, used to create a standard https.Agent. */
httpAgent?: HttpAgentConfig;
/** Optional proxy URL used to create an HTTPS proxy agent. */
proxyUrl?: string;
/** Highest precedence: pass a fully constructed Node HTTP(S) agent directly. */
customHttpAgent?: Agent | HttpsAgent;
/**
* When true, refundPayment never voids. Only settled/settling transactions may be refunded.
* Late requirement so future partial order refunds and order edits can be supported
Expand Down