From 4771e7f8e8f701ad296477e6a0aa1a135ce865af Mon Sep 17 00:00:00 2001 From: govind Date: Mon, 27 Jul 2026 22:56:06 +0530 Subject: [PATCH 1/9] fix: stash in progress --- .../src/core/braintree-base.ts | 345 +++++++++++------- 1 file changed, 213 insertions(+), 132 deletions(-) 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 a2ff5b47..fad7b660 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 @@ -94,6 +94,10 @@ type BraintreeValidationErrorsCollectionLike = { deepErrors?: () => BraintreeValidationErrorLike[]; }; +type TransactionSaleResponse = Awaited< + ReturnType +>; + type BraintreeErrorResponseLike = { message?: string; errors?: BraintreeValidationErrorsCollectionLike; @@ -104,6 +108,30 @@ type BraintreeErrorResponseLike = { }; }; +const BRAINTREE_DECLINED_TRANSACTION_STATUSES = ['processor_declined', 'settlement_declined'] as const; + +const VOIDABLE_REFUND_STATUSES: readonly TransactionStatus[] = ['submitted_for_settlement', 'authorized']; +const SETTLED_REFUND_STATUSES: readonly TransactionStatus[] = ['settled', 'settling']; + +const isVoidableRefundStatus = (status: TransactionStatus): boolean => + (VOIDABLE_REFUND_STATUSES as readonly string[]).includes(status); + +const isSettledRefundStatus = (status: TransactionStatus): boolean => + (SETTLED_REFUND_STATUSES as readonly string[]).includes(status); + +/** History entries persisted on payment session data under `braintreeRefund`. */ +type BraintreeRefundHistoryEntry = { + type: 'voided' | 'refund'; + transaction: Transaction; +}; + +type RefundAction = + | { kind: 'void'; transaction: Transaction } + | { kind: 'refund'; transaction: Transaction }; + +export const isBraintreeDeclinedTransactionStatus = (status?: string): boolean => + (BRAINTREE_DECLINED_TRANSACTION_STATUSES as readonly string[]).includes(status ?? ''); + // Type guard utilities for safe type validation const validateString = (value: unknown, fieldName: string): string => { if (typeof value !== 'string' || !value.trim()) { @@ -176,7 +204,7 @@ class BraintreeBase extends AbstractPaymentProvider { this.options_ = options; this.logger = container[ContainerRegistrationKeys.LOGGER]; this.cache = container[Modules.CACHE]; - this.init(); + this.gateway = this.init(); } async saveClientTokenToCache(clientToken: string, customerId: string, expiresOnEpochSeconds: number): Promise { @@ -256,7 +284,7 @@ class BraintreeBase extends AbstractPaymentProvider { return result.data as BraintreePaymentSessionData; } - init(): void { + init(): Braintree.BraintreeGateway { const envKey = (this.options_.environment || 'sandbox').toLowerCase(); const envMap: Record = { qa: Braintree.Environment.Qa, @@ -266,7 +294,7 @@ class BraintreeBase extends AbstractPaymentProvider { }; const environment = envMap[envKey] ?? Braintree.Environment.Sandbox; - this.gateway = + const gateway = this.gateway || new Braintree.BraintreeGateway({ environment, @@ -276,13 +304,14 @@ class BraintreeBase extends AbstractPaymentProvider { }); this.logDebug(`Gateway initialized (environment: ${envKey})`); + return gateway; } static validateOptions(options: BraintreeOptions): void { const requiredFields = ['merchantId', 'publicKey', 'privateKey', 'webhookSecret', 'environment']; for (const field of requiredFields) { - if (!isDefined(options[field]) || typeof options[field] !== '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`, @@ -306,7 +335,7 @@ class BraintreeBase extends AbstractPaymentProvider { const booleanFields = ['enable3DSecure', 'savePaymentMethod', 'autoCapture', 'allowRefundOnRefunded', 'logging']; for (const field of booleanFields) { - if (isDefined(options[field]) && typeof options[field] !== '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`, @@ -380,9 +409,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 = { @@ -582,62 +609,73 @@ class BraintreeBase extends AbstractPaymentProvider { }; } - private async createTransaction({ - input, - }: { - input: AuthorizePaymentInput; - }): Promise { - const sessionData = await this.parsePaymentSessionData(input.data ?? {}); + private rethrowGatewayError( + error: unknown, + operation: string, + context?: Record, + ): never { + if (error instanceof MedusaError) throw error; + this.logErrorDetail(operation, error, context); + throw buildBraintreeError(error, operation, this.logger, context); + } + + private async saleTransaction( + request: Braintree.TransactionRequest, + context: BraintreeTransactionContext | undefined, + ): Promise { + this.logDebug('createTransaction (sale)', { + amount: request.amount, + orderId: context?.orderId, + }); + + const saleResponse = await this.gateway.transaction.sale(request); + if (saleResponse.success) return saleResponse; - const _context = input.context as BraintreeTransactionContext | undefined; + 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 toPayDecimal = formatToTwoDecimalString(Number(sessionData.amount)); + /** Retrieve the created sale; void it if session sync fails so we don't leave an orphan auth. */ + private async retrieveOrVoidSale(saleResponse: TransactionSaleResponse): Promise { + const transactionId = saleResponse.transaction?.id; - const transactionCreateRequest = await this.getTransactionCreateRequestBody({ - amount: toPayDecimal, + try { + return await this.retrieveTransaction(saleResponse.transaction.id); + } catch (error) { + this.logErrorDetail('sync payment session (retrieveTransaction)', error, { transactionId }); + if (transactionId) { + await this.gateway.transaction.void(transactionId); + } + throw buildBraintreeError(error, 'sync payment session', this.logger, { transactionId }); + } + } + + 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: _context, + context, accountHolder: sessionData.account_holder, customer: input.context?.customer, }); - 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); - } - 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); - } - throw buildBraintreeError(error, 'sync payment session', this.logger, { - transactionId: saleResponse.transaction?.id, - }); - } + 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); } } @@ -734,113 +772,156 @@ class BraintreeBase extends AbstractPaymentProvider { }; } - async refundPayment(input: RefundPaymentInput): Promise { + private appendRefundHistory( + data: Record | undefined, + entry: BraintreeRefundHistoryEntry, + ): BraintreeRefundHistoryEntry[] { + const prior = (data?.braintreeRefund as BraintreeRefundHistoryEntry[] | undefined) ?? []; + return [...prior, entry]; + } + + private buildRefundPaymentOutput( + input: RefundPaymentInput, + transaction: Transaction, + entry: BraintreeRefundHistoryEntry, + ): RefundPaymentOutput { + return { + data: { + ...input.data, + transaction, + braintreeRefund: this.appendRefundHistory(input.data, entry), + }, + }; + } + + 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(); + const refundAmount = MathBN.convert(input.amount, 2).toNumber(); + if (!refundAmount) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, 'Refund amount is invalid'); + } - 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(sessionData.transaction?.id as string); + 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. */ + private async applyTestForceSettled(transaction: Transaction): Promise { + if (process.env.TEST_FORCE_SETTLED !== 'true') return transaction; - if (process.env.TEST_FORCE_SETTLED === 'true') { - shouldVoid = false; - await this.gateway.testing.settle(transaction.id); - transaction = await this.retrieveTransaction(transaction.id); + if (!this.isTestForceSettledEnabled()) { + this.logger.warn( + '[Braintree refund] TEST_FORCE_SETTLED ignored — only supported when environment is sandbox', + ); + return transaction; } - if (shouldVoid) { - const voidResponse = await this.gateway.transaction.void(transaction.id); - const voidSucceeded = voidResponse.success ?? false; - - if (!voidSucceeded) { - this.logErrorDetail('refundPayment (void)', new Error(voidResponse.message ?? 'Void failed'), { - transactionId: transaction.id, - message: voidResponse.message, - errors: (voidResponse as { errors?: unknown }).errors, - }); - throw new MedusaError(MedusaError.Types.PAYMENT_AUTHORIZATION_ERROR, 'Failed to void transaction'); - } + await this.gateway.testing.settle(transaction.id); + return this.retrieveTransaction(transaction.id); + } - const voidedTransaction = voidResponse?.transaction ?? (await this.retrieveTransaction(transaction.id)); + private async resolveRefundAction(transaction: Transaction): Promise { + const resolved = await this.applyTestForceSettled(transaction); - const refundResult: RefundPaymentOutput = { - data: { - ...input.data, - transaction: voidedTransaction, - braintreeRefund: { - success: true, - transactionId: voidedTransaction?.id, - type: 'void', - }, - }, - }; + if (isVoidableRefundStatus(resolved.status)) { + return { kind: 'void', transaction: resolved }; + } - return refundResult; + if (isSettledRefundStatus(resolved.status)) { + return { kind: 'refund', transaction: resolved }; } - const shouldRefund = ['settled', 'settling'].includes(transaction.status); + 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`, + ); + } - if (!shouldRefund) { - this.logger.error( - `Braintree transaction with ID ${transaction.id} cannot be refunded because it's in status ${transaction.status}`, - ); - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `Braintree transaction with ID ${transaction.id} cannot be refunded`, - ); + private async voidForRefund( + input: RefundPaymentInput, + transaction: Transaction, + ): Promise { + const voidResponse = await this.gateway.transaction.void(transaction.id); + this.logDebug('refundPayment void response', { response: voidResponse }); + + if (isBraintreeFailureResponse(voidResponse)) { + throwOnBraintreeFailure(voidResponse, 'refundPayment (void)', this.logErrorDetail.bind(this), { + 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 voidedTransaction = voidResponse.transaction ?? (await this.retrieveTransaction(transaction.id)); - const refundResponse = await this.gateway.transaction.refund(transaction.id, refundAmountDecimal); - - const refundSucceeded = refundResponse.success ?? false; - if (!refundSucceeded) { - this.logErrorDetail('refundPayment (refund)', new Error(refundResponse.message ?? 'Refund failed'), { - transactionId: transaction.id, - refundAmount: refundAmountDecimal, - message: refundResponse.message, - errors: (refundResponse as { errors?: unknown }).errors, - }); - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Failed to create Braintree refund: ${refundResponse.message}`, - ); - } + return this.buildRefundPaymentOutput(input, transaction, { + type: 'voided', + transaction: voidedTransaction, + }); + } - const refundTransaction = refundResponse.transaction ?? (await this.retrieveTransaction(transaction.id)); + private async refundSettled( + input: RefundPaymentInput, + transaction: Transaction, + refundAmount: number, + ): Promise { + const refundAmountDecimal = formatToTwoDecimalString(refundAmount); - const refundResult: RefundPaymentOutput = { - data: { - ...input.data, - transaction: refundTransaction, - braintreeRefund: refundTransaction, - }, - }; - return refundResult; - } catch (e) { - this.logErrorDetail('create Braintree refund', e, { + 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, }); - throw buildBraintreeError(e, 'create Braintree refund', this.logger); } + + const refundTransaction = refundResponse.transaction ?? (await this.retrieveTransaction(transaction.id)); + + return this.buildRefundPaymentOutput(input, transaction, { + type: 'refund', + transaction: refundTransaction, + }); + } catch (error) { + this.rethrowGatewayError(error, 'create Braintree refund', { + transactionId: transaction.id, + refundAmount: refundAmountDecimal, + }); } + } - throw new MedusaError(MedusaError.Types.NOT_FOUND, `Braintree transaction with ID ${transaction.id} not found`); + async refundPayment(input: RefundPaymentInput): Promise { + const { transaction, refundAmount } = await this.loadRefundContext(input); + const action = await this.resolveRefundAction(transaction); + + switch (action.kind) { + case 'void': + return this.voidForRefund(input, action.transaction); + case 'refund': + return this.refundSettled(input, action.transaction, refundAmount); + } } async retrievePayment(input: RetrievePaymentInput): Promise { From f0456d457a85576ee670e657cfa799741cf33073 Mon Sep 17 00:00:00 2001 From: govind Date: Wed, 5 Aug 2026 17:24:51 +0530 Subject: [PATCH 2/9] fix(braintree): document provider APIs and stop silently discarding errors Propagate MedusaError types through authorize/account-holder/webhook paths, preserve sync errors when orphan void fails, and align refund unit tests with the braintreeRefund history array shape. Co-authored-by: Cursor --- .gitignore | 1 + .../src/core/__tests__/braintree-base.spec.ts | 60 ++- .../src/core/braintree-base.ts | 366 +++++++++++++++++- .../src/core/braintree-import.ts | 8 +- 4 files changed, 405 insertions(+), 30 deletions(-) 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/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..9a09574f 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 @@ -5,13 +5,18 @@ 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[]; +}; + +const lastRefundEntry = (data: unknown): RefundHistoryEntry | undefined => { + const history = (data as RefundResultData)?.braintreeRefund; + return history?.[history.length - 1]; }; const buildService = (overrideOptions?: Partial) => { @@ -172,9 +177,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 +228,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 voids when transaction is submitted_for_settlement', async () => { @@ -245,7 +253,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 +279,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 +325,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 () => { @@ -468,7 +482,9 @@ 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 () => { @@ -488,7 +504,9 @@ describe('BraintreeProviderService core behaviors', () => { expect(logger.warn).toHaveBeenCalledWith( '[Braintree refund] TEST_FORCE_SETTLED 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('getPaymentStatus maps provider status correctly', async () => { @@ -517,4 +535,16 @@ describe('BraintreeProviderService core behaviors', () => { expect(result.action).toBe('captured'); expect((result as any).data.session_id).toBe('sess_123'); }); + + it('getWebhookActionAndData propagates webhook parse failures', async () => { + const { service, gateway } = buildService(); + gateway.webhookNotification.parse.mockRejectedValueOnce(new Error('invalid signature')); + + await expect( + service.getWebhookActionAndData({ data: 'bt_signature=bad&bt_payload=x' } as any), + ).rejects.toMatchObject({ + type: MedusaError.Types.INVALID_DATA, + message: expect.stringContaining('validate Braintree webhook'), + }); + }); }); 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 97373ba9..6d2fe40b 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,19 +86,26 @@ 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[]; }; @@ -98,6 +114,10 @@ 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; @@ -114,33 +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.braintreeRefund` 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 Trimmed string value (as provided; not trimmed before return) + * @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`); @@ -148,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[] => { @@ -155,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}: ` : ''; @@ -162,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; @@ -189,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, @@ -223,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, @@ -242,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; @@ -249,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); @@ -258,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); @@ -266,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; @@ -279,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); @@ -289,12 +401,23 @@ class BraintreeBase extends AbstractPaymentProvider { this.logger.info(`[Braintree] ERROR ${operation}: ${msg}${ctx}${stackLine}`); } + /** + * Whether sandbox test settlement is enabled (`TEST_FORCE_SETTLED=true` and env is sandbox). + */ private isTestForceSettledEnabled(): boolean { return ( process.env.TEST_FORCE_SETTLED === 'true' && 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, @@ -316,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(), @@ -341,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 = { @@ -364,6 +497,12 @@ class BraintreeBase extends AbstractPaymentProvider { 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']; @@ -401,6 +540,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; @@ -452,6 +597,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, @@ -494,10 +647,19 @@ class BraintreeBase extends AbstractPaymentProvider { 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'); + this.rethrowGatewayError(error, 'authorize payment', { + amount: (input.data as { amount?: number })?.amount, + currency_code: (input.data as { currency_code?: string })?.currency_code, + }); } } + /** + * 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 }); @@ -528,6 +690,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, @@ -573,6 +744,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); @@ -582,6 +759,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': @@ -606,6 +788,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(), @@ -633,6 +820,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, @@ -666,17 +859,32 @@ class BraintreeBase extends AbstractPaymentProvider { }; } + /** + * 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 (error instanceof MedusaError) throw error; + if (MedusaError.isMedusaError(error)) throw error; this.logErrorDetail(operation, error, context); throw buildBraintreeError(error, operation, this.logger, context); } - /** Fail on gateway error responses; otherwise return the transaction (retrieve if missing). */ + /** + * 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, @@ -690,6 +898,13 @@ class BraintreeBase extends AbstractPaymentProvider { return response.transaction ?? (await this.retrieveTransaction(fallbackTransactionId)); } + /** + * 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, @@ -714,7 +929,12 @@ class BraintreeBase extends AbstractPaymentProvider { 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. */ + /** + * 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` via {@link buildBraintreeError} for the sync failure + */ private async retrieveOrVoidSale(saleResponse: TransactionSaleResponse): Promise { const transactionId = saleResponse.transaction?.id; @@ -723,12 +943,26 @@ class BraintreeBase extends AbstractPaymentProvider { } catch (error) { this.logErrorDetail('sync payment session (retrieveTransaction)', error, { transactionId }); if (transactionId) { - await this.gateway.transaction.void(transactionId); + try { + await this.gateway.transaction.void(transactionId); + } 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; @@ -751,6 +985,12 @@ class BraintreeBase extends AbstractPaymentProvider { } } + /** + * 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; @@ -779,6 +1019,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; @@ -801,6 +1047,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, @@ -843,6 +1095,12 @@ class BraintreeBase extends AbstractPaymentProvider { }; } + /** + * Builds refund output `data`, appending one entry to `braintreeRefund` history. + * @param input - Original refund input (prior history read from `data.braintreeRefund`) + * @param transaction - Pre-refund Braintree transaction retained on session data + * @param entry - New void/refund history entry + */ private buildRefundPaymentOutput( input: RefundPaymentInput, transaction: Transaction, @@ -859,6 +1117,11 @@ class BraintreeBase extends AbstractPaymentProvider { }; } + /** + * 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; @@ -885,7 +1148,11 @@ class BraintreeBase extends AbstractPaymentProvider { }; } - /** Sandbox-only: force settle so refund paths can be exercised in tests. */ + /** + * Sandbox-only: force settle so refund paths can be exercised in tests. + * No-ops unless `TEST_FORCE_SETTLED=true` and environment is sandbox. + * @param transaction - Transaction to optionally settle + */ private async applyTestForceSettled(transaction: Transaction): Promise { if (process.env.TEST_FORCE_SETTLED !== 'true') return transaction; @@ -900,6 +1167,11 @@ class BraintreeBase extends AbstractPaymentProvider { return this.retrieveTransaction(transaction.id); } + /** + * Chooses void vs refund based on transaction status (after optional test settle). + * @param transaction - Live Braintree transaction + * @throws {MedusaError} `NOT_FOUND` when status is neither voidable nor refundable + */ private async resolveRefundAction(transaction: Transaction): Promise { const resolved = await this.applyTestForceSettled(transaction); @@ -920,6 +1192,12 @@ class BraintreeBase extends AbstractPaymentProvider { ); } + /** + * 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; @@ -957,6 +1235,11 @@ class BraintreeBase extends AbstractPaymentProvider { } } + /** + * Medusa refund hook: voids or refunds based on transaction status and + * appends history under `data.braintreeRefund`. + * @param input - Amount + session transaction + */ async refundPayment(input: RefundPaymentInput): Promise { const { transaction, refundAmount } = await this.loadRefundContext(input); const action = await this.resolveRefundAction(transaction); @@ -968,6 +1251,11 @@ class BraintreeBase extends AbstractPaymentProvider { }); } + /** + * 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 }); @@ -986,6 +1274,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({ @@ -997,6 +1290,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); @@ -1009,6 +1307,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; @@ -1046,10 +1350,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; @@ -1071,10 +1382,17 @@ 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 }); } } + /** + * 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 + * @throws {MedusaError} When signature/payload validation fails (does not swallow) + */ private async parseWebhookNotification( webhookData: ProviderWebhookPayload['payload'], ): Promise { @@ -1090,10 +1408,18 @@ class BraintreeBase extends AbstractPaymentProvider { } catch (error) { this.logErrorDetail('webhook validation', error, { hasPayload: !!webhookData?.data }); this.logger.error(`Braintree webhook validation failed : ${error}`); - return null; + if (MedusaError.isMedusaError(error)) throw error; + throw buildBraintreeError(error, 'validate Braintree webhook', this.logger, { + hasPayload: !!webhookData?.data, + }); } } + /** + * 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': @@ -1105,13 +1431,20 @@ class BraintreeBase extends AbstractPaymentProvider { } } + /** + * Medusa webhook hook: parses the notification and returns action + session_id/amount. + * Empty/null notification → `NOT_SUPPORTED`. Parse/signature failures propagate as errors. + * Missing custom field session id → empty string. + * @param webhookData - Raw provider webhook payload + * @throws {MedusaError} When webhook validation fails + */ async getWebhookActionAndData(webhookData: ProviderWebhookPayload['payload']): Promise { this.logDebug('getWebhookActionAndData', { hasData: !!webhookData?.data }); this.logger.info(`Received Braintree webhook with data: ${!!webhookData.data}`); const notification = await this.parseWebhookNotification(webhookData); if (!notification) { - return { action: PaymentActions.FAILED }; + return { action: PaymentActions.NOT_SUPPORTED }; } const action = this.mapWebhookKindToAction(notification.kind); @@ -1131,6 +1464,11 @@ class BraintreeBase extends AbstractPaymentProvider { }; } + /** + * 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..e67bcaec 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 @@ -145,8 +145,14 @@ class BraintreeImport extends AbstractPaymentProvider { this.logErrorDetail('initiatePayment (transaction.find)', error, { transactionId: session.transactionId, }); - this.logger.warn( + this.logger.error( `Could not find transaction with ID ${session.transactionId} in Braintree for imported payment`, + error instanceof Error ? error : undefined, + ); + if (MedusaError.isMedusaError(error)) throw error; + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Braintree transaction not found: ${session.transactionId}`, ); } } From 0f7f4e1c1ca313c15d34e68d7aa87bdf75afec15 Mon Sep 17 00:00:00 2001 From: govind Date: Wed, 5 Aug 2026 18:42:14 +0530 Subject: [PATCH 3/9] revert: leave braintree-import.ts unchanged Co-authored-by: Cursor --- .../payment-braintree/src/core/braintree-import.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) 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 e67bcaec..a83f9d5f 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 @@ -145,14 +145,8 @@ class BraintreeImport extends AbstractPaymentProvider { this.logErrorDetail('initiatePayment (transaction.find)', error, { transactionId: session.transactionId, }); - this.logger.error( + this.logger.warn( `Could not find transaction with ID ${session.transactionId} in Braintree for imported payment`, - error instanceof Error ? error : undefined, - ); - if (MedusaError.isMedusaError(error)) throw error; - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `Braintree transaction not found: ${session.transactionId}`, ); } } From c0d43ce2e9a7358eaffb87d1e66467d844b5ff0d Mon Sep 17 00:00:00 2001 From: govind Date: Wed, 5 Aug 2026 18:47:50 +0530 Subject: [PATCH 4/9] fix(braintree): restore refund helpers after bad merge conflict The main merge left half-merged refundPayment logic inside resolveRefundAction/executeRefundAction and broke the class syntax. Co-authored-by: Cursor --- .../src/core/braintree-base.ts | 83 +++---------------- 1 file changed, 11 insertions(+), 72 deletions(-) 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 611874a1..6d2fe40b 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 @@ -231,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); @@ -275,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, @@ -401,7 +405,9 @@ class BraintreeBase extends AbstractPaymentProvider { * Whether sandbox test settlement is enabled (`TEST_FORCE_SETTLED=true` and env is sandbox). */ private isTestForceSettledEnabled(): boolean { - return process.env.TEST_FORCE_SETTLED === 'true' && this.options_.environment.toLowerCase() === 'sandbox'; + return ( + process.env.TEST_FORCE_SETTLED === 'true' && this.options_.environment.toLowerCase() === 'sandbox' + ); } /** @@ -501,10 +507,7 @@ class BraintreeBase extends AbstractPaymentProvider { 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`, @@ -528,10 +531,7 @@ class BraintreeBase extends AbstractPaymentProvider { const booleanFields = ['enable3DSecure', 'savePaymentMethod', 'autoCapture', 'allowRefundOnRefunded', 'logging']; 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`, @@ -1174,44 +1174,6 @@ class BraintreeBase extends AbstractPaymentProvider { */ private async resolveRefundAction(transaction: Transaction): Promise { const resolved = await this.applyTestForceSettled(transaction); - 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); - } - } - - const shouldRefund = ['settled', 'settling'].includes(transaction.status); - - if (shouldVoid) { - const voidResponse = await this.gateway.transaction.void(transaction.id); - - this.logDebug('refundPayment void response', { response: voidResponse }); - - if (isBraintreeFailureResponse(voidResponse)) { - throwOnBraintreeFailure(voidResponse, 'refundPayment (void)', this.logErrorDetail.bind(this), { - transactionId: 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], - }, - }; if (isVoidableRefundStatus(resolved.status)) { return { kind: 'voided', transaction: resolved }; @@ -1219,14 +1181,6 @@ class BraintreeBase extends AbstractPaymentProvider { if (isSettledRefundStatus(resolved.status)) { return { kind: 'refund', transaction: resolved }; - if (!shouldRefund) { - this.logger.error( - `Braintree transaction with ID ${transaction.id} cannot be refunded because it's in status ${transaction.status}`, - ); - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `Braintree transaction with ID ${transaction.id} cannot be refunded because it's in status ${transaction.status}`, - ); } this.logger.error( @@ -1263,21 +1217,6 @@ class BraintreeBase extends AbstractPaymentProvider { this.logger.info( `Refunding transaction: ${transaction.id} with amount: ${refundAmountDecimal} (created from ${refundAmount})`, ); - 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], - }, - }; const response = await this.gateway.transaction.refund(transaction.id, refundAmountDecimal); this.logDebug('refundPayment refund response', { response }); From 8a476bd286624c4605b606d263de429ae6761cfa Mon Sep 17 00:00:00 2001 From: govind Date: Wed, 5 Aug 2026 18:50:18 +0530 Subject: [PATCH 5/9] fix(braintree): address CodeRabbit review on error and refund guards Guard missing sale transaction ids, legacy non-array braintreeRefund history, and absent webhook customFields; clarify validateString JSDoc. Co-authored-by: Cursor --- .../src/core/__tests__/braintree-base.spec.ts | 66 +++++++++++++++++++ .../src/core/braintree-base.ts | 41 +++++++----- 2 files changed, 92 insertions(+), 15 deletions(-) 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 9a09574f..c9873037 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 @@ -509,6 +509,32 @@ describe('BraintreeProviderService core behaviors', () => { 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)?.braintreeRefund; + + expect(Array.isArray(history)).toBe(true); + expect(history).toHaveLength(1); + expect(history?.[0]?.type).toBe('voided'); + }); + it('getPaymentStatus maps provider status correctly', async () => { const { service, gateway } = buildService(); const input = { data: { braintreeTransaction: { id: 't3' } } } as any; @@ -536,6 +562,46 @@ describe('BraintreeProviderService core behaviors', () => { 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('captured'); + 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('getWebhookActionAndData propagates webhook parse failures', async () => { const { service, gateway } = buildService(); gateway.webhookNotification.parse.mockRejectedValueOnce(new Error('invalid signature')); 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 6d2fe40b..637905dd 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 @@ -181,7 +181,7 @@ export const isBraintreeDeclinedTransactionStatus = (status?: string): boolean = * Asserts `value` is a non-empty string. * @param value - Value to validate * @param fieldName - Used in the error message - * @returns Trimmed string value (as provided; not trimmed before return) + * @returns The original string value (not trimmed) * @throws {MedusaError} `INVALID_ARGUMENT` when empty or not a string */ const validateString = (value: unknown, fieldName: string): string => { @@ -933,25 +933,31 @@ class BraintreeBase extends AbstractPaymentProvider { * 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` via {@link buildBraintreeError} for the sync failure + * @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(saleResponse.transaction.id); + return await this.retrieveTransaction(transactionId); } catch (error) { this.logErrorDetail('sync payment session (retrieveTransaction)', error, { transactionId }); - if (transactionId) { - try { - await this.gateway.transaction.void(transactionId); - } 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, - ); - } + try { + await this.gateway.transaction.void(transactionId); + } 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 }); } @@ -1097,6 +1103,7 @@ class BraintreeBase extends AbstractPaymentProvider { /** * Builds refund output `data`, appending one entry to `braintreeRefund` history. + * Legacy non-array `braintreeRefund` values are ignored so spreads stay safe. * @param input - Original refund input (prior history read from `data.braintreeRefund`) * @param transaction - Pre-refund Braintree transaction retained on session data * @param entry - New void/refund history entry @@ -1106,7 +1113,10 @@ class BraintreeBase extends AbstractPaymentProvider { transaction: Transaction, entry: BraintreeRefundHistoryEntry, ): RefundPaymentOutput { - const prior = (input.data?.braintreeRefund as BraintreeRefundHistoryEntry[] | undefined) ?? []; + const stored = input.data?.braintreeRefund; + const prior: BraintreeRefundHistoryEntry[] = Array.isArray(stored) + ? (stored as BraintreeRefundHistoryEntry[]) + : []; return { data: { @@ -1453,7 +1463,8 @@ class BraintreeBase extends AbstractPaymentProvider { } const paymentData = await this.gateway.transaction.find(notification.transaction.id); - const sessionId = (paymentData.customFields as CustomFields).medusa_payment_session_id ?? ''; + const customFields = (paymentData.customFields ?? {}) as CustomFields; + const sessionId = customFields.medusa_payment_session_id ?? ''; return { action, From 61be954e098200ccff18e4190bac262e6fcfcbad Mon Sep 17 00:00:00 2001 From: govind Date: Wed, 5 Aug 2026 21:28:28 +0530 Subject: [PATCH 6/9] fix(braintree): address remaining Currybot and CodeRabbit nits Log resolved unsuccessful orphan-void Results without overriding the sync error, drop duplicate authorize logging, and cover refund history accumulation plus orphan-void failure precedence. Co-authored-by: Cursor --- .../src/core/__tests__/braintree-base.spec.ts | 94 +++++++++++++++++++ .../src/core/braintree-base.ts | 16 +++- 2 files changed, 105 insertions(+), 5 deletions(-) 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 c9873037..dd715a89 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 @@ -233,6 +233,39 @@ describe('BraintreeProviderService core behaviors', () => { expect(entry?.transaction?.id).toBe('t1'); }); + it('refundPayment appends to existing braintreeRefund 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' }, + 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)?.braintreeRefund; + + expect(history).toHaveLength(2); + expect(history?.[0]).toMatchObject(priorEntry); + expect(history?.[1]?.type).toBe('refund'); + expect(history?.[1]?.transaction?.id).toBe('r-new'); + }); + it('refundPayment voids when transaction is submitted_for_settlement', async () => { const { service, gateway } = buildService(); @@ -602,6 +635,67 @@ describe('BraintreeProviderService core behaviors', () => { }); }); + 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 propagates webhook parse failures', async () => { const { service, gateway } = buildService(); gateway.webhookNotification.parse.mockRejectedValueOnce(new Error('invalid signature')); 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 637905dd..45dc4cc7 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 @@ -642,10 +642,6 @@ class BraintreeBase extends AbstractPaymentProvider { status: finalStatus, }; } catch (error) { - this.logErrorDetail('authorizePayment', error, { - 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); this.rethrowGatewayError(error, 'authorize payment', { amount: (input.data as { amount?: number })?.amount, @@ -951,7 +947,17 @@ class BraintreeBase extends AbstractPaymentProvider { } catch (error) { this.logErrorDetail('sync payment session (retrieveTransaction)', error, { transactionId }); try { - await this.gateway.transaction.void(transactionId); + 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}`, + ); + } } catch (voidError) { this.logErrorDetail('void orphan sale after sync failure', voidError, { transactionId }); this.logger.error( From d199540b7dc1999746916ab16c6f57e0544dbf2f Mon Sep 17 00:00:00 2001 From: govind Date: Fri, 7 Aug 2026 12:59:31 +0530 Subject: [PATCH 7/9] fix: enable no-void --- plugins/braintree-payment/CHANGELOG.md | 6 +++ plugins/braintree-payment/README.md | 3 ++ plugins/braintree-payment/package.json | 2 +- .../src/core/__tests__/braintree-base.spec.ts | 41 +++++++++++++++++++ .../core/__tests__/braintree-import.spec.ts | 18 +++++++- .../src/core/braintree-base.ts | 20 ++++++++- .../src/core/braintree-import.ts | 10 +++++ .../payment-braintree/src/types/index.ts | 6 +++ 8 files changed, 102 insertions(+), 4 deletions(-) diff --git a/plugins/braintree-payment/CHANGELOG.md b/plugins/braintree-payment/CHANGELOG.md index 2740130c..0a2fc57e 100644 --- a/plugins/braintree-payment/CHANGELOG.md +++ b/plugins/braintree-payment/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.9-next + +### Improvements + +- Add `disableVoidTransactions` option: when enabled, refunds never void and only proceed for `settled`/`settling` transactions (late requirement for future partial order refunds and order edits). Unsettled refunds throw `INVALID_DATA` with “cannot be refunded right now”. + ## 0.1.8 ### Fixes diff --git a/plugins/braintree-payment/README.md b/plugins/braintree-payment/README.md index d44a55aa..445dd81b 100644 --- a/plugins/braintree-payment/README.md +++ b/plugins/braintree-payment/README.md @@ -90,6 +90,7 @@ 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 } } @@ -106,6 +107,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, refunds on unsettled transactions fail with “cannot be refunded right now”. - **logging**: Enable verbose plugin debug logging (`true` or `false`, default: `false`). When `true`, the provider logs operation details (initiate, authorize, capture, refund, etc.) and expanded Braintree error context via Medusa's logger with a `[Braintree]` prefix. Set via `BRAINTREE_LOGGING=true` in `.env` or pass `logging: true` directly in provider options. Disable in production unless actively debugging. ### Debug logging @@ -140,6 +142,7 @@ Earlier README examples used `logging: process.env.NODE_ENV !== 'production'` (a > - `autoCapture`: If set to `true`, payments are captured automatically after authorization. > - `savePaymentMethod`: If set to `true`, customer payment methods are saved for future use. > - `allowRefundOnRefunded`: If set to `true`, the imported payment provider will gracefully handle refund attempts on transactions that have already been refunded in Braintree. Instead of throwing an error, it will log a warning and record the refund locally only. This is useful when orders are imported and later refunded directly in Braintree. +> - `disableVoidTransactions`: Late additional requirement so future partial order refunds and order edits can be supported. When `true`, the provider waits for `settled`/`settling` before refunding; otherwise refund fails with “cannot be refunded right now”. `cancelPayment` may still void. ### 3D Secure Setup diff --git a/plugins/braintree-payment/package.json b/plugins/braintree-payment/package.json index 002af7c9..69a71a2a 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.1.10-next", "description": "Braintree plugin for Medusa", "author": "Lambda Curry (https://lambdacurry.dev)", "license": "MIT", diff --git a/plugins/braintree-payment/src/providers/payment-braintree/src/core/__tests__/braintree-base.spec.ts b/plugins/braintree-payment/src/providers/payment-braintree/src/core/__tests__/braintree-base.spec.ts index dd715a89..a39d0dc1 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 @@ -233,6 +233,47 @@ describe('BraintreeProviderService core behaviors', () => { expect(entry?.transaction?.id).toBe('t1'); }); + it('refundPayment throws when disableVoidTransactions and status is authorized', async () => { + const { service, gateway } = buildService({ disableVoidTransactions: true }); + + const input: RefundPaymentInput = { + amount: 5, + data: { + client_token: 'ct', + amount: 1000, + currency_code: 'USD', + braintreeTransaction: { id: 't1' }, + }, + }; + + gateway.transaction.find.mockResolvedValueOnce({ id: 't1', status: 'authorized' }); + + await expect(service.refundPayment(input)).rejects.toMatchObject({ + type: MedusaError.Types.INVALID_DATA, + message: 'Braintree transaction with ID t1 cannot be refunded right now', + }); + expect(gateway.transaction.void).not.toHaveBeenCalled(); + expect(gateway.transaction.refund).not.toHaveBeenCalled(); + }); + + it('refundPayment refunds settled transactions when disableVoidTransactions is enabled', async () => { + const { service, gateway } = buildService({ disableVoidTransactions: true }); + + gateway.transaction.find + .mockResolvedValueOnce({ id: 't-settled', status: 'settled' }) + .mockResolvedValueOnce({ id: 't-settled', status: 'settled' }); + gateway.transaction.refund.mockResolvedValueOnce({ + success: true, + transaction: { id: 'r-settled', status: 'submitted_for_settlement' }, + }); + + const result = await service.refundPayment(settledRefundInput(10)); + + expect(gateway.transaction.void).not.toHaveBeenCalled(); + expect(gateway.transaction.refund).toHaveBeenCalledWith('t-settled', '10.00'); + expect(lastRefundEntry(result.data)?.type).toBe('refund'); + }); + it('refundPayment appends to existing braintreeRefund history', async () => { const { service, gateway } = buildService(); const priorEntry = { diff --git a/plugins/braintree-payment/src/providers/payment-braintree/src/core/__tests__/braintree-import.spec.ts b/plugins/braintree-payment/src/providers/payment-braintree/src/core/__tests__/braintree-import.spec.ts index 7dc3e84c..ed7db4b2 100644 --- a/plugins/braintree-payment/src/providers/payment-braintree/src/core/__tests__/braintree-import.spec.ts +++ b/plugins/braintree-payment/src/providers/payment-braintree/src/core/__tests__/braintree-import.spec.ts @@ -3,7 +3,7 @@ import { MedusaError } from '@medusajs/framework/utils'; import BraintreeImportService from '../../services/braintree-import'; import { BraintreeConstructorArgs } from '../braintree-base'; -const buildService = () => { +const buildService = (overrideOptions?: Record) => { const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() } as any; const cache = { get: jest.fn(), set: jest.fn() } as any; @@ -18,6 +18,7 @@ const buildService = () => { savePaymentMethod: false, webhookSecret: 'whsec', autoCapture: true, + ...overrideOptions, } as any; const service = new BraintreeImportService(container, options); @@ -33,7 +34,7 @@ const buildService = () => { (service as any).gateway = gateway; - return { service, gateway }; + return { service, gateway, logger }; }; describe('BraintreeImportService', () => { @@ -81,6 +82,19 @@ describe('BraintreeImportService', () => { expect((res.data as any).refundedTotal).toBe(10); }); + it('throws when disableVoidTransactions and status is authorized', async () => { + const { service, gateway } = buildService({ disableVoidTransactions: true }); + const session = { transactionId: 't2', importedAsRefunded: false, refundedTotal: 0, status: 'captured' } as any; + gateway.transaction.find.mockResolvedValueOnce({ id: 't2', status: 'authorized' }); + + await expect(service.refundPayment({ amount: 10, data: session } as any)).rejects.toMatchObject({ + type: MedusaError.Types.INVALID_DATA, + message: 'Braintree transaction with ID t2 cannot be refunded right now', + }); + expect(gateway.transaction.void).not.toHaveBeenCalled(); + expect(gateway.transaction.refund).not.toHaveBeenCalled(); + }); + it('performs real refund for settled/settling when not imported-refunded', async () => { const { service, gateway } = buildService(); const session = { transactionId: 't3', importedAsRefunded: false, refundedTotal: 1.25, status: 'captured' } as any; diff --git a/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-base.ts b/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-base.ts index 45dc4cc7..9e68880a 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 @@ -527,9 +527,17 @@ 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']; + const booleanFields = [ + 'enable3DSecure', + 'savePaymentMethod', + 'autoCapture', + 'allowRefundOnRefunded', + 'disableVoidTransactions', + 'logging', + ]; for (const field of booleanFields) { if (isDefined(options[field as keyof BraintreeOptions]) && typeof options[field as keyof BraintreeOptions] !== 'boolean') { throw new MedusaError( @@ -1186,12 +1194,22 @@ class BraintreeBase extends AbstractPaymentProvider { /** * Chooses void vs refund based on transaction status (after optional test settle). * @param transaction - Live Braintree transaction + * @throws {MedusaError} `INVALID_DATA` when void is disabled and status is voidable * @throws {MedusaError} `NOT_FOUND` when status is neither voidable nor refundable */ private async resolveRefundAction(transaction: Transaction): Promise { const resolved = await this.applyTestForceSettled(transaction); if (isVoidableRefundStatus(resolved.status)) { + if (this.options.disableVoidTransactions) { + this.logger.error( + `Braintree transaction with ID ${resolved.id} cannot be refunded right now because it's in status ${resolved.status}`, + ); + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Braintree transaction with ID ${resolved.id} cannot be refunded right now`, + ); + } return { kind: 'voided', transaction: resolved }; } diff --git a/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-import.ts b/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-import.ts index a83f9d5f..38594e62 100644 --- a/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-import.ts +++ b/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-import.ts @@ -285,6 +285,16 @@ class BraintreeImport extends AbstractPaymentProvider { const shouldVoid = ['submitted_for_settlement', 'authorized'].includes(transaction.status); if (shouldVoid) { + if (this.options.disableVoidTransactions) { + this.logger.error( + `Braintree transaction with ID ${transaction.id} cannot be refunded right now because it's in status ${transaction.status}`, + ); + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Braintree transaction with ID ${transaction.id} cannot be refunded right now`, + ); + } + const cancelResponse = await this.gateway.transaction.void(transaction.id); if (isBraintreeFailureResponse(cancelResponse)) { diff --git a/plugins/braintree-payment/src/providers/payment-braintree/src/types/index.ts b/plugins/braintree-payment/src/providers/payment-braintree/src/types/index.ts index 899dc49c..d0ddb45b 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,6 +11,12 @@ 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; } From 5a473b4e7a98df74189a8d058c17975a576cf858 Mon Sep 17 00:00:00 2001 From: govind Date: Mon, 10 Aug 2026 16:20:15 +0530 Subject: [PATCH 8/9] fix: improving operations --- plugins/braintree-payment/CHANGELOG.md | 1 + plugins/braintree-payment/README.md | 18 +++++++++++++---- plugins/braintree-payment/package.json | 2 +- .../src/core/__tests__/braintree-base.spec.ts | 20 +++++-------------- .../src/core/braintree-base.ts | 12 ++++++----- .../payment-braintree/src/types/index.ts | 6 ++++++ 6 files changed, 34 insertions(+), 25 deletions(-) diff --git a/plugins/braintree-payment/CHANGELOG.md b/plugins/braintree-payment/CHANGELOG.md index 0a2fc57e..dba1fcd4 100644 --- a/plugins/braintree-payment/CHANGELOG.md +++ b/plugins/braintree-payment/CHANGELOG.md @@ -5,6 +5,7 @@ ### Improvements - Add `disableVoidTransactions` option: when enabled, refunds never void and only proceed for `settled`/`settling` transactions (late requirement for future partial order refunds and order edits). Unsettled refunds throw `INVALID_DATA` with “cannot be refunded right now”. +- Move sandbox settle-before-refund from reading `process.env.TEST_FORCE_SETTLED` inside the provider to a `testForceSettled` option (wire `TEST_FORCE_SETTLED` in `medusa-config` if you still use the env var). ## 0.1.8 diff --git a/plugins/braintree-payment/README.md b/plugins/braintree-payment/README.md index 445dd81b..0646ac2d 100644 --- a/plugins/braintree-payment/README.md +++ b/plugins/braintree-payment/README.md @@ -41,8 +41,8 @@ BRAINTREE_PRIVATE_KEY= BRAINTREE_WEBHOOK_SECRET= BRAINTREE_ENVIRONMENT=sandbox|development|production|qa BRAINTREE_ENABLE_3D_SECURE=true|false -TEST_FORCE_SETTLED=true|false BRAINTREE_LOGGING=true|false +TEST_FORCE_SETTLED=true|false ``` - `BRAINTREE_PUBLIC_KEY`: Your Braintree public key. @@ -51,8 +51,8 @@ BRAINTREE_LOGGING=true|false - `BRAINTREE_WEBHOOK_SECRET`: Secret for validating Braintree webhooks. - `BRAINTREE_ENVIRONMENT`: One of `sandbox`, `development`, `production`, or `qa`. - `BRAINTREE_ENABLE_3D_SECURE`: Set to `true` to enable 3D Secure authentication, otherwise `false`. -- `TEST_FORCE_SETTLED`: **Sandbox only.** When set to `true` **and** `BRAINTREE_ENVIRONMENT=sandbox`, the refund flow settles the Braintree transaction via the sandbox testing API before attempting a refund. Use this to exercise the **refund** path (settled/settling) instead of the **void** path (authorized/submitted_for_settlement). Defaults to `false`. Ignored (with a warning) outside sandbox. Do not enable in production. - `BRAINTREE_LOGGING`: Optional. Set to `true` to enable plugin debug logging. Wire this to the provider `logging` option in `medusa-config.ts` (see below). Defaults to `false`. +- `TEST_FORCE_SETTLED`: Optional. **Sandbox only.** Wire this to the provider `testForceSettled` option in `medusa-config.ts` (see below). Defaults to `false`. Do not enable in production. ### Testing refunds in sandbox @@ -61,14 +61,22 @@ In Braintree sandbox, transactions often remain in `authorized` or `submitted_fo - **Void path:** `authorized`, `submitted_for_settlement` - **Refund path:** `settled`, `settling` -To test the refund path locally without waiting for settlement, set: +To test the refund path locally without waiting for settlement, set `environment: 'sandbox'` and `testForceSettled: true` in provider options (optionally via env): ```env BRAINTREE_ENVIRONMENT=sandbox TEST_FORCE_SETTLED=true ``` -When both are set, `refundPayment` calls Braintree's sandbox `testing.settle` on the transaction, re-fetches it, then proceeds with `transaction.refund`. If `TEST_FORCE_SETTLED=true` but the provider environment is not `sandbox`, the settle step is skipped and a warning is logged. +```javascript +options: { + environment: process.env.BRAINTREE_ENVIRONMENT || 'sandbox', + testForceSettled: process.env.TEST_FORCE_SETTLED === 'true', + // ... +} +``` + +When both are set, `refundPayment` calls Braintree's sandbox `testing.settle` on the transaction, re-fetches it, then proceeds with `transaction.refund`. If `testForceSettled` is `true` but the provider environment is not `sandbox`, the settle step is skipped and a warning is logged. ### Medusa Configuration @@ -92,6 +100,7 @@ dependencies:[Modules.CACHE] 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 } } ``` @@ -109,6 +118,7 @@ dependencies:[Modules.CACHE] - **allowRefundOnRefunded**: Allow refund attempts on already-refunded imported transactions (default: `false`). - **disableVoidTransactions**: When `true`, refunds never void; only `settled`/`settling` transactions may be refunded. Late requirement so future partial order refunds and order edits can be supported (void cancels the full authorization). Default: `false`. With this enabled, refunds on unsettled transactions fail with “cannot be refunded right now”. - **logging**: Enable verbose plugin debug logging (`true` or `false`, default: `false`). When `true`, the provider logs operation details (initiate, authorize, capture, refund, etc.) and expanded Braintree error context via Medusa's logger with a `[Braintree]` prefix. Set via `BRAINTREE_LOGGING=true` in `.env` or pass `logging: true` directly in provider options. Disable in production unless actively debugging. +- **testForceSettled**: **Sandbox only.** When `true` **and** `environment` is `sandbox`, the refund flow settles the Braintree transaction via the sandbox testing API before attempting a refund. Use this to exercise the **refund** path (settled/settling) instead of the **void** path (authorized/submitted_for_settlement). Defaults to `false`. Ignored (with a warning) outside sandbox. Set via `TEST_FORCE_SETTLED=true` in `.env` wired to this option, or pass `testForceSettled: true` directly. Do not enable in production. ### Debug logging diff --git a/plugins/braintree-payment/package.json b/plugins/braintree-payment/package.json index 69a71a2a..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.10-next", + "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 a39d0dc1..7a4380b3 100644 --- a/plugins/braintree-payment/src/providers/payment-braintree/src/core/__tests__/braintree-base.spec.ts +++ b/plugins/braintree-payment/src/providers/payment-braintree/src/core/__tests__/braintree-base.spec.ts @@ -75,20 +75,12 @@ const settledRefundInput = (amount: number, transactionId = 't-settled'): Refund }); describe('BraintreeProviderService core behaviors', () => { - const originalTestForceSettled = process.env.TEST_FORCE_SETTLED; - beforeEach(() => { jest.resetAllMocks(); - delete process.env.TEST_FORCE_SETTLED; }); afterEach(() => { jest.useRealTimers(); - if (originalTestForceSettled === undefined) { - delete process.env.TEST_FORCE_SETTLED; - } else { - process.env.TEST_FORCE_SETTLED = originalTestForceSettled; - } }); it('returns cached client token when available', async () => { @@ -538,9 +530,8 @@ describe('BraintreeProviderService core behaviors', () => { }); }); - it('refundPayment settles then refunds when TEST_FORCE_SETTLED is enabled in sandbox', async () => { - process.env.TEST_FORCE_SETTLED = 'true'; - const { service, gateway } = buildService({ environment: 'sandbox' }); + it('refundPayment settles then refunds when testForceSettled is enabled in sandbox', async () => { + const { service, gateway } = buildService({ environment: 'sandbox', testForceSettled: true }); gateway.transaction.find .mockResolvedValueOnce({ id: 't-force', status: 'authorized' }) @@ -561,9 +552,8 @@ describe('BraintreeProviderService core behaviors', () => { expect(forceEntry?.transaction?.id).toBe('r-force'); }); - it('refundPayment ignores TEST_FORCE_SETTLED outside sandbox and voids authorized transactions', async () => { - process.env.TEST_FORCE_SETTLED = 'true'; - const { service, gateway, logger } = buildService({ environment: 'production' }); + it('refundPayment ignores testForceSettled outside sandbox and voids authorized transactions', async () => { + const { service, gateway, logger } = buildService({ environment: 'production', testForceSettled: true }); gateway.transaction.find .mockResolvedValueOnce({ id: 't-prod', status: 'authorized' }) @@ -576,7 +566,7 @@ describe('BraintreeProviderService core behaviors', () => { expect(gateway.transaction.void).toHaveBeenCalledWith('t-prod'); expect(gateway.transaction.refund).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith( - '[Braintree refund] TEST_FORCE_SETTLED ignored — only supported when environment is sandbox', + '[Braintree refund] testForceSettled ignored — only supported when environment is sandbox', ); const prodEntry = lastRefundEntry(result.data); expect(prodEntry?.type).toBe('voided'); diff --git a/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-base.ts b/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-base.ts index 9e68880a..08adc733 100644 --- a/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-base.ts +++ b/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-base.ts @@ -402,11 +402,11 @@ class BraintreeBase extends AbstractPaymentProvider { } /** - * Whether sandbox test settlement is enabled (`TEST_FORCE_SETTLED=true` and env is sandbox). + * Whether sandbox test settlement is enabled (`testForceSettled` option and env is sandbox). */ private isTestForceSettledEnabled(): boolean { return ( - process.env.TEST_FORCE_SETTLED === 'true' && this.options_.environment.toLowerCase() === 'sandbox' + !!this.options_.testForceSettled && this.options_.environment.toLowerCase() === 'sandbox' ); } @@ -529,6 +529,7 @@ class BraintreeBase extends AbstractPaymentProvider { options.allowRefundOnRefunded = options.allowRefundOnRefunded ?? false; options.disableVoidTransactions = options.disableVoidTransactions ?? false; options.logging = options.logging ?? false; + options.testForceSettled = options.testForceSettled ?? false; const booleanFields = [ 'enable3DSecure', @@ -537,6 +538,7 @@ class BraintreeBase extends AbstractPaymentProvider { 'allowRefundOnRefunded', 'disableVoidTransactions', 'logging', + 'testForceSettled', ]; for (const field of booleanFields) { if (isDefined(options[field as keyof BraintreeOptions]) && typeof options[field as keyof BraintreeOptions] !== 'boolean') { @@ -1174,15 +1176,15 @@ class BraintreeBase extends AbstractPaymentProvider { /** * Sandbox-only: force settle so refund paths can be exercised in tests. - * No-ops unless `TEST_FORCE_SETTLED=true` and environment is sandbox. + * No-ops unless `testForceSettled` is true and environment is sandbox. * @param transaction - Transaction to optionally settle */ private async applyTestForceSettled(transaction: Transaction): Promise { - if (process.env.TEST_FORCE_SETTLED !== 'true') return transaction; + if (!this.options_.testForceSettled) return transaction; if (!this.isTestForceSettledEnabled()) { this.logger.warn( - '[Braintree refund] TEST_FORCE_SETTLED ignored — only supported when environment is sandbox', + '[Braintree refund] testForceSettled ignored — only supported when environment is sandbox', ); return transaction; } 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 d0ddb45b..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 @@ -19,6 +19,12 @@ export interface BraintreeOptions extends Braintree.ClientGatewayConfig { 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 = { From e06f10486cf3ca6df1fc150b08dcbb1df99f58d1 Mon Sep 17 00:00:00 2001 From: govind Date: Tue, 11 Aug 2026 13:02:35 +0530 Subject: [PATCH 9/9] fix: addressed the potential regression --- plugins/braintree-payment/CHANGELOG.md | 8 +- plugins/braintree-payment/README.md | 8 +- .../src/core/__tests__/braintree-base.spec.ts | 107 +++++++++++++++--- .../src/core/braintree-base.ts | 62 ++++++---- 4 files changed, 146 insertions(+), 39 deletions(-) diff --git a/plugins/braintree-payment/CHANGELOG.md b/plugins/braintree-payment/CHANGELOG.md index dba1fcd4..6a3f0782 100644 --- a/plugins/braintree-payment/CHANGELOG.md +++ b/plugins/braintree-payment/CHANGELOG.md @@ -1,10 +1,14 @@ # Changelog -## 0.1.9-next +## 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 and only proceed for `settled`/`settling` transactions (late requirement for future partial order refunds and order edits). Unsettled refunds throw `INVALID_DATA` with “cannot be refunded right now”. +- 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 diff --git a/plugins/braintree-payment/README.md b/plugins/braintree-payment/README.md index 0646ac2d..9a264f0d 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, refunds on unsettled transactions fail with “cannot be refunded right now”. +- **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. @@ -152,7 +152,11 @@ Earlier README examples used `logging: process.env.NODE_ENV !== 'production'` (a > - `autoCapture`: If set to `true`, payments are captured automatically after authorization. > - `savePaymentMethod`: If set to `true`, customer payment methods are saved for future use. > - `allowRefundOnRefunded`: If set to `true`, the imported payment provider will gracefully handle refund attempts on transactions that have already been refunded in Braintree. Instead of throwing an error, it will log a warning and record the refund locally only. This is useful when orders are imported and later refunded directly in Braintree. -> - `disableVoidTransactions`: Late additional requirement so future partial order refunds and order edits can be supported. When `true`, the provider waits for `settled`/`settling` before refunding; otherwise refund fails with “cannot be refunded right now”. `cancelPayment` may still void. + +### 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 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 7a4380b3..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,5 +1,5 @@ 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'; @@ -11,11 +11,12 @@ type RefundHistoryEntry = { }; type RefundResultData = { - braintreeRefund?: RefundHistoryEntry[]; + braintreeRefund?: RefundHistoryEntry[] | Record; + braintreeRefunds?: RefundHistoryEntry[]; }; const lastRefundEntry = (data: unknown): RefundHistoryEntry | undefined => { - const history = (data as RefundResultData)?.braintreeRefund; + const history = (data as RefundResultData)?.braintreeRefunds; return history?.[history.length - 1]; }; @@ -266,7 +267,41 @@ describe('BraintreeProviderService core behaviors', () => { expect(lastRefundEntry(result.data)?.type).toBe('refund'); }); - it('refundPayment appends to existing braintreeRefund history', async () => { + 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, @@ -291,12 +326,51 @@ describe('BraintreeProviderService core behaviors', () => { }); const result = await service.refundPayment(input); - const history = (result.data as RefundResultData)?.braintreeRefund; + 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 () => { @@ -592,11 +666,12 @@ describe('BraintreeProviderService core behaviors', () => { gateway.transaction.find.mockResolvedValueOnce({ id: 't1', status: 'voided' }); const result = await service.refundPayment(input); - const history = (result.data as RefundResultData)?.braintreeRefund; + 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 () => { @@ -622,7 +697,7 @@ 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'); }); @@ -641,7 +716,7 @@ describe('BraintreeProviderService core behaviors', () => { data: 'bt_signature=s&bt_payload=p', } as any); - expect(result.action).toBe('captured'); + expect(result.action).toBe(PaymentActions.SUCCESSFUL); expect((result as any).data.session_id).toBe(''); }); @@ -727,15 +802,15 @@ describe('BraintreeProviderService core behaviors', () => { ); }); - it('getWebhookActionAndData propagates webhook parse failures', async () => { - const { service, gateway } = buildService(); + it('getWebhookActionAndData returns NOT_SUPPORTED for webhook parse failures', async () => { + const { service, gateway, logger } = buildService(); gateway.webhookNotification.parse.mockRejectedValueOnce(new Error('invalid signature')); - await expect( - service.getWebhookActionAndData({ data: 'bt_signature=bad&bt_payload=x' } as any), - ).rejects.toMatchObject({ - type: MedusaError.Types.INVALID_DATA, - message: expect.stringContaining('validate Braintree webhook'), - }); + 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/braintree-base.ts b/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-base.ts index 08adc733..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 @@ -153,7 +153,7 @@ const isVoidableRefundStatus = (status: TransactionStatus): boolean => const isSettledRefundStatus = (status: TransactionStatus): boolean => (SETTLED_REFUND_STATUSES as readonly string[]).includes(status); -/** One entry appended to session `data.braintreeRefund` after a void or refund. */ +/** One entry appended to session `data.braintreeRefunds` after a void or refund. */ type BraintreeRefundHistoryEntry = { type: 'voided' | 'refund'; transaction: Transaction; @@ -1118,9 +1118,36 @@ class BraintreeBase extends AbstractPaymentProvider { } /** - * Builds refund output `data`, appending one entry to `braintreeRefund` history. - * Legacy non-array `braintreeRefund` values are ignored so spreads stay safe. - * @param input - Original refund input (prior history read from `data.braintreeRefund`) + * 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 */ @@ -1129,16 +1156,15 @@ class BraintreeBase extends AbstractPaymentProvider { transaction: Transaction, entry: BraintreeRefundHistoryEntry, ): RefundPaymentOutput { - const stored = input.data?.braintreeRefund; - const prior: BraintreeRefundHistoryEntry[] = Array.isArray(stored) - ? (stored as BraintreeRefundHistoryEntry[]) - : []; + const prior = this.readRefundHistory(input.data); + const data = { ...(input.data ?? {}) }; + delete data.braintreeRefund; return { data: { - ...input.data, + ...data, transaction, - braintreeRefund: [...prior, entry], + braintreeRefunds: [...prior, entry], }, }; } @@ -1203,7 +1229,7 @@ class BraintreeBase extends AbstractPaymentProvider { const resolved = await this.applyTestForceSettled(transaction); if (isVoidableRefundStatus(resolved.status)) { - if (this.options.disableVoidTransactions) { + 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}`, ); @@ -1273,7 +1299,7 @@ class BraintreeBase extends AbstractPaymentProvider { /** * Medusa refund hook: voids or refunds based on transaction status and - * appends history under `data.braintreeRefund`. + * appends history under `data.braintreeRefunds`. * @param input - Amount + session transaction */ async refundPayment(input: RefundPaymentInput): Promise { @@ -1427,7 +1453,7 @@ class BraintreeBase extends AbstractPaymentProvider { * 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 - * @throws {MedusaError} When signature/payload validation fails (does not swallow) + * or when signature/payload validation fails (logged; avoids Braintree retries) */ private async parseWebhookNotification( webhookData: ProviderWebhookPayload['payload'], @@ -1444,10 +1470,9 @@ class BraintreeBase extends AbstractPaymentProvider { } catch (error) { this.logErrorDetail('webhook validation', error, { hasPayload: !!webhookData?.data }); this.logger.error(`Braintree webhook validation failed : ${error}`); - if (MedusaError.isMedusaError(error)) throw error; - throw buildBraintreeError(error, 'validate Braintree webhook', this.logger, { - hasPayload: !!webhookData?.data, - }); + // Permanently invalid signatures/payloads cannot succeed on retry; return null so + // getWebhookActionAndData maps to NOT_SUPPORTED (Medusa 2xx) instead of throwing. + return null; } } @@ -1469,10 +1494,9 @@ class BraintreeBase extends AbstractPaymentProvider { /** * Medusa webhook hook: parses the notification and returns action + session_id/amount. - * Empty/null notification → `NOT_SUPPORTED`. Parse/signature failures propagate as errors. + * Empty/null notification or permanent parse/signature failures → `NOT_SUPPORTED`. * Missing custom field session id → empty string. * @param webhookData - Raw provider webhook payload - * @throws {MedusaError} When webhook validation fails */ async getWebhookActionAndData(webhookData: ProviderWebhookPayload['payload']): Promise { this.logDebug('getWebhookActionAndData', { hasData: !!webhookData?.data });