diff --git a/.changeset/eleven-cycles-mate.md b/.changeset/eleven-cycles-mate.md new file mode 100644 index 0000000000000..71c01eaad2496 --- /dev/null +++ b/.changeset/eleven-cycles-mate.md @@ -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 diff --git a/.eslintignore b/.eslintignore index 83458b34424af..201dccdc07700 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,6 +7,7 @@ jest* packages/* # List of packages to Lint !packages/medusa +!packages/medusa-payment-stripe diff --git a/.eslintrc.js b/.eslintrc.js index 39e7d73efd48b..9893804579d03 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -80,7 +80,10 @@ module.exports = { extends: ["plugin:@typescript-eslint/recommended"], parser: "@typescript-eslint/parser", parserOptions: { - project: "./packages/medusa/tsconfig.json", + project: [ + "./packages/medusa/tsconfig.json", + "./packages/medusa-payment-stripe/tsconfig.spec.json", + ] }, rules: { "valid-jsdoc": "off", diff --git a/packages/medusa-payment-paypal/package.json b/packages/medusa-payment-paypal/package.json index 32f908b8411be..2bb9ec35bc687 100644 --- a/packages/medusa-payment-paypal/package.json +++ b/packages/medusa-payment-paypal/package.json @@ -1,7 +1,7 @@ { "name": "medusa-payment-paypal", "version": "1.2.10", - "description": "Paypal Payment provider for Meduas Commerce", + "description": "Paypal Payment provider for Medusa Commerce", "main": "index.js", "repository": { "type": "git", diff --git a/packages/medusa-payment-stripe/.babelrc b/packages/medusa-payment-stripe/.babelrc deleted file mode 100644 index 75cbf1558b1b6..0000000000000 --- a/packages/medusa-payment-stripe/.babelrc +++ /dev/null @@ -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"] - } - } -} diff --git a/packages/medusa-payment-stripe/.gitignore b/packages/medusa-payment-stripe/.gitignore index f04d2490fe215..83cb36a41ea17 100644 --- a/packages/medusa-payment-stripe/.gitignore +++ b/packages/medusa-payment-stripe/.gitignore @@ -1,16 +1,4 @@ -/lib +dist node_modules .DS_store -.env* -/*.js -!index.js yarn.lock - -/dist - -/api -/services -/models -/subscribers -/helpers -/__mocks__ \ No newline at end of file diff --git a/packages/medusa-payment-stripe/.npmignore b/packages/medusa-payment-stripe/.npmignore deleted file mode 100644 index af4cbd1ba0b84..0000000000000 --- a/packages/medusa-payment-stripe/.npmignore +++ /dev/null @@ -1,8 +0,0 @@ -.DS_store -src -dist -yarn.lock -.babelrc - -.turbo -.yarn \ No newline at end of file diff --git a/packages/medusa-payment-stripe/README.md b/packages/medusa-payment-stripe/README.md index a52504cac4bc1..c685ca46ab30a 100644 --- a/packages/medusa-payment-stripe/README.md +++ b/packages/medusa-payment-stripe/README.md @@ -10,10 +10,18 @@ Learn more about how you can use this plugin in the [documentaion](https://docs. { api_key: "STRIPE_API_KEY", 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 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` \ No newline at end of file diff --git a/packages/medusa-payment-stripe/index.js b/packages/medusa-payment-stripe/index.js deleted file mode 100644 index 172f1ae6a468c..0000000000000 --- a/packages/medusa-payment-stripe/index.js +++ /dev/null @@ -1 +0,0 @@ -// noop diff --git a/packages/medusa-payment-stripe/jest.config.js b/packages/medusa-payment-stripe/jest.config.js new file mode 100644 index 0000000000000..e564d67c7053e --- /dev/null +++ b/packages/medusa-payment-stripe/jest.config.js @@ -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`], +} diff --git a/packages/medusa-payment-stripe/package.json b/packages/medusa-payment-stripe/package.json index c3a4d21f5882d..8c7c645836521 100644 --- a/packages/medusa-payment-stripe/package.json +++ b/packages/medusa-payment-stripe/package.json @@ -2,46 +2,37 @@ "name": "medusa-payment-stripe", "version": "1.1.53", "description": "Stripe Payment provider for Meduas Commerce", - "main": "index.js", + "main": "dist/index.js", "repository": { "type": "git", "url": "https://github.com/medusajs/medusa", "directory": "packages/medusa-payment-stripe" }, - "author": "Sebastian Rindom", + "files": [ + "dist" + ], + "author": "Medusa", "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": { "prepare": "cross-env NODE_ENV=production yarn run build", "test": "jest --passWithNoTests src", - "build": "babel src --out-dir . --ignore '**/__tests__','**/__mocks__'", - "watch": "babel -w src --out-dir . --ignore '**/__tests__','**/__mocks__'" + "build": "tsc", + "watch": "tsc --watch" + }, + "devDependencies": { + "@medusajs/medusa": "^1.7.7", + "@types/stripe": "^8.0.417", + "cross-env": "^5.2.1", + "jest": "^25.5.4" }, "peerDependencies": { - "medusa-interfaces": "1.3.6" + "@medusajs/medusa": "^1.7.7" }, "dependencies": { "body-parser": "^1.19.0", "express": "^4.17.1", - "medusa-core-utils": "^1.1.39", - "stripe": "^8.50.0" + "medusa-core-utils": "^1.1.38", + "stripe": "^11.10.0" }, "gitHead": "81a7ff73d012fda722f6e9ef0bd9ba0232d37808", "keywords": [ diff --git a/packages/medusa-payment-stripe/src/__fixtures__/data.ts b/packages/medusa-payment-stripe/src/__fixtures__/data.ts new file mode 100644 index 0000000000000..3db0a5d4d1dae --- /dev/null +++ b/packages/medusa-payment-stripe/src/__fixtures__/data.ts @@ -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", + }, +} diff --git a/packages/medusa-payment-stripe/src/__mocks__/cart.js b/packages/medusa-payment-stripe/src/__mocks__/cart.js deleted file mode 100644 index c301361bdaad7..0000000000000 --- a/packages/medusa-payment-stripe/src/__mocks__/cart.js +++ /dev/null @@ -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 diff --git a/packages/medusa-payment-stripe/src/__mocks__/customer.js b/packages/medusa-payment-stripe/src/__mocks__/customer.js deleted file mode 100644 index 421f661e8806a..0000000000000 --- a/packages/medusa-payment-stripe/src/__mocks__/customer.js +++ /dev/null @@ -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 diff --git a/packages/medusa-payment-stripe/src/__mocks__/eventbus.js b/packages/medusa-payment-stripe/src/__mocks__/eventbus.js deleted file mode 100644 index e9031d94284b5..0000000000000 --- a/packages/medusa-payment-stripe/src/__mocks__/eventbus.js +++ /dev/null @@ -1,10 +0,0 @@ -export const EventBusServiceMock = { - emit: jest.fn(), - subscribe: jest.fn(), -} - -const mock = jest.fn().mockImplementation(() => { - return EventBusServiceMock -}) - -export default mock diff --git a/packages/medusa-payment-stripe/src/__mocks__/stripe.js b/packages/medusa-payment-stripe/src/__mocks__/stripe.js deleted file mode 100644 index deb6bafc8513b..0000000000000 --- a/packages/medusa-payment-stripe/src/__mocks__/stripe.js +++ /dev/null @@ -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 diff --git a/packages/medusa-payment-stripe/src/__mocks__/stripe.ts b/packages/medusa-payment-stripe/src/__mocks__/stripe.ts new file mode 100644 index 0000000000000..9b99382ca363c --- /dev/null +++ b/packages/medusa-payment-stripe/src/__mocks__/stripe.ts @@ -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 diff --git a/packages/medusa-payment-stripe/src/__mocks__/totals.js b/packages/medusa-payment-stripe/src/__mocks__/totals.js deleted file mode 100644 index 5163d4ee662c6..0000000000000 --- a/packages/medusa-payment-stripe/src/__mocks__/totals.js +++ /dev/null @@ -1,12 +0,0 @@ -export const TotalsServiceMock = { - withTransaction: function () { - return this - }, - getTotal: jest.fn(), -} - -const mock = jest.fn().mockImplementation(() => { - return TotalsServiceMock -}) - -export default mock diff --git a/packages/medusa-payment-stripe/src/api/routes/hooks/index.js b/packages/medusa-payment-stripe/src/api/hooks/index.ts similarity index 73% rename from packages/medusa-payment-stripe/src/api/routes/hooks/index.js rename to packages/medusa-payment-stripe/src/api/hooks/index.ts index 68f1ccdb86fd7..87a02f60c0f39 100644 --- a/packages/medusa-payment-stripe/src/api/routes/hooks/index.js +++ b/packages/medusa-payment-stripe/src/api/hooks/index.ts @@ -1,6 +1,7 @@ +import stripeHooks from "./stripe" import { Router } from "express" import bodyParser from "body-parser" -import middlewares from "../../middlewares" +import { wrapHandler } from "@medusajs/medusa" const route = Router() @@ -11,7 +12,7 @@ export default (app) => { "/hooks", // stripe constructEvent fails without body-parser bodyParser.raw({ type: "application/json" }), - middlewares.wrap(require("./stripe").default) + wrapHandler(stripeHooks) ) return app } diff --git a/packages/medusa-payment-stripe/src/api/hooks/stripe.ts b/packages/medusa-payment-stripe/src/api/hooks/stripe.ts new file mode 100644 index 0000000000000..cb66a17e91b58 --- /dev/null +++ b/packages/medusa-payment-stripe/src/api/hooks/stripe.ts @@ -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) +} diff --git a/packages/medusa-payment-stripe/src/api/index.js b/packages/medusa-payment-stripe/src/api/index.ts similarity index 77% rename from packages/medusa-payment-stripe/src/api/index.js rename to packages/medusa-payment-stripe/src/api/index.ts index 50feeb7074d07..d704d468d5fdd 100644 --- a/packages/medusa-payment-stripe/src/api/index.js +++ b/packages/medusa-payment-stripe/src/api/index.ts @@ -1,5 +1,5 @@ import { Router } from "express" -import hooks from "./routes/hooks" +import hooks from "./hooks" export default (container) => { const app = Router() diff --git a/packages/medusa-payment-stripe/src/api/middlewares/await-middleware.js b/packages/medusa-payment-stripe/src/api/middlewares/await-middleware.js deleted file mode 100644 index ca4821e6af150..0000000000000 --- a/packages/medusa-payment-stripe/src/api/middlewares/await-middleware.js +++ /dev/null @@ -1,3 +0,0 @@ -export default (fn) => - (...args) => - fn(...args).catch(args[2]) diff --git a/packages/medusa-payment-stripe/src/api/middlewares/index.js b/packages/medusa-payment-stripe/src/api/middlewares/index.js deleted file mode 100644 index c784e319a9a33..0000000000000 --- a/packages/medusa-payment-stripe/src/api/middlewares/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import { default as wrap } from "./await-middleware" - -export default { - wrap, -} diff --git a/packages/medusa-payment-stripe/src/api/routes/hooks/stripe.js b/packages/medusa-payment-stripe/src/api/routes/hooks/stripe.js deleted file mode 100644 index 14ff981c645e6..0000000000000 --- a/packages/medusa-payment-stripe/src/api/routes/hooks/stripe.js +++ /dev/null @@ -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) - } -} diff --git a/packages/medusa-payment-stripe/src/api/utils/__fixtures__/container.ts b/packages/medusa-payment-stripe/src/api/utils/__fixtures__/container.ts new file mode 100644 index 0000000000000..9a995d81a563f --- /dev/null +++ b/packages/medusa-payment-stripe/src/api/utils/__fixtures__/container.ts @@ -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(), + }) +) diff --git a/packages/medusa-payment-stripe/src/api/utils/__fixtures__/data.ts b/packages/medusa-payment-stripe/src/api/utils/__fixtures__/data.ts new file mode 100644 index 0000000000000..9ccf6f0194504 --- /dev/null +++ b/packages/medusa-payment-stripe/src/api/utils/__fixtures__/data.ts @@ -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" diff --git a/packages/medusa-payment-stripe/src/api/utils/__tests__/utils.spec.ts b/packages/medusa-payment-stripe/src/api/utils/__tests__/utils.spec.ts new file mode 100644 index 0000000000000..0a46e2ff0c0e2 --- /dev/null +++ b/packages/medusa-payment-stripe/src/api/utils/__tests__/utils.spec.ts @@ -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}` + ) + }) + }) + }) +}) diff --git a/packages/medusa-payment-stripe/src/api/utils/utils.ts b/packages/medusa-payment-stripe/src/api/utils/utils.ts new file mode 100644 index 0000000000000..cb20ef5e23800 --- /dev/null +++ b/packages/medusa-payment-stripe/src/api/utils/utils.ts @@ -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() + ) + } + } +} diff --git a/packages/medusa-payment-stripe/src/core/__fixtures__/data.ts b/packages/medusa-payment-stripe/src/core/__fixtures__/data.ts new file mode 100644 index 0000000000000..3ef41086dbeb5 --- /dev/null +++ b/packages/medusa-payment-stripe/src/core/__fixtures__/data.ts @@ -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, + }, +} diff --git a/packages/medusa-payment-stripe/src/core/__fixtures__/stripe-test.ts b/packages/medusa-payment-stripe/src/core/__fixtures__/stripe-test.ts new file mode 100644 index 0000000000000..6d60d45c09236 --- /dev/null +++ b/packages/medusa-payment-stripe/src/core/__fixtures__/stripe-test.ts @@ -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 {} + } +} diff --git a/packages/medusa-payment-stripe/src/core/__tests__/stripe-base.spec.ts b/packages/medusa-payment-stripe/src/core/__tests__/stripe-base.spec.ts new file mode 100644 index 0000000000000..276aec919e838 --- /dev/null +++ b/packages/medusa-payment-stripe/src/core/__tests__/stripe-base.spec.ts @@ -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", + }) + }) + }) +}) diff --git a/packages/medusa-payment-stripe/src/core/stripe-base.ts b/packages/medusa-payment-stripe/src/core/stripe-base.ts new file mode 100644 index 0000000000000..2f6f7089bb3b0 --- /dev/null +++ b/packages/medusa-payment-stripe/src/core/stripe-base.ts @@ -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 + ): Promise { + 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 { + 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 + } 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, + context: Record + ): Promise< + | PaymentProcessorError + | { + status: PaymentSessionStatus + data: PaymentProcessorSessionResponse["session_data"] + } + > { + const status = await this.getPaymentStatus(paymentSessionData) + return { data: paymentSessionData, status } + } + + async cancelPayment( + paymentSessionData: Record + ): 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 + ): Promise< + PaymentProcessorError | PaymentProcessorSessionResponse["session_data"] + > { + return await this.cancelPayment(paymentSessionData) + } + + async refundPayment( + paymentSessionData: Record, + 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 + ): 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 { + 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 diff --git a/packages/medusa-payment-stripe/src/helpers/__tests__/stripe-base.js b/packages/medusa-payment-stripe/src/helpers/__tests__/stripe-base.js deleted file mode 100644 index 7bd3cf247d650..0000000000000 --- a/packages/medusa-payment-stripe/src/helpers/__tests__/stripe-base.js +++ /dev/null @@ -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", - }) - }) - }) -}) diff --git a/packages/medusa-payment-stripe/src/helpers/stripe-base.js b/packages/medusa-payment-stripe/src/helpers/stripe-base.js deleted file mode 100644 index 7f5ea1a81f6ac..0000000000000 --- a/packages/medusa-payment-stripe/src/helpers/stripe-base.js +++ /dev/null @@ -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} 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} 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} 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} 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} 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} 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} 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} 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} 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} 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 diff --git a/packages/medusa-payment-stripe/src/services/stripe-bancontact.js b/packages/medusa-payment-stripe/src/services/stripe-bancontact.ts similarity index 53% rename from packages/medusa-payment-stripe/src/services/stripe-bancontact.js rename to packages/medusa-payment-stripe/src/services/stripe-bancontact.ts index a12f9d52515f8..72f4d3224913f 100644 --- a/packages/medusa-payment-stripe/src/services/stripe-bancontact.js +++ b/packages/medusa-payment-stripe/src/services/stripe-bancontact.ts @@ -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 { - static identifier = "stripe-bancontact" + static identifier = PaymentProviderKeys.BAN_CONTACT constructor(_, options) { super(_, options) } - get paymentIntentOptions() { + get paymentIntentOptions(): PaymentIntentOptions { return { payment_method_types: ["bancontact"], capture_method: "automatic", diff --git a/packages/medusa-payment-stripe/src/services/stripe-blik.js b/packages/medusa-payment-stripe/src/services/stripe-blik.ts similarity index 52% rename from packages/medusa-payment-stripe/src/services/stripe-blik.js rename to packages/medusa-payment-stripe/src/services/stripe-blik.ts index 6e18a17fcc947..c53d7b78d3a40 100644 --- a/packages/medusa-payment-stripe/src/services/stripe-blik.js +++ b/packages/medusa-payment-stripe/src/services/stripe-blik.ts @@ -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 { - static identifier = "stripe-blik" + static identifier = PaymentProviderKeys.BLIK constructor(_, options) { super(_, options) } - get paymentIntentOptions() { + get paymentIntentOptions(): PaymentIntentOptions { return { payment_method_types: ["blik"], capture_method: "automatic", diff --git a/packages/medusa-payment-stripe/src/services/stripe-giropay.js b/packages/medusa-payment-stripe/src/services/stripe-giropay.ts similarity index 53% rename from packages/medusa-payment-stripe/src/services/stripe-giropay.js rename to packages/medusa-payment-stripe/src/services/stripe-giropay.ts index 94b19415f03f3..22d07c886730d 100644 --- a/packages/medusa-payment-stripe/src/services/stripe-giropay.js +++ b/packages/medusa-payment-stripe/src/services/stripe-giropay.ts @@ -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 { - static identifier = "stripe-giropay" + static identifier = PaymentProviderKeys.GIROPAY constructor(_, options) { super(_, options) } - get paymentIntentOptions() { + get paymentIntentOptions(): PaymentIntentOptions { return { payment_method_types: ["giropay"], capture_method: "automatic", diff --git a/packages/medusa-payment-stripe/src/services/stripe-ideal.js b/packages/medusa-payment-stripe/src/services/stripe-ideal.ts similarity index 52% rename from packages/medusa-payment-stripe/src/services/stripe-ideal.js rename to packages/medusa-payment-stripe/src/services/stripe-ideal.ts index 6d20df143cd5f..6fafbb6a3c4b7 100644 --- a/packages/medusa-payment-stripe/src/services/stripe-ideal.js +++ b/packages/medusa-payment-stripe/src/services/stripe-ideal.ts @@ -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 { - static identifier = "stripe-ideal" + static identifier = PaymentProviderKeys.IDEAL constructor(_, options) { super(_, options) } - get paymentIntentOptions() { + get paymentIntentOptions(): PaymentIntentOptions { return { payment_method_types: ["ideal"], capture_method: "automatic", diff --git a/packages/medusa-payment-stripe/src/services/stripe-provider.js b/packages/medusa-payment-stripe/src/services/stripe-provider.js deleted file mode 100644 index ac2f47a23220d..0000000000000 --- a/packages/medusa-payment-stripe/src/services/stripe-provider.js +++ /dev/null @@ -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 diff --git a/packages/medusa-payment-stripe/src/services/stripe-provider.ts b/packages/medusa-payment-stripe/src/services/stripe-provider.ts new file mode 100644 index 0000000000000..9188e4e889a72 --- /dev/null +++ b/packages/medusa-payment-stripe/src/services/stripe-provider.ts @@ -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 diff --git a/packages/medusa-payment-stripe/src/services/stripe-przelewy24.js b/packages/medusa-payment-stripe/src/services/stripe-przelewy24.ts similarity index 52% rename from packages/medusa-payment-stripe/src/services/stripe-przelewy24.js rename to packages/medusa-payment-stripe/src/services/stripe-przelewy24.ts index 236d8c38b017b..ae8a565680c75 100644 --- a/packages/medusa-payment-stripe/src/services/stripe-przelewy24.js +++ b/packages/medusa-payment-stripe/src/services/stripe-przelewy24.ts @@ -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 { - static identifier = "stripe-przelewy24" + static identifier = PaymentProviderKeys.PRZELEWY_24 constructor(_, options) { super(_, options) } - get paymentIntentOptions() { + get paymentIntentOptions(): PaymentIntentOptions { return { payment_method_types: ["p24"], capture_method: "automatic", diff --git a/packages/medusa-payment-stripe/src/types.ts b/packages/medusa-payment-stripe/src/types.ts new file mode 100644 index 0000000000000..724141272758a --- /dev/null +++ b/packages/medusa-payment-stripe/src/types.ts @@ -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", +} diff --git a/packages/medusa-payment-stripe/tsconfig.json b/packages/medusa-payment-stripe/tsconfig.json new file mode 100644 index 0000000000000..5043999ee2469 --- /dev/null +++ b/packages/medusa-payment-stripe/tsconfig.json @@ -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" + ] +} diff --git a/packages/medusa-payment-stripe/tsconfig.spec.json b/packages/medusa-payment-stripe/tsconfig.spec.json new file mode 100644 index 0000000000000..9b6240919113c --- /dev/null +++ b/packages/medusa-payment-stripe/tsconfig.spec.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/medusa/src/api/middlewares/index.ts b/packages/medusa/src/api/middlewares/index.ts index 654ae3e8b4fcb..119626ac86879 100644 --- a/packages/medusa/src/api/middlewares/index.ts +++ b/packages/medusa/src/api/middlewares/index.ts @@ -9,12 +9,24 @@ export { getRequestedBatchJob } from "./batch-job/get-requested-batch-job" export { doesConditionBelongToDiscount } from "./discount/does-condition-belong-to-discount" export { transformIncludesOptions } from "./transform-includes-options" 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" +/** + * @deprecated you can now import the middlewares directly without passing by the default export + * e.g `import { authenticate } from "@medusajs/medusa" + */ export default { authenticate, authenticateCustomer, requireCustomerAuthentication, normalizeQuery, + /** + * @deprecated use `import { wrapHandler } from "@medusajs/medusa"` + */ wrap, } diff --git a/packages/medusa/src/index.js b/packages/medusa/src/index.js index 57a6103537b66..a1e454f5b7986 100644 --- a/packages/medusa/src/index.js +++ b/packages/medusa/src/index.js @@ -1,4 +1,5 @@ export * from "./api" +export * from "./api/middlewares" export * from "./interfaces" export * from "./models" export * from "./services" diff --git a/packages/medusa/src/interfaces/cart-completion-strategy.ts b/packages/medusa/src/interfaces/cart-completion-strategy.ts index c28dc849bea84..933c569ff1e27 100644 --- a/packages/medusa/src/interfaces/cart-completion-strategy.ts +++ b/packages/medusa/src/interfaces/cart-completion-strategy.ts @@ -27,6 +27,7 @@ export interface ICartCompletionStrategy { } export abstract class AbstractCartCompletionStrategy + extends TransactionBaseService implements ICartCompletionStrategy { abstract complete( diff --git a/packages/medusa/src/interfaces/payment-processor.ts b/packages/medusa/src/interfaces/payment-processor.ts index 7d69c16f74496..96982d44deee5 100644 --- a/packages/medusa/src/interfaces/payment-processor.ts +++ b/packages/medusa/src/interfaces/payment-processor.ts @@ -33,11 +33,6 @@ export interface PaymentProcessor { */ getIdentifier(): string - /** - * Used to initialise anything like an SDK or similar - */ - init(): Promise - /** * Initiate a payment session with the external provider */ @@ -147,8 +142,6 @@ export abstract class AbstractPaymentProcessor implements PaymentProcessor { return ctr.identifier } - abstract init(): Promise - abstract capturePayment( paymentSessionData: Record ): Promise< diff --git a/packages/medusa/src/loaders/defaults.ts b/packages/medusa/src/loaders/defaults.ts index 805ebc291d6fe..31ae411a1adc7 100644 --- a/packages/medusa/src/loaders/defaults.ts +++ b/packages/medusa/src/loaders/defaults.ts @@ -205,12 +205,9 @@ async function registerPaymentProcessor({ ).filter((provider) => provider instanceof AbstractPaymentProcessor) const payIds: string[] = [] - await Promise.all( - payProviders.map((paymentProvider) => { - payIds.push(paymentProvider.getIdentifier()) - return paymentProvider.init() - }) - ) + payProviders.map((paymentProvider) => { + payIds.push(paymentProvider.getIdentifier()) + }) const pProviderService = container.resolve( "paymentProviderService" diff --git a/packages/medusa/src/loaders/plugins.ts b/packages/medusa/src/loaders/plugins.ts index c178189901936..ac3d27ac995eb 100644 --- a/packages/medusa/src/loaders/plugins.ts +++ b/packages/medusa/src/loaders/plugins.ts @@ -6,7 +6,6 @@ import glob from "glob" import _ from "lodash" import { createRequireFromPath } from "medusa-core-utils" import { - BaseService as LegacyBaseService, FileService, FulfillmentService, OauthService, @@ -23,7 +22,6 @@ import { isPriceSelectionStrategy, isSearchService, isTaxCalculationStrategy, - TransactionBaseService as BaseService, } from "../interfaces" import { MiddlewareService } from "../services" import { @@ -364,16 +362,6 @@ export async function registerServices( const loaded = require(fn).default const name = formatRegistrationName(fn) - if ( - !(loaded.prototype instanceof LegacyBaseService) && - !(loaded.prototype instanceof BaseService) - ) { - const logger = container.resolve("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 } registerPaymentServiceFromClass(loaded, context) @@ -627,8 +615,18 @@ function resolvePlugin(pluginName: string): { ) // 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 { - resolve: resolvedPath + (process.env.DEV_MODE ? "/src" : ""), + resolve: isDistExist ? resolvedPathToDist : computedResolvedPath, id: createPluginId(packageJSON.name), name: packageJSON.name, options: {}, diff --git a/packages/medusa/src/services/idempotency-key.ts b/packages/medusa/src/services/idempotency-key.ts index 490626990849a..998ce8f70151f 100644 --- a/packages/medusa/src/services/idempotency-key.ts +++ b/packages/medusa/src/services/idempotency-key.ts @@ -8,6 +8,8 @@ import { CreateIdempotencyKeyInput, IdempotencyCallbackResult, } from "../types/idempotency-key" +import { Selector } from "../types/common" +import { buildQuery, isString } from "../utils" const KEY_LOCKED_TIMEOUT = 1000 @@ -75,14 +77,16 @@ class IdempotencyKeyService extends TransactionBaseService { /** * Retrieves an idempotency key - * @param idempotencyKey - key to retrieve + * @param idempotencyKeyOrSelector - key or selector to retrieve * @return idempotency key */ - async retrieve(idempotencyKey: string): Promise { - if (!isDefined(idempotencyKey)) { + async retrieve( + idempotencyKeyOrSelector: string | Selector + ): Promise { + if (!isDefined(idempotencyKeyOrSelector)) { throw new MedusaError( MedusaError.Types.NOT_FOUND, - `"idempotencyKey" must be defined` + `"idempotencyKeyOrSelector" must be defined` ) } @@ -90,17 +94,36 @@ class IdempotencyKeyService extends TransactionBaseService { this.idempotencyKeyRepository_ ) - const iKey = await idempotencyKeyRepo.findOne({ - where: { idempotency_key: idempotencyKey }, - }) + const selector = isString(idempotencyKeyOrSelector) + ? { idempotency_key: idempotencyKeyOrSelector } + : idempotencyKeyOrSelector + const query = buildQuery(selector) - if (!iKey) { - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `Idempotency key ${idempotencyKey} was not found` + 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) { + let message + if (isString(idempotencyKeyOrSelector)) { + 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 } diff --git a/packages/medusa/src/services/payment-provider.ts b/packages/medusa/src/services/payment-provider.ts index 9ac7843e78af3..d50c6b00a1164 100644 --- a/packages/medusa/src/services/payment-provider.ts +++ b/packages/medusa/src/services/payment-provider.ts @@ -356,17 +356,16 @@ export default class PaymentProviderService extends TransactionBaseService { let paymentResponse if (provider instanceof AbstractPaymentProcessor) { - paymentResponse = - (await provider.updatePayment({ - amount: context.amount, - context: context.context, - currency_code: context.currency_code, - customer: context.customer, - email: context.email, - billing_address: context.billing_address, - resource_id: context.resource_id, - paymentSessionData: paymentSession.data, - })) ?? {} + paymentResponse = await provider.updatePayment({ + amount: context.amount, + context: context.context, + currency_code: context.currency_code, + customer: context.customer, + email: context.email, + billing_address: context.billing_address, + resource_id: context.resource_id, + paymentSessionData: paymentSession.data, + }) if (paymentResponse && "error" in paymentResponse) { this.throwFromPaymentProcessorError(paymentResponse) @@ -377,7 +376,7 @@ export default class PaymentProviderService extends TransactionBaseService { .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 (!sessionData) { diff --git a/packages/medusa/src/strategies/cart-completion.ts b/packages/medusa/src/strategies/cart-completion.ts index fd9173c282b5f..634d08674db0d 100644 --- a/packages/medusa/src/strategies/cart-completion.ts +++ b/packages/medusa/src/strategies/cart-completion.ts @@ -30,8 +30,6 @@ type InjectedDependencies = { } class CartCompletionStrategy extends AbstractCartCompletionStrategy { - protected manager_: EntityManager - // eslint-disable-next-line max-len protected readonly productVariantInventoryService_: ProductVariantInventoryService protected readonly paymentProviderService_: PaymentProviderService @@ -47,9 +45,9 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy { cartService, orderService, swapService, - manager, }: InjectedDependencies) { - super() + // eslint-disable-next-line prefer-rest-params + super(arguments[0]) this.paymentProviderService_ = paymentProviderService this.productVariantInventoryService_ = productVariantInventoryService @@ -57,7 +55,6 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy { this.cartService_ = cartService this.orderService_ = orderService this.swapService_ = swapService - this.manager_ = manager } async complete( @@ -73,7 +70,7 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy { while (inProgress) { switch (idempotencyKey.recovery_point) { case "started": { - await this.manager_ + await this.activeManager_ .transaction("SERIALIZABLE", async (transactionManager) => { idempotencyKey = await this.idempotencyKeyService_ .withTransaction(transactionManager) @@ -90,7 +87,7 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy { break } case "tax_lines_created": { - await this.manager_ + await this.activeManager_ .transaction("SERIALIZABLE", async (transactionManager) => { idempotencyKey = await this.idempotencyKeyService_ .withTransaction(transactionManager) @@ -111,7 +108,7 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy { } case "payment_authorized": { - await this.manager_ + await this.activeManager_ .transaction("SERIALIZABLE", async (transactionManager) => { idempotencyKey = await this.idempotencyKeyService_ .withTransaction(transactionManager) @@ -134,7 +131,7 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy { } default: - await this.manager_.transaction(async (transactionManager) => { + await this.activeManager_.transaction(async (transactionManager) => { idempotencyKey = await this.idempotencyKeyService_ .withTransaction(transactionManager) .update(idempotencyKey.idempotency_key, { @@ -149,7 +146,7 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy { if (err) { if (idempotencyKey.recovery_point !== "started") { - await this.manager_.transaction(async (transactionManager) => { + await this.activeManager_.transaction(async (transactionManager) => { try { await this.orderService_ .withTransaction(transactionManager) @@ -264,16 +261,19 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy { const cartServiceTx = this.cartService_.withTransaction(manager) const cart = await cartServiceTx.retrieveWithTotals(id, { - relations: ["region", "payment", "payment_sessions", "items.variant.product",], + relations: [ + "region", + "payment", + "payment_sessions", + "items.variant.product", + ], }) let allowBackorder = false - let swapId: string if (cart.type === "swap") { const swap = await swapServiceTx.retrieveByCartId(id) allowBackorder = swap.allow_backorder - swapId = swap.id } if (!allowBackorder) { diff --git a/packages/medusa/src/types/idempotency-key.ts b/packages/medusa/src/types/idempotency-key.ts index 1d885ee8c7d1b..ff20e9d0e1a42 100644 --- a/packages/medusa/src/types/idempotency-key.ts +++ b/packages/medusa/src/types/idempotency-key.ts @@ -1,7 +1,7 @@ export type CreateIdempotencyKeyInput = { - request_method: string - request_params: Record - request_path: string + request_method?: string + request_params?: Record + request_path?: string idempotency_key?: string } diff --git a/yarn.lock b/yarn.lock index 4c535ece6339b..8b9291e8d9786 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9215,6 +9215,15 @@ __metadata: languageName: node 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:*": version: 4.1.15 resolution: "@types/superagent@npm:4.1.15" @@ -25402,7 +25411,7 @@ __metadata: languageName: node 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 resolution: "medusa-core-utils@workspace:packages/medusa-core-utils" dependencies: @@ -25719,28 +25728,16 @@ __metadata: version: 0.0.0-use.local resolution: "medusa-payment-stripe@workspace:packages/medusa-payment-stripe" dependencies: - "@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 + "@medusajs/medusa": ^1.7.7 + "@types/stripe": ^8.0.417 body-parser: ^1.19.0 - client-sessions: ^0.8.0 cross-env: ^5.2.1 express: ^4.17.1 jest: ^25.5.4 - medusa-core-utils: ^1.1.39 - medusa-interfaces: ^1.3.6 - medusa-test-utils: ^1.1.37 - stripe: ^8.50.0 + medusa-core-utils: ^1.1.38 + stripe: ^11.10.0 peerDependencies: - medusa-interfaces: 1.3.6 + "@medusajs/medusa": ^1.7.7 languageName: unknown linkType: soft @@ -30339,7 +30336,7 @@ __metadata: languageName: node 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 resolution: "qs@npm:6.11.0" dependencies: @@ -33904,6 +33901,16 @@ __metadata: languageName: node 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": version: 8.222.0 resolution: "stripe@npm:8.222.0"