diff --git a/plugins/braintree-payment/README.md b/plugins/braintree-payment/README.md index a349c1a5..c8068f8c 100644 --- a/plugins/braintree-payment/README.md +++ b/plugins/braintree-payment/README.md @@ -116,7 +116,7 @@ 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”). +- **disableVoidTransactions**: When `true`, refunds never void; only `settled`/`settling` transactions may be refunded. Late requirement so future partial order refunds and order edits can be supported (void cancels the full authorization). Default: `false`. With this enabled, `authorized`/`submitted_for_settlement` refunds fail with `INVALID_DATA` (“cannot be refunded right now because it's in status …”); other non-refundable statuses fail with `NOT_FOUND` (“cannot be refunded because it's in status …”). - **logging**: Enable verbose plugin debug logging (`true` or `false`, default: `false`). When `true`, the provider logs operation details (initiate, authorize, capture, refund, etc.) and expanded Braintree error context via Medusa's logger with a `[Braintree]` prefix. Set via `BRAINTREE_LOGGING=true` in `.env` or pass `logging: true` directly in provider options. Disable in production unless actively debugging. - **testForceSettled**: **Sandbox only.** When `true` **and** `environment` is `sandbox`, the refund flow settles the Braintree transaction via the sandbox testing API before attempting a refund. Use this to exercise the **refund** path (settled/settling) instead of the **void** path (authorized/submitted_for_settlement). Defaults to `false`. Ignored (with a warning) outside sandbox. Set via `TEST_FORCE_SETTLED=true` in `.env` wired to this option, or pass `testForceSettled: true` directly. Do not enable in production. @@ -156,7 +156,7 @@ Earlier README examples used `logging: process.env.NODE_ENV !== 'production'` (a ### Upgrading to 0.2.2 > **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. +> - `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 because it's in status …”); other statuses fail with `NOT_FOUND` (“cannot be refunded because it's in status …”). `cancelPayment` may still void. ### 3D Secure Setup diff --git a/plugins/braintree-payment/package.json b/plugins/braintree-payment/package.json index ed11c829..bb7fc66a 100644 --- a/plugins/braintree-payment/package.json +++ b/plugins/braintree-payment/package.json @@ -1,6 +1,6 @@ { "name": "@lambdacurry/medusa-payment-braintree", - "version": "0.2.2", + "version": "0.2.4", "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 6d567247..50d7cda7 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 @@ -11,6 +11,7 @@ type RefundHistoryEntry = { }; type RefundResultData = { + transaction?: { id?: string }; braintreeRefund?: RefundHistoryEntry[] | Record; braintreeRefunds?: RefundHistoryEntry[]; }; @@ -221,6 +222,7 @@ describe('BraintreeProviderService core behaviors', () => { const result = await service.refundPayment(input); expect(gateway.transaction.void).toHaveBeenCalledWith('t1'); + expect((result.data as RefundResultData)?.transaction?.id).toBe('t1'); const entry = lastRefundEntry(result.data); expect(entry?.type).toBe('voided'); expect(entry?.transaction?.id).toBe('t1'); @@ -243,7 +245,7 @@ describe('BraintreeProviderService core behaviors', () => { await expect(service.refundPayment(input)).rejects.toMatchObject({ type: MedusaError.Types.INVALID_DATA, - message: 'Braintree transaction with ID t1 cannot be refunded right now', + message: "Braintree transaction with ID t1 cannot be refunded right now because it's in status authorized", }); expect(gateway.transaction.void).not.toHaveBeenCalled(); expect(gateway.transaction.refund).not.toHaveBeenCalled(); @@ -264,7 +266,9 @@ describe('BraintreeProviderService core behaviors', () => { expect(gateway.transaction.void).not.toHaveBeenCalled(); expect(gateway.transaction.refund).toHaveBeenCalledWith('t-settled', '10.00'); + expect((result.data as RefundResultData)?.transaction?.id).toBe('t-settled'); expect(lastRefundEntry(result.data)?.type).toBe('refund'); + expect(lastRefundEntry(result.data)?.transaction?.id).toBe('r-settled'); }); it('refundPayment appends to existing braintreeRefunds history', async () => { @@ -294,6 +298,7 @@ describe('BraintreeProviderService core behaviors', () => { const result = await service.refundPayment(input); const history = (result.data as RefundResultData)?.braintreeRefunds; + expect((result.data as RefundResultData)?.transaction?.id).toBe('t1'); expect(history).toHaveLength(2); expect(history?.[0]).toMatchObject(priorEntry); expect(history?.[1]?.type).toBe('refund'); @@ -301,6 +306,42 @@ describe('BraintreeProviderService core behaviors', () => { expect((result.data as RefundResultData)?.braintreeRefund).toBeUndefined(); }); + it('refundPayment keeps the original sale id across sequential partial refunds', async () => { + const { service, gateway } = buildService(); + + const input: RefundPaymentInput = { + amount: 3, + data: { + client_token: 'ct', + amount: 1000, + currency_code: 'USD', + braintreeTransaction: { id: 't1' }, + }, + }; + + gateway.transaction.find.mockResolvedValue({ id: 't1', status: 'settled' }); + gateway.transaction.refund + .mockResolvedValueOnce({ success: true, transaction: { id: 'r1' } }) + .mockResolvedValueOnce({ success: true, transaction: { id: 'r2' } }); + + const first = await service.refundPayment(input); + const firstData = first.data as RefundResultData; + + expect(gateway.transaction.refund).toHaveBeenCalledWith('t1', '3.00'); + expect(firstData?.transaction?.id).toBe('t1'); + expect(firstData?.braintreeRefunds).toHaveLength(1); + expect(firstData?.braintreeRefunds?.[0]?.transaction?.id).toBe('r1'); + + const second = await service.refundPayment({ amount: 2, data: first.data }); + const secondData = second.data as RefundResultData; + + expect(gateway.transaction.refund).toHaveBeenNthCalledWith(2, 't1', '2.00'); + expect(secondData?.transaction?.id).toBe('t1'); + expect(secondData?.braintreeRefunds).toHaveLength(2); + expect(secondData?.braintreeRefunds?.[0]?.transaction?.id).toBe('r1'); + expect(secondData?.braintreeRefunds?.[1]?.transaction?.id).toBe('r2'); + }); + it('refundPayment migrates leftover braintreeRefund array onto braintreeRefunds', async () => { const { service, gateway } = buildService(); const priorEntry = { @@ -419,6 +460,7 @@ describe('BraintreeProviderService core behaviors', () => { const result = await service.refundPayment(input); expect(gateway.transaction.refund).toHaveBeenCalledWith('t2', '7.50'); + expect((result.data as RefundResultData)?.transaction?.id).toBe('t2'); const entry = lastRefundEntry(result.data); expect(entry?.type).toBe('refund'); expect(entry?.transaction?.id).toBe('r2'); @@ -439,7 +481,33 @@ describe('BraintreeProviderService core behaviors', () => { gateway.transaction.find.mockResolvedValueOnce({ id: 't3', status: 'failed' }); - await expect(service.refundPayment(input)).rejects.toThrow(); + await expect(service.refundPayment(input)).rejects.toMatchObject({ + type: MedusaError.Types.NOT_FOUND, + message: "Braintree transaction with ID t3 cannot be refunded because it's in status failed", + }); + expect(gateway.transaction.void).not.toHaveBeenCalled(); + expect(gateway.transaction.refund).not.toHaveBeenCalled(); + }); + + it('refundPayment throws when transaction is already voided', async () => { + const { service, gateway } = buildService(); + + const input: RefundPaymentInput = { + amount: 10, + data: { + clientToken: 'ct', + amount: 1000, + currency_code: 'USD', + braintreeTransaction: { id: 't-voided' }, + }, + }; + + gateway.transaction.find.mockResolvedValueOnce({ id: 't-voided', status: 'voided' }); + + await expect(service.refundPayment(input)).rejects.toMatchObject({ + type: MedusaError.Types.NOT_FOUND, + message: "Braintree transaction with ID t-voided cannot be refunded because it's in status voided", + }); expect(gateway.transaction.void).not.toHaveBeenCalled(); expect(gateway.transaction.refund).not.toHaveBeenCalled(); }); @@ -465,6 +533,7 @@ describe('BraintreeProviderService core behaviors', () => { const result = await service.refundPayment(input); expect(gateway.transaction.refund).toHaveBeenCalledWith('t2', '5.00'); + expect((result.data as RefundResultData)?.transaction?.id).toBe('t2'); const entry = lastRefundEntry(result.data); expect(entry?.type).toBe('refund'); expect(entry?.transaction?.id).toBe('r1'); @@ -621,6 +690,7 @@ 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)?.transaction?.id).toBe('t-force'); const forceEntry = lastRefundEntry(result.data); expect(forceEntry?.type).toBe('refund'); expect(forceEntry?.transaction?.id).toBe('r-force'); 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 ed7db4b2..edd8efcb 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 @@ -89,7 +89,20 @@ describe('BraintreeImportService', () => { 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', + message: "Braintree transaction with ID t2 cannot be refunded right now because it's in status authorized", + }); + expect(gateway.transaction.void).not.toHaveBeenCalled(); + expect(gateway.transaction.refund).not.toHaveBeenCalled(); + }); + + it('throws when transaction is already voided', async () => { + const { service, gateway } = buildService(); + const session = { transactionId: 't-voided', importedAsRefunded: false, refundedTotal: 0, status: 'captured' } as any; + gateway.transaction.find.mockResolvedValueOnce({ id: 't-voided', status: 'voided' }); + + await expect(service.refundPayment({ amount: 10, data: session } as any)).rejects.toMatchObject({ + type: MedusaError.Types.NOT_FOUND, + message: "Braintree transaction with ID t-voided cannot be refunded because it's in status voided", }); expect(gateway.transaction.void).not.toHaveBeenCalled(); expect(gateway.transaction.refund).not.toHaveBeenCalled(); 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 accb59c6..95ec7612 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 @@ -1110,8 +1110,8 @@ class BraintreeBase extends AbstractPaymentProvider { /** * Reads refund/void history from session data. - * Prefers `braintreeRefunds` (0.1.8+). Falls back to an array on `braintreeRefund` - * (0.2.0-next / pre-0.2.2 regression). Non-array values on either key are discarded. + * Prefers `braintreeRefunds`. Falls back to an array on `braintreeRefund`. + * Non-array values on either key are discarded. * @param data - Payment session `data` bag */ private readRefundHistory(data: Record | undefined): BraintreeRefundHistoryEntry[] { @@ -1138,9 +1138,11 @@ class BraintreeBase extends AbstractPaymentProvider { * 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. + * Session `data.transaction` stays the original sale so later partial refunds + * still target that id. Credit/void results live only on `braintreeRefunds[]`. * @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 + * @param transaction - Original sale retained on session data (never the credit) + * @param entry - New void/refund history entry (credit or voided sale) */ private buildRefundPaymentOutput( input: RefundPaymentInput, @@ -1219,12 +1221,9 @@ class BraintreeBase extends AbstractPaymentProvider { 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`, + `Braintree transaction with ID ${resolved.id} cannot be refunded right now because it's in status ${resolved.status}`, ); } return { kind: 'voided', transaction: resolved }; @@ -1234,17 +1233,16 @@ class BraintreeBase extends AbstractPaymentProvider { return { kind: 'refund', transaction: resolved }; } - 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`, + `Braintree transaction with ID ${resolved.id} cannot be refunded because it's in status ${resolved.status}`, ); } /** * Executes void or refund against Braintree for the resolved {@link RefundAction}. + * Always calls void/refund with the original sale id. The returned transaction is + * the gateway record (credit `r1` or voided sale) for `braintreeRefunds[]` only. * @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} @@ -1287,6 +1285,8 @@ class BraintreeBase extends AbstractPaymentProvider { /** * Medusa refund hook: voids or refunds based on transaction status and * appends history under `data.braintreeRefunds`. + * Keeps the original sale on `data.transaction` so later partial refunds + * still use that id. Gateway credits/voids are records on `braintreeRefunds[]`. * @param input - Amount + session transaction */ async refundPayment(input: RefundPaymentInput): Promise { 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 38594e62..3f63c6bf 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 @@ -286,12 +286,9 @@ class BraintreeImport extends AbstractPaymentProvider { 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`, + `Braintree transaction with ID ${transaction.id} cannot be refunded right now because it's in status ${transaction.status}`, ); } @@ -317,7 +314,7 @@ class BraintreeImport extends AbstractPaymentProvider { if (!shouldRefund) { throw new MedusaError( MedusaError.Types.NOT_FOUND, - `Braintree transaction with ID ${transaction.id} cannot be refunded`, + `Braintree transaction with ID ${transaction.id} cannot be refunded because it's in status ${transaction.status}`, ); }