feat(medusa-payment-stripe): Add delay to Stripe webhook (#5838)
**What** - Migrate Stripe plugin to use API Routes + Subscribers - Change the behaviour of Stripe webhook Instead of processing webhook events immediately, we fire an event to perform the work in the background. The primary reason for changing the webhook is that the previous implementation led to occasional conflicts due to a race condition between the client's and the webhook's request to complete a cart. Now, we emit an event with a configurable delay (defaults to 2s) to prevent this from happening This approach was preferred over adding a timeout directly to the webhook execution, as it is generally best practice to respond to a webhook event immediately after receiving it.
This commit is contained in:
6
.changeset/metal-pans-beg.md
Normal file
6
.changeset/metal-pans-beg.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@medusajs/medusa": patch
|
||||
"medusa-payment-stripe": patch
|
||||
---
|
||||
|
||||
feat(medusa-payment-stripe): Add delay to Stripe webhook
|
||||
@@ -48,9 +48,9 @@
|
||||
"medusa-react": "^9.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@medusajs/utils": "^1.11.2",
|
||||
"body-parser": "^1.19.0",
|
||||
"express": "^4.17.1",
|
||||
"medusa-core-utils": "^1.2.0",
|
||||
"stripe": "^11.10.0"
|
||||
},
|
||||
"gitHead": "81a7ff73d012fda722f6e9ef0bd9ba0232d37808",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/medusa"
|
||||
import { getStripePayments } from "../../../../../controllers/get-payments"
|
||||
|
||||
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
|
||||
const payments = await getStripePayments(req)
|
||||
res.json({ payments })
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import stripeHooks from "./stripe"
|
||||
import { Router } from "express"
|
||||
import bodyParser from "body-parser"
|
||||
import { wrapHandler } from "@medusajs/medusa"
|
||||
|
||||
const route = Router()
|
||||
|
||||
export default (app) => {
|
||||
app.use("/stripe", route)
|
||||
|
||||
route.post(
|
||||
"/hooks",
|
||||
// stripe constructEvent fails without body-parser
|
||||
bodyParser.raw({ type: "application/json" }),
|
||||
wrapHandler(stripeHooks)
|
||||
)
|
||||
return app
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Request, Response } from "express"
|
||||
import { constructWebhook, handlePaymentHook } from "../utils/utils"
|
||||
|
||||
export default async (req: Request, res: Response) => {
|
||||
let event
|
||||
try {
|
||||
event = constructWebhook({
|
||||
signature: req.headers["stripe-signature"],
|
||||
body: req.body,
|
||||
container: req.scope,
|
||||
})
|
||||
} catch (err) {
|
||||
res.status(400).send(`Webhook Error: ${err.message}`)
|
||||
return
|
||||
}
|
||||
|
||||
const paymentIntent = event.data.object
|
||||
|
||||
const { statusCode } = await handlePaymentHook({
|
||||
event,
|
||||
container: req.scope,
|
||||
paymentIntent,
|
||||
})
|
||||
res.sendStatus(statusCode)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { authenticate } from "@medusajs/medusa"
|
||||
import { ConfigModule } from "@medusajs/types"
|
||||
import getConfigFile from "@medusajs/utils/dist/common/get-config-file"
|
||||
import cors from "cors"
|
||||
import { Router } from "express"
|
||||
import { getStripePayments } from "../controllers/get-payments"
|
||||
import hooks from "./hooks"
|
||||
|
||||
export default (rootDirectory) => {
|
||||
const app = Router()
|
||||
|
||||
hooks(app)
|
||||
|
||||
const { configModule } = getConfigFile<ConfigModule>(
|
||||
rootDirectory,
|
||||
"medusa-config"
|
||||
)
|
||||
|
||||
const corsOptions = {
|
||||
origin: configModule?.projectConfig?.admin_cors?.split(","),
|
||||
credentials: true,
|
||||
}
|
||||
|
||||
app.use(
|
||||
`/admin/orders/stripe-payments/:order_id`,
|
||||
cors(corsOptions),
|
||||
authenticate()
|
||||
)
|
||||
app.get(`/admin/orders/stripe-payments/:order_id`, async (req, res) => {
|
||||
const payments = await getStripePayments(req)
|
||||
res.json({ payments })
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
12
packages/medusa-payment-stripe/src/api/middlewares.ts
Normal file
12
packages/medusa-payment-stripe/src/api/middlewares.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { MiddlewaresConfig } from "@medusajs/medusa"
|
||||
import { raw } from "body-parser"
|
||||
|
||||
export const config: MiddlewaresConfig = {
|
||||
routes: [
|
||||
{
|
||||
bodyParser: false,
|
||||
matcher: "/stripe/hooks",
|
||||
middlewares: [raw({ type: "application/json" })],
|
||||
},
|
||||
],
|
||||
}
|
||||
28
packages/medusa-payment-stripe/src/api/stripe/hooks/route.ts
Normal file
28
packages/medusa-payment-stripe/src/api/stripe/hooks/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/medusa"
|
||||
import { constructWebhook } from "../../utils/utils"
|
||||
|
||||
const WEBHOOK_DELAY = process.env.STRIPE_WEBHOOK_DELAY ?? 5000 // 5s
|
||||
const WEBHOOK_RETRIES = process.env.STRIPE_WEBHOOK_RETRIES ?? 3
|
||||
|
||||
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
|
||||
try {
|
||||
const event = constructWebhook({
|
||||
signature: req.headers["stripe-signature"],
|
||||
body: req.body,
|
||||
container: req.scope,
|
||||
})
|
||||
|
||||
const eventBus = req.scope.resolve("eventBusService")
|
||||
|
||||
// we delay the processing of the event to avoid a conflict caused by a race condition
|
||||
await eventBus.emit("medusa.stripe_payment_intent_update", event, {
|
||||
delay: WEBHOOK_DELAY,
|
||||
attempts: WEBHOOK_RETRIES,
|
||||
})
|
||||
} catch (err) {
|
||||
res.status(400).send(`Webhook Error: ${err.message}`)
|
||||
return
|
||||
}
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { PostgresError } from "@medusajs/medusa"
|
||||
import Stripe from "stripe"
|
||||
import { EOL } from "os"
|
||||
import Stripe from "stripe"
|
||||
|
||||
import { buildError, handlePaymentHook, isPaymentCollection } from "../utils"
|
||||
import { container } from "../__fixtures__/container"
|
||||
import {
|
||||
existingCartId,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
paymentId,
|
||||
paymentIntentId,
|
||||
} from "../__fixtures__/data"
|
||||
import { buildError, handlePaymentHook, isPaymentCollection } from "../utils"
|
||||
|
||||
describe("Utils", () => {
|
||||
afterEach(() => {
|
||||
@@ -334,7 +334,7 @@ describe("Utils", () => {
|
||||
const paymentIntent = {
|
||||
id: paymentIntentId,
|
||||
metadata: { cart_id: nonExistingCartId },
|
||||
last_payment_error: { message: "error message" },
|
||||
last_payment_error: { message: "error message" } as any,
|
||||
}
|
||||
|
||||
await handlePaymentHook({ event, container, paymentIntent })
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
IdempotencyKeyService,
|
||||
PostgresError,
|
||||
} from "@medusajs/medusa"
|
||||
import { MedusaError } from "@medusajs/utils"
|
||||
import { AwilixContainer } from "awilix"
|
||||
import { MedusaError } from "medusa-core-utils"
|
||||
import { EOL } from "os"
|
||||
import Stripe from "stripe"
|
||||
|
||||
@@ -51,19 +51,15 @@ export async function handlePaymentHook({
|
||||
container,
|
||||
paymentIntent,
|
||||
}: {
|
||||
event: { type: string; id: string }
|
||||
event: Partial<Stripe.Event>
|
||||
container: AwilixContainer
|
||||
paymentIntent: {
|
||||
id: string
|
||||
metadata: { cart_id?: string; resource_id?: string }
|
||||
last_payment_error?: { message: string }
|
||||
}
|
||||
paymentIntent: Partial<Stripe.PaymentIntent>
|
||||
}): Promise<{ statusCode: number }> {
|
||||
const logger = container.resolve("logger")
|
||||
|
||||
const cartId =
|
||||
paymentIntent.metadata.cart_id ?? paymentIntent.metadata.resource_id // Backward compatibility
|
||||
const resourceId = paymentIntent.metadata.resource_id
|
||||
paymentIntent.metadata?.cart_id ?? paymentIntent.metadata?.resource_id // Backward compatibility
|
||||
const resourceId = paymentIntent.metadata?.resource_id
|
||||
|
||||
switch (event.type) {
|
||||
case "payment_intent.succeeded":
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
PaymentProcessorSessionResponse,
|
||||
PaymentSessionStatus,
|
||||
} from "@medusajs/medusa"
|
||||
import { MedusaError } from "@medusajs/utils"
|
||||
import { EOL } from "os"
|
||||
import Stripe from "stripe"
|
||||
import {
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
PaymentIntentOptions,
|
||||
StripeOptions,
|
||||
} from "../types"
|
||||
import { MedusaError } from "@medusajs/utils"
|
||||
|
||||
abstract class StripeBase extends AbstractPaymentProcessor {
|
||||
static identifier = ""
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export * from "./types"
|
||||
export * from "./core/stripe-base"
|
||||
export * from "./services/stripe-blik"
|
||||
export * from "./services/stripe-bancontact"
|
||||
export * from "./services/stripe-blik"
|
||||
export * from "./services/stripe-giropay"
|
||||
export * from "./services/stripe-ideal"
|
||||
export * from "./services/stripe-przelewy24"
|
||||
export * from "./services/stripe-provider"
|
||||
export * from "./services/stripe-przelewy24"
|
||||
export * from "./types"
|
||||
|
||||
24
packages/medusa-payment-stripe/src/subscribers/stripe.ts
Normal file
24
packages/medusa-payment-stripe/src/subscribers/stripe.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { type SubscriberArgs, type SubscriberConfig } from "@medusajs/medusa"
|
||||
import Stripe from "stripe"
|
||||
import { handlePaymentHook } from "../api/utils/utils"
|
||||
|
||||
export default async function stripeHandler({
|
||||
data,
|
||||
container,
|
||||
}: SubscriberArgs<Stripe.Event>) {
|
||||
const event = data
|
||||
const paymentIntent = event.data.object as Stripe.PaymentIntent
|
||||
|
||||
await handlePaymentHook({
|
||||
event,
|
||||
container,
|
||||
paymentIntent,
|
||||
})
|
||||
}
|
||||
|
||||
export const config: SubscriberConfig = {
|
||||
event: "medusa.stripe_payment_intent_update",
|
||||
context: {
|
||||
subscriberId: "medusa.stripe_payment_intent_update",
|
||||
},
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { IsEmail, IsNotEmpty } from "class-validator"
|
||||
import jwt from "jsonwebtoken"
|
||||
import { EntityManager } from "typeorm"
|
||||
import { defaultRelations } from "."
|
||||
import AuthService from "../../../../services/auth"
|
||||
import CustomerService from "../../../../services/customer"
|
||||
import { validator } from "../../../../utils/validator"
|
||||
import { defaultRelations } from "."
|
||||
|
||||
/**
|
||||
* @oas [post] /store/auth
|
||||
|
||||
@@ -35980,6 +35980,7 @@ __metadata:
|
||||
dependencies:
|
||||
"@medusajs/admin": ^7.1.7
|
||||
"@medusajs/medusa": ^1.17.4
|
||||
"@medusajs/utils": ^1.11.2
|
||||
"@tanstack/react-table": ^8.7.9
|
||||
"@types/stripe": ^8.0.417
|
||||
awilix: ^8.0.1
|
||||
@@ -35987,7 +35988,6 @@ __metadata:
|
||||
cross-env: ^5.2.1
|
||||
express: ^4.17.1
|
||||
jest: ^25.5.4
|
||||
medusa-core-utils: ^1.2.0
|
||||
medusa-react: ^9.0.11
|
||||
rimraf: ^5.0.1
|
||||
stripe: ^11.10.0
|
||||
|
||||
Reference in New Issue
Block a user