feat(medusa-payment-stripe): Stripe PaymentProcessor implementation (#3257)

This commit is contained in:
Adrien de Peretti
2023-02-28 18:44:24 +01:00
committed by GitHub
parent d61d6c7b7f
commit 589d1c09b0
55 changed files with 2325 additions and 1278 deletions

View File

@@ -0,0 +1,7 @@
---
"medusa-payment-paypal": minor
"medusa-payment-stripe": minor
"@medusajs/medusa": minor
---
feat(medusa-payment-stripe): Implement payment processor API on stripe plugin and fix web hook issues

View File

@@ -7,6 +7,7 @@ jest*
packages/* packages/*
# List of packages to Lint # List of packages to Lint
!packages/medusa !packages/medusa
!packages/medusa-payment-stripe

View File

@@ -80,7 +80,10 @@ module.exports = {
extends: ["plugin:@typescript-eslint/recommended"], extends: ["plugin:@typescript-eslint/recommended"],
parser: "@typescript-eslint/parser", parser: "@typescript-eslint/parser",
parserOptions: { parserOptions: {
project: "./packages/medusa/tsconfig.json", project: [
"./packages/medusa/tsconfig.json",
"./packages/medusa-payment-stripe/tsconfig.spec.json",
]
}, },
rules: { rules: {
"valid-jsdoc": "off", "valid-jsdoc": "off",

View File

@@ -1,7 +1,7 @@
{ {
"name": "medusa-payment-paypal", "name": "medusa-payment-paypal",
"version": "1.2.10", "version": "1.2.10",
"description": "Paypal Payment provider for Meduas Commerce", "description": "Paypal Payment provider for Medusa Commerce",
"main": "index.js", "main": "index.js",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@@ -1,14 +0,0 @@
{
"plugins": [
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-class-properties",
"@babel/plugin-transform-instanceof",
"@babel/plugin-transform-classes"
],
"presets": ["@babel/preset-env"],
"env": {
"test": {
"plugins": ["@babel/plugin-transform-runtime"]
}
}
}

View File

@@ -1,16 +1,4 @@
/lib dist
node_modules node_modules
.DS_store .DS_store
.env*
/*.js
!index.js
yarn.lock yarn.lock
/dist
/api
/services
/models
/subscribers
/helpers
/__mocks__

View File

@@ -1,8 +0,0 @@
.DS_store
src
dist
yarn.lock
.babelrc
.turbo
.yarn

View File

@@ -10,10 +10,18 @@ Learn more about how you can use this plugin in the [documentaion](https://docs.
{ {
api_key: "STRIPE_API_KEY", api_key: "STRIPE_API_KEY",
webhook_secret: "STRIPE_WEBHOOK_SECRET", webhook_secret: "STRIPE_WEBHOOK_SECRET",
automatic_payment_methods: true
// automatic_payment_methods: true,
// This description will be used if the cart context does not provide one.
// payment_description: "custom description to apply",
} }
``` ```
## Automatic Payment Methods ## Automatic Payment Methods
If you wish to use [Stripe's automatic payment methods](https://stripe.com/docs/connect/automatic-payment-methods) set the `automatic_payment_methods` flag to true. If you wish to use [Stripe's automatic payment methods](https://stripe.com/docs/connect/automatic-payment-methods) set the `automatic_payment_methods` flag to true.
## Deprecation
The stripe plugin version `>=1.2.x` requires medusa `>=1.8.x`

View File

@@ -1 +0,0 @@
// noop

View File

@@ -0,0 +1,13 @@
module.exports = {
globals: {
"ts-jest": {
tsconfig: "tsconfig.spec.json",
isolatedModules: false,
},
},
transform: {
"^.+\\.[jt]s?$": "ts-jest",
},
testEnvironment: `node`,
moduleFileExtensions: [`js`, `jsx`, `ts`, `tsx`, `json`],
}

View File

@@ -2,46 +2,37 @@
"name": "medusa-payment-stripe", "name": "medusa-payment-stripe",
"version": "1.1.53", "version": "1.1.53",
"description": "Stripe Payment provider for Meduas Commerce", "description": "Stripe Payment provider for Meduas Commerce",
"main": "index.js", "main": "dist/index.js",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/medusajs/medusa", "url": "https://github.com/medusajs/medusa",
"directory": "packages/medusa-payment-stripe" "directory": "packages/medusa-payment-stripe"
}, },
"author": "Sebastian Rindom", "files": [
"dist"
],
"author": "Medusa",
"license": "MIT", "license": "MIT",
"devDependencies": {
"@babel/cli": "^7.7.5",
"@babel/core": "^7.7.5",
"@babel/node": "^7.7.4",
"@babel/plugin-proposal-class-properties": "^7.7.4",
"@babel/plugin-proposal-optional-chaining": "^7.12.7",
"@babel/plugin-transform-classes": "^7.9.5",
"@babel/plugin-transform-instanceof": "^7.8.3",
"@babel/plugin-transform-runtime": "^7.7.6",
"@babel/preset-env": "^7.7.5",
"@babel/register": "^7.7.4",
"@babel/runtime": "^7.9.6",
"client-sessions": "^0.8.0",
"cross-env": "^5.2.1",
"jest": "^25.5.4",
"medusa-interfaces": "^1.3.6",
"medusa-test-utils": "^1.1.37"
},
"scripts": { "scripts": {
"prepare": "cross-env NODE_ENV=production yarn run build", "prepare": "cross-env NODE_ENV=production yarn run build",
"test": "jest --passWithNoTests src", "test": "jest --passWithNoTests src",
"build": "babel src --out-dir . --ignore '**/__tests__','**/__mocks__'", "build": "tsc",
"watch": "babel -w src --out-dir . --ignore '**/__tests__','**/__mocks__'" "watch": "tsc --watch"
},
"devDependencies": {
"@medusajs/medusa": "^1.7.7",
"@types/stripe": "^8.0.417",
"cross-env": "^5.2.1",
"jest": "^25.5.4"
}, },
"peerDependencies": { "peerDependencies": {
"medusa-interfaces": "1.3.6" "@medusajs/medusa": "^1.7.7"
}, },
"dependencies": { "dependencies": {
"body-parser": "^1.19.0", "body-parser": "^1.19.0",
"express": "^4.17.1", "express": "^4.17.1",
"medusa-core-utils": "^1.1.39", "medusa-core-utils": "^1.1.38",
"stripe": "^8.50.0" "stripe": "^11.10.0"
}, },
"gitHead": "81a7ff73d012fda722f6e9ef0bd9ba0232d37808", "gitHead": "81a7ff73d012fda722f6e9ef0bd9ba0232d37808",
"keywords": [ "keywords": [

View File

@@ -0,0 +1,30 @@
export const PaymentIntentDataByStatus = {
REQUIRES_PAYMENT_METHOD: {
id: "requires_payment_method",
status: "requires_payment_method",
},
REQUIRES_CONFIRMATION: {
id: "requires_confirmation",
status: "requires_confirmation",
},
PROCESSING: {
id: "processing",
status: "processing",
},
REQUIRES_ACTION: {
id: "requires_action",
status: "requires_action",
},
CANCELED: {
id: "canceled",
status: "canceled",
},
REQUIRES_CAPTURE: {
id: "requires_capture",
status: "requires_capture",
},
SUCCEEDED: {
id: "succeeded",
status: "succeeded",
},
}

View File

@@ -1,216 +0,0 @@
import { IdMap } from "medusa-test-utils"
export const carts = {
emptyCart: {
id: IdMap.getId("emptyCart"),
items: [],
region_id: IdMap.getId("testRegion"),
customer_id: "test-customer",
payment_sessions: [],
shipping_options: [
{
id: IdMap.getId("freeShipping"),
profile_id: "default_profile",
data: {
some_data: "yes",
},
},
],
},
frCart: {
id: IdMap.getId("fr-cart"),
email: "lebron@james.com",
title: "test",
region_id: IdMap.getId("region-france"),
items: [
{
id: IdMap.getId("line"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
unit_price: 8,
variant: {
id: IdMap.getId("eur-8-us-10"),
},
product: {
id: IdMap.getId("product"),
},
// {
// unit_price: 10,
// variant: {
// id: IdMap.getId("eur-10-us-12"),
// },
// product: {
// id: IdMap.getId("product"),
// },
// quantity: 1,
// },
quantity: 10,
},
{
id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
unit_price: 10,
variant: {
id: IdMap.getId("eur-10-us-12"),
},
product: {
id: IdMap.getId("product"),
},
quantity: 1,
},
],
shipping_methods: [
{
id: IdMap.getId("freeShipping"),
profile_id: "default_profile",
},
],
shipping_options: [
{
id: IdMap.getId("freeShipping"),
profile_id: "default_profile",
},
],
payment_sessions: [
{
provider_id: "stripe",
data: {
id: "pi_123456789",
customer: IdMap.getId("not-lebron"),
},
},
],
payment_method: {
provider_id: "stripe",
data: {
id: "pi_123456789",
customer: IdMap.getId("not-lebron"),
},
},
region: { currency_code: "usd" },
total: 100,
shipping_address: {},
billing_address: {},
discounts: [],
customer_id: IdMap.getId("lebron"),
context: {}
},
frCartNoStripeCustomer: {
id: IdMap.getId("fr-cart-no-customer"),
title: "test",
region_id: IdMap.getId("region-france"),
items: [
{
id: IdMap.getId("line"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: [
{
unit_price: 8,
variant: {
id: IdMap.getId("eur-8-us-10"),
},
product: {
id: IdMap.getId("product"),
},
quantity: 1,
},
{
unit_price: 10,
variant: {
id: IdMap.getId("eur-10-us-12"),
},
product: {
id: IdMap.getId("product"),
},
quantity: 1,
},
],
quantity: 10,
},
{
id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 10,
variant: {
id: IdMap.getId("eur-10-us-12"),
},
product: {
id: IdMap.getId("product"),
},
quantity: 1,
},
quantity: 10,
},
],
shipping_methods: [
{
id: IdMap.getId("freeShipping"),
profile_id: "default_profile",
},
],
shipping_options: [
{
id: IdMap.getId("freeShipping"),
profile_id: "default_profile",
},
],
payment_sessions: [
{
provider_id: "stripe",
data: {
id: "pi_no",
customer: IdMap.getId("not-lebron"),
},
},
],
payment_method: {
provider_id: "stripe",
data: {
id: "pi_no",
customer: IdMap.getId("not-lebron"),
},
},
shipping_address: {},
billing_address: {},
discounts: [],
customer_id: IdMap.getId("vvd"),
},
}
export const CartServiceMock = {
withTransaction: function () {
return this
},
retrieve: jest.fn().mockImplementation((cartId) => {
if (cartId === IdMap.getId("fr-cart")) {
return Promise.resolve(carts.frCart)
}
if (cartId === IdMap.getId("fr-cart-no-customer")) {
return Promise.resolve(carts.frCartNoStripeCustomer)
}
if (cartId === IdMap.getId("emptyCart")) {
return Promise.resolve(carts.emptyCart)
}
return Promise.resolve(undefined)
}),
updatePaymentSession: jest
.fn()
.mockImplementation((cartId, stripe, paymentIntent) => {
return Promise.resolve()
}),
}
const mock = jest.fn().mockImplementation(() => {
return CartServiceMock
})
export default mock

View File

@@ -1,39 +0,0 @@
import { IdMap } from "medusa-test-utils"
export const CustomerServiceMock = {
withTransaction: function () {
return this
},
retrieve: jest.fn().mockImplementation((id) => {
if (id === IdMap.getId("lebron")) {
return Promise.resolve({
_id: IdMap.getId("lebron"),
first_name: "LeBron",
last_name: "James",
email: "lebron@james.com",
password_hash: "1234",
metadata: {
stripe_id: "cus_123456789_new",
},
})
}
if (id === IdMap.getId("vvd")) {
return Promise.resolve({
_id: IdMap.getId("vvd"),
first_name: "Virgil",
last_name: "Van Dijk",
email: "virg@vvd.com",
password_hash: "1234",
metadata: {},
})
}
return Promise.resolve(undefined)
}),
setMetadata: jest.fn().mockReturnValue(Promise.resolve()),
}
const mock = jest.fn().mockImplementation(() => {
return CustomerServiceMock
})
export default mock

View File

@@ -1,10 +0,0 @@
export const EventBusServiceMock = {
emit: jest.fn(),
subscribe: jest.fn(),
}
const mock = jest.fn().mockImplementation(() => {
return EventBusServiceMock
})
export default mock

View File

@@ -1,87 +0,0 @@
export const StripeMock = {
customers: {
create: jest.fn().mockImplementation((data) => {
if (data.email === "virg@vvd.com") {
return Promise.resolve({
id: "cus_vvd",
email: "virg@vvd.com",
})
}
if (data.email === "lebron@james.com") {
return Promise.resolve({
id: "cus_lebron",
email: "lebron@james.com",
})
}
}),
},
paymentIntents: {
create: jest.fn().mockImplementation((data) => {
if (data.customer === "cus_123456789_new") {
return Promise.resolve({
id: "pi_lebron",
amount: data.amount,
customer: "cus_123456789_new",
description: data?.description,
})
}
if (data.customer === "cus_lebron") {
return Promise.resolve({
id: "pi_lebron",
amount: data.amount,
customer: "cus_lebron",
description: data?.description,
})
}
}),
retrieve: jest.fn().mockImplementation((data) => {
return Promise.resolve({
id: "pi_lebron",
customer: "cus_lebron",
})
}),
update: jest.fn().mockImplementation((pi, data) => {
if (data.customer === "cus_lebron_2") {
return Promise.resolve({
id: "pi_lebron",
customer: "cus_lebron_2",
amount: 1000,
})
}
return Promise.resolve({
id: "pi_lebron",
customer: "cus_lebron",
amount: 1000,
})
}),
capture: jest.fn().mockImplementation((data) => {
return Promise.resolve({
id: "pi_lebron",
customer: "cus_lebron",
amount: 1000,
status: "succeeded",
})
}),
cancel: jest.fn().mockImplementation((data) => {
return Promise.resolve({
id: "pi_lebron",
customer: "cus_lebron",
status: "cancelled",
})
}),
},
refunds: {
create: jest.fn().mockImplementation((data) => {
return Promise.resolve({
id: "re_123",
payment_intent: "pi_lebron",
amount: 1000,
status: "succeeded",
})
}),
},
}
const stripe = jest.fn(() => StripeMock)
export default stripe

View File

@@ -0,0 +1,99 @@
import { PaymentIntentDataByStatus } from "../__fixtures__/data"
import Stripe from "stripe";
import { ErrorCodes, ErrorIntentStatus } from "../types";
export const WRONG_CUSTOMER_EMAIL = "wrong@test.fr"
export const EXISTING_CUSTOMER_EMAIL = "right@test.fr"
export const STRIPE_ID = "test"
export const PARTIALLY_FAIL_INTENT_ID = "partially_unknown"
export const FAIL_INTENT_ID = "unknown"
export const StripeMock = {
paymentIntents: {
retrieve: jest.fn().mockImplementation(async (paymentId) => {
if (paymentId === FAIL_INTENT_ID) {
throw new Error("Error")
}
return Object.values(PaymentIntentDataByStatus).find(value => {
return value.id === paymentId
}) ?? {}
}),
update: jest.fn().mockImplementation(async (paymentId, updateData) => {
if (paymentId === FAIL_INTENT_ID) {
throw new Error("Error")
}
const data = Object.values(PaymentIntentDataByStatus).find(value => {
return value.id === paymentId
}) ?? {}
return { ...data, ...updateData }
}),
create: jest.fn().mockImplementation(async (data) => {
if (data.description === "fail") {
throw new Error("Error")
}
return data
}),
cancel: jest.fn().mockImplementation(async (paymentId) => {
if (paymentId === FAIL_INTENT_ID) {
throw new Error("Error")
}
if (paymentId === PARTIALLY_FAIL_INTENT_ID) {
throw new Stripe.errors.StripeError({
code: ErrorCodes.PAYMENT_INTENT_UNEXPECTED_STATE,
payment_intent: {
id: paymentId,
status: ErrorIntentStatus.CANCELED
} as unknown as Stripe.PaymentIntent,
type: "invalid_request_error"
})
}
return { id: paymentId }
}),
capture: jest.fn().mockImplementation(async (paymentId) => {
if (paymentId === FAIL_INTENT_ID) {
throw new Error("Error")
}
if (paymentId === PARTIALLY_FAIL_INTENT_ID) {
throw new Stripe.errors.StripeError({
code: ErrorCodes.PAYMENT_INTENT_UNEXPECTED_STATE,
payment_intent: {
id: paymentId,
status: ErrorIntentStatus.SUCCEEDED
} as unknown as Stripe.PaymentIntent,
type: "invalid_request_error"
})
}
return { id: paymentId }
})
},
refunds: {
create: jest.fn().mockImplementation(async ({ payment_intent: paymentId }) => {
if (paymentId === FAIL_INTENT_ID) {
throw new Error("Error")
}
return { id: paymentId }
})
},
customers: {
create: jest.fn().mockImplementation(async (data) => {
if (data.email === EXISTING_CUSTOMER_EMAIL) {
return { id: STRIPE_ID, ...data }
}
throw new Error("Error")
})
},
}
const stripe = jest.fn(() => StripeMock)
export default stripe

View File

@@ -1,12 +0,0 @@
export const TotalsServiceMock = {
withTransaction: function () {
return this
},
getTotal: jest.fn(),
}
const mock = jest.fn().mockImplementation(() => {
return TotalsServiceMock
})
export default mock

View File

@@ -1,6 +1,7 @@
import stripeHooks from "./stripe"
import { Router } from "express" import { Router } from "express"
import bodyParser from "body-parser" import bodyParser from "body-parser"
import middlewares from "../../middlewares" import { wrapHandler } from "@medusajs/medusa"
const route = Router() const route = Router()
@@ -11,7 +12,7 @@ export default (app) => {
"/hooks", "/hooks",
// stripe constructEvent fails without body-parser // stripe constructEvent fails without body-parser
bodyParser.raw({ type: "application/json" }), bodyParser.raw({ type: "application/json" }),
middlewares.wrap(require("./stripe").default) wrapHandler(stripeHooks)
) )
return app return app
} }

View File

@@ -0,0 +1,25 @@
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,5 +1,5 @@
import { Router } from "express" import { Router } from "express"
import hooks from "./routes/hooks" import hooks from "./hooks"
export default (container) => { export default (container) => {
const app = Router() const app = Router()

View File

@@ -1,3 +0,0 @@
export default (fn) =>
(...args) =>
fn(...args).catch(args[2])

View File

@@ -1,5 +0,0 @@
import { default as wrap } from "./await-middleware"
export default {
wrap,
}

View File

@@ -1,136 +0,0 @@
import { PostgresError } from "@medusajs/medusa/dist/utils"
export default async (req, res) => {
const signature = req.headers["stripe-signature"]
let event
try {
const stripeProviderService = req.scope.resolve("pp_stripe")
event = stripeProviderService.constructWebhookEvent(req.body, signature)
} catch (err) {
res.status(400).send(`Webhook Error: ${err.message}`)
return
}
function isPaymentCollection(id) {
return id && id.startsWith("paycol")
}
async function handleCartPayments(event, req, res, cartId) {
const manager = req.scope.resolve("manager")
const orderService = req.scope.resolve("orderService")
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) {
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
}`
}
this.logger_.warn(message)
return res.sendStatus(409)
}
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)
}
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 paymentIntentAmountCapturableEventHandler({
order,
cartId,
container,
transactionManager,
}) {
if (!order) {
const cartService = container.resolve("cartService")
const orderService = container.resolve("orderService")
const cartServiceTx = cartService.withTransaction(transactionManager)
await cartServiceTx.setPaymentSession(cartId, "stripe")
await cartServiceTx.authorizePayment(cartId)
await orderService
.withTransaction(transactionManager)
.createFromCart(cartId)
}
}

View File

@@ -0,0 +1,139 @@
import { asValue, createContainer } from "awilix"
import {
existingCartId,
existingCartIdWithCapturedStatus,
existingResourceId,
existingResourceNotCapturedId,
nonExistingCartId,
orderIdForExistingCartId,
paymentId,
paymentIntentId,
throwingCartId,
} from "./data"
export const container = createContainer()
container.register(
"logger",
asValue({
warn: jest.fn(),
error: jest.fn(),
})
)
container.register(
"manager",
asValue({
transaction: function (cb) {
return cb(this)
},
})
)
container.register(
"idempotencyKeyService",
asValue({
withTransaction: function () {
return this
},
retrieve: jest.fn().mockReturnValue(undefined),
create: jest.fn().mockReturnValue({}),
})
)
container.register(
"cartCompletionStrategy",
asValue({
withTransaction: function () {
return this
},
complete: jest.fn(),
})
)
container.register(
"cartService",
asValue({
withTransaction: function () {
return this
},
retrieve: jest.fn().mockReturnValue({ context: {} }),
})
)
container.register(
"orderService",
asValue({
withTransaction: function () {
return this
},
retrieveByCartId: jest.fn().mockImplementation(async (cartId) => {
if (cartId === existingCartId) {
return {
id: orderIdForExistingCartId,
payment_status: "pending",
}
}
if (cartId === existingCartIdWithCapturedStatus) {
return {
id: "order-1",
payment_status: "captured",
}
}
if (cartId === throwingCartId) {
throw new Error("Error")
}
if (cartId === nonExistingCartId) {
return undefined
}
return {}
}),
capturePayment: jest.fn(),
})
)
container.register(
"paymentCollectionService",
asValue({
withTransaction: function () {
return this
},
retrieve: jest.fn().mockImplementation(async (resourceId) => {
if (resourceId === existingResourceId) {
return {
id: existingResourceId,
payments: [
{
id: paymentId,
data: {
id: paymentIntentId,
},
captured_at: "date",
},
],
}
}
if (resourceId === existingResourceNotCapturedId) {
return {
id: existingResourceNotCapturedId,
payments: [
{
id: paymentId,
data: {
id: paymentIntentId,
},
captured_at: null,
},
],
}
}
return {}
}),
capture: jest.fn(),
})
)

View File

@@ -0,0 +1,14 @@
export const existingCartId = "existingCartId"
export const existingCartIdWithCapturedStatus =
"existingCartIdWithCapturedStatus"
export const nonExistingCartId = "nonExistingCartId"
export const throwingCartId = "throwingCartId"
export const existingResourceId = "paycol_existing"
export const existingResourceNotCapturedId = "paycol_existing_not_aptured"
export const orderIdForExistingCartId = "order-1"
export const paymentIntentId = "paymentIntentId"
export const paymentId = "paymentId"

View File

@@ -0,0 +1,351 @@
import { PostgresError } from "@medusajs/medusa"
import Stripe from "stripe"
import { EOL } from "os"
import { buildError, handlePaymentHook, isPaymentCollection } from "../utils"
import { container } from "../__fixtures__/container"
import {
existingCartId,
existingCartIdWithCapturedStatus,
existingResourceId,
existingResourceNotCapturedId,
nonExistingCartId,
orderIdForExistingCartId,
paymentId,
paymentIntentId,
} from "../__fixtures__/data"
describe("Utils", () => {
afterEach(() => {
jest.clearAllMocks()
})
describe("isPaymentCollection", () => {
it("should return return true if starts with paycol otherwise return false", () => {
let result = isPaymentCollection("paycol_test")
expect(result).toBeTruthy()
result = isPaymentCollection("nopaycol_test")
expect(result).toBeFalsy()
})
})
describe("buildError", () => {
it("should return the appropriate error message", () => {
let event = "test_event"
let error = {
code: PostgresError.SERIALIZATION_FAILURE,
detail: "some details",
} as Stripe.StripeRawError
let message = buildError(event, error)
expect(message).toBe(
`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.${EOL}${error.detail}`
)
event = "test_event"
error = {
code: "409",
detail: "some details",
} as Stripe.StripeRawError
message = buildError(event, error)
expect(message).toBe(
`Stripe webhook ${event} handle failed.${EOL}${error.detail}`
)
event = "test_event"
error = {
code: "",
detail: "some details",
} as Stripe.StripeRawError
message = buildError(event, error)
expect(message).toBe(
`Stripe webhook ${event} handling failed${EOL}${error.detail}`
)
})
})
describe("handlePaymentHook", () => {
describe("on event type payment_intent.succeeded", () => {
describe("in a payment context", () => {
it("should complete the cart on non existing order", async () => {
const event = { id: "event", type: "payment_intent.succeeded" }
const paymentIntent = {
id: paymentIntentId,
metadata: { cart_id: nonExistingCartId },
}
await handlePaymentHook({ event, container, paymentIntent })
const orderService = container.resolve("orderService")
const cartCompletionStrategy = container.resolve(
"cartCompletionStrategy"
)
const idempotencyKeyService = container.resolve(
"idempotencyKeyService"
)
const cartService = container.resolve("cartService")
expect(orderService.retrieveByCartId).toHaveBeenCalled()
expect(orderService.retrieveByCartId).toHaveBeenCalledWith(
paymentIntent.metadata.cart_id
)
expect(idempotencyKeyService.retrieve).toHaveBeenCalled()
expect(idempotencyKeyService.retrieve).toHaveBeenCalledWith({
request_path: "/stripe/hooks",
idempotency_key: event.id,
})
expect(idempotencyKeyService.create).toHaveBeenCalled()
expect(idempotencyKeyService.create).toHaveBeenCalledWith({
request_path: "/stripe/hooks",
idempotency_key: event.id,
})
expect(cartService.retrieve).toHaveBeenCalled()
expect(cartService.retrieve).toHaveBeenCalledWith(
paymentIntent.metadata.cart_id,
{ select: ["context"] }
)
expect(cartCompletionStrategy.complete).toHaveBeenCalled()
expect(cartCompletionStrategy.complete).toHaveBeenCalledWith(
paymentIntent.metadata.cart_id,
{},
{ id: undefined }
)
})
it("should not try to complete the cart on existing order", async () => {
const event = { id: "event", type: "payment_intent.succeeded" }
const paymentIntent = {
id: paymentIntentId,
metadata: { cart_id: existingCartId },
}
await handlePaymentHook({ event, container, paymentIntent })
const orderService = container.resolve("orderService")
const cartCompletionStrategy = container.resolve(
"cartCompletionStrategy"
)
const idempotencyKeyService = container.resolve(
"idempotencyKeyService"
)
const cartService = container.resolve("cartService")
expect(orderService.retrieveByCartId).toHaveBeenCalled()
expect(orderService.retrieveByCartId).toHaveBeenCalledWith(
paymentIntent.metadata.cart_id
)
expect(idempotencyKeyService.retrieve).not.toHaveBeenCalled()
expect(idempotencyKeyService.create).not.toHaveBeenCalled()
expect(cartService.retrieve).not.toHaveBeenCalled()
expect(cartCompletionStrategy.complete).not.toHaveBeenCalled()
})
it("should capture the payment if not already captured", async () => {
const event = { id: "event", type: "payment_intent.succeeded" }
const paymentIntent = {
id: paymentIntentId,
metadata: { cart_id: existingCartId },
}
await handlePaymentHook({ event, container, paymentIntent })
const orderService = container.resolve("orderService")
expect(orderService.retrieveByCartId).toHaveBeenCalled()
expect(orderService.retrieveByCartId).toHaveBeenCalledWith(
paymentIntent.metadata.cart_id
)
expect(orderService.capturePayment).toHaveBeenCalled()
expect(orderService.capturePayment).toHaveBeenCalledWith(
orderIdForExistingCartId
)
})
it("should not capture the payment if already captured", async () => {
const event = { id: "event", type: "payment_intent.succeeded" }
const paymentIntent = {
id: paymentIntentId,
metadata: { cart_id: existingCartIdWithCapturedStatus },
}
await handlePaymentHook({ event, container, paymentIntent })
const orderService = container.resolve("orderService")
expect(orderService.retrieveByCartId).toHaveBeenCalled()
expect(orderService.retrieveByCartId).toHaveBeenCalledWith(
paymentIntent.metadata.cart_id
)
expect(orderService.capturePayment).not.toHaveBeenCalled()
})
})
describe("in a payment collection context", () => {
it("should capture the payment collection if not already captured", async () => {
const event = { id: "event", type: "payment_intent.succeeded" }
const paymentIntent = {
id: paymentIntentId,
metadata: { resource_id: existingResourceNotCapturedId },
}
await handlePaymentHook({ event, container, paymentIntent })
const paymentCollectionService = container.resolve(
"paymentCollectionService"
)
expect(paymentCollectionService.retrieve).toHaveBeenCalled()
expect(paymentCollectionService.retrieve).toHaveBeenCalledWith(
paymentIntent.metadata.resource_id,
{ relations: ["payments"] }
)
expect(paymentCollectionService.capture).toHaveBeenCalled()
expect(paymentCollectionService.capture).toHaveBeenCalledWith(
paymentId
)
})
it("should not capture the payment collection if already captured", async () => {
const event = { id: "event", type: "payment_intent.succeeded" }
const paymentIntent = {
id: paymentIntentId,
metadata: { resource_id: existingResourceId },
}
await handlePaymentHook({ event, container, paymentIntent })
const paymentCollectionService = container.resolve(
"paymentCollectionService"
)
expect(paymentCollectionService.retrieve).toHaveBeenCalled()
expect(paymentCollectionService.retrieve).toHaveBeenCalledWith(
paymentIntent.metadata.resource_id,
{ relations: ["payments"] }
)
expect(paymentCollectionService.capture).not.toHaveBeenCalled()
})
})
})
describe("on event type payment_intent.amount_capturable_updated", () => {
it("should complete the cart on non existing order", async () => {
const event = {
id: "event",
type: "payment_intent.amount_capturable_updated",
}
const paymentIntent = {
id: paymentIntentId,
metadata: { cart_id: nonExistingCartId },
}
await handlePaymentHook({ event, container, paymentIntent })
const orderService = container.resolve("orderService")
const cartCompletionStrategy = container.resolve(
"cartCompletionStrategy"
)
const idempotencyKeyService = container.resolve("idempotencyKeyService")
const cartService = container.resolve("cartService")
expect(orderService.retrieveByCartId).toHaveBeenCalled()
expect(orderService.retrieveByCartId).toHaveBeenCalledWith(
paymentIntent.metadata.cart_id
)
expect(idempotencyKeyService.retrieve).toHaveBeenCalled()
expect(idempotencyKeyService.retrieve).toHaveBeenCalledWith({
request_path: "/stripe/hooks",
idempotency_key: event.id,
})
expect(idempotencyKeyService.create).toHaveBeenCalled()
expect(idempotencyKeyService.create).toHaveBeenCalledWith({
request_path: "/stripe/hooks",
idempotency_key: event.id,
})
expect(cartService.retrieve).toHaveBeenCalled()
expect(cartService.retrieve).toHaveBeenCalledWith(
paymentIntent.metadata.cart_id,
{ select: ["context"] }
)
expect(cartCompletionStrategy.complete).toHaveBeenCalled()
expect(cartCompletionStrategy.complete).toHaveBeenCalledWith(
paymentIntent.metadata.cart_id,
{},
{ id: undefined }
)
})
it("should not try to complete the cart on existing order", async () => {
const event = {
id: "event",
type: "payment_intent.amount_capturable_updated",
}
const paymentIntent = {
id: paymentIntentId,
metadata: { cart_id: existingCartId },
}
await handlePaymentHook({ event, container, paymentIntent })
const orderService = container.resolve("orderService")
const cartCompletionStrategy = container.resolve(
"cartCompletionStrategy"
)
const idempotencyKeyService = container.resolve("idempotencyKeyService")
const cartService = container.resolve("cartService")
expect(orderService.retrieveByCartId).toHaveBeenCalled()
expect(orderService.retrieveByCartId).toHaveBeenCalledWith(
paymentIntent.metadata.cart_id
)
expect(idempotencyKeyService.retrieve).not.toHaveBeenCalled()
expect(idempotencyKeyService.create).not.toHaveBeenCalled()
expect(cartService.retrieve).not.toHaveBeenCalled()
expect(cartCompletionStrategy.complete).not.toHaveBeenCalled()
})
})
describe("on event type payment_intent.payment_failed", () => {
it("should log the error", async () => {
const event = { id: "event", type: "payment_intent.payment_failed" }
const paymentIntent = {
id: paymentIntentId,
metadata: { cart_id: nonExistingCartId },
last_payment_error: { message: "error message" },
}
await handlePaymentHook({ event, container, paymentIntent })
const logger = container.resolve("logger")
expect(logger.error).toHaveBeenCalled()
expect(logger.error).toHaveBeenCalledWith(
`The payment of the payment intent ${paymentIntent.id} has failed${EOL}${paymentIntent.last_payment_error.message}`
)
})
})
})
})

View File

@@ -0,0 +1,259 @@
import { AwilixContainer } from "awilix"
import Stripe from "stripe"
import {
AbstractCartCompletionStrategy,
CartService,
IdempotencyKeyService,
PostgresError,
} from "@medusajs/medusa"
import { EOL } from "os"
import { MedusaError } from "medusa-core-utils"
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 buildError(event: string, err: Stripe.StripeRawError): string {
let message = `Stripe webhook ${event} handling failed${EOL}${
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.${EOL}${
err?.detail ?? err?.message
}`
}
if (err?.code === "409") {
message = `Stripe webhook ${event} handle failed.${EOL}${
err?.detail ?? err?.message
}`
}
return message
}
export async function handlePaymentHook({
event,
container,
paymentIntent,
}: {
event: { type: string; id: string }
container: AwilixContainer
paymentIntent: {
id: string
metadata: { cart_id?: string; resource_id?: string }
last_payment_error?: { message: string }
}
}): 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
switch (event.type) {
case "payment_intent.succeeded":
try {
await onPaymentIntentSucceeded({
eventId: event.id,
paymentIntent,
cartId,
resourceId,
isPaymentCollection: isPaymentCollection(resourceId),
container,
})
} catch (err) {
const message = buildError(event.type, err)
logger.warn(message)
return { statusCode: 409 }
}
break
case "payment_intent.amount_capturable_updated":
try {
await onPaymentAmountCapturableUpdate({
eventId: event.id,
cartId,
container,
})
} catch (err) {
const message = buildError(event.type, err)
logger.warn(message)
return { statusCode: 409 }
}
break
case "payment_intent.payment_failed": {
const message =
paymentIntent.last_payment_error &&
paymentIntent.last_payment_error.message
logger.error(
`The payment of the payment intent ${paymentIntent.id} has failed${EOL}${message}`
)
break
}
default:
return { statusCode: 204 }
}
return { statusCode: 200 }
}
async function onPaymentIntentSucceeded({
eventId,
paymentIntent,
cartId,
resourceId,
isPaymentCollection,
container,
}) {
const manager = container.resolve("manager")
await manager.transaction(async (transactionManager) => {
if (isPaymentCollection) {
await capturePaymenCollectiontIfNecessary({
paymentIntent,
resourceId,
container,
})
} else {
await completeCartIfNecessary({
eventId,
cartId,
container,
transactionManager,
})
await capturePaymentIfNecessary({
cartId,
transactionManager,
container,
})
}
})
}
async function onPaymentAmountCapturableUpdate({ eventId, cartId, container }) {
const manager = container.resolve("manager")
await manager.transaction(async (transactionManager) => {
await completeCartIfNecessary({
eventId,
cartId,
container,
transactionManager,
})
})
}
async function capturePaymenCollectiontIfNecessary({
paymentIntent,
resourceId,
container,
}) {
const manager = container.resolve("manager")
const paymentCollectionService = container.resolve("paymentCollectionService")
const paycol = await paymentCollectionService
.retrieve(resourceId, { relations: ["payments"] })
.catch(() => undefined)
if (paycol?.payments?.length) {
const payment = paycol.payments.find(
(pay) => pay.data.id === paymentIntent.id
)
if (payment && !payment.captured_at) {
await manager.transaction(async (manager) => {
await paymentCollectionService
.withTransaction(manager)
.capture(payment.id)
})
}
}
}
async function capturePaymentIfNecessary({
cartId,
transactionManager,
container,
}) {
const orderService = container.resolve("orderService")
const order = await orderService
.retrieveByCartId(cartId)
.catch(() => undefined)
if (order?.payment_status !== "captured") {
await orderService
.withTransaction(transactionManager)
.capturePayment(order.id)
}
}
async function completeCartIfNecessary({
eventId,
cartId,
container,
transactionManager,
}) {
const orderService = container.resolve("orderService")
const order = await orderService
.retrieveByCartId(cartId)
.catch(() => undefined)
if (!order) {
const completionStrat: AbstractCartCompletionStrategy = container.resolve(
"cartCompletionStrategy"
)
const cartService: CartService = container.resolve("cartService")
const idempotencyKeyService: IdempotencyKeyService = container.resolve(
"idempotencyKeyService"
)
const idempotencyKeyServiceTx =
idempotencyKeyService.withTransaction(transactionManager)
let idempotencyKey = await idempotencyKeyServiceTx.retrieve({
request_path: "/stripe/hooks",
idempotency_key: eventId,
})
if (!idempotencyKey) {
idempotencyKey = await idempotencyKeyService
.withTransaction(transactionManager)
.create({
request_path: "/stripe/hooks",
idempotency_key: eventId,
})
}
const cart = await cartService
.withTransaction(transactionManager)
.retrieve(cartId, { select: ["context"] })
const { response_code, response_body } = await completionStrat
.withTransaction(transactionManager)
.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()
)
}
}
}

View File

@@ -0,0 +1,214 @@
import {
EXISTING_CUSTOMER_EMAIL,
FAIL_INTENT_ID,
PARTIALLY_FAIL_INTENT_ID,
WRONG_CUSTOMER_EMAIL,
} from "../../__mocks__/stripe"
import { PaymentIntentDataByStatus } from "../../__fixtures__/data"
// INITIATE PAYMENT DATA
export const initiatePaymentContextWithExistingCustomer = {
email: EXISTING_CUSTOMER_EMAIL,
currency_code: "usd",
amount: 1000,
resource_id: "test",
customer: {},
context: {},
paymentSessionData: {},
}
export const initiatePaymentContextWithExistingCustomerStripeId = {
email: EXISTING_CUSTOMER_EMAIL,
currency_code: "usd",
amount: 1000,
resource_id: "test",
customer: {
metadata: {
stripe_id: "test",
},
},
context: {},
paymentSessionData: {},
}
export const initiatePaymentContextWithWrongEmail = {
email: WRONG_CUSTOMER_EMAIL,
currency_code: "usd",
amount: 1000,
resource_id: "test",
customer: {},
context: {},
paymentSessionData: {},
}
export const initiatePaymentContextWithFailIntentCreation = {
email: EXISTING_CUSTOMER_EMAIL,
currency_code: "usd",
amount: 1000,
resource_id: "test",
customer: {},
context: {
payment_description: "fail",
},
paymentSessionData: {},
}
// AUTHORIZE PAYMENT DATA
export const authorizePaymentSuccessData = {
id: PaymentIntentDataByStatus.SUCCEEDED.id,
}
// CANCEL PAYMENT DATA
export const cancelPaymentSuccessData = {
id: PaymentIntentDataByStatus.SUCCEEDED.id,
}
export const cancelPaymentFailData = {
id: FAIL_INTENT_ID,
}
export const cancelPaymentPartiallyFailData = {
id: PARTIALLY_FAIL_INTENT_ID,
}
// CAPTURE PAYMENT DATA
export const capturePaymentContextSuccessData = {
paymentSessionData: {
id: PaymentIntentDataByStatus.SUCCEEDED.id,
},
}
export const capturePaymentContextFailData = {
paymentSessionData: {
id: FAIL_INTENT_ID,
},
}
export const capturePaymentContextPartiallyFailData = {
paymentSessionData: {
id: PARTIALLY_FAIL_INTENT_ID,
},
}
// DELETE PAYMENT DATA
export const deletePaymentSuccessData = {
id: PaymentIntentDataByStatus.SUCCEEDED.id,
}
export const deletePaymentFailData = {
id: FAIL_INTENT_ID,
}
export const deletePaymentPartiallyFailData = {
id: PARTIALLY_FAIL_INTENT_ID,
}
// REFUND PAYMENT DATA
export const refundPaymentSuccessData = {
id: PaymentIntentDataByStatus.SUCCEEDED.id,
}
export const refundPaymentFailData = {
id: FAIL_INTENT_ID,
}
// RETRIEVE PAYMENT DATA
export const retrievePaymentSuccessData = {
id: PaymentIntentDataByStatus.SUCCEEDED.id,
}
export const retrievePaymentFailData = {
id: FAIL_INTENT_ID,
}
// UPDATE PAYMENT DATA
export const updatePaymentContextWithExistingCustomer = {
email: EXISTING_CUSTOMER_EMAIL,
currency_code: "usd",
amount: 1000,
resource_id: "test",
customer: {},
context: {},
paymentSessionData: {
customer: "test",
amount: 1000,
},
}
export const updatePaymentContextWithExistingCustomerStripeId = {
email: EXISTING_CUSTOMER_EMAIL,
currency_code: "usd",
amount: 1000,
resource_id: "test",
customer: {
metadata: {
stripe_id: "test",
},
},
context: {},
paymentSessionData: {
customer: "test",
amount: 1000,
},
}
export const updatePaymentContextWithWrongEmail = {
email: WRONG_CUSTOMER_EMAIL,
currency_code: "usd",
amount: 1000,
resource_id: "test",
customer: {},
context: {},
paymentSessionData: {
customer: "test",
amount: 1000,
},
}
export const updatePaymentContextWithDifferentAmount = {
email: WRONG_CUSTOMER_EMAIL,
currency_code: "usd",
amount: 2000,
resource_id: "test",
customer: {
metadata: {
stripe_id: "test",
},
},
context: {},
paymentSessionData: {
id: PaymentIntentDataByStatus.SUCCEEDED.id,
customer: "test",
amount: 1000,
},
}
export const updatePaymentContextFailWithDifferentAmount = {
email: WRONG_CUSTOMER_EMAIL,
currency_code: "usd",
amount: 2000,
resource_id: "test",
customer: {
metadata: {
stripe_id: "test",
},
},
context: {
metadata: {
stripe_id: "test",
},
},
paymentSessionData: {
id: FAIL_INTENT_ID,
customer: "test",
amount: 1000,
},
}

View File

@@ -0,0 +1,12 @@
import StripeBase from "../stripe-base"
import { PaymentIntentOptions } from "../../types"
export class StripeTest extends StripeBase {
constructor(_, options) {
super(_, options)
}
get paymentIntentOptions(): PaymentIntentOptions {
return {}
}
}

View File

@@ -0,0 +1,582 @@
import { EOL } from "os"
import { StripeTest } from "../__fixtures__/stripe-test"
import { PaymentIntentDataByStatus } from "../../__fixtures__/data"
import { PaymentSessionStatus } from "@medusajs/medusa"
import {
authorizePaymentSuccessData,
cancelPaymentFailData,
cancelPaymentPartiallyFailData,
cancelPaymentSuccessData,
capturePaymentContextFailData,
capturePaymentContextPartiallyFailData,
capturePaymentContextSuccessData,
deletePaymentFailData,
deletePaymentPartiallyFailData,
deletePaymentSuccessData,
initiatePaymentContextWithExistingCustomer,
initiatePaymentContextWithExistingCustomerStripeId,
initiatePaymentContextWithFailIntentCreation,
initiatePaymentContextWithWrongEmail,
refundPaymentFailData,
refundPaymentSuccessData,
retrievePaymentFailData,
retrievePaymentSuccessData,
updatePaymentContextFailWithDifferentAmount,
updatePaymentContextWithDifferentAmount,
updatePaymentContextWithExistingCustomer,
updatePaymentContextWithExistingCustomerStripeId,
updatePaymentContextWithWrongEmail,
} from "../__fixtures__/data"
import {
PARTIALLY_FAIL_INTENT_ID,
STRIPE_ID,
StripeMock,
} from "../../__mocks__/stripe"
import { ErrorIntentStatus } from "../../types"
const container = {}
describe("StripeTest", () => {
describe("getPaymentStatus", function () {
let stripeTest
beforeAll(async () => {
const scopedContainer = { ...container }
stripeTest = new StripeTest(scopedContainer, { api_key: "test" })
await stripeTest.init()
})
beforeEach(() => {
jest.clearAllMocks()
})
it("should return the correct status", async () => {
let status: PaymentSessionStatus
status = await stripeTest.getPaymentStatus({
id: PaymentIntentDataByStatus.REQUIRES_PAYMENT_METHOD.id,
})
expect(status).toBe(PaymentSessionStatus.PENDING)
status = await stripeTest.getPaymentStatus({
id: PaymentIntentDataByStatus.REQUIRES_CONFIRMATION.id,
})
expect(status).toBe(PaymentSessionStatus.PENDING)
status = await stripeTest.getPaymentStatus({
id: PaymentIntentDataByStatus.PROCESSING.id,
})
expect(status).toBe(PaymentSessionStatus.PENDING)
status = await stripeTest.getPaymentStatus({
id: PaymentIntentDataByStatus.REQUIRES_ACTION.id,
})
expect(status).toBe(PaymentSessionStatus.REQUIRES_MORE)
status = await stripeTest.getPaymentStatus({
id: PaymentIntentDataByStatus.CANCELED.id,
})
expect(status).toBe(PaymentSessionStatus.CANCELED)
status = await stripeTest.getPaymentStatus({
id: PaymentIntentDataByStatus.REQUIRES_CAPTURE.id,
})
expect(status).toBe(PaymentSessionStatus.AUTHORIZED)
status = await stripeTest.getPaymentStatus({
id: PaymentIntentDataByStatus.SUCCEEDED.id,
})
expect(status).toBe(PaymentSessionStatus.AUTHORIZED)
status = await stripeTest.getPaymentStatus({
id: "unknown-id",
})
expect(status).toBe(PaymentSessionStatus.PENDING)
})
})
describe("initiatePayment", function () {
let stripeTest
beforeAll(async () => {
const scopedContainer = { ...container }
stripeTest = new StripeTest(scopedContainer, { api_key: "test" })
await stripeTest.init()
})
beforeEach(() => {
jest.clearAllMocks()
})
it("should succeed with an existing customer but no stripe id", async () => {
const result = await stripeTest.initiatePayment(
initiatePaymentContextWithExistingCustomer
)
expect(StripeMock.customers.create).toHaveBeenCalled()
expect(StripeMock.customers.create).toHaveBeenCalledWith({
email: initiatePaymentContextWithExistingCustomer.email,
})
expect(StripeMock.paymentIntents.create).toHaveBeenCalled()
expect(StripeMock.paymentIntents.create).toHaveBeenCalledWith(
expect.objectContaining({
description: undefined,
amount: initiatePaymentContextWithExistingCustomer.amount,
currency: initiatePaymentContextWithExistingCustomer.currency_code,
metadata: {
resource_id: initiatePaymentContextWithExistingCustomer.resource_id,
},
capture_method: "manual",
})
)
expect(result).toEqual(
expect.objectContaining({
session_data: expect.any(Object),
update_requests: {
customer_metadata: {
stripe_id: STRIPE_ID,
},
},
})
)
})
it("should succeed with an existing customer with an existing stripe id", async () => {
const result = await stripeTest.initiatePayment(
initiatePaymentContextWithExistingCustomerStripeId
)
expect(StripeMock.customers.create).not.toHaveBeenCalled()
expect(StripeMock.paymentIntents.create).toHaveBeenCalled()
expect(StripeMock.paymentIntents.create).toHaveBeenCalledWith(
expect.objectContaining({
description: undefined,
amount: initiatePaymentContextWithExistingCustomer.amount,
currency: initiatePaymentContextWithExistingCustomer.currency_code,
metadata: {
resource_id: initiatePaymentContextWithExistingCustomer.resource_id,
},
capture_method: "manual",
})
)
expect(result).toEqual(
expect.objectContaining({
session_data: expect.any(Object),
update_requests: undefined,
})
)
})
it("should fail on customer creation", async () => {
const result = await stripeTest.initiatePayment(
initiatePaymentContextWithWrongEmail
)
expect(StripeMock.customers.create).toHaveBeenCalled()
expect(StripeMock.customers.create).toHaveBeenCalledWith({
email: initiatePaymentContextWithWrongEmail.email,
})
expect(StripeMock.paymentIntents.create).not.toHaveBeenCalled()
expect(result).toEqual({
error:
"An error occurred in initiatePayment when creating a Stripe customer",
code: "",
detail: "Error",
})
})
it("should fail on payment intents creation", async () => {
const result = await stripeTest.initiatePayment(
initiatePaymentContextWithFailIntentCreation
)
expect(StripeMock.customers.create).toHaveBeenCalled()
expect(StripeMock.customers.create).toHaveBeenCalledWith({
email: initiatePaymentContextWithFailIntentCreation.email,
})
expect(StripeMock.paymentIntents.create).toHaveBeenCalled()
expect(StripeMock.paymentIntents.create).toHaveBeenCalledWith(
expect.objectContaining({
description:
initiatePaymentContextWithFailIntentCreation.context
.payment_description,
amount: initiatePaymentContextWithFailIntentCreation.amount,
currency: initiatePaymentContextWithFailIntentCreation.currency_code,
metadata: {
resource_id:
initiatePaymentContextWithFailIntentCreation.resource_id,
},
capture_method: "manual",
})
)
expect(result).toEqual({
error:
"An error occurred in InitiatePayment during the creation of the stripe payment intent",
code: "",
detail: "Error",
})
})
})
describe("authorizePayment", function () {
let stripeTest
beforeAll(async () => {
const scopedContainer = { ...container }
stripeTest = new StripeTest(scopedContainer, { api_key: "test" })
await stripeTest.init()
})
beforeEach(() => {
jest.clearAllMocks()
})
it("should succeed", async () => {
const result = await stripeTest.authorizePayment(
authorizePaymentSuccessData
)
expect(result).toEqual({
data: authorizePaymentSuccessData,
status: PaymentSessionStatus.AUTHORIZED,
})
})
})
describe("cancelPayment", function () {
let stripeTest
beforeAll(async () => {
const scopedContainer = { ...container }
stripeTest = new StripeTest(scopedContainer, { api_key: "test" })
await stripeTest.init()
})
beforeEach(() => {
jest.clearAllMocks()
})
it("should succeed", async () => {
const result = await stripeTest.cancelPayment(cancelPaymentSuccessData)
expect(result).toEqual({
id: PaymentIntentDataByStatus.SUCCEEDED.id,
})
})
it("should fail on intent cancellation but still return the intent", async () => {
const result = await stripeTest.cancelPayment(
cancelPaymentPartiallyFailData
)
expect(result).toEqual({
id: PARTIALLY_FAIL_INTENT_ID,
status: ErrorIntentStatus.CANCELED,
})
})
it("should fail on intent cancellation", async () => {
const result = await stripeTest.cancelPayment(cancelPaymentFailData)
expect(result).toEqual({
error: "An error occurred in cancelPayment",
code: "",
detail: "Error",
})
})
})
describe("capturePayment", function () {
let stripeTest
beforeAll(async () => {
const scopedContainer = { ...container }
stripeTest = new StripeTest(scopedContainer, { api_key: "test" })
await stripeTest.init()
})
beforeEach(() => {
jest.clearAllMocks()
})
it("should succeed", async () => {
const result = await stripeTest.capturePayment(
capturePaymentContextSuccessData
)
expect(result).toEqual({
id: PaymentIntentDataByStatus.SUCCEEDED.id,
})
})
it("should fail on intent capture but still return the intent", async () => {
const result = await stripeTest.capturePayment(
capturePaymentContextPartiallyFailData
)
expect(result).toEqual({
id: PARTIALLY_FAIL_INTENT_ID,
status: ErrorIntentStatus.SUCCEEDED,
})
})
it("should fail on intent capture", async () => {
const result = await stripeTest.capturePayment(
capturePaymentContextFailData
)
expect(result).toEqual({
error: "An error occurred in deletePayment",
code: "",
detail: "Error",
})
})
})
describe("deletePayment", function () {
let stripeTest
beforeAll(async () => {
const scopedContainer = { ...container }
stripeTest = new StripeTest(scopedContainer, { api_key: "test" })
await stripeTest.init()
})
beforeEach(() => {
jest.clearAllMocks()
})
it("should succeed", async () => {
const result = await stripeTest.cancelPayment(deletePaymentSuccessData)
expect(result).toEqual({
id: PaymentIntentDataByStatus.SUCCEEDED.id,
})
})
it("should fail on intent cancellation but still return the intent", async () => {
const result = await stripeTest.cancelPayment(
deletePaymentPartiallyFailData
)
expect(result).toEqual({
id: PARTIALLY_FAIL_INTENT_ID,
status: ErrorIntentStatus.CANCELED,
})
})
it("should fail on intent cancellation", async () => {
const result = await stripeTest.cancelPayment(deletePaymentFailData)
expect(result).toEqual({
error: "An error occurred in cancelPayment",
code: "",
detail: "Error",
})
})
})
describe("refundPayment", function () {
let stripeTest
const refundAmount = 500
beforeAll(async () => {
const scopedContainer = { ...container }
stripeTest = new StripeTest(scopedContainer, { api_key: "test" })
await stripeTest.init()
})
beforeEach(() => {
jest.clearAllMocks()
})
it("should succeed", async () => {
const result = await stripeTest.refundPayment(
refundPaymentSuccessData,
refundAmount
)
expect(result).toEqual({
id: PaymentIntentDataByStatus.SUCCEEDED.id,
})
})
it("should fail on refund creation", async () => {
const result = await stripeTest.refundPayment(
refundPaymentFailData,
refundAmount
)
expect(result).toEqual({
error: "An error occurred in refundPayment",
code: "",
detail: "Error",
})
})
})
describe("retrievePayment", function () {
let stripeTest
beforeAll(async () => {
const scopedContainer = { ...container }
stripeTest = new StripeTest(scopedContainer, { api_key: "test" })
await stripeTest.init()
})
beforeEach(() => {
jest.clearAllMocks()
})
it("should succeed", async () => {
const result = await stripeTest.retrievePayment(
retrievePaymentSuccessData
)
expect(result).toEqual({
id: PaymentIntentDataByStatus.SUCCEEDED.id,
status: PaymentIntentDataByStatus.SUCCEEDED.status,
})
})
it("should fail on refund creation", async () => {
const result = await stripeTest.retrievePayment(retrievePaymentFailData)
expect(result).toEqual({
error: "An error occurred in retrievePayment",
code: "",
detail: "Error",
})
})
})
describe("updatePayment", function () {
let stripeTest
beforeAll(async () => {
const scopedContainer = { ...container }
stripeTest = new StripeTest(scopedContainer, { api_key: "test" })
await stripeTest.init()
})
beforeEach(() => {
jest.clearAllMocks()
})
it("should succeed to initiate a payment with an existing customer but no stripe id", async () => {
const result = await stripeTest.updatePayment(
updatePaymentContextWithExistingCustomer
)
expect(StripeMock.customers.create).toHaveBeenCalled()
expect(StripeMock.customers.create).toHaveBeenCalledWith({
email: updatePaymentContextWithExistingCustomer.email,
})
expect(StripeMock.paymentIntents.create).toHaveBeenCalled()
expect(StripeMock.paymentIntents.create).toHaveBeenCalledWith(
expect.objectContaining({
description: undefined,
amount: updatePaymentContextWithExistingCustomer.amount,
currency: updatePaymentContextWithExistingCustomer.currency_code,
metadata: {
resource_id: updatePaymentContextWithExistingCustomer.resource_id,
},
capture_method: "manual",
})
)
expect(result).toEqual(
expect.objectContaining({
session_data: expect.any(Object),
update_requests: {
customer_metadata: {
stripe_id: STRIPE_ID,
},
},
})
)
})
it("should fail to initiate a payment with an existing customer but no stripe id", async () => {
const result = await stripeTest.updatePayment(
updatePaymentContextWithWrongEmail
)
expect(StripeMock.customers.create).toHaveBeenCalled()
expect(StripeMock.customers.create).toHaveBeenCalledWith({
email: updatePaymentContextWithWrongEmail.email,
})
expect(StripeMock.paymentIntents.create).not.toHaveBeenCalled()
expect(result).toEqual({
error:
"An error occurred in updatePayment during the initiate of the new payment for the new customer",
code: "",
detail:
"An error occurred in initiatePayment when creating a Stripe customer" +
EOL +
"Error",
})
})
it("should succeed but no update occurs when the amount did not changed", async () => {
const result = await stripeTest.updatePayment(
updatePaymentContextWithExistingCustomerStripeId
)
expect(StripeMock.paymentIntents.update).not.toHaveBeenCalled()
expect(result).not.toBeDefined()
})
it("should succeed to update the intent with the new amount", async () => {
const result = await stripeTest.updatePayment(
updatePaymentContextWithDifferentAmount
)
expect(StripeMock.paymentIntents.update).toHaveBeenCalled()
expect(StripeMock.paymentIntents.update).toHaveBeenCalledWith(
updatePaymentContextWithDifferentAmount.paymentSessionData.id,
{
amount: updatePaymentContextWithDifferentAmount.amount,
}
)
expect(result).toEqual({
session_data: expect.objectContaining({
amount: updatePaymentContextWithDifferentAmount.amount,
}),
})
})
it("should fail to update the intent with the new amount", async () => {
const result = await stripeTest.updatePayment(
updatePaymentContextFailWithDifferentAmount
)
expect(StripeMock.paymentIntents.update).toHaveBeenCalled()
expect(StripeMock.paymentIntents.update).toHaveBeenCalledWith(
updatePaymentContextFailWithDifferentAmount.paymentSessionData.id,
{
amount: updatePaymentContextFailWithDifferentAmount.amount,
}
)
expect(result).toEqual({
error: "An error occurred in updatePayment",
code: "",
detail: "Error",
})
})
})
})

View File

@@ -0,0 +1,315 @@
import Stripe from "stripe"
import { EOL } from "os"
import {
AbstractPaymentProcessor,
isPaymentProcessorError,
PaymentProcessorContext,
PaymentProcessorError,
PaymentProcessorSessionResponse,
PaymentSessionStatus,
} from "@medusajs/medusa"
import {
ErrorCodes,
ErrorIntentStatus,
PaymentIntentOptions,
StripeOptions,
} from "../types"
abstract class StripeBase extends AbstractPaymentProcessor {
static identifier = ""
protected readonly options_: StripeOptions
protected stripe_: Stripe
protected constructor(_, options) {
super(_, options)
this.options_ = options
this.init()
}
protected init(): void {
this.stripe_ =
this.stripe_ ||
new Stripe(this.options_.api_key, {
apiVersion: "2022-11-15",
})
}
abstract get paymentIntentOptions(): PaymentIntentOptions
getPaymentIntentOptions(): PaymentIntentOptions {
const options: PaymentIntentOptions = {}
if (this?.paymentIntentOptions?.capture_method) {
options.capture_method = this.paymentIntentOptions.capture_method
}
if (this?.paymentIntentOptions?.setup_future_usage) {
options.setup_future_usage = this.paymentIntentOptions.setup_future_usage
}
if (this?.paymentIntentOptions?.payment_method_types) {
options.payment_method_types =
this.paymentIntentOptions.payment_method_types
}
return options
}
async getPaymentStatus(
paymentSessionData: Record<string, unknown>
): Promise<PaymentSessionStatus> {
const id = paymentSessionData.id as string
const paymentIntent = await this.stripe_.paymentIntents.retrieve(id)
switch (paymentIntent.status) {
case "requires_payment_method":
case "requires_confirmation":
case "processing":
return PaymentSessionStatus.PENDING
case "requires_action":
return PaymentSessionStatus.REQUIRES_MORE
case "canceled":
return PaymentSessionStatus.CANCELED
case "requires_capture":
case "succeeded":
return PaymentSessionStatus.AUTHORIZED
default:
return PaymentSessionStatus.PENDING
}
}
async initiatePayment(
context: PaymentProcessorContext
): Promise<PaymentProcessorError | PaymentProcessorSessionResponse> {
const intentRequestData = this.getPaymentIntentOptions()
const {
email,
context: cart_context,
currency_code,
amount,
resource_id,
customer,
} = context
const description = (cart_context.payment_description ??
this.options_?.payment_description) as string
const intentRequest: Stripe.PaymentIntentCreateParams = {
description,
amount: Math.round(amount),
currency: currency_code,
metadata: { resource_id },
capture_method: this.options_.capture ? "automatic" : "manual",
...intentRequestData,
}
if (this.options_?.automatic_payment_methods) {
intentRequest.automatic_payment_methods = { enabled: true }
}
if (customer?.metadata?.stripe_id) {
intentRequest.customer = customer.metadata.stripe_id as string
} else {
let stripeCustomer
try {
stripeCustomer = await this.stripe_.customers.create({
email,
})
} catch (e) {
return this.buildError(
"An error occurred in initiatePayment when creating a Stripe customer",
e
)
}
intentRequest.customer = stripeCustomer.id
}
let session_data
try {
session_data = (await this.stripe_.paymentIntents.create(
intentRequest
)) as unknown as Record<string, unknown>
} catch (e) {
return this.buildError(
"An error occurred in InitiatePayment during the creation of the stripe payment intent",
e
)
}
return {
session_data,
update_requests: customer?.metadata?.stripe_id
? undefined
: {
customer_metadata: {
stripe_id: intentRequest.customer,
},
},
}
}
async authorizePayment(
paymentSessionData: Record<string, unknown>,
context: Record<string, unknown>
): Promise<
| PaymentProcessorError
| {
status: PaymentSessionStatus
data: PaymentProcessorSessionResponse["session_data"]
}
> {
const status = await this.getPaymentStatus(paymentSessionData)
return { data: paymentSessionData, status }
}
async cancelPayment(
paymentSessionData: Record<string, unknown>
): Promise<
PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]
> {
try {
const id = paymentSessionData.id as string
return (await this.stripe_.paymentIntents.cancel(
id
)) as unknown as PaymentProcessorSessionResponse["session_data"]
} catch (error) {
if (error.payment_intent?.status === ErrorIntentStatus.CANCELED) {
return error.payment_intent
}
return this.buildError("An error occurred in cancelPayment", error)
}
}
async capturePayment(
context: PaymentProcessorContext
): Promise<
PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]
> {
const id = context.paymentSessionData.id as string
try {
const intent = await this.stripe_.paymentIntents.capture(id)
return intent as unknown as PaymentProcessorSessionResponse["session_data"]
} catch (error) {
if (error.code === ErrorCodes.PAYMENT_INTENT_UNEXPECTED_STATE) {
if (error.payment_intent?.status === ErrorIntentStatus.SUCCEEDED) {
return error.payment_intent
}
}
return this.buildError("An error occurred in deletePayment", error)
}
}
async deletePayment(
paymentSessionData: Record<string, unknown>
): Promise<
PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]
> {
return await this.cancelPayment(paymentSessionData)
}
async refundPayment(
paymentSessionData: Record<string, unknown>,
refundAmount: number
): Promise<
PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]
> {
const id = paymentSessionData.id as string
try {
await this.stripe_.refunds.create({
amount: Math.round(refundAmount),
payment_intent: id as string,
})
} catch (e) {
return this.buildError("An error occurred in refundPayment", e)
}
return paymentSessionData
}
async retrievePayment(
paymentSessionData: Record<string, unknown>
): Promise<
PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]
> {
try {
const id = paymentSessionData.id as string
const intent = await this.stripe_.paymentIntents.retrieve(id)
return intent as unknown as PaymentProcessorSessionResponse["session_data"]
} catch (e) {
return this.buildError("An error occurred in retrievePayment", e)
}
}
async updatePayment(
context: PaymentProcessorContext
): Promise<PaymentProcessorError | PaymentProcessorSessionResponse | void> {
const { amount, customer, paymentSessionData } = context
const stripeId = customer?.metadata?.stripe_id
if (stripeId !== paymentSessionData.customer) {
const result = await this.initiatePayment(context)
if (isPaymentProcessorError(result)) {
return this.buildError(
"An error occurred in updatePayment during the initiate of the new payment for the new customer",
result
)
}
return result
} else {
if (amount && paymentSessionData.amount === Math.round(amount)) {
return
}
try {
const id = paymentSessionData.id as string
const sessionData = (await this.stripe_.paymentIntents.update(id, {
amount: Math.round(amount),
})) as unknown as PaymentProcessorSessionResponse["session_data"]
return { session_data: sessionData }
} catch (e) {
return this.buildError("An error occurred in updatePayment", e)
}
}
}
/**
* Constructs Stripe Webhook event
* @param {object} data - the data of the webhook request: req.body
* @param {object} signature - the Stripe signature on the event, that
* ensures integrity of the webhook event
* @return {object} Stripe Webhook event
*/
constructWebhookEvent(data, signature) {
return this.stripe_.webhooks.constructEvent(
data,
signature,
this.options_.webhook_secret
)
}
protected buildError(
message: string,
e: Stripe.StripeRawError | PaymentProcessorError | Error
): PaymentProcessorError {
return {
error: message,
code: "code" in e ? e.code : "",
detail: isPaymentProcessorError(e)
? `${e.error}${EOL}${e.detail ?? ""}`
: "detail" in e
? e.detail
: e.message ?? "",
}
}
}
export default StripeBase

View File

@@ -1,284 +0,0 @@
import { carts } from "../../__mocks__/cart"
import StripeBase from "../stripe-base";
const fakeContainer = {}
describe("StripeBase", () => {
describe("createPayment", () => {
let result
const stripeBase = new StripeBase(
fakeContainer,
{
api_key: "test"
}
)
beforeEach(async () => {
jest.clearAllMocks()
})
it("returns created stripe payment intent for cart with existing customer", async () => {
const cart = carts.frCart
const context = {
cart,
amount: cart.total,
currency_code: cart.region?.currency_code,
}
Object.assign(context, cart)
result = await stripeBase.createPayment(context)
expect(result).toEqual({
session_data: {
id: "pi_lebron",
customer: "cus_lebron",
description: undefined,
amount: 100,
},
update_requests: {
customer_metadata: {
stripe_id: "cus_lebron"
}
}
})
})
it("returns created stripe payment intent for cart with no customer", async () => {
const cart = carts.frCart
const context = {
cart,
amount: cart.total,
currency_code: cart.region?.currency_code,
}
Object.assign(context, cart)
context.cart.context.payment_description = 'some description'
result = await stripeBase.createPayment(context)
expect(result).toEqual({
session_data: {
id: "pi_lebron",
customer: "cus_lebron",
description: 'some description',
amount: 100,
},
update_requests: {
customer_metadata: {
stripe_id: "cus_lebron"
}
}
})
})
it("returns created stripe payment intent for cart with no customer and the options default description", async () => {
const localStripeProviderService = new StripeBase(
fakeContainer,
{
api_key: "test",
payment_description: "test options description"
})
const cart = carts.frCart
const context = {
cart,
amount: cart.total,
currency_code: cart.region?.currency_code,
}
Object.assign(context, cart)
context.cart.context.payment_description = null
result = await localStripeProviderService.createPayment(context)
expect(result).toEqual({
session_data: {
id: "pi_lebron",
customer: "cus_lebron",
description: "test options description",
amount: 100,
},
update_requests: {
customer_metadata: {
stripe_id: "cus_lebron"
}
}
})
})
})
describe("retrievePayment", () => {
let result
beforeAll(async () => {
jest.clearAllMocks()
const stripeBase = new StripeBase(
fakeContainer,
{
api_key: "test",
}
)
result = await stripeBase.retrievePayment({
payment_method: {
data: {
id: "pi_lebron",
},
},
})
})
it("returns stripe payment intent", () => {
expect(result).toEqual({
id: "pi_lebron",
customer: "cus_lebron",
})
})
})
describe("updatePayment", () => {
let result
beforeAll(async () => {
jest.clearAllMocks()
const stripeBase = new StripeBase(
fakeContainer,
{
api_key: "test",
}
)
result = await stripeBase.updatePayment(
{
id: "pi_lebron",
amount: 800,
},
{
total: 1000,
}
)
})
it("returns updated stripe payment intent", () => {
expect(result).toEqual({
id: "pi_lebron",
customer: "cus_lebron",
amount: 1000,
})
})
})
describe("updatePaymentIntentCustomer", () => {
let result
beforeAll(async () => {
jest.clearAllMocks()
const stripeBase = new StripeBase(
fakeContainer,
{
api_key: "test",
}
)
result = await stripeBase.updatePaymentIntentCustomer(
"pi_lebron",
"cus_lebron_2"
)
})
it("returns update stripe payment intent", () => {
expect(result).toEqual({
id: "pi_lebron",
customer: "cus_lebron_2",
amount: 1000,
})
})
})
describe("capturePayment", () => {
let result
beforeAll(async () => {
jest.clearAllMocks()
const stripeBase = new StripeBase(
fakeContainer,
{
api_key: "test",
}
)
result = await stripeBase.capturePayment({
data: {
id: "pi_lebron",
customer: "cus_lebron",
amount: 1000,
},
})
})
it("returns captured stripe payment intent", () => {
expect(result).toEqual({
id: "pi_lebron",
customer: "cus_lebron",
amount: 1000,
status: "succeeded",
})
})
})
describe("refundPayment", () => {
let result
beforeAll(async () => {
jest.clearAllMocks()
const stripeBase = new StripeBase(
fakeContainer,
{
api_key: "test",
}
)
result = await stripeBase.refundPayment(
{
data: {
id: "re_123",
payment_intent: "pi_lebron",
amount: 1000,
status: "succeeded",
},
},
1000
)
})
it("returns refunded stripe payment intent", () => {
expect(result).toEqual({
id: "re_123",
payment_intent: "pi_lebron",
amount: 1000,
status: "succeeded",
})
})
})
describe("cancelPayment", () => {
let result
beforeAll(async () => {
jest.clearAllMocks()
const stripeBase = new StripeBase(
fakeContainer,
{
api_key: "test",
}
)
result = await stripeBase.cancelPayment({
data: {
id: "pi_lebron",
customer: "cus_lebron",
status: "cancelled",
},
})
})
it("returns cancelled stripe payment intent", () => {
expect(result).toEqual({
id: "pi_lebron",
customer: "cus_lebron",
status: "cancelled",
})
})
})
})

View File

@@ -1,305 +0,0 @@
import { AbstractPaymentService } from "@medusajs/medusa"
import Stripe from "stripe"
import { PaymentSessionStatus } from "@medusajs/medusa/dist";
class StripeBase extends AbstractPaymentService {
static identifier = null
constructor(_, options) {
super(_, options)
/**
* Required Stripe options:
* {
* api_key: "stripe_secret_key", REQUIRED
* webhook_secret: "stripe_webhook_secret", REQUIRED
* // Use this flag to capture payment immediately (default is false)
* capture: true
* }
*/
this.options_ = options
/** @private @const {Stripe} */
this.stripe_ = Stripe(options.api_key)
}
getPaymentIntentOptions() {
const options = {}
if (this?.paymentIntentOptions?.capture_method) {
options.capture_method = this.paymentIntentOptions.capture_method
}
if (this?.paymentIntentOptions?.setup_future_usage) {
options.setup_future_usage = this.paymentIntentOptions.setup_future_usage
}
if (this?.paymentIntentOptions?.payment_method_types) {
options.payment_method_types =
this.paymentIntentOptions.payment_method_types
}
return options
}
/**
* Get payment session status
* statuses.
* @param {PaymentSessionData} paymentData - the data stored with the payment session
* @return {Promise<PaymentSessionStatus>} the status of the order
*/
async getStatus(paymentData) {
const { id } = paymentData
const paymentIntent = await this.stripe_.paymentIntents.retrieve(id)
switch (paymentIntent.status) {
case "requires_payment_method":
case "requires_confirmation":
case "processing":
return PaymentSessionStatus.PENDING
case "requires_action":
return PaymentSessionStatus.REQUIRES_MORE
case "canceled":
return PaymentSessionStatus.CANCELED
case "requires_capture":
case "succeeded":
return PaymentSessionStatus.AUTHORIZED
default:
return PaymentSessionStatus.PENDING
}
}
/**
* Fetches a customers saved payment methods if registered in Stripe.
* @param {Customer} customer - customer to fetch saved cards for
* @return {Promise<Data[]>} saved payments methods
*/
async retrieveSavedMethods(customer) {
if (customer.metadata && customer.metadata.stripe_id) {
const methods = await this.stripe_.paymentMethods.list({
customer: customer.metadata.stripe_id,
type: "card",
})
return methods.data
}
return []
}
/**
* Fetches a Stripe customer
* @param {string} customerId - Stripe customer id
* @return {Promise<object>} Stripe customer
*/
async retrieveCustomer(customerId) {
if (!customerId) {
return
}
return await this.stripe_.customers.retrieve(customerId)
}
/**
* Creates a Stripe payment intent.
* If customer is not registered in Stripe, we do so.
* @param {Cart & PaymentContext} context - context to use to create a payment for
* @return {Promise<PaymentSessionResponse>} Stripe payment intent
*/
async createPayment(context) {
const intentRequestData = this.getPaymentIntentOptions()
const { id: cart_id, email, context: cart_context, currency_code, amount, resource_id, customer } = context
const intentRequest = {
description:
cart_context.payment_description ??
this.options_?.payment_description,
amount: Math.round(amount),
currency: currency_code,
metadata: { cart_id, resource_id },
capture_method: this.options_.capture ? "automatic" : "manual",
...intentRequestData,
}
if (this.options_?.automatic_payment_methods) {
intentRequest.automatic_payment_methods = { enabled: true }
}
if (customer?.metadata?.stripe_id) {
intentRequest.customer = customer?.metadata?.stripe_id
} else {
const stripeCustomer = await this.stripe_.customers.create({
email,
})
intentRequest.customer = stripeCustomer.id
}
const session_data = await this.stripe_.paymentIntents.create(
intentRequest
)
return {
session_data,
update_requests: customer?.metadata?.stripe_id ? undefined : {
customer_metadata: {
stripe_id: intentRequest.customer
}
}
}
}
/**
* Retrieves Stripe payment intent.
* @param {PaymentData} data - the data of the payment to retrieve
* @return {Promise<Data>} Stripe payment intent
*/
async retrievePayment(data) {
return await this.stripe_.paymentIntents.retrieve(data.id)
}
/**
* Gets a Stripe payment intent and returns it.
* @param {PaymentSession} paymentSession - the data of the payment to retrieve
* @return {Promise<PaymentData>} Stripe payment intent
*/
async getPaymentData(paymentSession) {
return await this.stripe_.paymentIntents.retrieve(paymentSession.data.id)
}
/**
* Authorizes Stripe payment intent by simply returning
* the status for the payment intent in use.
* @param {PaymentSession} paymentSession - payment session data
* @param {Data} context - properties relevant to current context
* @return {Promise<{ data: PaymentSessionData; status: PaymentSessionStatus }>} result with data and status
*/
async authorizePayment(paymentSession, context = {}) {
const stat = await this.getStatus(paymentSession.data)
return { data: paymentSession.data, status: stat }
}
async updatePaymentData(sessionData, update) {
return await this.stripe_.paymentIntents.update(sessionData.id, {
...update.data,
})
}
/**
* Updates Stripe payment intent.
* @param {PaymentSessionData} paymentSessionData - payment session data.
* @param {Cart & PaymentContext} context
* @return {Promise<PaymentSessionData>} Stripe payment intent
*/
async updatePayment(paymentSessionData, context) {
const { amount, customer } = context
const stripeId = customer?.metadata?.stripe_id || undefined
if (stripeId !== paymentSessionData.customer) {
return await this.createPayment(context)
} else {
if (
amount &&
paymentSessionData.amount === Math.round(amount)
) {
return paymentSessionData
}
return await this.stripe_.paymentIntents.update(paymentSessionData.id, {
amount: Math.round(amount),
})
}
}
async deletePayment(payment) {
const { id } = payment.data
return this.stripe_.paymentIntents.cancel(id).catch((err) => {
if (err.statusCode === 400) {
return
}
throw err
})
}
/**
* Updates customer of Stripe payment intent.
* @param {string} paymentIntentId - id of payment intent to update
* @param {string} customerId - id of \ Stripe customer
* @return {object} Stripe payment intent
*/
async updatePaymentIntentCustomer(paymentIntentId, customerId) {
return await this.stripe_.paymentIntents.update(paymentIntentId, {
customer: customerId,
})
}
/**
* Captures payment for Stripe payment intent.
* @param {Payment} payment - payment method data from cart
* @return {Promise<PaymentData>} Stripe payment intent
*/
async capturePayment(payment) {
const { id } = payment.data
try {
const intent = await this.stripe_.paymentIntents.capture(id)
return intent
} catch (error) {
if (error.code === "payment_intent_unexpected_state") {
if (error.payment_intent.status === "succeeded") {
return error.payment_intent
}
}
throw error
}
}
/**
* Refunds payment for Stripe payment intent.
* @param {Payment} payment - payment method data from cart
* @param {number} refundAmount - amount to refund
* @return {Promise<PaymentData>} refunded payment intent
*/
async refundPayment(payment, amountToRefund) {
const { id } = payment.data
await this.stripe_.refunds.create({
amount: Math.round(amountToRefund),
payment_intent: id,
})
return payment.data
}
/**
* Cancels payment for Stripe payment intent.
* @param {Payment} payment - payment method data from cart
* @return {Promise<PaymentData>} canceled payment intent
*/
async cancelPayment(payment) {
const { id } = payment.data
try {
return await this.stripe_.paymentIntents.cancel(id)
} catch (error) {
if (error.payment_intent.status === "canceled") {
return error.payment_intent
}
throw error
}
}
/**
* Constructs Stripe Webhook event
* @param {object} data - the data of the webhook request: req.body
* @param {object} signature - the Stripe signature on the event, that
* ensures integrity of the webhook event
* @return {object} Stripe Webhook event
*/
constructWebhookEvent(data, signature) {
return this.stripe_.webhooks.constructEvent(
data,
signature,
this.options_.webhook_secret
)
}
}
export default StripeBase

View File

@@ -1,13 +1,14 @@
import StripeBase from "../helpers/stripe-base" import StripeBase from "../core/stripe-base"
import { PaymentIntentOptions, PaymentProviderKeys } from "../types"
class BancontactProviderService extends StripeBase { class BancontactProviderService extends StripeBase {
static identifier = "stripe-bancontact" static identifier = PaymentProviderKeys.BAN_CONTACT
constructor(_, options) { constructor(_, options) {
super(_, options) super(_, options)
} }
get paymentIntentOptions() { get paymentIntentOptions(): PaymentIntentOptions {
return { return {
payment_method_types: ["bancontact"], payment_method_types: ["bancontact"],
capture_method: "automatic", capture_method: "automatic",

View File

@@ -1,13 +1,14 @@
import StripeBase from "../helpers/stripe-base" import StripeBase from "../core/stripe-base"
import { PaymentIntentOptions, PaymentProviderKeys } from "../types"
class BlikProviderService extends StripeBase { class BlikProviderService extends StripeBase {
static identifier = "stripe-blik" static identifier = PaymentProviderKeys.BLIK
constructor(_, options) { constructor(_, options) {
super(_, options) super(_, options)
} }
get paymentIntentOptions() { get paymentIntentOptions(): PaymentIntentOptions {
return { return {
payment_method_types: ["blik"], payment_method_types: ["blik"],
capture_method: "automatic", capture_method: "automatic",

View File

@@ -1,13 +1,14 @@
import StripeBase from "../helpers/stripe-base" import StripeBase from "../core/stripe-base"
import { PaymentIntentOptions, PaymentProviderKeys } from "../types"
class GiropayProviderService extends StripeBase { class GiropayProviderService extends StripeBase {
static identifier = "stripe-giropay" static identifier = PaymentProviderKeys.GIROPAY
constructor(_, options) { constructor(_, options) {
super(_, options) super(_, options)
} }
get paymentIntentOptions() { get paymentIntentOptions(): PaymentIntentOptions {
return { return {
payment_method_types: ["giropay"], payment_method_types: ["giropay"],
capture_method: "automatic", capture_method: "automatic",

View File

@@ -1,13 +1,14 @@
import StripeBase from "../helpers/stripe-base" import StripeBase from "../core/stripe-base"
import { PaymentIntentOptions, PaymentProviderKeys } from "../types"
class IdealProviderService extends StripeBase { class IdealProviderService extends StripeBase {
static identifier = "stripe-ideal" static identifier = PaymentProviderKeys.IDEAL
constructor(_, options) { constructor(_, options) {
super(_, options) super(_, options)
} }
get paymentIntentOptions() { get paymentIntentOptions(): PaymentIntentOptions {
return { return {
payment_method_types: ["ideal"], payment_method_types: ["ideal"],
capture_method: "automatic", capture_method: "automatic",

View File

@@ -1,15 +0,0 @@
import StripeBase from "../helpers/stripe-base";
class StripeProviderService extends StripeBase {
static identifier = "stripe"
constructor(_, options) {
super(_, options)
}
get paymentIntentOptions() {
return {}
}
}
export default StripeProviderService

View File

@@ -0,0 +1,16 @@
import StripeBase from "../core/stripe-base"
import { PaymentIntentOptions, PaymentProviderKeys } from "../types"
class StripeProviderService extends StripeBase {
static identifier = PaymentProviderKeys.STRIPE
constructor(_, options) {
super(_, options)
}
get paymentIntentOptions(): PaymentIntentOptions {
return {}
}
}
export default StripeProviderService

View File

@@ -1,13 +1,14 @@
import StripeBase from "../helpers/stripe-base" import StripeBase from "../core/stripe-base"
import { PaymentIntentOptions, PaymentProviderKeys } from "../types"
class Przelewy24ProviderService extends StripeBase { class Przelewy24ProviderService extends StripeBase {
static identifier = "stripe-przelewy24" static identifier = PaymentProviderKeys.PRZELEWY_24
constructor(_, options) { constructor(_, options) {
super(_, options) super(_, options)
} }
get paymentIntentOptions() { get paymentIntentOptions(): PaymentIntentOptions {
return { return {
payment_method_types: ["p24"], payment_method_types: ["p24"],
capture_method: "automatic", capture_method: "automatic",

View File

@@ -0,0 +1,40 @@
export interface StripeOptions {
api_key: string
webhook_secret: string
/**
* Use this flag to capture payment immediately (default is false)
*/
capture?: boolean
/**
* set `automatic_payment_methods` to `{ enabled: true }`
*/
automatic_payment_methods?: boolean
/**
* Set a default description on the intent if the context does not provide one
*/
payment_description?: string
}
export interface PaymentIntentOptions {
capture_method?: "automatic" | "manual"
setup_future_usage?: "on_session" | "off_session"
payment_method_types?: string[]
}
export const ErrorCodes = {
PAYMENT_INTENT_UNEXPECTED_STATE: "payment_intent_unexpected_state",
}
export const ErrorIntentStatus = {
SUCCEEDED: "succeeded",
CANCELED: "canceled",
}
export const PaymentProviderKeys = {
STRIPE: "stripe",
BAN_CONTACT: "stripe-bancontact",
BLIK: "stripe-blik",
GIROPAY: "stripe-giropay",
IDEAL: "stripe-ideal",
PRZELEWY_24: "stripe-przelewy24",
}

View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"lib": [
"es5",
"es6",
"es2019"
],
"target": "es5",
"outDir": "./dist",
"esModuleInterop": true,
"declaration": true,
"module": "commonjs",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": true,
"noImplicitReturns": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"allowJs": true,
"skipLibCheck": true,
"downlevelIteration": true // to use ES5 specific tooling
},
"include": ["src"],
"exclude": [
"dist",
"src/**/__tests__",
"src/**/__mocks__",
"src/**/__fixtures__",
"node_modules"
]
}

View File

@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@@ -9,12 +9,24 @@ export { getRequestedBatchJob } from "./batch-job/get-requested-batch-job"
export { doesConditionBelongToDiscount } from "./discount/does-condition-belong-to-discount" export { doesConditionBelongToDiscount } from "./discount/does-condition-belong-to-discount"
export { transformIncludesOptions } from "./transform-includes-options" export { transformIncludesOptions } from "./transform-includes-options"
export { transformBody } from "./transform-body" export { transformBody } from "./transform-body"
export { default as authenticate } from "./authenticate"
export { default as authenticateCustomer } from "./authenticate-customer"
export { default as wrapHandler } from "./await-middleware"
export { default as normalizeQuery } from "./normalized-query"
export { default as requireCustomerAuthentication } from "./require-customer-authentication"
export { transformQuery, transformStoreQuery } from "./transform-query" export { transformQuery, transformStoreQuery } from "./transform-query"
/**
* @deprecated you can now import the middlewares directly without passing by the default export
* e.g `import { authenticate } from "@medusajs/medusa"
*/
export default { export default {
authenticate, authenticate,
authenticateCustomer, authenticateCustomer,
requireCustomerAuthentication, requireCustomerAuthentication,
normalizeQuery, normalizeQuery,
/**
* @deprecated use `import { wrapHandler } from "@medusajs/medusa"`
*/
wrap, wrap,
} }

View File

@@ -1,4 +1,5 @@
export * from "./api" export * from "./api"
export * from "./api/middlewares"
export * from "./interfaces" export * from "./interfaces"
export * from "./models" export * from "./models"
export * from "./services" export * from "./services"

View File

@@ -27,6 +27,7 @@ export interface ICartCompletionStrategy {
} }
export abstract class AbstractCartCompletionStrategy export abstract class AbstractCartCompletionStrategy
extends TransactionBaseService
implements ICartCompletionStrategy implements ICartCompletionStrategy
{ {
abstract complete( abstract complete(

View File

@@ -33,11 +33,6 @@ export interface PaymentProcessor {
*/ */
getIdentifier(): string getIdentifier(): string
/**
* Used to initialise anything like an SDK or similar
*/
init(): Promise<void>
/** /**
* Initiate a payment session with the external provider * Initiate a payment session with the external provider
*/ */
@@ -147,8 +142,6 @@ export abstract class AbstractPaymentProcessor implements PaymentProcessor {
return ctr.identifier return ctr.identifier
} }
abstract init(): Promise<void>
abstract capturePayment( abstract capturePayment(
paymentSessionData: Record<string, unknown> paymentSessionData: Record<string, unknown>
): Promise< ): Promise<

View File

@@ -205,12 +205,9 @@ async function registerPaymentProcessor({
).filter((provider) => provider instanceof AbstractPaymentProcessor) ).filter((provider) => provider instanceof AbstractPaymentProcessor)
const payIds: string[] = [] const payIds: string[] = []
await Promise.all( payProviders.map((paymentProvider) => {
payProviders.map((paymentProvider) => { payIds.push(paymentProvider.getIdentifier())
payIds.push(paymentProvider.getIdentifier()) })
return paymentProvider.init()
})
)
const pProviderService = container.resolve<PaymentProviderService>( const pProviderService = container.resolve<PaymentProviderService>(
"paymentProviderService" "paymentProviderService"

View File

@@ -6,7 +6,6 @@ import glob from "glob"
import _ from "lodash" import _ from "lodash"
import { createRequireFromPath } from "medusa-core-utils" import { createRequireFromPath } from "medusa-core-utils"
import { import {
BaseService as LegacyBaseService,
FileService, FileService,
FulfillmentService, FulfillmentService,
OauthService, OauthService,
@@ -23,7 +22,6 @@ import {
isPriceSelectionStrategy, isPriceSelectionStrategy,
isSearchService, isSearchService,
isTaxCalculationStrategy, isTaxCalculationStrategy,
TransactionBaseService as BaseService,
} from "../interfaces" } from "../interfaces"
import { MiddlewareService } from "../services" import { MiddlewareService } from "../services"
import { import {
@@ -364,16 +362,6 @@ export async function registerServices(
const loaded = require(fn).default const loaded = require(fn).default
const name = formatRegistrationName(fn) const name = formatRegistrationName(fn)
if (
!(loaded.prototype instanceof LegacyBaseService) &&
!(loaded.prototype instanceof BaseService)
) {
const logger = container.resolve<Logger>("logger")
const message = `The class must be a valid service implementation, please check ${fn}`
logger.error(message)
throw new Error(message)
}
const context = { container, pluginDetails, registrationName: name } const context = { container, pluginDetails, registrationName: name }
registerPaymentServiceFromClass(loaded, context) registerPaymentServiceFromClass(loaded, context)
@@ -627,8 +615,18 @@ function resolvePlugin(pluginName: string): {
) )
// warnOnIncompatiblePeerDependency(packageJSON.name, packageJSON) // warnOnIncompatiblePeerDependency(packageJSON.name, packageJSON)
const computedResolvedPath =
resolvedPath + (process.env.DEV_MODE ? "/src" : "")
// Add support for a plugin to output the build into a dist directory
const resolvedPathToDist = resolvedPath + "/dist"
const isDistExist =
resolvedPathToDist &&
!process.env.DEV_MODE &&
existsSync(resolvedPath + "/dist")
return { return {
resolve: resolvedPath + (process.env.DEV_MODE ? "/src" : ""), resolve: isDistExist ? resolvedPathToDist : computedResolvedPath,
id: createPluginId(packageJSON.name), id: createPluginId(packageJSON.name),
name: packageJSON.name, name: packageJSON.name,
options: {}, options: {},

View File

@@ -8,6 +8,8 @@ import {
CreateIdempotencyKeyInput, CreateIdempotencyKeyInput,
IdempotencyCallbackResult, IdempotencyCallbackResult,
} from "../types/idempotency-key" } from "../types/idempotency-key"
import { Selector } from "../types/common"
import { buildQuery, isString } from "../utils"
const KEY_LOCKED_TIMEOUT = 1000 const KEY_LOCKED_TIMEOUT = 1000
@@ -75,14 +77,16 @@ class IdempotencyKeyService extends TransactionBaseService {
/** /**
* Retrieves an idempotency key * Retrieves an idempotency key
* @param idempotencyKey - key to retrieve * @param idempotencyKeyOrSelector - key or selector to retrieve
* @return idempotency key * @return idempotency key
*/ */
async retrieve(idempotencyKey: string): Promise<IdempotencyKey | never> { async retrieve(
if (!isDefined(idempotencyKey)) { idempotencyKeyOrSelector: string | Selector<IdempotencyKey>
): Promise<IdempotencyKey | never> {
if (!isDefined(idempotencyKeyOrSelector)) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.NOT_FOUND, MedusaError.Types.NOT_FOUND,
`"idempotencyKey" must be defined` `"idempotencyKeyOrSelector" must be defined`
) )
} }
@@ -90,15 +94,34 @@ class IdempotencyKeyService extends TransactionBaseService {
this.idempotencyKeyRepository_ this.idempotencyKeyRepository_
) )
const iKey = await idempotencyKeyRepo.findOne({ const selector = isString(idempotencyKeyOrSelector)
where: { idempotency_key: idempotencyKey }, ? { idempotency_key: idempotencyKeyOrSelector }
}) : idempotencyKeyOrSelector
const query = buildQuery(selector)
const iKeys = await idempotencyKeyRepo.find(query)
if (iKeys.length > 1) {
throw new Error(
`Multiple keys were found for constraints: ${JSON.stringify(
idempotencyKeyOrSelector
)}. There should only be one.`
)
}
const iKey = iKeys[0]
if (!iKey) { if (!iKey) {
throw new MedusaError( let message
MedusaError.Types.NOT_FOUND, if (isString(idempotencyKeyOrSelector)) {
`Idempotency key ${idempotencyKey} was not found` message = `Idempotency key ${idempotencyKeyOrSelector} was not found`
) } else {
message = `Idempotency key with constraints ${JSON.stringify(
idempotencyKeyOrSelector
)} was not found`
}
throw new MedusaError(MedusaError.Types.NOT_FOUND, message)
} }
return iKey return iKey

View File

@@ -356,17 +356,16 @@ export default class PaymentProviderService extends TransactionBaseService {
let paymentResponse let paymentResponse
if (provider instanceof AbstractPaymentProcessor) { if (provider instanceof AbstractPaymentProcessor) {
paymentResponse = paymentResponse = await provider.updatePayment({
(await provider.updatePayment({ amount: context.amount,
amount: context.amount, context: context.context,
context: context.context, currency_code: context.currency_code,
currency_code: context.currency_code, customer: context.customer,
customer: context.customer, email: context.email,
email: context.email, billing_address: context.billing_address,
billing_address: context.billing_address, resource_id: context.resource_id,
resource_id: context.resource_id, paymentSessionData: paymentSession.data,
paymentSessionData: paymentSession.data, })
})) ?? {}
if (paymentResponse && "error" in paymentResponse) { if (paymentResponse && "error" in paymentResponse) {
this.throwFromPaymentProcessorError(paymentResponse) this.throwFromPaymentProcessorError(paymentResponse)
@@ -377,7 +376,7 @@ export default class PaymentProviderService extends TransactionBaseService {
.updatePayment(paymentSession.data, context) .updatePayment(paymentSession.data, context)
} }
const sessionData = paymentResponse.session_data ?? paymentResponse const sessionData = paymentResponse?.session_data ?? paymentResponse
// If no update occurs, return the original session // If no update occurs, return the original session
if (!sessionData) { if (!sessionData) {

View File

@@ -30,8 +30,6 @@ type InjectedDependencies = {
} }
class CartCompletionStrategy extends AbstractCartCompletionStrategy { class CartCompletionStrategy extends AbstractCartCompletionStrategy {
protected manager_: EntityManager
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
protected readonly productVariantInventoryService_: ProductVariantInventoryService protected readonly productVariantInventoryService_: ProductVariantInventoryService
protected readonly paymentProviderService_: PaymentProviderService protected readonly paymentProviderService_: PaymentProviderService
@@ -47,9 +45,9 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy {
cartService, cartService,
orderService, orderService,
swapService, swapService,
manager,
}: InjectedDependencies) { }: InjectedDependencies) {
super() // eslint-disable-next-line prefer-rest-params
super(arguments[0])
this.paymentProviderService_ = paymentProviderService this.paymentProviderService_ = paymentProviderService
this.productVariantInventoryService_ = productVariantInventoryService this.productVariantInventoryService_ = productVariantInventoryService
@@ -57,7 +55,6 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy {
this.cartService_ = cartService this.cartService_ = cartService
this.orderService_ = orderService this.orderService_ = orderService
this.swapService_ = swapService this.swapService_ = swapService
this.manager_ = manager
} }
async complete( async complete(
@@ -73,7 +70,7 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy {
while (inProgress) { while (inProgress) {
switch (idempotencyKey.recovery_point) { switch (idempotencyKey.recovery_point) {
case "started": { case "started": {
await this.manager_ await this.activeManager_
.transaction("SERIALIZABLE", async (transactionManager) => { .transaction("SERIALIZABLE", async (transactionManager) => {
idempotencyKey = await this.idempotencyKeyService_ idempotencyKey = await this.idempotencyKeyService_
.withTransaction(transactionManager) .withTransaction(transactionManager)
@@ -90,7 +87,7 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy {
break break
} }
case "tax_lines_created": { case "tax_lines_created": {
await this.manager_ await this.activeManager_
.transaction("SERIALIZABLE", async (transactionManager) => { .transaction("SERIALIZABLE", async (transactionManager) => {
idempotencyKey = await this.idempotencyKeyService_ idempotencyKey = await this.idempotencyKeyService_
.withTransaction(transactionManager) .withTransaction(transactionManager)
@@ -111,7 +108,7 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy {
} }
case "payment_authorized": { case "payment_authorized": {
await this.manager_ await this.activeManager_
.transaction("SERIALIZABLE", async (transactionManager) => { .transaction("SERIALIZABLE", async (transactionManager) => {
idempotencyKey = await this.idempotencyKeyService_ idempotencyKey = await this.idempotencyKeyService_
.withTransaction(transactionManager) .withTransaction(transactionManager)
@@ -134,7 +131,7 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy {
} }
default: default:
await this.manager_.transaction(async (transactionManager) => { await this.activeManager_.transaction(async (transactionManager) => {
idempotencyKey = await this.idempotencyKeyService_ idempotencyKey = await this.idempotencyKeyService_
.withTransaction(transactionManager) .withTransaction(transactionManager)
.update(idempotencyKey.idempotency_key, { .update(idempotencyKey.idempotency_key, {
@@ -149,7 +146,7 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy {
if (err) { if (err) {
if (idempotencyKey.recovery_point !== "started") { if (idempotencyKey.recovery_point !== "started") {
await this.manager_.transaction(async (transactionManager) => { await this.activeManager_.transaction(async (transactionManager) => {
try { try {
await this.orderService_ await this.orderService_
.withTransaction(transactionManager) .withTransaction(transactionManager)
@@ -264,16 +261,19 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy {
const cartServiceTx = this.cartService_.withTransaction(manager) const cartServiceTx = this.cartService_.withTransaction(manager)
const cart = await cartServiceTx.retrieveWithTotals(id, { 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 allowBackorder = false
let swapId: string
if (cart.type === "swap") { if (cart.type === "swap") {
const swap = await swapServiceTx.retrieveByCartId(id) const swap = await swapServiceTx.retrieveByCartId(id)
allowBackorder = swap.allow_backorder allowBackorder = swap.allow_backorder
swapId = swap.id
} }
if (!allowBackorder) { if (!allowBackorder) {

View File

@@ -1,7 +1,7 @@
export type CreateIdempotencyKeyInput = { export type CreateIdempotencyKeyInput = {
request_method: string request_method?: string
request_params: Record<string, unknown> request_params?: Record<string, unknown>
request_path: string request_path?: string
idempotency_key?: string idempotency_key?: string
} }

View File

@@ -9215,6 +9215,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/stripe@npm:^8.0.417":
version: 8.0.417
resolution: "@types/stripe@npm:8.0.417"
dependencies:
stripe: "*"
checksum: 97e3a5ad6948a94366d6749328b721a3c9004ba68795e7675efaa74e4b4c51d57c1dfc785747f4221192dabd7a13fb41778e88e080bafd5d3938a50df7f4ef98
languageName: node
linkType: hard
"@types/superagent@npm:*": "@types/superagent@npm:*":
version: 4.1.15 version: 4.1.15
resolution: "@types/superagent@npm:4.1.15" resolution: "@types/superagent@npm:4.1.15"
@@ -25402,7 +25411,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"medusa-core-utils@^1.1.31, medusa-core-utils@^1.1.39, medusa-core-utils@workspace:packages/medusa-core-utils": "medusa-core-utils@^1.1.31, medusa-core-utils@^1.1.38, medusa-core-utils@^1.1.39, medusa-core-utils@workspace:packages/medusa-core-utils":
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "medusa-core-utils@workspace:packages/medusa-core-utils" resolution: "medusa-core-utils@workspace:packages/medusa-core-utils"
dependencies: dependencies:
@@ -25719,28 +25728,16 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "medusa-payment-stripe@workspace:packages/medusa-payment-stripe" resolution: "medusa-payment-stripe@workspace:packages/medusa-payment-stripe"
dependencies: dependencies:
"@babel/cli": ^7.7.5 "@medusajs/medusa": ^1.7.7
"@babel/core": ^7.7.5 "@types/stripe": ^8.0.417
"@babel/node": ^7.7.4
"@babel/plugin-proposal-class-properties": ^7.7.4
"@babel/plugin-proposal-optional-chaining": ^7.12.7
"@babel/plugin-transform-classes": ^7.9.5
"@babel/plugin-transform-instanceof": ^7.8.3
"@babel/plugin-transform-runtime": ^7.7.6
"@babel/preset-env": ^7.7.5
"@babel/register": ^7.7.4
"@babel/runtime": ^7.9.6
body-parser: ^1.19.0 body-parser: ^1.19.0
client-sessions: ^0.8.0
cross-env: ^5.2.1 cross-env: ^5.2.1
express: ^4.17.1 express: ^4.17.1
jest: ^25.5.4 jest: ^25.5.4
medusa-core-utils: ^1.1.39 medusa-core-utils: ^1.1.38
medusa-interfaces: ^1.3.6 stripe: ^11.10.0
medusa-test-utils: ^1.1.37
stripe: ^8.50.0
peerDependencies: peerDependencies:
medusa-interfaces: 1.3.6 "@medusajs/medusa": ^1.7.7
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@@ -30339,7 +30336,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"qs@npm:^6.10.0, qs@npm:^6.10.3, qs@npm:^6.5.1, qs@npm:^6.6.0, qs@npm:^6.9.4": "qs@npm:^6.10.0, qs@npm:^6.10.3, qs@npm:^6.11.0, qs@npm:^6.5.1, qs@npm:^6.6.0, qs@npm:^6.9.4":
version: 6.11.0 version: 6.11.0
resolution: "qs@npm:6.11.0" resolution: "qs@npm:6.11.0"
dependencies: dependencies:
@@ -33904,6 +33901,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"stripe@npm:*, stripe@npm:^11.10.0":
version: 11.10.0
resolution: "stripe@npm:11.10.0"
dependencies:
"@types/node": ">=8.1.0"
qs: ^6.11.0
checksum: e50b7f671f608557a6dec74ae817c13df73ded75f586e2c5069e6acf86d5e459753695c59454226e8ede3ab7df00915f6a5becda8e7153226ea2eacbd7fb87a2
languageName: node
linkType: hard
"stripe@npm:^8.50.0": "stripe@npm:^8.50.0":
version: 8.222.0 version: 8.222.0
resolution: "stripe@npm:8.222.0" resolution: "stripe@npm:8.222.0"