diff --git a/.gitignore b/.gitignore index 1585c7b4..1711dae8 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ build !.yarn/releases !.yarn/sdks !.yarn/versions +references/ .medusa diff --git a/plugins/braintree-payment/CHANGELOG.md b/plugins/braintree-payment/CHANGELOG.md index 2740130c..6a3f0782 100644 --- a/plugins/braintree-payment/CHANGELOG.md +++ b/plugins/braintree-payment/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.2.0-next + +### Fixes + +- Keep refund/void history on `braintreeRefunds[]` (same key as 0.1.8). Read leftover `braintreeRefund` arrays from the 0.2.0-next regression and migrate them onto `braintreeRefunds` on the next refund so both keys cannot drift. + +### Improvements + +- Add `disableVoidTransactions` option: when enabled, refunds never void. Only `settled`/`settling` may be refunded; `authorized`/`submitted_for_settlement` throw `INVALID_DATA` with “cannot be refunded right now”; other statuses throw `NOT_FOUND` with “cannot be refunded” (late requirement for future partial order refunds and order edits). +- 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..9a264f0d 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, `authorized`/`submitted_for_settlement` refunds fail with `INVALID_DATA` (“cannot be refunded right now”); other non-refundable statuses fail with `NOT_FOUND` (“cannot be refunded”). - **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 @@ -141,6 +153,11 @@ Earlier README examples used `logging: process.env.NODE_ENV !== 'production'` (a > - `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. +### Upgrading to 0.2.0-next + +> **Note:** +> - `disableVoidTransactions`: Late additional requirement so future partial order refunds and order edits can be supported. When `true`, only `settled`/`settling` may be refunded; `authorized`/`submitted_for_settlement` fail with `INVALID_DATA` (“cannot be refunded right now”); other statuses fail with `NOT_FOUND` (“cannot be refunded”). `cancelPayment` may still void. + ### 3D Secure Setup If you enable 3D Secure (`BRAINTREE_ENABLE_3D_SECURE=true`), you may need to make additional changes on your storefront to support 3D Secure flows. Refer to the [Braintree 3D Secure documentation](https://developer.paypal.com/braintree/docs/guides/3d-secure/overview/) for more details. 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 a89c81cb..28270768 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 @@ -1,17 +1,23 @@ import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; -import { MedusaError } from '@medusajs/framework/utils'; +import { MedusaError, PaymentActions } from '@medusajs/framework/utils'; import type { RefundPaymentInput } from '@medusajs/types'; import BraintreeProviderService from '../../services/braintree-provider'; import { BraintreeConstructorArgs, BraintreePaymentSessionData } from '../braintree-base'; import type { BraintreeOptions } from '../../types'; +type RefundHistoryEntry = { + type?: 'voided' | 'refund'; + transaction?: { id?: string; status?: string }; +}; + type RefundResultData = { - braintreeRefund?: { - id?: string; - success?: boolean; - transactionId?: string; - type?: string; - }; + braintreeRefund?: RefundHistoryEntry[] | Record; + braintreeRefunds?: RefundHistoryEntry[]; +}; + +const lastRefundEntry = (data: unknown): RefundHistoryEntry | undefined => { + const history = (data as RefundResultData)?.braintreeRefunds; + return history?.[history.length - 1]; }; const buildService = (overrideOptions?: Partial) => { @@ -70,20 +76,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 () => { @@ -172,9 +170,10 @@ describe('BraintreeProviderService core behaviors', () => { transaction: {}, }); - await expect(service.authorizePayment(input)).rejects.toThrow( - 'Failed to create Braintree transaction: BT: postalCode: Postal code is invalid. (81813)', - ); + await expect(service.authorizePayment(input)).rejects.toMatchObject({ + type: MedusaError.Types.PAYMENT_AUTHORIZATION_ERROR, + message: 'BT: postalCode: Postal code is invalid. (81813)', + }); }); it('capturePayment submits for settlement when status is authorized', async () => { @@ -222,7 +221,156 @@ describe('BraintreeProviderService core behaviors', () => { const result = await service.refundPayment(input); expect(gateway.transaction.void).toHaveBeenCalledWith('t1'); - expect((result.data as RefundResultData)?.braintreeRefund?.success).toBe(true); + const entry = lastRefundEntry(result.data); + expect(entry?.type).toBe('voided'); + 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 braintreeRefunds history', async () => { + const { service, gateway } = buildService(); + const priorEntry = { + type: 'refund' as const, + transaction: { id: 'r-prior', status: 'submitted_for_settlement' }, + }; + + const input: RefundPaymentInput = { + amount: 3, + data: { + client_token: 'ct', + amount: 1000, + currency_code: 'USD', + braintreeTransaction: { id: 't1' }, + braintreeRefunds: [priorEntry], + }, + }; + + gateway.transaction.find.mockResolvedValueOnce({ id: 't1', status: 'settled' }); + gateway.transaction.refund.mockResolvedValueOnce({ + success: true, + transaction: { id: 'r-new', status: 'submitted_for_settlement' }, + }); + + const result = await service.refundPayment(input); + const history = (result.data as RefundResultData)?.braintreeRefunds; + + expect(history).toHaveLength(2); + expect(history?.[0]).toMatchObject(priorEntry); + expect(history?.[1]?.type).toBe('refund'); + expect(history?.[1]?.transaction?.id).toBe('r-new'); + expect((result.data as RefundResultData)?.braintreeRefund).toBeUndefined(); + }); + + it('refundPayment migrates leftover braintreeRefund array onto braintreeRefunds', async () => { + const { service, gateway } = buildService(); + const priorEntry = { + type: 'refund' as const, + transaction: { id: 'r-prior', status: 'submitted_for_settlement' }, + }; + + const input: RefundPaymentInput = { + amount: 3, + data: { + client_token: 'ct', + amount: 1000, + currency_code: 'USD', + braintreeTransaction: { id: 't1' }, + braintreeRefund: [priorEntry], + }, + }; + + gateway.transaction.find.mockResolvedValueOnce({ id: 't1', status: 'settled' }); + gateway.transaction.refund.mockResolvedValueOnce({ + success: true, + transaction: { id: 'r-new', status: 'submitted_for_settlement' }, + }); + + const result = await service.refundPayment(input); + const history = (result.data as RefundResultData)?.braintreeRefunds; + + expect(history).toHaveLength(2); + expect(history?.[0]).toMatchObject(priorEntry); + expect(history?.[1]?.type).toBe('refund'); + expect(history?.[1]?.transaction?.id).toBe('r-new'); + expect((result.data as RefundResultData)?.braintreeRefund).toBeUndefined(); + }); + + it('refundPayment prefers braintreeRefunds when both history keys are present', async () => { + const { service, gateway } = buildService(); + const pluralEntry = { + type: 'refund' as const, + transaction: { id: 'r-plural', status: 'submitted_for_settlement' }, + }; + const singularEntry = { + type: 'refund' as const, + transaction: { id: 'r-singular', status: 'submitted_for_settlement' }, + }; + + const input: RefundPaymentInput = { + amount: 3, + data: { + client_token: 'ct', + amount: 1000, + currency_code: 'USD', + braintreeTransaction: { id: 't1' }, + braintreeRefunds: [pluralEntry], + braintreeRefund: [singularEntry], + }, + }; + + gateway.transaction.find.mockResolvedValueOnce({ id: 't1', status: 'settled' }); + gateway.transaction.refund.mockResolvedValueOnce({ + success: true, + transaction: { id: 'r-new', status: 'submitted_for_settlement' }, + }); + + const result = await service.refundPayment(input); + const history = (result.data as RefundResultData)?.braintreeRefunds; + + expect(history).toHaveLength(2); + expect(history?.[0]).toMatchObject(pluralEntry); + expect(history?.[1]?.transaction?.id).toBe('r-new'); + expect((result.data as RefundResultData)?.braintreeRefund).toBeUndefined(); }); it('refundPayment voids when transaction is submitted_for_settlement', async () => { @@ -245,7 +393,9 @@ describe('BraintreeProviderService core behaviors', () => { const result = await service.refundPayment(input); expect(gateway.transaction.void).toHaveBeenCalledWith('t1'); - expect((result.data as RefundResultData)?.braintreeRefund?.success).toBe(true); + const entry = lastRefundEntry(result.data); + expect(entry?.type).toBe('voided'); + expect(entry?.transaction?.id).toBe('t1'); }); it('refundPayment refunds with 2dp when transaction is settling', async () => { @@ -269,7 +419,9 @@ describe('BraintreeProviderService core behaviors', () => { const result = await service.refundPayment(input); expect(gateway.transaction.refund).toHaveBeenCalledWith('t2', '7.50'); - expect((result.data as RefundResultData)?.braintreeRefund?.id).toBe('r2'); + const entry = lastRefundEntry(result.data); + expect(entry?.type).toBe('refund'); + expect(entry?.transaction?.id).toBe('r2'); }); it('refundPayment throws for non-refundable statuses', async () => { @@ -313,7 +465,9 @@ describe('BraintreeProviderService core behaviors', () => { const result = await service.refundPayment(input); expect(gateway.transaction.refund).toHaveBeenCalledWith('t2', '5.00'); - expect((result.data as RefundResultData)?.braintreeRefund?.id).toBe('r1'); + const entry = lastRefundEntry(result.data); + expect(entry?.type).toBe('refund'); + expect(entry?.transaction?.id).toBe('r1'); }); it('refundPayment throws PAYMENT_AUTHORIZATION_ERROR with processor code 2005 on decline', async () => { @@ -450,9 +604,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' }) @@ -468,12 +621,13 @@ describe('BraintreeProviderService core behaviors', () => { expect(gateway.testing.settle).toHaveBeenCalledWith('t-force'); expect(gateway.transaction.void).not.toHaveBeenCalled(); expect(gateway.transaction.refund).toHaveBeenCalledWith('t-force', '10.00'); - expect((result.data as RefundResultData).braintreeRefund?.id).toBe('r-force'); + const forceEntry = lastRefundEntry(result.data); + expect(forceEntry?.type).toBe('refund'); + 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' }) @@ -486,9 +640,38 @@ 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', ); - expect((result.data as RefundResultData).braintreeRefund?.success).toBe(true); + const prodEntry = lastRefundEntry(result.data); + expect(prodEntry?.type).toBe('voided'); + expect(prodEntry?.transaction?.id).toBe('t-prod'); + }); + + it('refundPayment tolerates legacy non-array braintreeRefund session data', async () => { + const { service, gateway } = buildService(); + + const input: RefundPaymentInput = { + amount: 5, + data: { + client_token: 'ct', + amount: 1000, + currency_code: 'USD', + braintreeTransaction: { id: 't1' }, + braintreeRefund: { success: true, type: 'void' }, + }, + }; + + gateway.transaction.find.mockResolvedValueOnce({ id: 't1', status: 'authorized' }); + gateway.transaction.void.mockResolvedValueOnce({ success: true }); + gateway.transaction.find.mockResolvedValueOnce({ id: 't1', status: 'voided' }); + + const result = await service.refundPayment(input); + const history = (result.data as RefundResultData)?.braintreeRefunds; + + expect(Array.isArray(history)).toBe(true); + expect(history).toHaveLength(1); + expect(history?.[0]?.type).toBe('voided'); + expect((result.data as RefundResultData)?.braintreeRefund).toBeUndefined(); }); it('getPaymentStatus maps provider status correctly', async () => { @@ -514,7 +697,120 @@ describe('BraintreeProviderService core behaviors', () => { }); const result = await service.getWebhookActionAndData({ data: payloadStr } as any); - expect(result.action).toBe('captured'); + expect(result.action).toBe(PaymentActions.SUCCESSFUL); expect((result as any).data.session_id).toBe('sess_123'); }); + + it('getWebhookActionAndData tolerates transactions without customFields', async () => { + const { service, gateway } = buildService(); + gateway.webhookNotification.parse.mockResolvedValueOnce({ + kind: 'transaction_settled', + transaction: { id: 't-foreign' }, + }); + gateway.transaction.find.mockResolvedValueOnce({ + id: 't-foreign', + amount: '1.00', + }); + + const result = await service.getWebhookActionAndData({ + data: 'bt_signature=s&bt_payload=p', + } as any); + + expect(result.action).toBe(PaymentActions.SUCCESSFUL); + expect((result as any).data.session_id).toBe(''); + }); + + it('authorizePayment fails clearly when sale Result omits transaction id', async () => { + const { service, gateway } = buildService(); + + gateway.transaction.sale.mockResolvedValueOnce({ success: true, transaction: undefined }); + + await expect( + service.authorizePayment({ + data: { + clientToken: 'ct', + amount: 10, + currency_code: 'USD', + payment_method_nonce: 'fake-nonce', + }, + context: { idempotency_key: 'idem_missing_tx' }, + } as any), + ).rejects.toMatchObject({ + type: MedusaError.Types.INVALID_DATA, + message: 'Braintree sale succeeded without a transaction id', + }); + }); + + it('authorizePayment preserves sync error when orphan void rejects', async () => { + const { service, gateway, logger } = buildService(); + + gateway.transaction.sale.mockResolvedValueOnce({ success: true, transaction: { id: 't-orphan' } }); + gateway.transaction.find.mockRejectedValueOnce(new Error('sync failed')); + gateway.transaction.void.mockRejectedValueOnce(new Error('void network error')); + + await expect( + service.authorizePayment({ + data: { + clientToken: 'ct', + amount: 10, + currency_code: 'USD', + payment_method_nonce: 'fake-nonce', + }, + context: { idempotency_key: 'idem_orphan_reject' }, + } as any), + ).rejects.toMatchObject({ + type: MedusaError.Types.INVALID_DATA, + message: expect.stringContaining('sync payment session'), + }); + + expect(gateway.transaction.void).toHaveBeenCalledWith('t-orphan'); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to void orphan Braintree transaction t-orphan'), + expect.any(Error), + ); + }); + + it('authorizePayment preserves sync error when orphan void returns success false', async () => { + const { service, gateway, logger } = buildService(); + + gateway.transaction.sale.mockResolvedValueOnce({ success: true, transaction: { id: 't-orphan2' } }); + gateway.transaction.find.mockRejectedValueOnce(new Error('sync failed')); + gateway.transaction.void.mockResolvedValueOnce({ + success: false, + message: 'Cannot void', + transaction: { status: 'processor_declined', processorResponseText: 'Do Not Honor' }, + }); + + await expect( + service.authorizePayment({ + data: { + clientToken: 'ct', + amount: 10, + currency_code: 'USD', + payment_method_nonce: 'fake-nonce', + }, + context: { idempotency_key: 'idem_orphan_false' }, + } as any), + ).rejects.toMatchObject({ + type: MedusaError.Types.INVALID_DATA, + message: expect.stringContaining('sync payment session'), + }); + + expect(gateway.transaction.void).toHaveBeenCalledWith('t-orphan2'); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to void orphan Braintree transaction t-orphan2 after sync failure'), + ); + }); + + it('getWebhookActionAndData returns NOT_SUPPORTED for webhook parse failures', async () => { + const { service, gateway, logger } = buildService(); + gateway.webhookNotification.parse.mockRejectedValueOnce(new Error('invalid signature')); + + const result = await service.getWebhookActionAndData({ + data: 'bt_signature=bad&bt_payload=x', + } as any); + + expect(result.action).toBe(PaymentActions.NOT_SUPPORTED); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('webhook validation failed')); + }); }); 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 0f9630ee..4b4585cc 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 @@ -49,11 +49,16 @@ import { z } from 'zod'; import { formatToTwoDecimalString } from '../../../../utils/format-amount'; import type { BraintreeOptions, CustomFields } from '../types'; +/** Medusa DI container fields required by {@link BraintreeBase}. */ export type BraintreeConstructorArgs = Record & { logger: Logger; cache: ICacheService; }; +/** + * Payment provider context plus optional Braintree sale fields + * (billing, shipping, line items, amounts) passed through to `transaction.sale`. + */ export type BraintreeTransactionContext = PaymentProviderContext & Pick< Braintree.TransactionRequest, @@ -68,6 +73,10 @@ export type BraintreeTransactionContext = PaymentProviderContext & | 'discountAmount' >; +/** + * Shape stored on the Medusa payment session `data` for this provider. + * Accepts camelCase aliases when parsing (normalized to snake_case fields). + */ export interface BraintreePaymentSessionData { client_token: string; transaction: Transaction; @@ -77,23 +86,38 @@ export interface BraintreePaymentSessionData { account_holder?: PaymentAccountHolderDTO; } +/** Optional fields accepted on {@link BraintreeBase.initiatePayment} input `data`. */ export interface BraintreeInitiatePaymentData { payment_method_nonce?: string; } +/** + * Builds the cache key used for client tokens scoped to a Medusa customer id. + * @param customerId - Medusa customer id + */ const buildTokenCacheKey = (customerId: string) => `braintree:clientToken:${customerId}`; const UNKNOWN_BRAINTREE_ERROR = 'Unknown error'; +/** Minimal shape of a Braintree validation error entry. */ type BraintreeValidationErrorLike = { attribute?: string; code?: string; message?: string; }; +/** Braintree `errors` collection that exposes `deepErrors()`. */ type BraintreeValidationErrorsCollectionLike = { deepErrors?: () => BraintreeValidationErrorLike[]; }; +type TransactionSaleResponse = Awaited< + ReturnType +>; + +/** + * Subset of Braintree Result / transaction fields used when classifying failures + * and building user-facing error messages. + */ type BraintreeErrorResponseLike = { message?: string; success?: boolean; @@ -110,10 +134,56 @@ type BraintreeErrorResponseLike = { const BRAINTREE_DECLINED_TRANSACTION_STATUSES = ['processor_declined', 'settlement_declined'] as const; +/** Transaction statuses refunded via void rather than a separate refund call. */ +const VOIDABLE_REFUND_STATUSES: readonly TransactionStatus[] = ['submitted_for_settlement', 'authorized']; +/** Transaction statuses refunded via `transaction.refund`. */ +const SETTLED_REFUND_STATUSES: readonly TransactionStatus[] = ['settled', 'settling']; + +/** + * @param status - Braintree transaction status + * @returns Whether a refund should void the transaction instead of calling refund + */ +const isVoidableRefundStatus = (status: TransactionStatus): boolean => + (VOIDABLE_REFUND_STATUSES as readonly string[]).includes(status); + +/** + * @param status - Braintree transaction status + * @returns Whether a refund should call `transaction.refund` + */ +const isSettledRefundStatus = (status: TransactionStatus): boolean => + (SETTLED_REFUND_STATUSES as readonly string[]).includes(status); + +/** One entry appended to session `data.braintreeRefunds` after a void or refund. */ +type BraintreeRefundHistoryEntry = { + type: 'voided' | 'refund'; + transaction: Transaction; +}; + +/** Resolved void-vs-refund action for {@link BraintreeBase.executeRefundAction}. */ +type RefundAction = { + kind: BraintreeRefundHistoryEntry['type']; + transaction: Transaction; +}; + +/** Gateway Result that may include a transaction payload. */ +type GatewayTransactionResponse = BraintreeErrorResponseLike & { + transaction?: Transaction; +}; + +/** + * Whether a Braintree transaction status is a known processor/settlement decline. + * @param status - Optional Braintree transaction status string + */ export const isBraintreeDeclinedTransactionStatus = (status?: string): boolean => (BRAINTREE_DECLINED_TRANSACTION_STATUSES as readonly string[]).includes(status ?? ''); -// Type guard utilities for safe type validation +/** + * Asserts `value` is a non-empty string. + * @param value - Value to validate + * @param fieldName - Used in the error message + * @returns The original string value (not trimmed) + * @throws {MedusaError} `INVALID_ARGUMENT` when empty or not a string + */ const validateString = (value: unknown, fieldName: string): string => { if (typeof value !== 'string' || !value.trim()) { throw new MedusaError(MedusaError.Types.INVALID_ARGUMENT, `${fieldName} must be a non-empty string`); @@ -121,6 +191,10 @@ const validateString = (value: unknown, fieldName: string): string => { return value; }; +/** + * Flattens Braintree validation errors that include a message. + * @param errors - Optional Braintree errors collection + */ const getBraintreeValidationErrors = ( errors?: BraintreeValidationErrorsCollectionLike, ): BraintreeValidationErrorLike[] => { @@ -128,6 +202,10 @@ const getBraintreeValidationErrors = ( return errors.deepErrors().filter((error): error is BraintreeValidationErrorLike => Boolean(error?.message)); }; +/** + * Formats a single Braintree validation error for logging / messages. + * @param error - Validation error entry + */ const formatBraintreeValidationError = (error: BraintreeValidationErrorLike): string => { const attribute = error.attribute?.trim(); const prefix = attribute ? `${attribute}: ` : ''; @@ -135,6 +213,11 @@ const formatBraintreeValidationError = (error: BraintreeValidationErrorLike): st return `BT: ${prefix}${error.message}${suffix}`; }; +/** + * Picks the best human-readable message from a Braintree Result + * (gateway rejection → processor → settlement → validation → message). + * @param response - Braintree-like failure response + */ const getBraintreeErrorMessage = (response: BraintreeErrorResponseLike): string => { const gatewayRejectionReason = response.transaction?.gatewayRejectionReason?.trim(); if (gatewayRejectionReason) return gatewayRejectionReason; @@ -148,7 +231,9 @@ const getBraintreeErrorMessage = (response: BraintreeErrorResponseLike): string const settlementResponseText = response.transaction?.processorSettlementResponseText?.trim(); if (settlementResponseText) { const settlementResponseCode = response.transaction?.processorSettlementResponseCode?.trim(); - return settlementResponseCode ? `${settlementResponseText} (${settlementResponseCode})` : settlementResponseText; + return settlementResponseCode + ? `${settlementResponseText} (${settlementResponseCode})` + : settlementResponseText; } const validationErrors = getBraintreeValidationErrors(response.errors).map(formatBraintreeValidationError); @@ -160,11 +245,27 @@ const getBraintreeErrorMessage = (response: BraintreeErrorResponseLike): string return UNKNOWN_BRAINTREE_ERROR; }; +/** + * Whether a Braintree Result should be treated as a failure + * (`success === false` or declined transaction status). + * @param response - Braintree-like response + */ export const isBraintreeFailureResponse = (response: BraintreeErrorResponseLike): boolean => !response.success || isBraintreeDeclinedTransactionStatus(response.transaction?.status); +/** Logger callback used by {@link throwOnBraintreeFailure}. */ type BraintreeFailureLogFn = (operation: string, error: unknown, context?: Record) => void; +/** + * Logs and throws a {@link MedusaError} for a failed Braintree Result. + * Uses `PAYMENT_AUTHORIZATION_ERROR` when processor/gateway signals are present; + * otherwise `INVALID_DATA`. + * @param response - Failed Braintree-like response + * @param operation - Label for logs (e.g. `refundPayment (void)`) + * @param log - Detail logger (typically `logErrorDetail`) + * @param context - Extra fields merged into the log payload + * @throws {MedusaError} Always throws; never returns + */ export function throwOnBraintreeFailure( response: BraintreeErrorResponseLike, operation: string, @@ -176,7 +277,9 @@ export function throwOnBraintreeFailure( response.transaction?.gatewayRejectionReason || response.transaction?.processorResponseText || response.transaction?.processorSettlementResponseText; - const type = hasProcessorSignal ? MedusaError.Types.PAYMENT_AUTHORIZATION_ERROR : MedusaError.Types.INVALID_DATA; + const type = hasProcessorSignal + ? MedusaError.Types.PAYMENT_AUTHORIZATION_ERROR + : MedusaError.Types.INVALID_DATA; log(`${operation} failed`, new Error(message), { ...context, @@ -192,7 +295,16 @@ export function throwOnBraintreeFailure( throw new MedusaError(type, message); } -// Error handling utility that preserves full error context +/** + * Wraps an unknown exception as `MedusaError` (`INVALID_DATA`) after logging. + * Does not inspect or preserve an existing {@link MedusaError} type — callers + * that need rethrow-as-is should check before calling this. + * @param error - Caught value + * @param operation - Verb phrase used in the message (`Failed to ${operation}`) + * @param logger - Medusa logger + * @param context - Optional structured context appended to the log line + * @returns A new `INVALID_DATA` MedusaError (does not throw) + */ export const buildBraintreeError = ( error: unknown, operation: string, @@ -211,6 +323,11 @@ export const buildBraintreeError = ( return new MedusaError(MedusaError.Types.INVALID_DATA, `Failed to ${operation}: ${errorMessage}`); }; +/** + * Medusa {@link AbstractPaymentProvider} implementation for Braintree. + * Handles client tokens, sale/authorize, capture, void/cancel, refunds, + * vaulted payment methods, account holders (Braintree customers), and webhooks. + */ class BraintreeBase extends AbstractPaymentProvider { identifier = 'braintree'; protected readonly options_: BraintreeOptions; @@ -218,6 +335,10 @@ class BraintreeBase extends AbstractPaymentProvider { logger: Logger; cache: ICacheService; + /** + * @param container - Medusa container with logger and cache + * @param options - Validated Braintree plugin options + */ protected constructor(container: BraintreeConstructorArgs, options: BraintreeOptions) { super(container, options); @@ -227,6 +348,14 @@ class BraintreeBase extends AbstractPaymentProvider { this.gateway = this.init(); } + /** + * Caches a Braintree client token for a Medusa customer until near expiry. + * No-ops when TTL is non-positive or the token is empty. + * @param clientToken - Token from `clientToken.generate` + * @param customerId - Medusa customer id (required) + * @param expiresOnEpochSeconds - Absolute expiry in unix seconds + * @throws {MedusaError} `INVALID_ARGUMENT` when `customerId` is empty + */ async saveClientTokenToCache(clientToken: string, customerId: string, expiresOnEpochSeconds: number): Promise { if (!customerId) throw new MedusaError(MedusaError.Types.INVALID_ARGUMENT, 'Customer ID is required'); const nowSeconds = Math.floor(Date.now() / 1000); @@ -235,12 +364,21 @@ class BraintreeBase extends AbstractPaymentProvider { await this.cache.set(buildTokenCacheKey(customerId), clientToken, ttlSeconds); } + /** + * Reads a cached client token for a Medusa customer, if present. + * @param customerId - Medusa customer id + * @returns Cached token or `null` + */ async getClientTokenFromCache(customerId: string): Promise { const token = (await this.cache.get(buildTokenCacheKey(customerId))) as string | null; return token; } - /** Logs to console when options.logging is true. Use for debugging. */ + /** + * Logs an info line when `options.logging` is true. + * @param message - Message prefix + * @param context - Optional structured context JSON-stringified onto the line + */ protected logDebug(message: string, context?: Record): void { if (this.options_.logging) { const msg = context ? `${message} ${JSON.stringify(context)}` : message; @@ -248,7 +386,12 @@ class BraintreeBase extends AbstractPaymentProvider { } } - /** When options.logging is true, logs error details to help debug vague failures. */ + /** + * Logs error details when `options.logging` is true (message, context, stack). + * @param operation - Operation label + * @param error - Caught value + * @param context - Optional structured context + */ protected logErrorDetail(operation: string, error: unknown, context?: Record): void { if (!this.options_.logging) return; const msg = error instanceof Error ? error.message : String(error); @@ -258,10 +401,23 @@ class BraintreeBase extends AbstractPaymentProvider { this.logger.info(`[Braintree] ERROR ${operation}: ${msg}${ctx}${stackLine}`); } + /** + * 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'; + return ( + !!this.options_.testForceSettled && this.options_.environment.toLowerCase() === 'sandbox' + ); } + /** + * Returns a usable Braintree client token, preferring cache for known customers. + * Guests always generate a fresh token. `accountHolder` is accepted for API symmetry + * but is not used when generating the token today. + * @param medusaCustomerId - Optional Medusa customer id for cache keying + * @param accountHolder - Optional account holder (unused for token generation) + * @returns Client token string, or `null` only if generation returned none (unusual) + */ async getValidClientToken( medusaCustomerId: string | undefined, accountHolder: PaymentAccountHolderDTO | undefined, @@ -283,6 +439,12 @@ class BraintreeBase extends AbstractPaymentProvider { return generatedToken.clientToken; } + /** + * Validates and normalizes payment session `data` (camelCase → snake_case aliases). + * @param data - Raw session / payment `data` bag + * @returns Normalized {@link BraintreePaymentSessionData} + * @throws {MedusaError} `INVALID_ARGUMENT` when Zod validation fails + */ private async parsePaymentSessionData(data: Record): Promise { const schema = z.object({ clientToken: z.string().optional(), @@ -308,6 +470,10 @@ class BraintreeBase extends AbstractPaymentProvider { return result.data as BraintreePaymentSessionData; } + /** + * Creates (or reuses) the Braintree SDK gateway from plugin options. + * @returns Configured {@link Braintree.BraintreeGateway} + */ init(): Braintree.BraintreeGateway { const envKey = (this.options_.environment || 'sandbox').toLowerCase(); const envMap: Record = { @@ -318,7 +484,7 @@ class BraintreeBase extends AbstractPaymentProvider { }; const environment = envMap[envKey] ?? Braintree.Environment.Sandbox; - this.gateway = + const gateway = this.gateway || new Braintree.BraintreeGateway({ environment, @@ -328,17 +494,20 @@ class BraintreeBase extends AbstractPaymentProvider { }); this.logDebug(`Gateway initialized (environment: ${envKey})`); - return this.gateway; + return gateway; } + /** + * Validates plugin options at module load (Medusa `validateOptions` hook). + * Mutates `options` to apply boolean defaults when unset. + * @param options - Raw provider options + * @throws {MedusaError} `INVALID_ARGUMENT` for missing/invalid fields + */ static validateOptions(options: BraintreeOptions): void { const requiredFields = ['merchantId', 'publicKey', 'privateKey', 'webhookSecret', 'environment']; for (const field of requiredFields) { - if ( - !isDefined(options[field as keyof BraintreeOptions]) || - typeof options[field as keyof BraintreeOptions] !== 'string' - ) { + if (!isDefined(options[field as keyof BraintreeOptions]) || typeof options[field as keyof BraintreeOptions] !== 'string') { throw new MedusaError( MedusaError.Types.INVALID_ARGUMENT, `Required option "${field}" is missing or invalid in Braintree plugin`, @@ -358,14 +527,21 @@ 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' - ) { + if (isDefined(options[field as keyof BraintreeOptions]) && typeof options[field as keyof BraintreeOptions] !== 'boolean') { throw new MedusaError( MedusaError.Types.INVALID_ARGUMENT, `Option "${field}" must be a boolean in Braintree plugin`, @@ -374,6 +550,12 @@ class BraintreeBase extends AbstractPaymentProvider { } } + /** + * Medusa capture hook: submits an authorized transaction for settlement, + * or returns current data if already settling/settled. + * @param input - Payment `data` including Braintree `transaction` + * @throws {MedusaError} `NOT_FOUND` when transaction missing, settle fails, or status is not capturable + */ async capturePayment(input: CapturePaymentInput): Promise { const sessionData = await this.parsePaymentSessionData(input.data ?? {}); const transaction = sessionData.transaction; @@ -425,6 +607,14 @@ class BraintreeBase extends AbstractPaymentProvider { } } + /** + * Medusa authorize hook (cart completion): creates a Braintree sale from the + * payment method nonce when no transaction exists yet, then returns mapped status. + * When `autoCapture` is on and status is authorized, returns `captured`. + * Propagates existing {@link MedusaError} values; wraps unknown errors via {@link rethrowGatewayError}. + * @param input - Session `data` including amount, nonce, optional transaction + * @throws {MedusaError} `INVALID_ARGUMENT` without nonce; auth/gateway errors otherwise + */ async authorizePayment(input: AuthorizePaymentInput): Promise { this.logDebug('authorizePayment', { amount: (input.data as { amount?: number })?.amount, @@ -439,9 +629,7 @@ class BraintreeBase extends AbstractPaymentProvider { throw new MedusaError(MedusaError.Types.INVALID_ARGUMENT, 'Payment method nonce is required'); if (!transaction) { - transaction = await this.createTransaction({ - input, - }); + transaction = await this.createTransaction(input); } const paymentStatusRequest: GetPaymentStatusInput = { @@ -464,15 +652,20 @@ class BraintreeBase extends AbstractPaymentProvider { status: finalStatus, }; } catch (error) { - this.logErrorDetail('authorizePayment', error, { + this.logger.error(`Error authorizing transaction: ${(error as Error).message}`, error as Error); + this.rethrowGatewayError(error, 'authorize payment', { amount: (input.data as { amount?: number })?.amount, currency_code: (input.data as { currency_code?: string })?.currency_code, }); - this.logger.error(`Error authorizing transaction: ${(error as Error).message}`, error as Error); - throw new MedusaError(MedusaError.Types.INVALID_DATA, (error as Error).message ?? 'Unknown error'); } } + /** + * Medusa cancel hook: voids the Braintree transaction when status allows + * (authorized, submitted_for_settlement, or PayPal settling). + * @param input - Payment `data` with transaction id + * @throws {MedusaError} `NOT_FOUND` when void fails or status is not voidable + */ async cancelPayment(input: CancelPaymentInput): Promise { const sessionData = await this.parsePaymentSessionData(input.data ?? {}); this.logDebug('cancelPayment', { transactionId: sessionData.transaction?.id }); @@ -503,6 +696,15 @@ class BraintreeBase extends AbstractPaymentProvider { throw new MedusaError(MedusaError.Types.NOT_FOUND, `Payment cannot be cancelled ${transaction.id}`); } + /** + * Builds a Braintree `TransactionRequest` for `transaction.sale` + * (amount, nonce, vault/3DS/autoCapture options, optional order context). + * @param params.accountHolder - Used for Braintree `customerId` when present + * @param params.customer - Medusa customer (reserved; not mapped into request today) + * @param params.amount - Two-decimal amount string + * @param params.nonce - Payment method nonce (required by caller) + * @param params.context - Optional billing/shipping/line-item context + */ private async getTransactionCreateRequestBody({ accountHolder, customer, @@ -548,6 +750,12 @@ class BraintreeBase extends AbstractPaymentProvider { return transactionRequest; } + /** + * Fetches a transaction by id from Braintree. + * @param id - Braintree transaction id + * @param throwOnMissing - When true (default), throw if the find result is falsy + * @throws {MedusaError} `NOT_FOUND` when missing and `throwOnMissing` is true + */ private async retrieveTransaction(id: string, throwOnMissing: boolean = true): Promise { const transactionData = await this.gateway.transaction.find(id); @@ -557,6 +765,11 @@ class BraintreeBase extends AbstractPaymentProvider { return transactionData; } + /** + * Maps a Braintree transaction status to Medusa {@link PaymentSessionStatus}. + * Unmapped statuses (including declines) fall through to `PENDING`. + * @param status - Braintree transaction status + */ private mapTransactionStatusToPaymentStatus(status: TransactionStatus): PaymentSessionStatus { switch (status) { case 'authorization_expired': @@ -581,6 +794,11 @@ class BraintreeBase extends AbstractPaymentProvider { } } + /** + * Validates initiate-payment `data` and normalizes nonce field aliases. + * @param data - Raw initiate input `data` + * @throws {MedusaError} `INVALID_ARGUMENT` when Zod validation fails + */ private validateInitiatePaymentData(data: Record): BraintreeInitiatePaymentData { const schema = z.object({ paymentMethodNonce: z.string().optional(), @@ -608,6 +826,12 @@ class BraintreeBase extends AbstractPaymentProvider { return result.data; } + /** + * Medusa initiate hook: generates/caches a client token and stores session data. + * Does not create a Braintree sale (sale happens in {@link authorizePayment}). + * @param input - Amount, currency, context (`idempotency_key` = payment session id) + * @throws {MedusaError} `INVALID_ARGUMENT` for bad session id or failed token generation + */ async initiatePayment(input: InitiatePaymentInput): Promise { this.logDebug('initiatePayment', { amount: input.amount, @@ -641,65 +865,154 @@ class BraintreeBase extends AbstractPaymentProvider { }; } - private async createTransaction({ - input, - }: { - input: AuthorizePaymentInput; - }): Promise { - const sessionData = await this.parsePaymentSessionData(input.data ?? {}); + /** + * Re-throws {@link MedusaError} unchanged; otherwise wraps via {@link buildBraintreeError}. + * Uses {@link MedusaError.isMedusaError} so typed errors survive across package boundaries. + * @param error - Caught value + * @param operation - Verb phrase for wrap message + * @param context - Optional log context + * @throws {MedusaError} Always throws + */ + private rethrowGatewayError( + error: unknown, + operation: string, + context?: Record, + ): never { + if (MedusaError.isMedusaError(error)) throw error; + this.logErrorDetail(operation, error, context); + throw buildBraintreeError(error, operation, this.logger, context); + } - const _context = input.context as BraintreeTransactionContext | undefined; + /** + * Fail on gateway error responses; otherwise return the transaction (retrieve if missing). + * @param response - Braintree Result-like payload + * @param operation - Label passed to {@link throwOnBraintreeFailure} + * @param context - Log context + * @param fallbackTransactionId - Id used when `response.transaction` is absent + * @throws {MedusaError} Via {@link throwOnBraintreeFailure} on failure responses + */ + private async requireGatewayTransaction( + response: GatewayTransactionResponse, + operation: string, + context: Record, + fallbackTransactionId: string, + ): Promise { + if (isBraintreeFailureResponse(response)) { + throwOnBraintreeFailure(response, operation, this.logErrorDetail.bind(this), context); + } - const toPayDecimal = formatToTwoDecimalString(Number(sessionData.amount)); + return response.transaction ?? (await this.retrieveTransaction(fallbackTransactionId)); + } - const transactionCreateRequest = await this.getTransactionCreateRequestBody({ - amount: toPayDecimal, - nonce: validateString(sessionData.payment_method_nonce, 'Payment method nonce'), - context: _context, - accountHolder: sessionData.account_holder, - customer: input.context?.customer, + /** + * Executes `gateway.transaction.sale`. Successful Results are returned as-is. + * Failures always throw `PAYMENT_AUTHORIZATION_ERROR` (including validation declines). + * @param request - Sale request body + * @param context - Optional order context (logging only) + * @throws {MedusaError} `PAYMENT_AUTHORIZATION_ERROR` when `success` is false + */ + private async saleTransaction( + request: Braintree.TransactionRequest, + context: BraintreeTransactionContext | undefined, + ): Promise { + this.logDebug('createTransaction (sale)', { + amount: request.amount, + orderId: context?.orderId, }); - try { - this.logDebug('createTransaction (sale)', { - amount: transactionCreateRequest.amount, - orderId: _context?.orderId, - }); - const saleResponse = await this.gateway.transaction.sale(transactionCreateRequest); - - if (!saleResponse.success) { - const errorMessage = getBraintreeErrorMessage(saleResponse); - this.logErrorDetail('transaction.sale failed', new Error(errorMessage), { - transactionId: saleResponse.transaction?.id, - gatewayRejectionReason: saleResponse.transaction?.gatewayRejectionReason, - processorResponseCode: saleResponse.transaction?.processorResponseCode, - processorResponseText: saleResponse.transaction?.processorResponseText, - validationErrors: getBraintreeValidationErrors(saleResponse.errors).map(formatBraintreeValidationError), - }); - throw new MedusaError(MedusaError.Types.PAYMENT_AUTHORIZATION_ERROR, errorMessage); - } + const saleResponse = await this.gateway.transaction.sale(request); + if (saleResponse.success) return saleResponse; + + // Sales always surface as authorization failures (even validation declines). + const errorMessage = getBraintreeErrorMessage(saleResponse); + this.logErrorDetail('transaction.sale failed', new Error(errorMessage), { + transactionId: saleResponse.transaction?.id, + gatewayRejectionReason: saleResponse.transaction?.gatewayRejectionReason, + processorResponseCode: saleResponse.transaction?.processorResponseCode, + processorResponseText: saleResponse.transaction?.processorResponseText, + validationErrors: getBraintreeValidationErrors(saleResponse.errors).map(formatBraintreeValidationError), + }); + throw new MedusaError(MedusaError.Types.PAYMENT_AUTHORIZATION_ERROR, errorMessage); + } + + /** + * Retrieve the created sale; void it if session sync fails so we don't leave an orphan auth. + * A failed void is logged but does not replace the original sync error. + * @param saleResponse - Successful sale Result (must include `transaction.id`) + * @throws {MedusaError} `INVALID_DATA` when the sale Result has no transaction id, or via + * {@link buildBraintreeError} for the sync failure + */ + private async retrieveOrVoidSale(saleResponse: TransactionSaleResponse): Promise { + const transactionId = saleResponse.transaction?.id; + + if (!transactionId) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + 'Braintree sale succeeded without a transaction id', + ); + } + + try { + return await this.retrieveTransaction(transactionId); + } catch (error) { + this.logErrorDetail('sync payment session (retrieveTransaction)', error, { transactionId }); try { - return await this.retrieveTransaction(saleResponse.transaction.id); - } catch (error) { - this.logErrorDetail('sync payment session (retrieveTransaction)', error, { - transactionId: saleResponse.transaction?.id, - }); - if (saleResponse.transaction?.id) { - await this.gateway.transaction.void(saleResponse.transaction.id); + const voidResponse = await this.gateway.transaction.void(transactionId); + if (isBraintreeFailureResponse(voidResponse)) { + const voidMessage = getBraintreeErrorMessage(voidResponse); + this.logErrorDetail('void orphan sale after sync failure', new Error(voidMessage), { + transactionId, + transactionStatus: voidResponse.transaction?.status, + }); + this.logger.error( + `Failed to void orphan Braintree transaction ${transactionId} after sync failure: ${voidMessage}`, + ); } - throw buildBraintreeError(error, 'sync payment session', this.logger, { - transactionId: saleResponse.transaction?.id, - }); + } catch (voidError) { + this.logErrorDetail('void orphan sale after sync failure', voidError, { transactionId }); + this.logger.error( + `Failed to void orphan Braintree transaction ${transactionId} after sync failure`, + voidError instanceof Error ? voidError : undefined, + ); } + throw buildBraintreeError(error, 'sync payment session', this.logger, { transactionId }); + } + } + + /** + * Creates a Braintree sale from authorize input (amount + nonce + context). + * @param input - Authorize payment input with session data + * @returns Created Braintree transaction + * @throws {MedusaError} Propagated from sale / sync / {@link rethrowGatewayError} + */ + private async createTransaction(input: AuthorizePaymentInput): Promise { + const sessionData = await this.parsePaymentSessionData(input.data ?? {}); + const context = input.context as BraintreeTransactionContext | undefined; + const request = await this.getTransactionCreateRequestBody({ + amount: formatToTwoDecimalString(Number(sessionData.amount)), + nonce: validateString(sessionData.payment_method_nonce, 'Payment method nonce'), + context, + accountHolder: sessionData.account_holder, + customer: input.context?.customer, + }); + + try { + const saleResponse = await this.saleTransaction(request, context); + return await this.retrieveOrVoidSale(saleResponse); } catch (error) { - this.logErrorDetail('create Braintree transaction', error, { - amount: transactionCreateRequest.amount, - orderId: _context?.orderId, + this.rethrowGatewayError(error, 'create Braintree transaction', { + amount: request.amount, + orderId: context?.orderId, }); - throw buildBraintreeError(error, 'create Braintree transaction', this.logger); } } + /** + * Medusa delete-payment hook: cancels (voids) when a transaction exists; + * otherwise returns `data` unchanged. + * @param input - Payment `data` + * @throws {MedusaError} Via {@link rethrowGatewayError} when cancel fails + */ async deletePayment(input: DeletePaymentInput): Promise { const sessionData = await this.parsePaymentSessionData(input.data ?? {}); const transaction = sessionData.transaction; @@ -716,8 +1029,7 @@ class BraintreeBase extends AbstractPaymentProvider { }, }; } catch (e) { - this.logErrorDetail('delete Braintree payment', e, { transactionId: transaction?.id }); - throw buildBraintreeError(e, 'delete Braintree payment', this.logger); + this.rethrowGatewayError(e, 'delete Braintree payment', { transactionId: transaction?.id }); } } else { return { @@ -729,6 +1041,12 @@ class BraintreeBase extends AbstractPaymentProvider { } } + /** + * Medusa status hook: maps live Braintree transaction status to {@link PaymentSessionStatus}. + * Accepts `data.transaction` or `data.braintreeTransaction`. Missing id → `PENDING`. + * @param input - Session/payment data containing a transaction id + * @throws Re-throws raw gateway errors from `transaction.find` (unchanged) + */ async getPaymentStatus(input: GetPaymentStatusInput): Promise { // Support both `data.transaction` and `data.braintreeTransaction` without requiring full session parsing const tx = (input.data?.transaction ?? input.data?.braintreeTransaction) as Transaction | undefined; @@ -751,6 +1069,12 @@ class BraintreeBase extends AbstractPaymentProvider { return { status }; } + /** + * Medusa save-payment-method hook: vaults a nonce onto the Braintree customer + * identified by `context.account_holder.data.id`. + * @param input - Session data with nonce + account holder context + * @throws {MedusaError} `INVALID_ARGUMENT` without customer id/nonce; `INVALID_DATA` on create failure + */ async savePaymentMethod(input: SavePaymentMethodInput): Promise { this.logDebug('savePaymentMethod', { accountHolderId: input.context?.account_holder?.data?.id, @@ -793,129 +1117,207 @@ class BraintreeBase extends AbstractPaymentProvider { }; } - async refundPayment(input: RefundPaymentInput): Promise { + /** + * Reads refund/void history from session data. + * Prefers `braintreeRefunds` (0.1.8+). Falls back to an array on `braintreeRefund` + * (0.2.0-next regression). Non-array values on either key are discarded. + * @param data - Payment session `data` bag + */ + private readRefundHistory(data: Record | undefined): BraintreeRefundHistoryEntry[] { + const fromPlural = data?.braintreeRefunds; + if (Array.isArray(fromPlural)) { + return fromPlural as BraintreeRefundHistoryEntry[]; + } + if (fromPlural !== undefined) { + this.logger.warn('[Braintree] Discarding legacy non-array braintreeRefunds session data'); + } + + const fromSingular = data?.braintreeRefund; + if (Array.isArray(fromSingular)) { + return fromSingular as BraintreeRefundHistoryEntry[]; + } + if (fromSingular !== undefined) { + this.logger.warn('[Braintree] Discarding legacy non-array braintreeRefund session data'); + } + + return []; + } + + /** + * Builds refund output `data`, appending one entry to `braintreeRefunds` history. + * Reads prior history from `braintreeRefunds` (preferred) or leftover `braintreeRefund`. + * Writes only `braintreeRefunds` so the two keys cannot drift. + * @param input - Original refund input (prior history read from session `data`) + * @param transaction - Pre-refund Braintree transaction retained on session data + * @param entry - New void/refund history entry + */ + private buildRefundPaymentOutput( + input: RefundPaymentInput, + transaction: Transaction, + entry: BraintreeRefundHistoryEntry, + ): RefundPaymentOutput { + const prior = this.readRefundHistory(input.data); + const data = { ...(input.data ?? {}) }; + delete data.braintreeRefund; + + return { + data: { + ...data, + transaction, + braintreeRefunds: [...prior, entry], + }, + }; + } + + /** + * Loads and validates refund amount + live transaction for {@link refundPayment}. + * @param input - Refund input with amount and session transaction + * @throws {MedusaError} `INVALID_DATA` for bad amount; `NOT_FOUND` without transaction id + */ + private async loadRefundContext(input: RefundPaymentInput): Promise<{ + transaction: Transaction; + refundAmount: number; + }> { const sessionData = await this.parsePaymentSessionData(input.data ?? {}); this.logDebug('refundPayment', { transactionId: sessionData.transaction?.id, amount: input.amount, }); - const refundAmountBN = MathBN.convert(input.amount, 2); - const refundAmount = refundAmountBN.toNumber(); - - if (!refundAmount) throw new MedusaError(MedusaError.Types.INVALID_DATA, 'Refund amount is invalid'); + const refundAmount = MathBN.convert(input.amount, 2).toNumber(); + if (!refundAmount) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, 'Refund amount is invalid'); + } const transactionId = sessionData.transaction?.id; if (!transactionId) { throw new MedusaError(MedusaError.Types.NOT_FOUND, 'Braintree transaction not found'); } - let transaction = await this.retrieveTransaction(transactionId); + return { + transaction: await this.retrieveTransaction(transactionId), + refundAmount, + }; + } - let shouldVoid = ['submitted_for_settlement', 'authorized'].includes(transaction.status); + /** + * Sandbox-only: force settle so refund paths can be exercised in tests. + * No-ops unless `testForceSettled` is true and environment is sandbox. + * @param transaction - Transaction to optionally settle + */ + private async applyTestForceSettled(transaction: Transaction): Promise { + if (!this.options_.testForceSettled) return transaction; + + if (!this.isTestForceSettledEnabled()) { + this.logger.warn( + '[Braintree refund] testForceSettled ignored — only supported when environment is sandbox', + ); + return transaction; + } + + await this.gateway.testing.settle(transaction.id); + return this.retrieveTransaction(transaction.id); + } - if (process.env.TEST_FORCE_SETTLED === 'true') { - if (!this.isTestForceSettledEnabled()) { - this.logger.warn('[Braintree refund] TEST_FORCE_SETTLED ignored — only supported when environment is sandbox'); - } else { - shouldVoid = false; - await this.gateway.testing.settle(transaction.id); - transaction = await this.retrieveTransaction(transaction.id); + /** + * 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 }; } - const shouldRefund = ['settled', 'settling'].includes(transaction.status); + if (isSettledRefundStatus(resolved.status)) { + return { kind: 'refund', transaction: resolved }; + } - if (shouldVoid) { - const voidResponse = await this.gateway.transaction.void(transaction.id); + this.logger.error( + `Braintree transaction with ID ${resolved.id} cannot be refunded because it's in status ${resolved.status}`, + ); + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Braintree transaction with ID ${resolved.id} cannot be refunded`, + ); + } - this.logDebug('refundPayment void response', { response: voidResponse }); + /** + * Executes void or refund against Braintree for the resolved {@link RefundAction}. + * @param action - Void or refund with target transaction + * @param refundAmount - Amount used for refund calls (ignored for void) + * @throws {MedusaError} Via {@link requireGatewayTransaction} / {@link rethrowGatewayError} + */ + private async executeRefundAction(action: RefundAction, refundAmount: number): Promise { + const { transaction, kind } = action; - if (isBraintreeFailureResponse(voidResponse)) { - throwOnBraintreeFailure(voidResponse, 'refundPayment (void)', this.logErrorDetail.bind(this), { - transactionId: transaction.id, - }); + try { + if (kind === 'voided') { + const response = await this.gateway.transaction.void(transaction.id); + this.logDebug('refundPayment void response', { response }); + return await this.requireGatewayTransaction( + response, + 'refundPayment (void)', + { transactionId: transaction.id }, + transaction.id, + ); } - const voidedTransaction = voidResponse?.transaction ?? (await this.retrieveTransaction(transaction.id)); - const braintreeRefund = { - success: true, - transactionId: voidedTransaction?.id, - type: 'void', - }; - const priorRefunds = Array.isArray(input.data?.braintreeRefunds) ? input.data.braintreeRefunds : []; - // we need to preserve the original transaction data and store the refund history separately - const refundResult: RefundPaymentOutput = { - data: { - ...input.data, - transaction: transaction, - braintreeRefunds: [...priorRefunds, braintreeRefund], - }, - }; - - return refundResult; - } + const refundAmountDecimal = formatToTwoDecimalString(refundAmount); + this.logger.info( + `Refunding transaction: ${transaction.id} with amount: ${refundAmountDecimal} (created from ${refundAmount})`, + ); - if (!shouldRefund) { - this.logger.error( - `Braintree transaction with ID ${transaction.id} cannot be refunded because it's in status ${transaction.status}`, + const response = await this.gateway.transaction.refund(transaction.id, refundAmountDecimal); + this.logDebug('refundPayment refund response', { response }); + return await this.requireGatewayTransaction( + response, + 'refundPayment (refund)', + { transactionId: transaction.id, refundAmount: refundAmountDecimal }, + transaction.id, ); - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `Braintree transaction with ID ${transaction.id} cannot be refunded because it's in status ${transaction.status}`, + } catch (error) { + this.rethrowGatewayError( + error, + kind === 'voided' ? 'void Braintree transaction' : 'create Braintree refund', + { transactionId: transaction.id }, ); } + } - if (transaction.id) { - const refundAmountDecimal = formatToTwoDecimalString(refundAmount); - try { - this.logger.info( - `Refunding transaction: ${transaction.id} with amount: ${refundAmountDecimal} (created from ${refundAmount})`, - ); - - const refundResponse = await this.gateway.transaction.refund(transaction.id, refundAmountDecimal); - - this.logDebug('refundPayment refund response', { response: refundResponse }); - - if (isBraintreeFailureResponse(refundResponse)) { - throwOnBraintreeFailure(refundResponse, 'refundPayment (refund)', this.logErrorDetail.bind(this), { - transactionId: transaction.id, - refundAmount: refundAmountDecimal, - }); - } - - const refundTransaction = refundResponse.transaction ?? (await this.retrieveTransaction(transaction.id)); - - const braintreeRefund = { - success: true, - transactionId: refundTransaction?.id, - type: 'refund', - }; - const priorRefunds = Array.isArray(input.data?.braintreeRefunds) ? input.data.braintreeRefunds : []; - // we need to preserve the original transaction data and store the refund history separately. This is to support multiple partial refunds - - const refundResult: RefundPaymentOutput = { - data: { - ...input.data, - transaction: transaction, - braintreeRefunds: [...priorRefunds, braintreeRefund], - }, - }; - - return refundResult; - } catch (e) { - if (e instanceof MedusaError) throw e; - this.logErrorDetail('create Braintree refund', e, { - transactionId: transaction.id, - refundAmount: refundAmountDecimal, - }); - throw buildBraintreeError(e, 'create Braintree refund', this.logger); - } - } + /** + * Medusa refund hook: voids or refunds based on transaction status and + * appends history under `data.braintreeRefunds`. + * @param input - Amount + session transaction + */ + async refundPayment(input: RefundPaymentInput): Promise { + const { transaction, refundAmount } = await this.loadRefundContext(input); + const action = await this.resolveRefundAction(transaction); + const resultTransaction = await this.executeRefundAction(action, refundAmount); - throw new MedusaError(MedusaError.Types.NOT_FOUND, `Braintree transaction with ID ${transaction.id} not found`); + return this.buildRefundPaymentOutput(input, action.transaction, { + type: action.kind, + transaction: resultTransaction, + }); } + /** + * Medusa retrieve hook: refreshes the Braintree transaction on payment `data`. + * @param input - Payment `data` with transaction id + * @throws {MedusaError} `NOT_FOUND` when transaction id is missing + */ async retrievePayment(input: RetrievePaymentInput): Promise { const paymentSessionData = await this.parsePaymentSessionData(input.data ?? {}); this.logDebug('retrievePayment', { transactionId: paymentSessionData.transaction?.id }); @@ -934,6 +1336,11 @@ class BraintreeBase extends AbstractPaymentProvider { }; } + /** + * Medusa update-payment hook: updates amount/currency on session `data` only + * (no Braintree API call). + * @param input - New amount and currency + */ async updatePayment(input: UpdatePaymentInput): Promise { this.logDebug('updatePayment', { amount: input.amount, currency_code: input.currency_code }); return Promise.resolve({ @@ -945,6 +1352,11 @@ class BraintreeBase extends AbstractPaymentProvider { }); } + /** + * Medusa create-account-holder hook: creates a Braintree customer from Medusa customer fields. + * @param input - Context with Medusa customer + * @throws {MedusaError} `INVALID_DATA` when Braintree customer create fails + */ async createAccountHolder(input: CreateAccountHolderInput): Promise { this.logDebug('createAccountHolder', { customerId: input.context.customer?.id }); const customer = await this.createBraintreeCustomer(input.context.customer); @@ -957,6 +1369,12 @@ class BraintreeBase extends AbstractPaymentProvider { }; } + /** + * Medusa update-account-holder hook: updates Braintree customer fields. + * Propagates existing {@link MedusaError}; wraps unknown errors via {@link buildBraintreeError}. + * @param input - Account holder id + customer patch fields + * @throws {MedusaError} `INVALID_DATA` / `NOT_FOUND` / wrapped gateway failures + */ async updateAccountHolder(input: UpdateAccountHolderInput): Promise { const { context } = input; const accountHolderId = context.account_holder?.data?.id as string; @@ -994,10 +1412,17 @@ class BraintreeBase extends AbstractPaymentProvider { }; } catch (e) { this.logErrorDetail('update account holder', e, { accountHolderId }); - throw buildBraintreeError(e, 'update account holder', this.logger); + if (MedusaError.isMedusaError(e)) throw e; + throw buildBraintreeError(e, 'update account holder', this.logger, { accountHolderId }); } } + /** + * Medusa delete-account-holder hook: deletes the Braintree customer. + * Propagates existing {@link MedusaError}; wraps unknown errors via {@link buildBraintreeError}. + * @param input - Account holder id in context + * @throws {MedusaError} `INVALID_DATA` / `NOT_FOUND` / wrapped gateway failures + */ async deleteAccountHolder(input: DeleteAccountHolderInput): Promise { const { context } = input; @@ -1019,63 +1444,92 @@ class BraintreeBase extends AbstractPaymentProvider { }; } catch (e) { this.logErrorDetail('delete account holder', e, { accountHolderId }); - throw buildBraintreeError(e, 'delete account holder', this.logger); + if (MedusaError.isMedusaError(e)) throw e; + throw buildBraintreeError(e, 'delete account holder', this.logger, { accountHolderId }); } } - async getWebhookActionAndData(webhookData: ProviderWebhookPayload['payload']): Promise { - const logger = this.logger; - - this.logDebug('getWebhookActionAndData', { hasData: !!webhookData?.data }); - logger.info(`Received Braintree webhook with data: ${!!webhookData.data}`); - + /** + * Parses a Braintree webhook notification from form-encoded signature + payload. + * @param webhookData - Provider webhook payload from Medusa + * @returns Parsed notification, or `null` when parse succeeds with an empty body + * or when signature/payload validation fails (logged; avoids Braintree retries) + */ + private async parseWebhookNotification( + webhookData: ProviderWebhookPayload['payload'], + ): Promise { const decodedPayload = new URLSearchParams(webhookData.data as unknown as string); - let notification: TransactionNotification; try { - notification = (await this.gateway.webhookNotification.parse( + const notification = (await this.gateway.webhookNotification.parse( decodedPayload.get('bt_signature') ?? '', decodedPayload.get('bt_payload') ?? '', - )) as TransactionNotification; + )) as TransactionNotification | null; - if (!notification) { - return { action: PaymentActions.FAILED }; - } + return notification ?? null; } catch (error) { this.logErrorDetail('webhook validation', error, { hasPayload: !!webhookData?.data }); - logger.error(`Braintree webhook validation failed : ${error}`); + this.logger.error(`Braintree webhook validation failed : ${error}`); + // Permanently invalid signatures/payloads cannot succeed on retry; return null so + // getWebhookActionAndData maps to NOT_SUPPORTED (Medusa 2xx) instead of throwing. + return null; + } + } - return { action: PaymentActions.FAILED }; + /** + * Maps Braintree webhook `kind` to Medusa {@link PaymentActions}. + * Unknown kinds → `NOT_SUPPORTED`. + * @param kind - Braintree notification kind + */ + private mapWebhookKindToAction(kind: TransactionNotification['kind']): PaymentActions { + switch (kind) { + case 'transaction_settled': + return PaymentActions.SUCCESSFUL; + case 'transaction_settlement_declined': + return PaymentActions.FAILED; + default: + return PaymentActions.NOT_SUPPORTED; } + } - const paymentData = await this.gateway.transaction.find(notification.transaction.id); + /** + * Medusa webhook hook: parses the notification and returns action + session_id/amount. + * Empty/null notification or permanent parse/signature failures → `NOT_SUPPORTED`. + * Missing custom field session id → empty string. + * @param webhookData - Raw provider webhook payload + */ + async getWebhookActionAndData(webhookData: ProviderWebhookPayload['payload']): Promise { + this.logDebug('getWebhookActionAndData', { hasData: !!webhookData?.data }); + this.logger.info(`Received Braintree webhook with data: ${!!webhookData.data}`); - const customFields = paymentData.customFields as CustomFields; + const notification = await this.parseWebhookNotification(webhookData); + if (!notification) { + return { action: PaymentActions.NOT_SUPPORTED }; + } - switch (notification.kind) { - case 'transaction_settled': - return { - action: PaymentActions.SUCCESSFUL, - data: { - session_id: customFields.medusa_payment_session_id ?? '', - amount: paymentData.amount, - }, - }; + const action = this.mapWebhookKindToAction(notification.kind); + if (action === PaymentActions.NOT_SUPPORTED) { + return { action }; + } - case 'transaction_settlement_declined': - return { - action: PaymentActions.FAILED, - data: { - session_id: customFields.medusa_payment_session_id ?? '', - amount: paymentData.amount, - }, - }; + const paymentData = await this.gateway.transaction.find(notification.transaction.id); + const customFields = (paymentData.customFields ?? {}) as CustomFields; + const sessionId = customFields.medusa_payment_session_id ?? ''; - default: - return { action: PaymentActions.NOT_SUPPORTED }; - } + return { + action, + data: { + session_id: sessionId, + amount: paymentData.amount, + }, + }; } + /** + * Creates a Braintree customer from a Medusa {@link PaymentCustomerDTO}. + * @param customer - Medusa customer fields + * @throws {MedusaError} `INVALID_DATA` when `customer.create` is unsuccessful + */ private async createBraintreeCustomer(customer: PaymentCustomerDTO): Promise { const customerResult = await this.gateway.customer.create({ email: customer.email, 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 = {