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:
@@ -504,6 +504,11 @@ export default class PaymentModuleService
|
|||||||
status !== PaymentSessionStatus.AUTHORIZED &&
|
status !== PaymentSessionStatus.AUTHORIZED &&
|
||||||
status !== PaymentSessionStatus.CAPTURED
|
status !== PaymentSessionStatus.CAPTURED
|
||||||
) {
|
) {
|
||||||
|
await this.paymentSessionService_.update({
|
||||||
|
id: session.id,
|
||||||
|
status,
|
||||||
|
data,
|
||||||
|
}, sharedContext);
|
||||||
throw new MedusaError(
|
throw new MedusaError(
|
||||||
MedusaError.Types.NOT_ALLOWED,
|
MedusaError.Types.NOT_ALLOWED,
|
||||||
`Session: ${session.id} was not authorized with the provider.`
|
`Session: ${session.id} was not authorized with the provider.`
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Stripe from "stripe"
|
import Stripe from "stripe"
|
||||||
|
import { setTimeout } from "timers/promises"
|
||||||
import {
|
import {
|
||||||
AuthorizePaymentInput,
|
AuthorizePaymentInput,
|
||||||
AuthorizePaymentOutput,
|
AuthorizePaymentOutput,
|
||||||
@@ -50,6 +50,14 @@ import {
|
|||||||
getSmallestUnit,
|
getSmallestUnit,
|
||||||
} from "../utils/get-smallest-unit"
|
} 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> {
|
abstract class StripeBase extends AbstractPaymentProvider<StripeOptions> {
|
||||||
protected readonly options_: StripeOptions
|
protected readonly options_: StripeOptions
|
||||||
protected stripe_: Stripe
|
protected stripe_: Stripe
|
||||||
@@ -115,6 +123,87 @@ abstract class StripeBase extends AbstractPaymentProvider<StripeOptions> {
|
|||||||
return res
|
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({
|
async getPaymentStatus({
|
||||||
data,
|
data,
|
||||||
}: GetPaymentStatusInput): Promise<GetPaymentStatusOutput> {
|
}: GetPaymentStatusInput): Promise<GetPaymentStatusOutput> {
|
||||||
@@ -173,21 +262,15 @@ abstract class StripeBase extends AbstractPaymentProvider<StripeOptions> {
|
|||||||
| string
|
| string
|
||||||
| undefined
|
| undefined
|
||||||
|
|
||||||
let sessionData
|
const sessionData = await this.executeWithRetry<Stripe.PaymentIntent>(
|
||||||
try {
|
() => this.stripe_.paymentIntents.create(intentRequest, {
|
||||||
sessionData = (await this.stripe_.paymentIntents.create(intentRequest, {
|
|
||||||
idempotencyKey: context?.idempotency_key,
|
idempotencyKey: context?.idempotency_key,
|
||||||
})) as unknown as Record<string, unknown>
|
})
|
||||||
} catch (e) {
|
)
|
||||||
throw this.buildError(
|
const isPaymentIntent = 'id' in sessionData;
|
||||||
"An error occurred in InitiatePayment during the creation of the stripe payment intent",
|
|
||||||
e
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: sessionData.id,
|
id: isPaymentIntent ? sessionData.id : (data?.session_id as string),
|
||||||
data: sessionData,
|
data: sessionData as unknown as Record<string, unknown>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user