fix: preserve payment sessions during certain Stripe errors for webhook reconciliation (#11798)

* fix: preserve payment sessions during certain Stripe errors for webhook reconciliation

fix: add retry mechanism for errors that might be fixed after retry

fix: authorizePaymentSession method will update payment_session.status regardless regardless of wether or not the authorization is successful

* Refactor: improve handling structure and syntax

-Move HandledErrorType definition to the top of stripe-base

- Use timers/promises for setTimeout

- Removed data in HandledErrorType when retry is true

* refactor: improve error handling flow and logic

- Simplify return statement in initiatePayment to handle null cases

- Remove redundant if-check in handleStripeError and rely on switch

- Reorder conditional checks in executeWithRetry for clearer flow

- Update executeWithRetry to check for retry=false condition first

* clean up

* fix: improve payment error handling and traceability

- Return structured error state for StripeAPIError instead of null

- Throw error when retries are exhausted and no payment intent exists

- Update type definitions to support error state tracking

* fix formatting and naming
This commit is contained in:
Saryazdi-Saman
2025-03-13 09:46:31 -04:00
committed by GitHub
parent 28b0d08591
commit e05491c24f
2 changed files with 102 additions and 14 deletions

View File

@@ -504,6 +504,11 @@ export default class PaymentModuleService
status !== PaymentSessionStatus.AUTHORIZED &&
status !== PaymentSessionStatus.CAPTURED
) {
await this.paymentSessionService_.update({
id: session.id,
status,
data,
}, sharedContext);
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Session: ${session.id} was not authorized with the provider.`

View File

@@ -1,5 +1,5 @@
import Stripe from "stripe"
import { setTimeout } from "timers/promises"
import {
AuthorizePaymentInput,
AuthorizePaymentOutput,
@@ -50,6 +50,14 @@ import {
getSmallestUnit,
} from "../utils/get-smallest-unit"
type StripeIndeterminateState = {
indeterminate_due_to: string,
}
type StripeErrorData = Stripe.PaymentIntent | StripeIndeterminateState
type HandledErrorType =
| { retry: true; }
| { retry: false; data: StripeErrorData };
abstract class StripeBase extends AbstractPaymentProvider<StripeOptions> {
protected readonly options_: StripeOptions
protected stripe_: Stripe
@@ -115,6 +123,87 @@ abstract class StripeBase extends AbstractPaymentProvider<StripeOptions> {
return res
}
handleStripeError(error: any): HandledErrorType {
switch (error.type) {
case 'StripeCardError':
// Stripe has created a payment intent but it failed
// Extract and return paymentIntent object to be stored in payment_session
// Allows for reference to the failed intent and potential webhook reconciliation
const stripeError = error.raw as Stripe.errors.StripeCardError
if (stripeError.payment_intent) {
return {
retry: false,
data: stripeError.payment_intent
}
} else {
throw this.buildError(
"An error occurred in InitiatePayment during creation of stripe payment intent",
error
)
}
case 'StripeConnectionError':
case 'StripeRateLimitError':
// Connection or rate limit errors indicate an uncertain result
// Retry the operation
return {
retry: true,
}
case 'StripeAPIError': {
// API errors should be treated as indeterminate per Stripe documentation
// Rely on webhooks rather than assuming failure
return {
retry: false,
data: {
indeterminate_due_to: "stripe_api_error"
}
}
}
default:
// For all other errors, there was likely an issue creating the session
// on Stripe's servers. Throw an error which will trigger cleanup
// and deletion of the payment session.
throw this.buildError(
"An error occurred in InitiatePayment during creation of stripe payment intent",
error
)
}
}
async executeWithRetry<T>(
apiCall: () => Promise<T>,
maxRetries: number = 3,
baseDelay: number = 1000,
currentAttempt: number = 1
): Promise<T | StripeErrorData> {
try {
return await apiCall();
} catch (error) {
const handledError = this.handleStripeError(error)
if (!handledError.retry) {
// If retry is false, we know data exists per the type definition
return handledError.data;
}
if (
handledError.retry &&
currentAttempt <= maxRetries
) {
// Logic for retrying
const delay = baseDelay * Math.pow(2, currentAttempt - 1) * (0.5 + Math.random() * 0.5);
await setTimeout(delay);
return this.executeWithRetry(apiCall, maxRetries, baseDelay, currentAttempt + 1);
}
// Retries are exhausted
throw this.buildError(
"An error occurred in InitiatePayment during creation of stripe payment intent",
error
)
}
}
async getPaymentStatus({
data,
}: GetPaymentStatusInput): Promise<GetPaymentStatusOutput> {
@@ -173,21 +262,15 @@ abstract class StripeBase extends AbstractPaymentProvider<StripeOptions> {
| string
| undefined
let sessionData
try {
sessionData = (await this.stripe_.paymentIntents.create(intentRequest, {
const sessionData = await this.executeWithRetry<Stripe.PaymentIntent>(
() => this.stripe_.paymentIntents.create(intentRequest, {
idempotencyKey: context?.idempotency_key,
})) as unknown as Record<string, unknown>
} catch (e) {
throw this.buildError(
"An error occurred in InitiatePayment during the creation of the stripe payment intent",
e
)
}
})
)
const isPaymentIntent = 'id' in sessionData;
return {
id: sessionData.id,
data: sessionData,
id: isPaymentIntent ? sessionData.id : (data?.session_id as string),
data: sessionData as unknown as Record<string, unknown>,
}
}