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:
Oli Juhl
2024-01-05 13:37:53 +01:00
committed by GitHub
parent 37fba9a168
commit 99a4f94db5
16 changed files with 92 additions and 98 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/medusa": patch
"medusa-payment-stripe": patch
---
feat(medusa-payment-stripe): Add delay to Stripe webhook

View File

@@ -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",

View File

@@ -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 })
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View 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" })],
},
],
}

View 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)
}

View File

@@ -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 })

View File

@@ -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":

View File

@@ -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 = ""

View File

@@ -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"

View 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",
},
}

View File

@@ -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

View File

@@ -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