Skip to content

Commit

Permalink
stripe webhook
Browse files Browse the repository at this point in the history
  • Loading branch information
adrien2p committed Feb 21, 2023
1 parent 7a8dc0c commit e80c444
Show file tree
Hide file tree
Showing 5 changed files with 243 additions and 3 deletions.
18 changes: 18 additions & 0 deletions packages/medusa-payment-stripe-processor/src/api/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import stripeHooks from "./stripe"
import { Router } from "express"
import bodyParser from "body-parser"
import middlewares from "@medusajs/medusa/dist/api/middlewares"

const route = Router()

export default (app) => {
app.use("/stripe", route)

route.post(
"/hooks",
// stripe constructEvent fails without body-parser
bodyParser.raw({ type: "application/json" }),
middlewares.wrap(stripeHooks)
)
return app
}
165 changes: 165 additions & 0 deletions packages/medusa-payment-stripe-processor/src/api/hooks/stripe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { Request, Response } from "express"
import {
buildHandleCartPaymentErrorMessage,
constructWebhook,
isPaymentCollection,
} from "../utils/utils"
import {
AbstractCartCompletionStrategy,
CartService,
IdempotencyKeyService,
} from "@medusajs/medusa"
import { MedusaError } from "medusa-core-utils"

export const RETRY_STATUS_CODE = 409

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 cartId = paymentIntent.metadata.cart_id // Backward compatibility
const resourceId = paymentIntent.metadata.resource_id

if (isPaymentCollection(resourceId)) {
await handlePaymentCollection(event, req, res, resourceId, paymentIntent.id)
} else {
await handleCartPayments(event, req, res, cartId ?? resourceId)
}
}

async function handleCartPayments(event, req, res, cartId) {
const manager = req.scope.resolve("manager")
const orderService = req.scope.resolve("orderService")
const logger = req.scope.resolve("logger")

const order = await orderService
.retrieveByCartId(cartId)
.catch(() => undefined)

// handle payment intent events
switch (event.type) {
case "payment_intent.succeeded":
if (order) {
// If order is created but not captured, we attempt to do so
if (order.payment_status !== "captured") {
await manager.transaction(async (manager) => {
await orderService.withTransaction(manager).capturePayment(order.id)
})
} else {
// Otherwise, respond with 200 preventing Stripe from retrying
return res.sendStatus(200)
}
} else {
// If order is not created, we respond with 404 to trigger Stripe retry mechanism
return res.sendStatus(404)
}
break
case "payment_intent.amount_capturable_updated":
try {
await manager.transaction(async (manager) => {
await paymentIntentAmountCapturableEventHandler({
order,
cartId,
container: req.scope,
transactionManager: manager,
})
})
} catch (err) {
const message = buildHandleCartPaymentErrorMessage(event, err)
logger.warn(message)
return res.sendStatus(RETRY_STATUS_CODE)
}
break
default:
res.sendStatus(204)
return
}

res.sendStatus(200)
}

async function handlePaymentCollection(event, req, res, id, paymentIntentId) {
const manager = req.scope.resolve("manager")
const paymentCollectionService = req.scope.resolve("paymentCollectionService")

const paycol = await paymentCollectionService
.retrieve(id, { relations: ["payments"] })
.catch(() => undefined)

if (paycol?.payments?.length) {
if (event.type === "payment_intent.succeeded") {
const payment = paycol.payments.find(
(pay) => pay.data.id === paymentIntentId
)
if (payment && !payment.captured_at) {
await manager.transaction(async (manager) => {
await paymentCollectionService
.withTransaction(manager)
.capture(payment.id)
})
}

res.sendStatus(200)
return
}
}
res.sendStatus(204)
}

async function paymentIntentAmountCapturableEventHandler({
order,
cartId,
container,
transactionManager,
}) {
if (!order) {
const completionStrat: AbstractCartCompletionStrategy = container.resolve(
"cartCompletionStrategy"
)
const cartService: CartService = container.resolve("cartService")
const idempotencyKeyService: IdempotencyKeyService = container.resolve(
"idempotencyKeyService"
)

let idempotencyKey
try {
idempotencyKey = await idempotencyKeyService
.withTransaction(transactionManager)
.initializeRequest("", "post", { cart_id: cartId }, "/stripe/hooks")
} catch (error) {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"Failed to create idempotency key",
"409"
)
}

const cart = await cartService
.withTransaction(transactionManager)
.retrieve(cartId, { select: ["context"] })

const { response_code, response_body } = await completionStrat.complete(
cartId,
idempotencyKey,
{ ip: cart.context?.ip as string }
)

if (response_code !== 200) {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
response_body["message"],
response_body["code"].toString()
)
}
}
}
10 changes: 10 additions & 0 deletions packages/medusa-payment-stripe-processor/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Router } from "express"
import hooks from "./hooks"

export default (container) => {
const app = Router()

hooks(app)

return app
}
44 changes: 44 additions & 0 deletions packages/medusa-payment-stripe-processor/src/api/utils/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { AwilixContainer } from "awilix"
import Stripe from "stripe"
import { PostgresError } from "@medusajs/medusa"
import { RETRY_STATUS_CODE } from "../hooks/stripe"

const PAYMENT_PROVIDER_KEY = "pp_stripe"

export function constructWebhook({
signature,
body,
container,
}: {
signature: string | string[] | undefined
body: any
container: AwilixContainer
}): Stripe.Event {
const stripeProviderService = container.resolve(PAYMENT_PROVIDER_KEY)
return stripeProviderService.constructWebhookEvent(body, signature)
}

export function isPaymentCollection(id) {
return id && id.startsWith("paycol")
}

export function buildHandleCartPaymentErrorMessage(
event: string,
err: Stripe.errors.StripeError
): string {
let message = `Stripe webhook ${event} handling failed\n${
err?.detail ?? err?.message
}`
if (err?.code === PostgresError.SERIALIZATION_FAILURE) {
message = `Stripe webhook ${event} handle failed. This can happen when this webhook is triggered during a cart completion and can be ignored. This event should be retried automatically.\n${
err?.detail ?? err?.message
}`
}
if (err?.code === RETRY_STATUS_CODE.toString()) {
message = `Stripe webhook ${event} handle failed.\n${
err?.detail ?? err?.message
}`
}

return message
}
9 changes: 6 additions & 3 deletions packages/medusa/src/strategies/cart-completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,16 +264,19 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy {
const cartServiceTx = this.cartService_.withTransaction(manager)

const cart = await cartServiceTx.retrieveWithTotals(id, {
relations: ["region", "payment", "payment_sessions", "items.variant.product",],
relations: [
"region",
"payment",
"payment_sessions",
"items.variant.product",
],
})

let allowBackorder = false
let swapId: string

if (cart.type === "swap") {
const swap = await swapServiceTx.retrieveByCartId(id)
allowBackorder = swap.allow_backorder
swapId = swap.id
}

if (!allowBackorder) {
Expand Down

0 comments on commit e80c444

Please sign in to comment.