Skip to content

Commit a21d6a2

Browse files
committed
Merge branch 'main' of https://github.com/lambda-curry/medusa-plugins into mohsen/360t-898-braintree-fraud-detection-cases-are-not-covered
2 parents 87ad0e2 + 513d617 commit a21d6a2

11 files changed

Lines changed: 233 additions & 189 deletions

File tree

plugins/braintree-payment/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ dependencies:[Modules.CACHE]
6161
id: 'braintree',
6262
options: {
6363
environment: process.env.BRAINTREE_ENVIRONMENT || (process.env.NODE_ENV !== 'production' ? 'sandbox' : 'production'),
64+
defaultCurrencyCode: "USD",
6465
merchantId: process.env.BRAINTREE_MERCHANT_ID,
6566
publicKey: process.env.BRAINTREE_PUBLIC_KEY,
6667
privateKey: process.env.BRAINTREE_PRIVATE_KEY,
@@ -75,16 +76,19 @@ dependencies:[Modules.CACHE]
7576
#### Options
7677

7778
- **merchantId**: Your Braintree Merchant ID.
79+
- **defaultCurrencyCode**: An optional field to indicate default currency code
7880
- **publicKey**: Your Braintree Public Key.
7981
- **privateKey**: Your Braintree Private Key.
8082
- **webhookSecret**: Secret for validating Braintree webhooks.
8183
- **enable3DSecure**: Enable 3D Secure authentication (`true` or `false`).
8284
- **savePaymentMethod**: Save payment methods for future use (default: `true`).
8385
- **autoCapture**: Automatically capture payments (default: `true`).
86+
- **allowRefundOnRefunded**: Allow refund attempts on already-refunded imported transactions (default: `false`).
8487

8588
> **Note:**
8689
> - `autoCapture`: If set to `true`, payments are captured automatically after authorization.
8790
> - `savePaymentMethod`: If set to `true`, customer payment methods are saved for future use.
91+
> - `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.
8892
8993
### 3D Secure Setup
9094

@@ -160,4 +164,4 @@ Implementation detail: the provider passes `context.custom_fields` directly to B
160164

161165
This plugin is licensed under the [MIT License](LICENSE).
162166

163-
For more information, visit the [Braintree Documentation](https://developer.paypal.com/braintree/docs).
167+
For more information, visit the [Braintree Documentation](https://developer.paypal.com/braintree/docs).

plugins/braintree-payment/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@lambdacurry/medusa-payment-braintree",
3-
"version": "0.0.15",
3+
"version": "0.0.20",
44
"description": "Braintree plugin for Medusa",
55
"author": "Lambda Curry (https://lambdacurry.dev)",
66
"license": "MIT",

plugins/braintree-payment/src/providers/payment-braintree/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Add the following configuration to the `payment` section of your `medusa-config.
3535
id: 'braintree',
3636
options: {
3737
environment: process.env.NODE_ENV !== 'production' ? 'sandbox' : 'production',
38+
defaultCurrencyCode: "USD",
3839
merchantId: process.env.BRAINTREE_MERCHANT_ID,
3940
publicKey: process.env.BRAINTREE_PUBLIC_KEY,
4041
privateKey: process.env.BRAINTREE_PRIVATE_KEY,
@@ -56,6 +57,7 @@ Add the following configuration to the `payment` section of your `medusa-config.
5657
- **enable3DSecure**: Enable 3D Secure authentication (`true` or `false`).
5758
- **savePaymentMethod**: Save payment methods for future use (default: `true`).
5859
- **autoCapture**: Automatically capture payments (default: `true`).
60+
- **defaultCurrencyCode**: The default currency to use. This is optional
5961
- **customFields**: Array of Braintree custom field API names permitted to be forwarded from `data.custom_fields`. If empty or omitted, no user-provided custom fields are sent.
6062

6163
## Features

plugins/braintree-payment/src/providers/payment-braintree/src/core/__tests__/braintree-base.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
1+
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
22
import BraintreeProviderService from '../../services/braintree-provider';
33
import { BraintreeConstructorArgs, BraintreePaymentSessionData } from '../braintree-base';
44

@@ -193,7 +193,7 @@ describe('BraintreeProviderService core behaviors', () => {
193193
gateway.transaction.find
194194
.mockResolvedValueOnce({ id: 't2', status: 'settling' })
195195
.mockResolvedValueOnce({ id: 't2', status: 'settling' });
196-
gateway.transaction.refund.mockResolvedValueOnce({ transaction: { id: 'r2' } });
196+
gateway.transaction.refund.mockResolvedValueOnce({ success: true, transaction: { id: 'r2' } });
197197

198198
const result = await service.refundPayment(input);
199199

@@ -238,7 +238,7 @@ describe('BraintreeProviderService core behaviors', () => {
238238
gateway.transaction.find
239239
.mockResolvedValueOnce({ id: 't2', status: 'settled' }) // retrieveTransaction
240240
.mockResolvedValueOnce({ id: 't2', status: 'settled' }); // updated after refund
241-
gateway.transaction.refund.mockResolvedValueOnce({ transaction: { id: 'r1' } });
241+
gateway.transaction.refund.mockResolvedValueOnce({ success: true, transaction: { id: 'r1' } });
242242

243243
const result = await service.refundPayment(input);
244244

plugins/braintree-payment/src/providers/payment-braintree/src/core/__tests__/braintree-import.spec.ts

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
1+
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
22
import BraintreeImportService from '../../services/braintree-import';
33
import { BraintreeConstructorArgs } from '../braintree-base';
44

@@ -49,10 +49,11 @@ describe('BraintreeImportService', () => {
4949
context: { idempotency_key: 'idem' },
5050
} as any);
5151
expect(init.id).toBeDefined();
52+
expect(gateway.transaction.find).toHaveBeenCalledTimes(1);
53+
expect(gateway.transaction.find).toHaveBeenCalledWith('t1');
5254

5355
const auth = await service.authorizePayment({ data: init.data } as any);
5456
expect(auth.status).toBe('authorized');
55-
expect(gateway.transaction.find).not.toHaveBeenCalled();
5657

5758
const cap = await service.capturePayment({ data: auth.data } as any);
5859
expect((cap.data as any).status).toBe('captured');
@@ -83,10 +84,88 @@ describe('BraintreeImportService', () => {
8384
const { service, gateway } = buildService();
8485
const session = { transactionId: 't3', importedAsRefunded: false, refundedTotal: 1.25, status: 'captured' } as any;
8586
gateway.transaction.find.mockResolvedValueOnce({ id: 't3', status: 'settled' });
86-
gateway.transaction.refund.mockResolvedValueOnce({ transaction: { id: 'r3' } });
87+
gateway.transaction.refund.mockResolvedValueOnce({ success: true, transaction: { id: 'r3' } });
8788

8889
const res = await service.refundPayment({ amount: 2.75, data: session } as any);
8990
expect(gateway.transaction.refund).toHaveBeenCalledWith('t3', '2.75');
9091
expect((res.data as any).refundedTotal).toBe(4.0);
9192
});
93+
94+
it('gracefully handles already-refunded transactions when allowRefundOnRefunded is enabled', async () => {
95+
const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() } as any;
96+
const cache = { get: jest.fn(), set: jest.fn() } as any;
97+
98+
const container: BraintreeConstructorArgs = { logger, cache };
99+
100+
const options = {
101+
environment: 'sandbox' as const,
102+
merchantId: 'merchant',
103+
publicKey: 'public',
104+
privateKey: 'private',
105+
enable3DSecure: false,
106+
savePaymentMethod: false,
107+
webhookSecret: 'whsec',
108+
autoCapture: true,
109+
allowRefundOnRefunded: true, // Enable graceful handling
110+
} as any;
111+
112+
const service = new BraintreeImportService(container, options);
113+
114+
const gateway = {
115+
transaction: {
116+
find: jest.fn(),
117+
void: jest.fn(),
118+
refund: jest.fn(),
119+
},
120+
} as any;
121+
122+
(service as any).gateway = gateway;
123+
124+
const session = { transactionId: 't4', importedAsRefunded: false, refundedTotal: 0, status: 'captured' } as any;
125+
gateway.transaction.find.mockResolvedValueOnce({ id: 't4', status: 'settled' });
126+
gateway.transaction.refund.mockRejectedValueOnce(new Error('Transaction has already been refunded'));
127+
128+
const res = await service.refundPayment({ amount: 10, data: session } as any);
129+
130+
// Should not throw error, but log warning and update locally
131+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('already refunded in Braintree'));
132+
expect((res.data as any).refundedTotal).toBe(10);
133+
});
134+
135+
it('throws error on non-refund-related errors even with allowRefundOnRefunded enabled', async () => {
136+
const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() } as any;
137+
const cache = { get: jest.fn(), set: jest.fn() } as any;
138+
139+
const container: BraintreeConstructorArgs = { logger, cache };
140+
141+
const options = {
142+
environment: 'sandbox' as const,
143+
merchantId: 'merchant',
144+
publicKey: 'public',
145+
privateKey: 'private',
146+
enable3DSecure: false,
147+
savePaymentMethod: false,
148+
webhookSecret: 'whsec',
149+
autoCapture: true,
150+
allowRefundOnRefunded: true,
151+
} as any;
152+
153+
const service = new BraintreeImportService(container, options);
154+
155+
const gateway = {
156+
transaction: {
157+
find: jest.fn(),
158+
void: jest.fn(),
159+
refund: jest.fn(),
160+
},
161+
} as any;
162+
163+
(service as any).gateway = gateway;
164+
165+
const session = { transactionId: 't5', importedAsRefunded: false, refundedTotal: 0, status: 'captured' } as any;
166+
gateway.transaction.find.mockResolvedValueOnce({ id: 't5', status: 'settled' });
167+
gateway.transaction.refund.mockRejectedValueOnce(new Error('Network timeout'));
168+
169+
await expect(service.refundPayment({ amount: 10, data: session } as any)).rejects.toThrow('Network timeout');
170+
});
92171
});

0 commit comments

Comments
 (0)