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
7 changes: 7 additions & 0 deletions plugins/braintree-payment/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 0.1.9-next

### Improvements

- Add `disableVoidTransactions` option: when enabled, refunds never void and only proceed for `settled`/`settling` transactions (late requirement for future partial order refunds and order edits). Unsettled refunds throw `INVALID_DATA` with “cannot be refunded right now”.
Comment on lines +3 to +7

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align release metadata and upgrade documentation.

The package declares 0.1.10-next, the changelog entry says 0.1.9-next, and the README places the new option under Upgrading to 0.1.2. This can publish one version with release notes and migration guidance for different versions.

  • plugins/braintree-payment/CHANGELOG.md#L3-L7: use the intended package version in the top heading.
  • plugins/braintree-payment/README.md#L137-L145: move the disableVoidTransactions note under the release that introduces it.
  • plugins/braintree-payment/package.json#L3-L3: keep the package version consistent with the changelog heading.
📍 Affects 3 files
  • plugins/braintree-payment/CHANGELOG.md#L3-L7 (this comment)
  • plugins/braintree-payment/README.md#L137-L145
  • plugins/braintree-payment/package.json#L3-L3
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/braintree-payment/CHANGELOG.md` around lines 3 - 7, Align the release
metadata across plugins/braintree-payment/CHANGELOG.md lines 3-7 and
plugins/braintree-payment/package.json line 3 by using the intended 0.1.10-next
version in both; in plugins/braintree-payment/README.md lines 137-145, move the
disableVoidTransactions upgrade note under the release section that introduces
0.1.10-next rather than Upgrading to 0.1.2.

- Move sandbox settle-before-refund from reading `process.env.TEST_FORCE_SETTLED` inside the provider to a `testForceSettled` option (wire `TEST_FORCE_SETTLED` in `medusa-config` if you still use the env var).

## 0.1.8

### Fixes
Expand Down
21 changes: 17 additions & 4 deletions plugins/braintree-payment/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ BRAINTREE_PRIVATE_KEY=<your_private_key>
BRAINTREE_WEBHOOK_SECRET=<your_webhook_secret>
BRAINTREE_ENVIRONMENT=sandbox|development|production|qa
BRAINTREE_ENABLE_3D_SECURE=true|false
TEST_FORCE_SETTLED=true|false
BRAINTREE_LOGGING=true|false
TEST_FORCE_SETTLED=true|false
```

- `BRAINTREE_PUBLIC_KEY`: Your Braintree public key.
Expand All @@ -51,8 +51,8 @@ BRAINTREE_LOGGING=true|false
- `BRAINTREE_WEBHOOK_SECRET`: Secret for validating Braintree webhooks.
- `BRAINTREE_ENVIRONMENT`: One of `sandbox`, `development`, `production`, or `qa`.
- `BRAINTREE_ENABLE_3D_SECURE`: Set to `true` to enable 3D Secure authentication, otherwise `false`.
- `TEST_FORCE_SETTLED`: **Sandbox only.** When set to `true` **and** `BRAINTREE_ENVIRONMENT=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. Do not enable in production.
- `BRAINTREE_LOGGING`: Optional. Set to `true` to enable plugin debug logging. Wire this to the provider `logging` option in `medusa-config.ts` (see below). Defaults to `false`.
- `TEST_FORCE_SETTLED`: Optional. **Sandbox only.** Wire this to the provider `testForceSettled` option in `medusa-config.ts` (see below). Defaults to `false`. Do not enable in production.

### Testing refunds in sandbox

Expand All @@ -61,14 +61,22 @@ In Braintree sandbox, transactions often remain in `authorized` or `submitted_fo
- **Void path:** `authorized`, `submitted_for_settlement`
- **Refund path:** `settled`, `settling`

To test the refund path locally without waiting for settlement, set:
To test the refund path locally without waiting for settlement, set `environment: 'sandbox'` and `testForceSettled: true` in provider options (optionally via env):

```env
BRAINTREE_ENVIRONMENT=sandbox
TEST_FORCE_SETTLED=true
```

When both are set, `refundPayment` calls Braintree's sandbox `testing.settle` on the transaction, re-fetches it, then proceeds with `transaction.refund`. If `TEST_FORCE_SETTLED=true` but the provider environment is not `sandbox`, the settle step is skipped and a warning is logged.
```javascript
options: {
environment: process.env.BRAINTREE_ENVIRONMENT || 'sandbox',
testForceSettled: process.env.TEST_FORCE_SETTLED === 'true',
// ...
}
```

When both are set, `refundPayment` calls Braintree's sandbox `testing.settle` on the transaction, re-fetches it, then proceeds with `transaction.refund`. If `testForceSettled` is `true` but the provider environment is not `sandbox`, the settle step is skipped and a warning is logged.

### Medusa Configuration

Expand All @@ -90,7 +98,9 @@ dependencies:[Modules.CACHE]
savePaymentMethod: true, // Save payment methods for future use
autoCapture: true, // Automatically capture payments
allowRefundOnRefunded: false,
disableVoidTransactions: false,
logging: process.env.BRAINTREE_LOGGING === 'true', // Enable plugin debug logs
testForceSettled: process.env.TEST_FORCE_SETTLED === 'true', // Sandbox: settle before refund
}
}
```
Expand All @@ -106,7 +116,9 @@ 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`).
- **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, refunds on unsettled transactions fail with “cannot be refunded right now”.

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Document the actual refund status and error contract.

The implementation returns INVALID_DATA with “cannot be refunded right now” only for authorized and submitted_for_settlement. It refunds settled and settling transactions, and returns NOT_FOUND with a different message for other statuses.

  • plugins/braintree-payment/README.md#L110-L110: list the exact rejected statuses and their error behavior.
  • plugins/braintree-payment/CHANGELOG.md#L7-L7: replace the broad “unsettled refunds” statement with the same status-specific contract.
📍 Affects 2 files
  • plugins/braintree-payment/README.md#L110-L110 (this comment)
  • plugins/braintree-payment/CHANGELOG.md#L7-L7
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/braintree-payment/README.md` at line 110, The refund documentation
must match the implementation’s status-specific contract. In
plugins/braintree-payment/README.md at lines 110-110, update
disableVoidTransactions to state that authorized and submitted_for_settlement
transactions return INVALID_DATA with “cannot be refunded right now,” while
settled and settling transactions are refundable and other statuses return
NOT_FOUND with their distinct error message. Apply the same status-specific
wording in plugins/braintree-payment/CHANGELOG.md at lines 7-7, replacing the
broad unsettled-refunds statement.

- **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.

### Debug logging

Expand Down Expand Up @@ -140,6 +152,7 @@ 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.
> - `disableVoidTransactions`: Late additional requirement so future partial order refunds and order edits can be supported. When `true`, the provider waits for `settled`/`settling` before refunding; otherwise refund fails with “cannot be refunded right now”. `cancelPayment` may still void.

### 3D Secure Setup

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.1.8",
"version": "0.2.0-next",
"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 @@ -75,20 +75,12 @@ const settledRefundInput = (amount: number, transactionId = 't-settled'): Refund
});

describe('BraintreeProviderService core behaviors', () => {
const originalTestForceSettled = process.env.TEST_FORCE_SETTLED;

beforeEach(() => {
jest.resetAllMocks();
delete process.env.TEST_FORCE_SETTLED;
});

afterEach(() => {
jest.useRealTimers();
if (originalTestForceSettled === undefined) {
delete process.env.TEST_FORCE_SETTLED;
} else {
process.env.TEST_FORCE_SETTLED = originalTestForceSettled;
}
});

it('returns cached client token when available', async () => {
Expand Down Expand Up @@ -233,6 +225,47 @@ describe('BraintreeProviderService core behaviors', () => {
expect(entry?.transaction?.id).toBe('t1');
});

it('refundPayment throws when disableVoidTransactions and status is authorized', async () => {
const { service, gateway } = buildService({ disableVoidTransactions: true });

const input: RefundPaymentInput = {
amount: 5,
data: {
client_token: 'ct',
amount: 1000,
currency_code: 'USD',
braintreeTransaction: { id: 't1' },
},
};

gateway.transaction.find.mockResolvedValueOnce({ id: 't1', status: 'authorized' });

await expect(service.refundPayment(input)).rejects.toMatchObject({
type: MedusaError.Types.INVALID_DATA,
message: 'Braintree transaction with ID t1 cannot be refunded right now',
});
expect(gateway.transaction.void).not.toHaveBeenCalled();
expect(gateway.transaction.refund).not.toHaveBeenCalled();
});

it('refundPayment refunds settled transactions when disableVoidTransactions is enabled', async () => {
const { service, gateway } = buildService({ disableVoidTransactions: true });

gateway.transaction.find
.mockResolvedValueOnce({ id: 't-settled', status: 'settled' })
.mockResolvedValueOnce({ id: 't-settled', status: 'settled' });
gateway.transaction.refund.mockResolvedValueOnce({
success: true,
transaction: { id: 'r-settled', status: 'submitted_for_settlement' },
});

const result = await service.refundPayment(settledRefundInput(10));

expect(gateway.transaction.void).not.toHaveBeenCalled();
expect(gateway.transaction.refund).toHaveBeenCalledWith('t-settled', '10.00');
expect(lastRefundEntry(result.data)?.type).toBe('refund');
});

it('refundPayment appends to existing braintreeRefund history', async () => {
const { service, gateway } = buildService();
const priorEntry = {
Expand Down Expand Up @@ -497,9 +530,8 @@ describe('BraintreeProviderService core behaviors', () => {
});
});

it('refundPayment settles then refunds when TEST_FORCE_SETTLED is enabled in sandbox', async () => {
process.env.TEST_FORCE_SETTLED = 'true';
const { service, gateway } = buildService({ environment: 'sandbox' });
it('refundPayment settles then refunds when testForceSettled is enabled in sandbox', async () => {
const { service, gateway } = buildService({ environment: 'sandbox', testForceSettled: true });

gateway.transaction.find
.mockResolvedValueOnce({ id: 't-force', status: 'authorized' })
Expand All @@ -520,9 +552,8 @@ describe('BraintreeProviderService core behaviors', () => {
expect(forceEntry?.transaction?.id).toBe('r-force');
});

it('refundPayment ignores TEST_FORCE_SETTLED outside sandbox and voids authorized transactions', async () => {
process.env.TEST_FORCE_SETTLED = 'true';
const { service, gateway, logger } = buildService({ environment: 'production' });
it('refundPayment ignores testForceSettled outside sandbox and voids authorized transactions', async () => {
const { service, gateway, logger } = buildService({ environment: 'production', testForceSettled: true });

gateway.transaction.find
.mockResolvedValueOnce({ id: 't-prod', status: 'authorized' })
Expand All @@ -535,7 +566,7 @@ describe('BraintreeProviderService core behaviors', () => {
expect(gateway.transaction.void).toHaveBeenCalledWith('t-prod');
expect(gateway.transaction.refund).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'[Braintree refund] TEST_FORCE_SETTLED ignored — only supported when environment is sandbox',
'[Braintree refund] testForceSettled ignored — only supported when environment is sandbox',
);
const prodEntry = lastRefundEntry(result.data);
expect(prodEntry?.type).toBe('voided');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { MedusaError } from '@medusajs/framework/utils';
import BraintreeImportService from '../../services/braintree-import';
import { BraintreeConstructorArgs } from '../braintree-base';

const buildService = () => {
const buildService = (overrideOptions?: Record<string, unknown>) => {
const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() } as any;
const cache = { get: jest.fn(), set: jest.fn() } as any;

Expand All @@ -18,6 +18,7 @@ const buildService = () => {
savePaymentMethod: false,
webhookSecret: 'whsec',
autoCapture: true,
...overrideOptions,
} as any;

const service = new BraintreeImportService(container, options);
Expand All @@ -33,7 +34,7 @@ const buildService = () => {

(service as any).gateway = gateway;

return { service, gateway };
return { service, gateway, logger };
};

describe('BraintreeImportService', () => {
Expand Down Expand Up @@ -81,6 +82,19 @@ describe('BraintreeImportService', () => {
expect((res.data as any).refundedTotal).toBe(10);
});

it('throws when disableVoidTransactions and status is authorized', async () => {
const { service, gateway } = buildService({ disableVoidTransactions: true });
const session = { transactionId: 't2', importedAsRefunded: false, refundedTotal: 0, status: 'captured' } as any;
gateway.transaction.find.mockResolvedValueOnce({ id: 't2', status: 'authorized' });

await expect(service.refundPayment({ amount: 10, data: session } as any)).rejects.toMatchObject({
type: MedusaError.Types.INVALID_DATA,
message: 'Braintree transaction with ID t2 cannot be refunded right now',
});
expect(gateway.transaction.void).not.toHaveBeenCalled();
expect(gateway.transaction.refund).not.toHaveBeenCalled();
});

it('performs real refund for settled/settling when not imported-refunded', async () => {
const { service, gateway } = buildService();
const session = { transactionId: 't3', importedAsRefunded: false, refundedTotal: 1.25, status: 'captured' } as any;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -402,11 +402,11 @@ class BraintreeBase extends AbstractPaymentProvider<BraintreeOptions> {
}

/**
* Whether sandbox test settlement is enabled (`TEST_FORCE_SETTLED=true` and env is sandbox).
* Whether sandbox test settlement is enabled (`testForceSettled` option and env is sandbox).
*/
private isTestForceSettledEnabled(): boolean {
return (
process.env.TEST_FORCE_SETTLED === 'true' && this.options_.environment.toLowerCase() === 'sandbox'
!!this.options_.testForceSettled && this.options_.environment.toLowerCase() === 'sandbox'
);
}

Expand Down Expand Up @@ -527,9 +527,19 @@ class BraintreeBase extends AbstractPaymentProvider<BraintreeOptions> {
options.savePaymentMethod = options.savePaymentMethod ?? false;
options.autoCapture = options.autoCapture ?? false;
options.allowRefundOnRefunded = options.allowRefundOnRefunded ?? false;
options.disableVoidTransactions = options.disableVoidTransactions ?? false;
options.logging = options.logging ?? false;

const booleanFields = ['enable3DSecure', 'savePaymentMethod', 'autoCapture', 'allowRefundOnRefunded', 'logging'];
options.testForceSettled = options.testForceSettled ?? false;

const booleanFields = [
'enable3DSecure',
'savePaymentMethod',
'autoCapture',
'allowRefundOnRefunded',
'disableVoidTransactions',
'logging',
'testForceSettled',
];
for (const field of booleanFields) {
if (isDefined(options[field as keyof BraintreeOptions]) && typeof options[field as keyof BraintreeOptions] !== 'boolean') {
throw new MedusaError(
Expand Down Expand Up @@ -1166,15 +1176,15 @@ class BraintreeBase extends AbstractPaymentProvider<BraintreeOptions> {

/**
* Sandbox-only: force settle so refund paths can be exercised in tests.
* No-ops unless `TEST_FORCE_SETTLED=true` and environment is sandbox.
* No-ops unless `testForceSettled` is true and environment is sandbox.
* @param transaction - Transaction to optionally settle
*/
private async applyTestForceSettled(transaction: Transaction): Promise<Transaction> {
if (process.env.TEST_FORCE_SETTLED !== 'true') return transaction;
if (!this.options_.testForceSettled) return transaction;

if (!this.isTestForceSettledEnabled()) {
this.logger.warn(
'[Braintree refund] TEST_FORCE_SETTLED ignored — only supported when environment is sandbox',
'[Braintree refund] testForceSettled ignored — only supported when environment is sandbox',
);
return transaction;
}
Expand All @@ -1186,12 +1196,22 @@ class BraintreeBase extends AbstractPaymentProvider<BraintreeOptions> {
/**
* Chooses void vs refund based on transaction status (after optional test settle).
* @param transaction - Live Braintree transaction
* @throws {MedusaError} `INVALID_DATA` when void is disabled and status is voidable
* @throws {MedusaError} `NOT_FOUND` when status is neither voidable nor refundable
*/
private async resolveRefundAction(transaction: Transaction): Promise<RefundAction> {
const resolved = await this.applyTestForceSettled(transaction);

if (isVoidableRefundStatus(resolved.status)) {
if (this.options.disableVoidTransactions) {
this.logger.error(
`Braintree transaction with ID ${resolved.id} cannot be refunded right now because it's in status ${resolved.status}`,
);
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Braintree transaction with ID ${resolved.id} cannot be refunded right now`,
);
}
return { kind: 'voided', transaction: resolved };
}
Comment on lines 1202 to 1216

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm whether AbstractPaymentProvider declares an `options` member.
fd -t f 'abstract-payment-provider*' node_modules/@medusajs 2>/dev/null | head
rg -nP --type=ts -C4 '\b(protected|public|readonly)?\s*options\s*[:;=]' \
  $(fd -t f -p 'abstract-payment-provider' node_modules/@medusajs 2>/dev/null | head -5) 2>/dev/null

# Show every option read inside braintree-base.ts for consistency.
rg -nP --type=ts '\bthis\.options_?\.' plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-base.ts

Repository: lambda-curry/medusa-plugins

Length of output: 165


🏁 Script executed:

#!/bin/bash
set -e

printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(abstract-payment-provider|braintree-base|package\.json|pnpm-lock|yarn\.lock|package-lock\.json)' | head -80

printf '%s\n' '--- BraintreeBase option declarations and reads ---'
rg -n -C3 'options_|this\.options\b|disableVoidTransactions' plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-base.ts

printf '%s\n' '--- provider base references in source and manifests ---'
rg -n -C3 'AbstractPaymentProvider|`@medusajs/medusa`|disableVoidTransactions' plugins/braintree-payment/src plugins/braintree-payment/package.json package.json 2>/dev/null | head -240

Repository: lambda-curry/medusa-plugins

Length of output: 17921


🌐 Web query:

Medusa 2.15.2 AbstractPaymentProvider options_ options source code

💡 Result:

In Medusa 2.x, the AbstractPaymentProvider class serves as the base for all payment module providers [1][2]. The options passed to your payment provider (defined in medusa-config.ts) are injected into your provider service through its constructor [3][4]. To handle these options, you typically define a TypeScript type (e.g., Options) and pass it as a generic type argument when extending AbstractPaymentProvider [5][2]. Implementation Details: 1. Type Definition and Injection: Define your options type and use the constructor to receive and store them [4][5]. ts import { AbstractPaymentProvider } from "`@medusajs/framework/utils`" type Options = { apiKey: string // Add other provider-specific options here } class MyPaymentProviderService extends AbstractPaymentProvider<Options> { protected readonly options_: Options constructor( container: Record<string, unknown>, options: Options ) { super(container, options) this.options_ = options } } 2. Validation: You can implement the static validateOptions method to ensure the configuration provided in medusa-config.ts is correct before the application starts [3][5]. ts class MyPaymentProviderService extends AbstractPaymentProvider<Options> { static validateOptions(options: Record<any, any>): void | never { if (!options.apiKey) { throw new Error("API key is required in the provider's options.") } } } 3. Configuration: These options are then passed to the payment module in your medusa-config.ts file [5]: ts // medusa-config.ts modules: [ { resolve: "`@medusajs/medusa/payment`", options: { providers: [ { resolve: "./src/modules/my-payment", id: "my-payment", options: { apiKey: "your-api-key" } } ] } } ] The AbstractPaymentProvider internally stores these in a config property [3][4], but it is standard practice to manually assign them to a protected class property (such as options_) within your constructor as shown above to make them easily accessible throughout your service methods [4][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -e

printf '%s\n' '--- constructor and test setup ---'
sed -n '320,355p' plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-base.ts
sed -n '1,75p' plugins/braintree-payment/src/providers/payment-braintree/src/core/__tests__/braintree-base.spec.ts
sed -n '220,270p' plugins/braintree-payment/src/providers/payment-braintree/src/core/__tests__/braintree-base.spec.ts

printf '%s\n' '--- dependency lock entries ---'
rg -n -C2 '(`@medusajs/medusa`|`@medusajs/framework`)' plugins/braintree-payment/package.json yarn.lock | head -160

Repository: lambda-curry/medusa-plugins

Length of output: 14556


🏁 Script executed:

#!/bin/bash
set -e

url='https://raw.githubusercontent.com/medusajs/medusa/v2.15.2/packages/core/utils/src/payment/abstract-payment-provider.ts'
src="$(curl -fsSL "$url")"

printf '%s\n' '--- AbstractPaymentProvider option members ---'
printf '%s\n' "$src" | rg -n -C5 '\b(options|config)\b|constructor'

printf '%s\n' '--- BraintreeBase option access classification ---'
python3 - <<'PY'
from pathlib import Path
p = Path("plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-base.ts")
text = p.read_text()
for i, line in enumerate(text.splitlines(), 1):
    if "this.options" in line or "options_" in line:
        print(f"{i}: {line}")
PY

Repository: lambda-curry/medusa-plugins

Length of output: 4983


Use this.options_ for the option read.

AbstractPaymentProvider exposes config, not options, and BraintreeBase declares options_. This access causes a TypeScript error; if transpiled, it throws before blocking the void.

♻️ Proposed change
     if (isVoidableRefundStatus(resolved.status)) {
-      if (this.options.disableVoidTransactions) {
+      if (this.options_.disableVoidTransactions) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private async resolveRefundAction(transaction: Transaction): Promise<RefundAction> {
const resolved = await this.applyTestForceSettled(transaction);
if (isVoidableRefundStatus(resolved.status)) {
if (this.options.disableVoidTransactions) {
this.logger.error(
`Braintree transaction with ID ${resolved.id} cannot be refunded right now because it's in status ${resolved.status}`,
);
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Braintree transaction with ID ${resolved.id} cannot be refunded right now`,
);
}
return { kind: 'voided', transaction: resolved };
}
private async resolveRefundAction(transaction: Transaction): Promise<RefundAction> {
const resolved = await this.applyTestForceSettled(transaction);
if (isVoidableRefundStatus(resolved.status)) {
if (this.options_.disableVoidTransactions) {
this.logger.error(
`Braintree transaction with ID ${resolved.id} cannot be refunded right now because it's in status ${resolved.status}`,
);
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Braintree transaction with ID ${resolved.id} cannot be refunded right now`,
);
}
return { kind: 'voided', transaction: resolved };
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-base.ts`
around lines 1200 - 1214, Update resolveRefundAction to read
disableVoidTransactions from the declared BraintreeBase options_ property
instead of this.options, preserving the existing void-blocking behavior and
error handling.


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,16 @@ class BraintreeImport extends AbstractPaymentProvider<BraintreeOptions> {
const shouldVoid = ['submitted_for_settlement', 'authorized'].includes(transaction.status);

if (shouldVoid) {
if (this.options.disableVoidTransactions) {
this.logger.error(
`Braintree transaction with ID ${transaction.id} cannot be refunded right now because it's in status ${transaction.status}`,
);
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Braintree transaction with ID ${transaction.id} cannot be refunded right now`,
);
}

Comment on lines +288 to +297

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Exclude this policy error from the already-refunded fallback.

When allowRefundOnRefunded is enabled, refundPayment catches errors at Lines 234-248 and treats messages containing refunded or cannot be refunded as proof that Braintree already refunded the transaction. The new message at Line 294 matches that predicate. The code then increments refundedTotal and returns success without calling void or refund.

Use a structured gateway-error check for already-refunded cases. Re-throw the local disableVoidTransactions INVALID_DATA error. Add a regression test with both options enabled.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-import.ts`
around lines 288 - 297, The refund fallback in refundPayment must not classify
the disableVoidTransactions policy error as an already-refunded result. Replace
message-based matching with a structured gateway-error check, and ensure the
INVALID_DATA error thrown in the disableVoidTransactions branch is re-thrown
even when allowRefundOnRefunded is enabled. Add a regression test covering both
options enabled.

const cancelResponse = await this.gateway.transaction.void(transaction.id);

if (isBraintreeFailureResponse(cancelResponse)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,20 @@ export interface BraintreeOptions extends Braintree.ClientGatewayConfig {
webhookSecret: string;
autoCapture: boolean;
allowRefundOnRefunded?: boolean;
/**
* 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
* (void cancels the full authorization).
*/
disableVoidTransactions?: boolean;
/** When true, logs important operations to the console for debugging. */
logging?: boolean;
/**
* Sandbox only. When true, refundPayment settles the transaction via the
* Braintree testing API before refunding (exercises refund vs void path).
* Ignored outside sandbox. Default: false.
*/
testForceSettled?: boolean;
}

export const PaymentProviderKeys = {
Expand Down