diff --git a/plugins/braintree-payment/CHANGELOG.md b/plugins/braintree-payment/CHANGELOG.md index 2740130c..dba1fcd4 100644 --- a/plugins/braintree-payment/CHANGELOG.md +++ b/plugins/braintree-payment/CHANGELOG.md @@ -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”. +- 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 diff --git a/plugins/braintree-payment/README.md b/plugins/braintree-payment/README.md index d44a55aa..0646ac2d 100644 --- a/plugins/braintree-payment/README.md +++ b/plugins/braintree-payment/README.md @@ -41,8 +41,8 @@ BRAINTREE_PRIVATE_KEY= BRAINTREE_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. @@ -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 @@ -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 @@ -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 } } ``` @@ -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”. - **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 @@ -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 diff --git a/plugins/braintree-payment/package.json b/plugins/braintree-payment/package.json index 002af7c9..ec2117a8 100644 --- a/plugins/braintree-payment/package.json +++ b/plugins/braintree-payment/package.json @@ -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", diff --git a/plugins/braintree-payment/src/providers/payment-braintree/src/core/__tests__/braintree-base.spec.ts b/plugins/braintree-payment/src/providers/payment-braintree/src/core/__tests__/braintree-base.spec.ts index dd715a89..7a4380b3 100644 --- a/plugins/braintree-payment/src/providers/payment-braintree/src/core/__tests__/braintree-base.spec.ts +++ b/plugins/braintree-payment/src/providers/payment-braintree/src/core/__tests__/braintree-base.spec.ts @@ -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 () => { @@ -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 = { @@ -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' }) @@ -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' }) @@ -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'); diff --git a/plugins/braintree-payment/src/providers/payment-braintree/src/core/__tests__/braintree-import.spec.ts b/plugins/braintree-payment/src/providers/payment-braintree/src/core/__tests__/braintree-import.spec.ts index 7dc3e84c..ed7db4b2 100644 --- a/plugins/braintree-payment/src/providers/payment-braintree/src/core/__tests__/braintree-import.spec.ts +++ b/plugins/braintree-payment/src/providers/payment-braintree/src/core/__tests__/braintree-import.spec.ts @@ -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) => { const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() } as any; const cache = { get: jest.fn(), set: jest.fn() } as any; @@ -18,6 +18,7 @@ const buildService = () => { savePaymentMethod: false, webhookSecret: 'whsec', autoCapture: true, + ...overrideOptions, } as any; const service = new BraintreeImportService(container, options); @@ -33,7 +34,7 @@ const buildService = () => { (service as any).gateway = gateway; - return { service, gateway }; + return { service, gateway, logger }; }; describe('BraintreeImportService', () => { @@ -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; diff --git a/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-base.ts b/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-base.ts index 45dc4cc7..08adc733 100644 --- a/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-base.ts +++ b/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-base.ts @@ -402,11 +402,11 @@ class BraintreeBase extends AbstractPaymentProvider { } /** - * 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' ); } @@ -527,9 +527,19 @@ class BraintreeBase extends AbstractPaymentProvider { 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( @@ -1166,15 +1176,15 @@ class BraintreeBase extends AbstractPaymentProvider { /** * 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 { - 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; } @@ -1186,12 +1196,22 @@ class BraintreeBase extends AbstractPaymentProvider { /** * 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 { 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 }; } diff --git a/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-import.ts b/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-import.ts index a83f9d5f..38594e62 100644 --- a/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-import.ts +++ b/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-import.ts @@ -285,6 +285,16 @@ class BraintreeImport extends AbstractPaymentProvider { 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`, + ); + } + const cancelResponse = await this.gateway.transaction.void(transaction.id); if (isBraintreeFailureResponse(cancelResponse)) { diff --git a/plugins/braintree-payment/src/providers/payment-braintree/src/types/index.ts b/plugins/braintree-payment/src/providers/payment-braintree/src/types/index.ts index 899dc49c..802bc467 100644 --- a/plugins/braintree-payment/src/providers/payment-braintree/src/types/index.ts +++ b/plugins/braintree-payment/src/providers/payment-braintree/src/types/index.ts @@ -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 = {