Skip to content

Commit 8b77dd5

Browse files
authored
Merge pull request #22 from lambda-curry/braintree-import-refactor
2 parents e1a5fe5 + 9ed3fe5 commit 8b77dd5

File tree

16 files changed

+2697
-432
lines changed

16 files changed

+2697
-432
lines changed

.vscode/settings.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,8 @@
99
"editor.codeActionsOnSave": {
1010
"quickfix.biome": "explicit",
1111
},
12-
"editor.formatOnSave": true
13-
}
12+
"editor.formatOnSave": true,
13+
"cSpell.words": [
14+
"Braintree"
15+
]
16+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/** @type {import('jest').Config} */
2+
module.exports = {
3+
preset: 'ts-jest',
4+
testEnvironment: 'node',
5+
roots: ['<rootDir>/src'],
6+
testMatch: ['**/__tests__/**/*.spec.ts', '**/*.spec.ts'],
7+
transform: {
8+
'^.+\\.(ts|tsx)$': [
9+
'ts-jest',
10+
{ tsconfig: '<rootDir>/tsconfig.spec.json', diagnostics: false, isolatedModules: true }
11+
],
12+
},
13+
moduleFileExtensions: ['ts', 'tsx', 'js', 'json'],
14+
verbose: false,
15+
collectCoverage: false,
16+
// Ignore compiled medusa build output
17+
testPathIgnorePatterns: ['/node_modules/', '/.medusa/'],
18+
};
19+
20+

plugins/braintree-payment/package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@lambdacurry/medusa-payment-braintree",
3-
"version": "0.0.8",
3+
"version": "0.0.9",
44
"description": "Braintree plugin for Medusa",
55
"author": "Lambda Curry (https://lambdacurry.dev)",
66
"license": "MIT",
@@ -47,11 +47,15 @@
4747
"@medusajs/ui": "^4.0.3",
4848
"@swc/core": "1.5.7",
4949
"@types/braintree": "^3.3.14",
50+
"@types/jest": "^29.5.12",
5051
"@types/jsonwebtoken": "^9.0.10",
51-
"jsonwebtoken": "^9.0.2"
52+
"jest": "^29.7.0",
53+
"jsonwebtoken": "^9.0.2",
54+
"ts-jest": "^29.2.5"
5255
},
5356
"scripts": {
5457
"build": "npx medusa plugin:build",
58+
"test": "jest --config jest.config.cjs --runInBand",
5559
"plugin:dev": "npx medusa plugin:develop",
5660
"prepublishOnly": "npx medusa plugin:build"
5761
},
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
2+
import BraintreeProviderService from '../../services/braintree-provider';
3+
import { BraintreeConstructorArgs, BraintreePaymentSessionData } from '../braintree-base';
4+
5+
const buildService = () => {
6+
const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() } as any;
7+
const cache = { get: jest.fn(), set: jest.fn() } as any;
8+
9+
const container: BraintreeConstructorArgs = {
10+
logger,
11+
cache,
12+
};
13+
14+
const options = {
15+
environment: 'sandbox' as const,
16+
merchantId: 'merchant',
17+
publicKey: 'public',
18+
privateKey: 'private',
19+
enable3DSecure: false,
20+
savePaymentMethod: false,
21+
webhookSecret: 'whsec',
22+
autoCapture: true,
23+
} as any; // satisfy BraintreeOptions without bringing full type deps
24+
25+
const service = new BraintreeProviderService(container, options);
26+
27+
// Replace gateway with a mock implementation
28+
const gateway = {
29+
clientToken: { generate: jest.fn() },
30+
transaction: {
31+
sale: jest.fn(),
32+
find: jest.fn(),
33+
submitForSettlement: jest.fn(),
34+
void: jest.fn(),
35+
refund: jest.fn(),
36+
},
37+
paymentMethod: { create: jest.fn() },
38+
customer: { find: jest.fn(), create: jest.fn(), update: jest.fn(), delete: jest.fn() },
39+
webhookNotification: { parse: jest.fn() },
40+
} as any;
41+
42+
(service as any).gateway = gateway;
43+
44+
return { service, gateway, logger, cache };
45+
};
46+
47+
describe('BraintreeProviderService core behaviors', () => {
48+
beforeEach(() => {
49+
jest.resetAllMocks();
50+
});
51+
52+
afterEach(() => {
53+
jest.useRealTimers();
54+
});
55+
56+
it('returns cached client token when available', async () => {
57+
const { service, gateway, cache } = buildService();
58+
cache.get.mockResolvedValueOnce('cached-token');
59+
60+
const token = await (service as any).getValidClientToken('cust_1');
61+
62+
expect(token).toBe('cached-token');
63+
expect(gateway.clientToken.generate).not.toHaveBeenCalled();
64+
});
65+
66+
it('generates and caches client token when missing, with correct TTL', async () => {
67+
const { service, gateway, cache } = buildService();
68+
jest.useFakeTimers();
69+
jest.setSystemTime(new Date('2020-01-01T00:00:00Z'));
70+
71+
cache.get.mockResolvedValueOnce(null);
72+
gateway.clientToken.generate.mockResolvedValueOnce({ clientToken: 'new-token' });
73+
74+
const token = await (service as any).getValidClientToken('cust_2');
75+
76+
expect(token).toBe('new-token');
77+
expect(cache.set).toHaveBeenCalled();
78+
const setArgs = cache.set.mock.calls[0];
79+
// [key, value, ttlSeconds]
80+
expect(setArgs[1]).toBe('new-token');
81+
// 24h - 1s
82+
expect(setArgs[2]).toBe(24 * 3600 - 1);
83+
});
84+
85+
it('authorizePayment creates a sale with decimal string amount (2dp) and returns captured when autoCapture=true', async () => {
86+
const { service, gateway } = buildService();
87+
88+
const input = {
89+
data: {
90+
clientToken: 'ct',
91+
amount: 10, // standard unit -> "10.00"
92+
currency_code: 'USD',
93+
paymentMethodNonce: 'fake-nonce',
94+
},
95+
context: {
96+
idempotency_key: 'idem_1',
97+
customer: { id: 'cust', email: '[email protected]' },
98+
},
99+
} as any;
100+
101+
gateway.transaction.sale.mockResolvedValueOnce({ success: true, transaction: { id: 't1' } });
102+
gateway.transaction.find.mockResolvedValue({ id: 't1', status: 'authorized', amount: '10.00' });
103+
104+
const result = await service.authorizePayment(input);
105+
106+
expect(gateway.transaction.sale).toHaveBeenCalled();
107+
const saleArgs = gateway.transaction.sale.mock.calls[0][0];
108+
expect(saleArgs.amount).toBe('10.00');
109+
expect(result.status).toBe('captured');
110+
});
111+
112+
it('capturePayment submits for settlement when status is authorized', async () => {
113+
const { service, gateway } = buildService();
114+
115+
const input = {
116+
data: {
117+
clientToken: 'ct',
118+
amount: 1000,
119+
currency_code: 'USD',
120+
braintreeTransaction: { id: 't1' },
121+
},
122+
} as any;
123+
124+
gateway.transaction.find
125+
.mockResolvedValueOnce({ id: 't1', status: 'authorized', amount: '10.00' }) // pre-check
126+
.mockResolvedValueOnce({ id: 't1', status: 'submitted_for_settlement', amount: '10.00' }); // retrieve after submit
127+
gateway.transaction.submitForSettlement.mockResolvedValueOnce({ success: true });
128+
129+
const result = await service.capturePayment(input);
130+
131+
expect(gateway.transaction.submitForSettlement).toHaveBeenCalledWith('t1', '10.00');
132+
133+
const data = result.data as unknown as BraintreePaymentSessionData;
134+
expect(data?.braintreeTransaction?.id).toBe('t1');
135+
});
136+
137+
it('refundPayment voids when transaction is authorized', async () => {
138+
const { service, gateway } = buildService();
139+
140+
const input = {
141+
amount: 5, // standard unit, will be converted internally
142+
data: {
143+
clientToken: 'ct',
144+
amount: 1000,
145+
currency_code: 'USD',
146+
braintreeTransaction: { id: 't1' },
147+
},
148+
} as any;
149+
150+
gateway.transaction.find.mockResolvedValueOnce({ id: 't1', status: 'authorized' });
151+
gateway.transaction.void.mockResolvedValueOnce({ success: true });
152+
gateway.transaction.find.mockResolvedValueOnce({ id: 't1', status: 'voided' });
153+
154+
const result = await service.refundPayment(input);
155+
156+
expect(gateway.transaction.void).toHaveBeenCalledWith('t1');
157+
expect((result.data as any)?.braintreeRefund?.success).toBe(true);
158+
});
159+
160+
it('refundPayment voids when transaction is submitted_for_settlement', async () => {
161+
const { service, gateway } = buildService();
162+
163+
const input = {
164+
amount: 10, // standard unit
165+
data: {
166+
clientToken: 'ct',
167+
amount: 1000,
168+
currency_code: 'USD',
169+
braintreeTransaction: { id: 't1' },
170+
},
171+
} as any;
172+
173+
gateway.transaction.find.mockResolvedValueOnce({ id: 't1', status: 'submitted_for_settlement' });
174+
gateway.transaction.void.mockResolvedValueOnce({ success: true });
175+
gateway.transaction.find.mockResolvedValueOnce({ id: 't1', status: 'voided' });
176+
177+
const result = await service.refundPayment(input);
178+
179+
expect(gateway.transaction.void).toHaveBeenCalledWith('t1');
180+
expect((result.data as any)?.braintreeRefund?.success).toBe(true);
181+
});
182+
183+
it('refundPayment refunds with 2dp when transaction is settling', async () => {
184+
const { service, gateway } = buildService();
185+
186+
const input = {
187+
amount: 7.5, // -> "7.50"
188+
data: {
189+
clientToken: 'ct',
190+
amount: 1000,
191+
currency_code: 'USD',
192+
braintreeTransaction: { id: 't2' },
193+
},
194+
} as any;
195+
196+
gateway.transaction.find
197+
.mockResolvedValueOnce({ id: 't2', status: 'settling' })
198+
.mockResolvedValueOnce({ id: 't2', status: 'settling' });
199+
gateway.transaction.refund.mockResolvedValueOnce({ transaction: { id: 'r2' } });
200+
201+
const result = await service.refundPayment(input);
202+
203+
expect(gateway.transaction.refund).toHaveBeenCalledWith('t2', '7.50');
204+
expect((result.data as any)?.braintreeRefund?.id).toBe('r2');
205+
});
206+
207+
it('refundPayment throws for non-refundable statuses', async () => {
208+
const { service, gateway } = buildService();
209+
210+
const input = {
211+
amount: 5,
212+
data: {
213+
clientToken: 'ct',
214+
amount: 1000,
215+
currency_code: 'USD',
216+
braintreeTransaction: { id: 't3' },
217+
},
218+
} as any;
219+
220+
gateway.transaction.find.mockResolvedValueOnce({ id: 't3', status: 'failed' });
221+
222+
await expect(service.refundPayment(input)).rejects.toThrow();
223+
expect(gateway.transaction.void).not.toHaveBeenCalled();
224+
expect(gateway.transaction.refund).not.toHaveBeenCalled();
225+
});
226+
227+
// NOTE: Import/refund simulation moved to the dedicated import provider tests
228+
229+
it('refundPayment refunds with 2dp decimal string when transaction is settled', async () => {
230+
const { service, gateway } = buildService();
231+
232+
const input = {
233+
amount: 5.001, // -> "5.00"
234+
data: {
235+
clientToken: 'ct',
236+
amount: 1000,
237+
currency_code: 'USD',
238+
braintreeTransaction: { id: 't2' },
239+
},
240+
} as any;
241+
242+
gateway.transaction.find
243+
.mockResolvedValueOnce({ id: 't2', status: 'settled' }) // retrieveTransaction
244+
.mockResolvedValueOnce({ id: 't2', status: 'settled' }); // updated after refund
245+
gateway.transaction.refund.mockResolvedValueOnce({ transaction: { id: 'r1' } });
246+
247+
const result = await service.refundPayment(input);
248+
249+
expect(gateway.transaction.refund).toHaveBeenCalledWith('t2', '5.00');
250+
expect((result.data as any)?.braintreeRefund?.id).toBe('r1');
251+
});
252+
253+
it('getPaymentStatus maps provider status correctly', async () => {
254+
const { service, gateway } = buildService();
255+
const input = { data: { braintreeTransaction: { id: 't3' } } } as any;
256+
gateway.transaction.find.mockResolvedValueOnce({ id: 't3', status: 'failed' });
257+
258+
const result = await service.getPaymentStatus(input);
259+
expect(result.status).toBe('error');
260+
});
261+
262+
it('getWebhookActionAndData returns successful for transaction_settled', async () => {
263+
const { service, gateway } = buildService();
264+
const payloadStr = 'bt_signature=s&bt_payload=p';
265+
gateway.webhookNotification.parse.mockResolvedValueOnce({
266+
kind: 'transaction_settled',
267+
transaction: { id: 't4' },
268+
});
269+
gateway.transaction.find.mockResolvedValueOnce({
270+
id: 't4',
271+
amount: '12.34',
272+
customFields: { medusa_payment_session_id: 'sess_123' },
273+
});
274+
275+
const result = await service.getWebhookActionAndData({ data: payloadStr } as any);
276+
expect(result.action).toBe('captured');
277+
expect((result as any).data.session_id).toBe('sess_123');
278+
});
279+
});

0 commit comments

Comments
 (0)