From 53074088941719ac7ca435e76e3e64ba23fac200 Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Tue, 28 Mar 2023 13:49:09 +0200 Subject: [PATCH] feat(medusa-payment-paypal): Migrate to the new payment processor API (#3414) * feat(medusa-payment-paypal): Migrate to the new payment processor API * WIP * WIP * WIP unit tests * WIP * unit tests * fix package.json * yarn * cleanup * address feedback 1/2 * Start to implement a new Paypal SDK * cleanup * finalise sdk * cleanup * fix push missing file * rename sdk methods * unit test the http client * WIP * fix http client * Create .changeset/empty-melons-eat.md * refactor tests * fix quote * fix options * cleanup * do not retry auth * WIP * retry mechanism max attempts * use both old and new options * fix capture * remove totals fields * add missing method * cleanup * fix current tests * authorize should update the data with the fresh order * remove comments * fix tests * Update packages/medusa-payment-paypal/src/core/paypal-http-client.ts Co-authored-by: Riqwan Thamir * fix unit tests * update changeset --------- Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> Co-authored-by: Riqwan Thamir --- .changeset/empty-melons-eat.md | 7 + .eslintignore | 1 + .eslintrc.js | 2 + packages/medusa-payment-paypal/.babelrc | 14 - packages/medusa-payment-paypal/.gitignore | 14 +- packages/medusa-payment-paypal/.npmignore | 8 - packages/medusa-payment-paypal/README.md | 10 +- packages/medusa-payment-paypal/index.js | 1 - packages/medusa-payment-paypal/jest.config.js | 16 + packages/medusa-payment-paypal/package.json | 39 +- .../src/__fixtures__/data.ts | 34 ++ ...t-server-sdk.js => checkout-server-sdk.ts} | 65 ++- .../src/api/{index.js => index.ts} | 2 +- .../src/api/middlewares/await-middleware.js | 1 - .../src/api/middlewares/index.js | 5 - .../src/api/routes/hooks/index.js | 13 - .../src/api/routes/hooks/index.ts | 15 + .../api/routes/hooks/{paypal.js => paypal.ts} | 11 +- .../src/core/__mocks__/paypal-sdk.ts | 62 +++ .../core/__tests__/paypal-http-client.spec.ts | 238 +++++++++ .../medusa-payment-paypal/src/core/index.ts | 2 + .../src/core/paypal-http-client.ts | 150 ++++++ .../src/core/paypal-sdk.ts | 127 +++++ .../src/core/types/common.ts | 178 +++++++ .../src/core/types/constant.ts | 17 + .../src/core/types/index.ts | 10 + .../src/core/types/order.ts | 40 ++ .../src/core/types/payment.ts | 76 +++ .../src/core/types/webhook.ts | 22 + packages/medusa-payment-paypal/src/index.ts | 2 + .../src/services/__fixtures__/data.ts | 233 ++++++++ .../src/services/__tests__/paypal-provider.js | 447 ---------------- .../__tests__/paypal-provider.spec.ts | 500 ++++++++++++++++++ .../src/services/paypal-provider.js | 426 --------------- .../src/services/paypal-provider.ts | 327 ++++++++++++ .../src/services/utils/utils.ts | 8 + packages/medusa-payment-paypal/src/types.ts | 52 ++ packages/medusa-payment-paypal/tsconfig.json | 33 ++ .../medusa-payment-paypal/tsconfig.spec.json | 5 + .../src/core/__tests__/stripe-base.spec.ts | 16 +- .../src/core/stripe-base.ts | 6 +- packages/medusa-payment-stripe/src/index.ts | 8 + .../src/api/routes/store/orders/index.ts | 7 - yarn.lock | 59 +-- 44 files changed, 2272 insertions(+), 1037 deletions(-) create mode 100644 .changeset/empty-melons-eat.md delete mode 100644 packages/medusa-payment-paypal/.babelrc delete mode 100644 packages/medusa-payment-paypal/.npmignore delete mode 100644 packages/medusa-payment-paypal/index.js create mode 100644 packages/medusa-payment-paypal/jest.config.js create mode 100644 packages/medusa-payment-paypal/src/__fixtures__/data.ts rename packages/medusa-payment-paypal/src/__mocks__/@paypal/{checkout-server-sdk.js => checkout-server-sdk.ts} (60%) rename packages/medusa-payment-paypal/src/api/{index.js => index.ts} (79%) delete mode 100644 packages/medusa-payment-paypal/src/api/middlewares/await-middleware.js delete mode 100644 packages/medusa-payment-paypal/src/api/middlewares/index.js delete mode 100644 packages/medusa-payment-paypal/src/api/routes/hooks/index.js create mode 100644 packages/medusa-payment-paypal/src/api/routes/hooks/index.ts rename packages/medusa-payment-paypal/src/api/routes/hooks/{paypal.js => paypal.ts} (93%) create mode 100644 packages/medusa-payment-paypal/src/core/__mocks__/paypal-sdk.ts create mode 100644 packages/medusa-payment-paypal/src/core/__tests__/paypal-http-client.spec.ts create mode 100644 packages/medusa-payment-paypal/src/core/index.ts create mode 100644 packages/medusa-payment-paypal/src/core/paypal-http-client.ts create mode 100644 packages/medusa-payment-paypal/src/core/paypal-sdk.ts create mode 100644 packages/medusa-payment-paypal/src/core/types/common.ts create mode 100644 packages/medusa-payment-paypal/src/core/types/constant.ts create mode 100644 packages/medusa-payment-paypal/src/core/types/index.ts create mode 100644 packages/medusa-payment-paypal/src/core/types/order.ts create mode 100644 packages/medusa-payment-paypal/src/core/types/payment.ts create mode 100644 packages/medusa-payment-paypal/src/core/types/webhook.ts create mode 100644 packages/medusa-payment-paypal/src/index.ts create mode 100644 packages/medusa-payment-paypal/src/services/__fixtures__/data.ts delete mode 100644 packages/medusa-payment-paypal/src/services/__tests__/paypal-provider.js create mode 100644 packages/medusa-payment-paypal/src/services/__tests__/paypal-provider.spec.ts delete mode 100644 packages/medusa-payment-paypal/src/services/paypal-provider.js create mode 100644 packages/medusa-payment-paypal/src/services/paypal-provider.ts create mode 100644 packages/medusa-payment-paypal/src/services/utils/utils.ts create mode 100644 packages/medusa-payment-paypal/src/types.ts create mode 100644 packages/medusa-payment-paypal/tsconfig.json create mode 100644 packages/medusa-payment-paypal/tsconfig.spec.json create mode 100644 packages/medusa-payment-stripe/src/index.ts diff --git a/.changeset/empty-melons-eat.md b/.changeset/empty-melons-eat.md new file mode 100644 index 0000000000..1d2b374c40 --- /dev/null +++ b/.changeset/empty-melons-eat.md @@ -0,0 +1,7 @@ +--- +"medusa-payment-paypal": patch +"medusa-payment-stripe": patch +"@medusajs/medusa": patch +--- + +feat(medusa-payment-paypal): Migrate to the new payment processor API diff --git a/.eslintignore b/.eslintignore index 138f43bfd4..64364f4d01 100644 --- a/.eslintignore +++ b/.eslintignore @@ -10,6 +10,7 @@ packages/* !packages/admin-ui !packages/admin !packages/medusa-payment-stripe +!packages/medusa-payment-paypal !packages/event-bus-redis !packages/event-bus-local !packages/medusa-plugin-meilisearch diff --git a/.eslintrc.js b/.eslintrc.js index df53d8c537..228b7876fb 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -83,6 +83,8 @@ module.exports = { project: [ "./packages/medusa/tsconfig.json", "./packages/medusa-payment-stripe/tsconfig.spec.json", + "./packages/medusa-payment-paypal/tsconfig.spec.json", + "./packages/admin-ui/tsconfig.json", "./packages/event-bus-local/tsconfig.spec.json", "./packages/event-bus-redis/tsconfig.spec.json", "./packages/medusa-plugin-meilisearch/tsconfig.spec.json", diff --git a/packages/medusa-payment-paypal/.babelrc b/packages/medusa-payment-paypal/.babelrc deleted file mode 100644 index 75cbf1558b..0000000000 --- a/packages/medusa-payment-paypal/.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-paypal/.gitignore b/packages/medusa-payment-paypal/.gitignore index 5b89b55576..83cb36a41e 100644 --- a/packages/medusa-payment-paypal/.gitignore +++ b/packages/medusa-payment-paypal/.gitignore @@ -1,16 +1,4 @@ -/lib +dist node_modules .DS_store -.env* -/*.js -!index.js yarn.lock - -/dist - -/api -/services -/models -/subscribers -/loaders -/__mocks__ diff --git a/packages/medusa-payment-paypal/.npmignore b/packages/medusa-payment-paypal/.npmignore deleted file mode 100644 index af4cbd1ba0..0000000000 --- a/packages/medusa-payment-paypal/.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-paypal/README.md b/packages/medusa-payment-paypal/README.md index 6cc1d6753f..8938a491b4 100644 --- a/packages/medusa-payment-paypal/README.md +++ b/packages/medusa-payment-paypal/README.md @@ -9,8 +9,12 @@ Learn more about how you can use this plugin in the [documentaion](https://docs. ```js { sandbox: true, //default false - client_id: "CLIENT_ID", // REQUIRED - client_secret: "CLIENT_SECRET", // REQUIRED - auth_webhook_id: "WEBHOOK_ID" //REQUIRED for webhook to work + clientId: "CLIENT_ID", // REQUIRED + clientSecret: "CLIENT_SECRET", // REQUIRED + authWebhookId: "WEBHOOK_ID", //REQUIRED for webhook to work } ``` + +## Deprecation + +The paypal plugin version `>=1.3.x` requires medusa `>=1.8.x` \ No newline at end of file diff --git a/packages/medusa-payment-paypal/index.js b/packages/medusa-payment-paypal/index.js deleted file mode 100644 index 172f1ae6a4..0000000000 --- a/packages/medusa-payment-paypal/index.js +++ /dev/null @@ -1 +0,0 @@ -// noop diff --git a/packages/medusa-payment-paypal/jest.config.js b/packages/medusa-payment-paypal/jest.config.js new file mode 100644 index 0000000000..212b345660 --- /dev/null +++ b/packages/medusa-payment-paypal/jest.config.js @@ -0,0 +1,16 @@ +module.exports = { + globals: { + "ts-jest": { + tsconfig: "tsconfig.spec.json", + isolatedModules: false, + }, + }, + transform: { + "^.+\\.[jt]s?$": "ts-jest", + }, + transformIgnorePatterns: [ + "/node_modules/(?!(axios)/).*" + ], + testEnvironment: `node`, + moduleFileExtensions: [`js`, `jsx`, `ts`, `tsx`, `json`], +} diff --git a/packages/medusa-payment-paypal/package.json b/packages/medusa-payment-paypal/package.json index 53c9329d6b..0cd8f9d6cb 100644 --- a/packages/medusa-payment-paypal/package.json +++ b/packages/medusa-payment-paypal/package.json @@ -2,44 +2,35 @@ "name": "medusa-payment-paypal", "version": "1.3.0-rc.0", "description": "Paypal Payment provider for Medusa Commerce", - "main": "index.js", + "main": "dist/index.js", "repository": { "type": "git", "url": "https://github.com/medusajs/medusa", "directory": "packages/medusa-payment-paypal" }, - "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", - "jest-environment-node": "25.5.0", - "medusa-interfaces": "^1.3.7-rc.0", - "medusa-test-utils": "^1.1.40-rc.0" - }, "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.8.0-rc.0", + "@types/stripe": "^8.0.417", + "cross-env": "^5.2.1", + "jest": "^25.5.4" }, "peerDependencies": { - "medusa-interfaces": "1.3.7-rc.0" + "@medusajs/medusa": "^1.7.7" }, "dependencies": { "@paypal/checkout-server-sdk": "^1.0.3", + "axios": "^1.3.4", "body-parser": "^1.19.0", "express": "^4.17.1", "medusa-core-utils": "^1.2.0-rc.0" diff --git a/packages/medusa-payment-paypal/src/__fixtures__/data.ts b/packages/medusa-payment-paypal/src/__fixtures__/data.ts new file mode 100644 index 0000000000..4195f93012 --- /dev/null +++ b/packages/medusa-payment-paypal/src/__fixtures__/data.ts @@ -0,0 +1,34 @@ +import { INVOICE_ID } from "../__mocks__/@paypal/checkout-server-sdk" + +export const PaymentIntentDataByStatus = { + CREATED: { + id: "CREATED", + status: "CREATED", + invoice_id: INVOICE_ID, + }, + COMPLETED: { + id: "COMPLETED", + status: "COMPLETED", + invoice_id: INVOICE_ID, + }, + SAVED: { + id: "SAVED", + status: "SAVED", + invoice_id: INVOICE_ID, + }, + APPROVED: { + id: "APPROVED", + status: "APPROVED", + invoice_id: INVOICE_ID, + }, + PAYER_ACTION_REQUIRED: { + id: "PAYER_ACTION_REQUIRED", + status: "PAYER_ACTION_REQUIRED", + invoice_id: INVOICE_ID, + }, + VOIDED: { + id: "VOIDED", + status: "VOIDED", + invoice_id: INVOICE_ID, + }, +} diff --git a/packages/medusa-payment-paypal/src/__mocks__/@paypal/checkout-server-sdk.js b/packages/medusa-payment-paypal/src/__mocks__/@paypal/checkout-server-sdk.ts similarity index 60% rename from packages/medusa-payment-paypal/src/__mocks__/@paypal/checkout-server-sdk.js rename to packages/medusa-payment-paypal/src/__mocks__/@paypal/checkout-server-sdk.ts index 379ee7e24c..4940dba3a6 100644 --- a/packages/medusa-payment-paypal/src/__mocks__/@paypal/checkout-server-sdk.js +++ b/packages/medusa-payment-paypal/src/__mocks__/@paypal/checkout-server-sdk.ts @@ -1,3 +1,9 @@ +import { PaymentIntentDataByStatus } from "../../__fixtures__/data"; + +export const SUCCESS_INTENT = "right@test.fr" +export const FAIL_INTENT_ID = "unknown" +export const INVOICE_ID = "invoice_id" + export const PayPalClientMock = { execute: jest.fn().mockImplementation((r) => { return { @@ -8,6 +14,7 @@ export const PayPalClientMock = { export const PayPalMock = { core: { + env: {}, SandboxEnvironment: function () { this.env = { sandbox: true, @@ -32,7 +39,11 @@ export const PayPalMock = { status: "VOIDED" } }), - AuthorizationsCaptureRequest: jest.fn().mockImplementation(() => { + AuthorizationsCaptureRequest: jest.fn().mockImplementation((id) => { + if (id === FAIL_INTENT_ID) { + throw new Error("Error.") + } + return { result: { id: "test", @@ -40,13 +51,17 @@ export const PayPalMock = { capture: true, } }), - CapturesRefundRequest: jest.fn().mockImplementation(() => { + CapturesRefundRequest: jest.fn().mockImplementation((paymentId) => { + if (paymentId === FAIL_INTENT_ID) { + throw new Error("Error") + } + return { result: { id: "test", }, status: "COMPLETED", - invoice_id: 'invoice_id', + invoice_id: INVOICE_ID, body: null, requestBody: function (d) { this.body = d @@ -64,11 +79,19 @@ export const PayPalMock = { order: true, body: null, requestBody: function (d) { + if (d.purchase_units[0].custom_id === FAIL_INTENT_ID) { + throw new Error("Error.") + } + this.body = d - }, + } } }), - OrdersPatchRequest: jest.fn().mockImplementation(() => { + OrdersPatchRequest: jest.fn().mockImplementation((id) => { + if (id === FAIL_INTENT_ID) { + throw new Error("Error.") + } + return { result: { id: "test", @@ -80,29 +103,15 @@ export const PayPalMock = { }, } }), - OrdersGetRequest: jest.fn().mockImplementation((id) => { - switch (id) { - case "test-refund": - return { - result: { - id: "test-refund", - status: "COMPLETED", - invoice_id: "invoice_id" - } - } - case "test-voided": - return { - result: { - id: "test-voided", - status: "VOIDED" - } - } - default: - return { - result: { - id: "test", - }, - } + OrdersGetRequest: jest.fn().mockImplementation((paymentId) => { + if (paymentId === FAIL_INTENT_ID) { + throw new Error("Error") + } + + return { + result: Object.values(PaymentIntentDataByStatus).find(value => { + return value.id === paymentId + }) ?? {} } }), }, diff --git a/packages/medusa-payment-paypal/src/api/index.js b/packages/medusa-payment-paypal/src/api/index.ts similarity index 79% rename from packages/medusa-payment-paypal/src/api/index.js rename to packages/medusa-payment-paypal/src/api/index.ts index 50feeb7074..2c21d96f55 100644 --- a/packages/medusa-payment-paypal/src/api/index.js +++ b/packages/medusa-payment-paypal/src/api/index.ts @@ -1,7 +1,7 @@ import { Router } from "express" import hooks from "./routes/hooks" -export default (container) => { +export default () => { const app = Router() hooks(app) diff --git a/packages/medusa-payment-paypal/src/api/middlewares/await-middleware.js b/packages/medusa-payment-paypal/src/api/middlewares/await-middleware.js deleted file mode 100644 index 1c3692b377..0000000000 --- a/packages/medusa-payment-paypal/src/api/middlewares/await-middleware.js +++ /dev/null @@ -1 +0,0 @@ -export default (fn) => (...args) => fn(...args).catch(args[2]) diff --git a/packages/medusa-payment-paypal/src/api/middlewares/index.js b/packages/medusa-payment-paypal/src/api/middlewares/index.js deleted file mode 100644 index c784e319a9..0000000000 --- a/packages/medusa-payment-paypal/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-paypal/src/api/routes/hooks/index.js b/packages/medusa-payment-paypal/src/api/routes/hooks/index.js deleted file mode 100644 index 9205bae966..0000000000 --- a/packages/medusa-payment-paypal/src/api/routes/hooks/index.js +++ /dev/null @@ -1,13 +0,0 @@ -import { Router } from "express" -import bodyParser from "body-parser" -import middlewares from "../../middlewares" - -const route = Router() - -export default (app) => { - app.use("/paypal", route) - - route.use(bodyParser.json()) - route.post("/hooks", middlewares.wrap(require("./paypal").default)) - return app -} diff --git a/packages/medusa-payment-paypal/src/api/routes/hooks/index.ts b/packages/medusa-payment-paypal/src/api/routes/hooks/index.ts new file mode 100644 index 0000000000..8ca2162e89 --- /dev/null +++ b/packages/medusa-payment-paypal/src/api/routes/hooks/index.ts @@ -0,0 +1,15 @@ +import { Router } from "express" +import bodyParser from "body-parser" +import { wrapHandler } from "@medusajs/medusa" +import paypalWebhookHandler from "./paypal" + +const route = Router() + +export default (app) => { + app.use("/paypal/hooks", route) + + route.use(bodyParser.json()) + route.post("/", wrapHandler(paypalWebhookHandler)) + + return app +} diff --git a/packages/medusa-payment-paypal/src/api/routes/hooks/paypal.js b/packages/medusa-payment-paypal/src/api/routes/hooks/paypal.ts similarity index 93% rename from packages/medusa-payment-paypal/src/api/routes/hooks/paypal.js rename to packages/medusa-payment-paypal/src/api/routes/hooks/paypal.ts index cb1e3ad6cb..6bf3db1005 100644 --- a/packages/medusa-payment-paypal/src/api/routes/hooks/paypal.js +++ b/packages/medusa-payment-paypal/src/api/routes/hooks/paypal.ts @@ -1,3 +1,5 @@ +import PaypalProvider from "../../../services/paypal-provider" + export default async (req, res) => { const auth_algo = req.headers["paypal-auth-algo"] const cert_url = req.headers["paypal-cert-url"] @@ -5,7 +7,9 @@ export default async (req, res) => { const transmission_sig = req.headers["paypal-transmission-sig"] const transmission_time = req.headers["paypal-transmission-time"] - const paypalService = req.scope.resolve("paypalProviderService") + const paypalService: PaypalProvider = req.scope.resolve( + "paypalProviderService" + ) try { await paypalService.verifyWebhook({ @@ -88,6 +92,11 @@ export default async (req, res) => { const order = await paypalService.retrieveOrderFromAuth(auth) + if (!order) { + res.sendStatus(200) + return + } + const purchaseUnit = order.purchase_units[0] const customId = purchaseUnit.custom_id diff --git a/packages/medusa-payment-paypal/src/core/__mocks__/paypal-sdk.ts b/packages/medusa-payment-paypal/src/core/__mocks__/paypal-sdk.ts new file mode 100644 index 0000000000..0125e18ae8 --- /dev/null +++ b/packages/medusa-payment-paypal/src/core/__mocks__/paypal-sdk.ts @@ -0,0 +1,62 @@ +import { PaymentIntentDataByStatus } from "../../__fixtures__/data" + +export const SUCCESS_INTENT = "right@test.fr" +export const FAIL_INTENT_ID = "unknown" +export const INVOICE_ID = "invoice_id" + +export const PayPalMock = { + cancelAuthorizedPayment: jest.fn().mockImplementation(() => { + return { + status: "VOIDED" + } + }), + captureAuthorizedPayment: jest.fn().mockImplementation((id) => { + if (id === FAIL_INTENT_ID) { + throw new Error("Error.") + } + + return { + id: "test", + capture: true, + } + }), + refundPayment: jest.fn().mockImplementation((paymentId) => { + if (paymentId === FAIL_INTENT_ID) { + throw new Error("Error") + } + + return undefined + }), + createOrder: jest.fn().mockImplementation((d) => { + if (d.purchase_units[0].custom_id === FAIL_INTENT_ID) { + throw new Error("Error.") + } + + return d + }), + patchOrder: jest.fn().mockImplementation((id) => { + if (id === FAIL_INTENT_ID) { + throw new Error("Error.") + } + + return { + id: "test", + order: true, + body: null, + requestBody: function (d) { + this.body = d + }, + } + }), + getOrder: jest.fn().mockImplementation((paymentId) => { + if (paymentId === FAIL_INTENT_ID) { + throw new Error("Error") + } + + return Object.values(PaymentIntentDataByStatus).find(value => { + return value.id === paymentId + }) ?? {} + }), +} + +export default PayPalMock diff --git a/packages/medusa-payment-paypal/src/core/__tests__/paypal-http-client.spec.ts b/packages/medusa-payment-paypal/src/core/__tests__/paypal-http-client.spec.ts new file mode 100644 index 0000000000..47828e902c --- /dev/null +++ b/packages/medusa-payment-paypal/src/core/__tests__/paypal-http-client.spec.ts @@ -0,0 +1,238 @@ +import axios from "axios" +import { PaypalHttpClient } from "../paypal-http-client" +import { PaypalApiPath } from "../types" + +jest.mock("axios") +const mockedAxios = axios as jest.Mocked + +const accessToken = "accessToken" + +const responseData = { test: "test" } + +const options = { + clientId: "fake", + clientSecret: "fake", + logger: { + error: jest.fn(), + } as any, + sandbox: true, +} as any + +describe("PaypalHttpClient", function () { + let paypalHttpClient: PaypalHttpClient + + beforeAll(() => { + mockedAxios.create.mockReturnThis() + paypalHttpClient = new PaypalHttpClient(options) + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("should succeed", async () => { + mockedAxios.request.mockResolvedValue(Promise.resolve("resolve")) + + const argument = { url: PaypalApiPath.CREATE_ORDER } + await paypalHttpClient.request(argument) + + expect(mockedAxios.request).toHaveBeenCalledTimes(1) + + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: argument.url, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer undefined`, + }, + }), + undefined, + 0 + ) + }) + + it("should fail and retry after authentication until reaches the maximum number of attempts", async () => { + mockedAxios.request.mockImplementation((async ( + config, + originalConfig, + retryCount = 0 + ) => { + if (retryCount <= 2) { + // eslint-disable-next-line prefer-promise-reject-errors + return Promise.reject({ response: { status: 401 } }) + } + + return { status: 200, data: responseData } + }) as any) + + const argument = { url: PaypalApiPath.CREATE_ORDER } + await paypalHttpClient.request(argument).catch((e) => e) + + expect(mockedAxios.request).toHaveBeenCalledTimes(3) + + expect(mockedAxios.request).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + method: "POST", + url: argument.url, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer undefined`, + }, + }), + undefined, + 0 + ) + + expect(mockedAxios.request).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: "POST", + url: PaypalApiPath.AUTH, + auth: { password: options.clientId, username: options.clientSecret }, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + data: { + grant_type: "client_credentials", + }, + }), + expect.objectContaining({ + method: "POST", + url: argument.url, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer undefined`, + }, + }), + 1 + ) + + expect(mockedAxios.request).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + method: "POST", + url: PaypalApiPath.AUTH, + auth: { password: options.clientId, username: options.clientSecret }, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + data: { + grant_type: "client_credentials", + }, + }), + expect.objectContaining({ + method: "POST", + url: argument.url, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer undefined`, + }, + }), + 2 + ) + }) + + it("should fail and retry after authentication and then succeed", async () => { + mockedAxios.request.mockImplementation((async ( + config, + originalConfig, + retryCount = 0 + ) => { + if (retryCount >= 2 && config.url === PaypalApiPath.AUTH) { + return { + data: { + access_token: accessToken, + }, + } + } + + if (retryCount < 2) { + // eslint-disable-next-line prefer-promise-reject-errors + return Promise.reject({ response: { status: 401 } }) + } + + return { status: 200, data: responseData } + }) as any) + + const argument = { url: PaypalApiPath.CREATE_ORDER } + await paypalHttpClient.request(argument).catch((e) => e) + + expect(mockedAxios.request).toHaveBeenCalledTimes(4) + + expect(mockedAxios.request).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + method: "POST", + url: argument.url, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer undefined`, + }, + }), + undefined, + 0 + ) + + expect(mockedAxios.request).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: "POST", + url: PaypalApiPath.AUTH, + auth: { password: options.clientId, username: options.clientSecret }, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + data: { + grant_type: "client_credentials", + }, + }), + expect.objectContaining({ + method: "POST", + url: argument.url, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer undefined`, + }, + }), + 1 + ) + + expect(mockedAxios.request).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + method: "POST", + url: PaypalApiPath.AUTH, + auth: { password: options.clientId, username: options.clientSecret }, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + data: { + grant_type: "client_credentials", + }, + }), + expect.objectContaining({ + method: "POST", + url: argument.url, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer undefined`, + }, + }), + 2 + ) + + expect(mockedAxios.request).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ + method: "POST", + url: argument.url, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }) + ) + }) +}) diff --git a/packages/medusa-payment-paypal/src/core/index.ts b/packages/medusa-payment-paypal/src/core/index.ts new file mode 100644 index 0000000000..dcfbb36f2d --- /dev/null +++ b/packages/medusa-payment-paypal/src/core/index.ts @@ -0,0 +1,2 @@ +export * from "./paypal-sdk" +export * from "./types" diff --git a/packages/medusa-payment-paypal/src/core/paypal-http-client.ts b/packages/medusa-payment-paypal/src/core/paypal-http-client.ts new file mode 100644 index 0000000000..9088557cce --- /dev/null +++ b/packages/medusa-payment-paypal/src/core/paypal-http-client.ts @@ -0,0 +1,150 @@ +import axios, { AxiosInstance, AxiosRequestConfig, Method } from "axios" +import { + PaypalApiPath, + PaypalEnvironmentPaths, + PaypalSdkOptions, +} from "./types" +import { Logger } from "@medusajs/medusa" + +const MAX_ATTEMPTS = 2 + +export class PaypalHttpClient { + protected readonly baseUrl_: string = PaypalEnvironmentPaths.LIVE + protected readonly httpClient_: AxiosInstance + protected readonly options_: PaypalSdkOptions + protected readonly logger_?: Logger + protected accessToken_: string + + constructor(options: PaypalSdkOptions) { + this.options_ = options + + this.logger_ = options.logger + + if (options.sandbox) { + this.baseUrl_ = PaypalEnvironmentPaths.SANDBOX + } + + const axiosInstance = axios.create({ + baseURL: this.baseUrl_, + }) + + this.httpClient_ = new Proxy(axiosInstance, { + // Handle automatic retry mechanism + get: (target, prop) => { + // @ts-ignore + // eslint-disable-next-line prefer-rest-params + const original = Reflect.get(...arguments) + + if ("request" === (prop as string)) { + return this.retryIfNecessary(original) + } + + return original + }, + }) + } + + /** + * Run a request and return the result + * @param url + * @param data + * @param method + * @protected + */ + async request({ + url, + data, + method, + }: { + url: string + data?: T + method?: Method + }): Promise { + return await this.httpClient_.request({ + method: method ?? "POST", + url, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.accessToken_}`, + }, + data, + }) + } + + /** + * Will run the original method and retry it if an unauthorized error is returned + * @param originalMethod + * @protected + */ + protected retryIfNecessary(originalMethod: Function) { + return async (config, originalConfig, retryCount = 0) => { + if (retryCount > MAX_ATTEMPTS) { + throw new Error( + `An error occurred while requesting Paypal API after ${MAX_ATTEMPTS} attempts` + ) + } + + return await originalMethod + .apply(this.httpClient_, [config, originalConfig, retryCount]) + .then((res) => res.data) + .catch(async (err) => { + if (err.response?.status === 401) { + ++retryCount + + if (!originalConfig) { + originalConfig = config + } + + await this.authenticate(originalConfig, retryCount) + + config = { + ...(originalConfig ?? {}), + headers: { + ...(originalConfig?.headers ?? {}), + Authorization: `Bearer ${this.accessToken_}`, + }, + } + + return await originalMethod + .apply(this.httpClient_, [config]) + .then((res) => res.data) + } + + this.logger_?.error(err.response.message) + throw err + }) + } + } + + /** + * Authenticate and store the access token + * @protected + */ + protected async authenticate( + originalConfig?: AxiosRequestConfig, + retryCount = 0 + ) { + const res: { access_token: string } = await ( + this.httpClient_.request as any + )( + { + method: "POST", + url: PaypalApiPath.AUTH, + auth: { + username: this.options_.clientId ?? this.options_.client_id, + password: this.options_.clientSecret ?? this.options_.client_secret, + }, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + data: { + grant_type: "client_credentials", + }, + }, + originalConfig, + retryCount + ) + + this.accessToken_ = res.access_token + } +} diff --git a/packages/medusa-payment-paypal/src/core/paypal-sdk.ts b/packages/medusa-payment-paypal/src/core/paypal-sdk.ts new file mode 100644 index 0000000000..bd0f09be0b --- /dev/null +++ b/packages/medusa-payment-paypal/src/core/paypal-sdk.ts @@ -0,0 +1,127 @@ +import { + CreateOrder, + CreateOrderResponse, + GetOrderResponse, + PatchOrder, + PaypalApiPath, + PaypalSdkOptions, +} from "./types" +import { + CaptureAuthorizedPayment, + CapturesAuthorizationResponse, + CapturesRefundResponse, + GetAuthorizationPaymentResponse, + RefundPayment, +} from "./types/payment" +import { PaypalHttpClient } from "./paypal-http-client" +import { VerifyWebhookSignature } from "./types/webhook" + +export class PaypalSdk { + protected readonly httpClient_: PaypalHttpClient + + constructor(options: PaypalSdkOptions) { + this.httpClient_ = new PaypalHttpClient(options) + } + + /** + * Create a new order. + * @param data + */ + async createOrder(data: CreateOrder): Promise { + const url = PaypalApiPath.CREATE_ORDER + return await this.httpClient_.request({ url, data }) + } + + /** + * Retrieve an order. + * @param orderId + */ + async getOrder(orderId: string): Promise { + const url = PaypalApiPath.GET_ORDER.replace("{id}", orderId) + return await this.httpClient_.request({ url, method: "GET" }) + } + + /** + * Patch an order. + * @param orderId + * @param data + */ + async patchOrder(orderId: string, data?: PatchOrder[]): Promise { + const url = PaypalApiPath.PATCH_ORDER.replace("{id}", orderId) + return await this.httpClient_.request({ url, method: "PATCH" }) + } + + /** + * Authorizes payment for an order. To successfully authorize payment for an order, + * the buyer must first approve the order or a valid payment_source must be provided in the request. + * A buyer can approve the order upon being redirected to the rel:approve URL that was returned in the HATEOAS links in the create order response. + * @param orderId + */ + async authorizeOrder(orderId: string): Promise { + const url = PaypalApiPath.AUTHORIZE_ORDER.replace("{id}", orderId) + + return await this.httpClient_.request({ url }) + } + + /** + * Refunds a captured payment, by ID. For a full refund, include an empty + * payload in the JSON request body. For a partial refund, include an amount + * object in the JSON request body. + * @param paymentId + * @param data + */ + async refundPayment( + paymentId: string, + data?: RefundPayment + ): Promise { + const url = PaypalApiPath.CAPTURE_REFUND.replace("{id}", paymentId) + return await this.httpClient_.request({ url, data }) + } + + /** + * Voids, or cancels, an authorized payment, by ID. You cannot void an authorized payment that has been fully captured. + * @param authorizationId + */ + async cancelAuthorizedPayment(authorizationId: string): Promise { + const url = PaypalApiPath.AUTHORIZATION_VOID.replace( + "{id}", + authorizationId + ) + + return await this.httpClient_.request({ url }) + } + + /** + * Captures an authorized payment, by ID. + * @param authorizationId + * @param data + */ + async captureAuthorizedPayment( + authorizationId: string, + data?: CaptureAuthorizedPayment + ): Promise { + const url = PaypalApiPath.AUTHORIZATION_CAPTURE.replace( + "{id}", + authorizationId + ) + + return await this.httpClient_.request({ url, data }) + } + + /** + * Captures an authorized payment, by ID. + * @param authorizationId + */ + async getAuthorizationPayment( + authorizationId: string + ): Promise { + const url = PaypalApiPath.AUTHORIZATION_GET.replace("{id}", authorizationId) + + return await this.httpClient_.request({ url }) + } + + async verifyWebhook(data: VerifyWebhookSignature) { + const url = PaypalApiPath.VERIFY_WEBHOOK_SIGNATURE + return await this.httpClient_.request({ url, data }) + } +} diff --git a/packages/medusa-payment-paypal/src/core/types/common.ts b/packages/medusa-payment-paypal/src/core/types/common.ts new file mode 100644 index 0000000000..ef2d0623da --- /dev/null +++ b/packages/medusa-payment-paypal/src/core/types/common.ts @@ -0,0 +1,178 @@ +export type Links = { href: string; rel: string; method: string }[] + +export interface Address { + country_code: string + address_line_1?: string + address_line_2?: string + admin_area_1?: string + admin_area_2?: string + postal_code?: string +} + +export interface MoneyAmount { + value: string | number + currency_code: string +} + +export interface MoneyBreakdown { + discount: MoneyAmount + handling: MoneyAmount + insurance: MoneyAmount + item_total: MoneyAmount + shipping: MoneyAmount + shipping_discount: MoneyAmount + tax_total: MoneyAmount +} + +export interface PaymentInstruction { + disbursement_mode: "INSTANT" | "DELAYED" + payee_pricing_tier_id: string + payee_receivable_fx_rate_id: string + platform_fees: Array +} + +export interface Payee { + email_address: string + merchant_id: string +} + +export interface PlatformFee { + amount: MoneyAmount + payee: Payee +} + +export interface PurchaseUnitItem { + name: string + quantity: number + unit_amount: MoneyAmount + category?: "DIGITAL_GOODS" | "PHYSICAL_GOODS" | "DONATION" + description?: string + sku?: string + tax?: MoneyAmount +} + +export interface PurchaseUnit { + amount: MoneyAmount | MoneyBreakdown + custom_id?: string + description?: string + invoice_id?: string + items?: Array + payee?: Payee + payment_instruction?: PaymentInstruction + reference_id?: string + shipping?: { + address?: Address + name?: { full_name: string } + type?: "SHIPPING" | "PICKUP_IN_PERSON" + } + soft_descriptor?: string +} + +export interface ExperienceContext { + brand_name?: string + cancel_url?: string + locale?: string + return_url?: string + shipping_preference?: "GET_FROM_FILE" | "NO_SHIPPING" | "SET_PROVIDED_ADDRESS" +} + +export interface PaymentSourceBase { + name: string + country_name: string + experience_context?: ExperienceContext +} + +/* eslint @typescript-eslint/no-empty-interface: "off" */ +export interface Bancontact extends PaymentSourceBase {} + +export interface Blik extends PaymentSourceBase { + email?: string +} + +export interface Card { + billing_address?: Address + expiry?: string + name?: string + number?: string + security_code?: string + stored_credential?: { + payment_initiator: "CUSTOMER" | "MERCHANT" + payment_type: "ONE_TIME" | "RECURRING" | "UNSCHEDULED" + previous_network_transaction_reference?: { + id: string + network: + | "VISA" + | "MASTERCARD" + | "DISCOVER" + | "AMEX" + | "SOLO" + | "JCB" + | "STAR" + | "DELTA" + | "SWITCH" + | "MAESTRO" + | "CB_NATIONALE" + | "CONFIGOGA" + | "CONFIDIS" + | "ELECTRON" + | "CETELEM" + | "CHINA_UNION_PAY" + date?: string + } + usageenum?: "FIRST" | "SUBSEQUENT" | "DERIVED" + } + vault_id?: string +} + +/* eslint @typescript-eslint/no-empty-interface: "off" */ +export interface EPS extends PaymentSourceBase {} + +/* eslint @typescript-eslint/no-empty-interface: "off" */ +export interface Giropay extends PaymentSourceBase {} + +export interface Ideal extends PaymentSourceBase { + bic?: string +} + +/* eslint @typescript-eslint/no-empty-interface: "off" */ +export interface MyBank extends PaymentSourceBase {} + +export interface P24 extends PaymentSourceBase { + email: string +} + +export interface Paypal { + address?: Address + birth_date?: string + email_address?: string + experience_context?: ExperienceContext +} + +/* eslint @typescript-eslint/no-empty-interface: "off" */ +export interface Sofort extends PaymentSourceBase {} + +export interface Token { + id: string + type: "BILLING_AGREEMENT" +} + +export interface Trustly { + bic?: string + country_code?: string + iban_last_chars?: string + name?: string +} + +export interface PaymentSource { + bancontact?: Bancontact + blik?: Blik + card?: Card + eps?: EPS + ideal?: Ideal + myBank?: MyBank + p24?: P24 + paypal?: Paypal + sofort?: Sofort + token?: Token + trustly?: Trustly +} diff --git a/packages/medusa-payment-paypal/src/core/types/constant.ts b/packages/medusa-payment-paypal/src/core/types/constant.ts new file mode 100644 index 0000000000..7bb16f443f --- /dev/null +++ b/packages/medusa-payment-paypal/src/core/types/constant.ts @@ -0,0 +1,17 @@ +export const PaypalEnvironmentPaths = { + SANDBOX: "https://api-m.sandbox.paypal.com", + LIVE: "https://api-m.paypal.com", +} + +export const PaypalApiPath = { + AUTH: "/v1/oauth2/token", + GET_ORDER: "/v2/checkout/orders/{id}", + PATCH_ORDER: "/v2/checkout/orders/{id}", + CREATE_ORDER: "/v2/checkout/orders", + AUTHORIZE_ORDER: "/v2/checkout/orders/{id}/authorize", + CAPTURE_REFUND: "/v2/payments/captures/{id}/refund", + AUTHORIZATION_GET: "/v2/payments/authorizations/{id}", + AUTHORIZATION_CAPTURE: "/v2/payments/authorizations/{id}/capture", + AUTHORIZATION_VOID: "/v2/payments/authorizations/{id}/void", + VERIFY_WEBHOOK_SIGNATURE: "/v1/notifications/verify-webhook-signature", +} diff --git a/packages/medusa-payment-paypal/src/core/types/index.ts b/packages/medusa-payment-paypal/src/core/types/index.ts new file mode 100644 index 0000000000..f8b3048576 --- /dev/null +++ b/packages/medusa-payment-paypal/src/core/types/index.ts @@ -0,0 +1,10 @@ +import { Logger } from "@medusajs/medusa" +import { PaypalOptions } from "../../types" + +export type PaypalSdkOptions = PaypalOptions & { + logger?: Logger +} + +export * from "./common" +export * from "./order" +export * from "./constant" diff --git a/packages/medusa-payment-paypal/src/core/types/order.ts b/packages/medusa-payment-paypal/src/core/types/order.ts new file mode 100644 index 0000000000..f08a2f78dc --- /dev/null +++ b/packages/medusa-payment-paypal/src/core/types/order.ts @@ -0,0 +1,40 @@ +import { Links, PaymentSource, PurchaseUnit } from "./common" + +export interface CreateOrder { + intent: "CAPTURE" | "AUTHORIZE" + purchase_units: Array + payment_source?: PaymentSource +} + +export interface CreateOrderResponse { + id: string + status: + | "CREATED" + | "SAVED" + | "APPROVED" + | "VOIDED" + | "COMPLETED" + | "PAYER_ACTION_REQUIRED" + payment_source?: PaymentSource + links?: Links + intent?: CreateOrder["intent"] + processing_instruction?: + | "ORDER_COMPLETE_ON_PAYMENT_APPROVAL" + | "NO_INSTRUCTION" + purchase_units: Array + create_time?: string + update_time?: string +} + +/* eslint @typescript-eslint/no-empty-interface: "off" */ +export interface GetOrderResponse extends CreateOrderResponse {} + +export interface PatchOrder { + op: "replace" | "add" | "remove" + path: string + value: + | CreateOrder["intent"] + | PurchaseUnit + | { client_configuration?: any } + | Record +} diff --git a/packages/medusa-payment-paypal/src/core/types/payment.ts b/packages/medusa-payment-paypal/src/core/types/payment.ts new file mode 100644 index 0000000000..27e5b54a89 --- /dev/null +++ b/packages/medusa-payment-paypal/src/core/types/payment.ts @@ -0,0 +1,76 @@ +import { Links, MoneyAmount, PaymentInstruction } from "./common" + +export interface RefundPayment { + amount?: MoneyAmount + invoice_id?: string + note_to_payer?: string + payment_instruction?: PaymentInstruction +} + +export interface CapturesRefundResponse { + id: string + status: "CANCELLED" | "FAILED" | "PENDING" | "COMPLETED" + status_details?: any + amount?: MoneyAmount + note_to_payer?: string + seller_payable_breakdown?: any + invoice_id?: string + create_time?: string + update_time?: string + links?: Links +} + +export interface CaptureAuthorizedPayment { + amount?: MoneyAmount + final_capture?: boolean + invoice_id?: string + note_to_payer?: string + payment_instruction?: PaymentInstruction + soft_descriptor?: string +} + +export interface CapturesAuthorizationResponse { + id: string + status: + | "COMPLETED" + | "DECLINED" + | "PARTIALLY_REFUNDED" + | "PENDING" + | "REFUNDED" + | "FAILED" + status_details?: any + amount?: MoneyAmount + created_time?: string + update_time?: string + custom_id?: string + disbursement_mode?: "INSTANT" | "DELAYED" + final_capture?: boolean + invoice_id?: string + links?: Links + processor_response?: any + seller_protection?: any + seller_receivable_breakdown?: any + supplementary_data?: any +} + +export interface GetAuthorizationPaymentResponse { + amount?: MoneyAmount + create_time?: string + custom_id?: string + expiration_time?: string + id?: string + invoice_id?: string + links?: Links + seller_protection?: any + status?: + | "CREATED" + | "DENIED" + | "CAPTURED" + | "VOIDED" + | "EXPIRED" + | "PARTIALLY_CAPTURED" + | "PENDING" + status_details?: any + supplementary_data?: any + update_time?: string +} diff --git a/packages/medusa-payment-paypal/src/core/types/webhook.ts b/packages/medusa-payment-paypal/src/core/types/webhook.ts new file mode 100644 index 0000000000..8a3ecbc937 --- /dev/null +++ b/packages/medusa-payment-paypal/src/core/types/webhook.ts @@ -0,0 +1,22 @@ +import { Links } from "./common" + +export interface WebhookEvent { + id?: string + create_time?: string + event_version?: string + links?: Links + resource?: any + resource_type?: string + resource_version?: string + summary?: string +} + +export interface VerifyWebhookSignature { + auth_algo: string + cert_url: string + transmission_id: string + transmission_sig: string + transmission_time: string + webhook_event: WebhookEvent + webhook_id: string +} diff --git a/packages/medusa-payment-paypal/src/index.ts b/packages/medusa-payment-paypal/src/index.ts new file mode 100644 index 0000000000..09474f996a --- /dev/null +++ b/packages/medusa-payment-paypal/src/index.ts @@ -0,0 +1,2 @@ +export * from "./types" +export * from "./services/paypal-provider" diff --git a/packages/medusa-payment-paypal/src/services/__fixtures__/data.ts b/packages/medusa-payment-paypal/src/services/__fixtures__/data.ts new file mode 100644 index 0000000000..1124e71e7e --- /dev/null +++ b/packages/medusa-payment-paypal/src/services/__fixtures__/data.ts @@ -0,0 +1,233 @@ +import { + FAIL_INTENT_ID, + SUCCESS_INTENT, +} from "../../__mocks__/@paypal/checkout-server-sdk" +import { PaymentIntentDataByStatus } from "../../__fixtures__/data" + +// INITIATE PAYMENT DATA + +export const initiatePaymentContextSuccess = { + currency_code: "usd", + amount: 1000, + resource_id: SUCCESS_INTENT, + customer: {}, + context: {}, + paymentSessionData: {}, +} + +export const initiatePaymentContextFail = { + currency_code: "usd", + amount: 1000, + resource_id: FAIL_INTENT_ID, + customer: { + metadata: { + stripe_id: "test", + }, + }, + context: {}, + paymentSessionData: {}, +} + +// AUTHORIZE PAYMENT DATA + +export const authorizePaymentSuccessData = { + id: PaymentIntentDataByStatus.COMPLETED.id, +} + +// CANCEL PAYMENT DATA + +export const cancelPaymentSuccessData = { + id: PaymentIntentDataByStatus.APPROVED.id, + purchase_units: [ + { + payments: { + authorizations: [ + { + id: "id", + }, + ], + }, + }, + ], +} + +export const cancelPaymentRefundAlreadyCaptureSuccessData = { + id: PaymentIntentDataByStatus.APPROVED.id, + purchase_units: [ + { + payments: { + captures: [ + { + id: "id", + }, + ], + authorizations: [ + { + id: "id", + }, + ], + }, + }, + ], +} + +export const cancelPaymentRefundAlreadyCanceledSuccessData = { + id: PaymentIntentDataByStatus.VOIDED.id, +} + +export const cancelPaymentFailData = { + id: FAIL_INTENT_ID, + purchase_units: [ + { + payments: { + captures: [ + { + id: "id", + }, + ], + authorizations: [ + { + id: "id", + }, + ], + }, + }, + ], +} + +// CAPTURE PAYMENT DATA + +export const capturePaymentContextSuccessData = { + paymentSessionData: { + id: PaymentIntentDataByStatus.APPROVED.id, + purchase_units: [ + { + payments: { + authorizations: [ + { + id: SUCCESS_INTENT, + }, + ], + }, + }, + ], + }, +} + +export const capturePaymentContextFailData = { + paymentSessionData: { + id: PaymentIntentDataByStatus.APPROVED.id, + purchase_units: [ + { + payments: { + authorizations: [ + { + id: FAIL_INTENT_ID, + }, + ], + }, + }, + ], + }, +} + +// REFUND PAYMENT DATA + +export const refundPaymentSuccessData = { + id: PaymentIntentDataByStatus.APPROVED.id, + purchase_units: [ + { + amount: { + currency_code: "USD", + value: "100.00", + }, + payments: { + captures: [ + { + id: "id", + }, + ], + authorizations: [ + { + id: FAIL_INTENT_ID, + }, + ], + }, + }, + ], +} + +export const refundPaymentFailNotYetCapturedData = { + id: PaymentIntentDataByStatus.APPROVED.id, + purchase_units: [ + { + payments: { + captures: [], + authorizations: [ + { + id: FAIL_INTENT_ID, + }, + ], + }, + }, + ], +} + +export const refundPaymentFailData = { + id: FAIL_INTENT_ID, + purchase_units: [ + { + amount: { + currency_code: "USD", + value: "100.00", + }, + payments: { + captures: [ + { + id: "id", + }, + ], + authorizations: [ + { + id: FAIL_INTENT_ID, + }, + ], + }, + }, + ], +} + +// RETRIEVE PAYMENT DATA + +export const retrievePaymentSuccessData = { + id: PaymentIntentDataByStatus.APPROVED.id, +} + +export const retrievePaymentFailData = { + id: FAIL_INTENT_ID, +} + +// UPDATE PAYMENT DATA + +export const updatePaymentSuccessData = { + paymentSessionData: { + id: PaymentIntentDataByStatus.APPROVED.id, + }, + currency_code: "USD", + amount: 1000, +} + +export const updatePaymentFailData = { + currency_code: "USD", + amount: 1000, + resource_id: FAIL_INTENT_ID, + customer: { + metadata: { + stripe_id: "test", + }, + }, + context: {}, + paymentSessionData: { + id: FAIL_INTENT_ID, + }, +} diff --git a/packages/medusa-payment-paypal/src/services/__tests__/paypal-provider.js b/packages/medusa-payment-paypal/src/services/__tests__/paypal-provider.js deleted file mode 100644 index 24d5d47fa4..0000000000 --- a/packages/medusa-payment-paypal/src/services/__tests__/paypal-provider.js +++ /dev/null @@ -1,447 +0,0 @@ -import PayPalProviderService from "../paypal-provider" -import { PayPalClientMock, PayPalMock, } from "../../__mocks__/@paypal/checkout-server-sdk" - -const TotalsServiceMock = { - getTotal: jest.fn().mockImplementation((c) => c.total), -} - -const RegionServiceMock = { - retrieve: jest.fn().mockImplementation((id) => - Promise.resolve({ - currency_code: "eur", - }) - ), -} - -describe("PaypalProviderService", () => { - describe("createPayment", () => { - let result - const paypalProviderService = new PayPalProviderService( - { - regionService: RegionServiceMock, - totalsService: TotalsServiceMock, - }, - { - api_key: "test", - } - ) - - beforeEach(async () => { - jest.clearAllMocks() - }) - - it("creates paypal order", async () => { - result = await paypalProviderService.createPayment({ - id: "test_cart", - region_id: "fr", - total: 1000, - }) - - expect(PayPalMock.orders.OrdersCreateRequest).toHaveBeenCalledTimes(1) - expect(PayPalClientMock.execute).toHaveBeenCalledTimes(1) - expect(PayPalClientMock.execute).toHaveBeenCalledWith( - expect.objectContaining({ - order: true, - body: { - intent: "AUTHORIZE", - application_context: { - shipping_preference: "NO_SHIPPING", - }, - purchase_units: [ - { - custom_id: "test_cart", - amount: { - currency_code: "EUR", - value: "10.00", - }, - }, - ], - }, - }) - ) - - expect(result.id).toEqual("test") - }) - - it("creates paypal order using new API", async () => { - result = await paypalProviderService.createPayment({ - id: "", - region_id: "fr", - total: 1000, - resource_id: "resource_id" - }) - - expect(PayPalMock.orders.OrdersCreateRequest).toHaveBeenCalledTimes(1) - expect(PayPalClientMock.execute).toHaveBeenCalledTimes(1) - expect(PayPalClientMock.execute).toHaveBeenCalledWith( - expect.objectContaining({ - order: true, - body: { - intent: "AUTHORIZE", - application_context: { - shipping_preference: "NO_SHIPPING", - }, - purchase_units: [ - { - custom_id: "resource_id", - amount: { - currency_code: "EUR", - value: "10.00", - }, - }, - ], - }, - }) - ) - - expect(result.id).toEqual("test") - }) - }) - - describe("retrievePayment", () => { - let result - const paypalProviderService = new PayPalProviderService( - { - regionService: RegionServiceMock, - }, - { - api_key: "test", - } - ) - - beforeEach(async () => { - jest.clearAllMocks() - }) - - it("retrieves paypal order", async () => { - result = await paypalProviderService.retrievePayment({ id: "test" }) - expect(PayPalMock.orders.OrdersGetRequest).toHaveBeenCalledTimes(1) - expect(PayPalMock.orders.OrdersGetRequest).toHaveBeenCalledWith("test") - expect(PayPalClientMock.execute).toHaveBeenCalledTimes(1) - - expect(result.id).toEqual("test") - }) - }) - - describe("updatePayment", () => { - let result - const paypalProviderService = new PayPalProviderService( - { - regionService: RegionServiceMock, - }, - { - api_key: "test", - } - ) - - beforeEach(async () => { - jest.clearAllMocks() - }) - - it("updates paypal order", async () => { - result = await paypalProviderService.updatePayment( - { id: "test" }, - { - id: "test_cart", - region_id: "fr", - total: 1000, - } - ) - - expect(PayPalMock.orders.OrdersPatchRequest).toHaveBeenCalledTimes(1) - expect(PayPalMock.orders.OrdersPatchRequest).toHaveBeenCalledWith("test") - expect(PayPalClientMock.execute).toHaveBeenCalledTimes(1) - expect(PayPalClientMock.execute).toHaveBeenCalledWith( - expect.objectContaining({ - order: true, - body: [ - { - op: "replace", - path: "/purchase_units/@reference_id=='default'", - value: { - amount: { - currency_code: "EUR", - value: "10.00", - }, - }, - }, - ], - }) - ) - - expect(result.id).toEqual("test") - }) - - it("updates paypal order using new API", async () => { - result = await paypalProviderService.updatePayment( - { id: "test" }, - { - id: "", - region_id: "fr", - total: 1000, - resource_id: "resource_id" - } - ) - - expect(PayPalMock.orders.OrdersPatchRequest).toHaveBeenCalledTimes(1) - expect(PayPalMock.orders.OrdersPatchRequest).toHaveBeenCalledWith("test") - expect(PayPalClientMock.execute).toHaveBeenCalledTimes(1) - expect(PayPalClientMock.execute).toHaveBeenCalledWith( - expect.objectContaining({ - order: true, - body: [ - { - op: "replace", - path: "/purchase_units/@reference_id=='default'", - value: { - amount: { - currency_code: "EUR", - value: "10.00", - }, - }, - }, - ], - }) - ) - - expect(result.id).toEqual("test") - }) - }) - - describe("capturePayment", () => { - let result - const paypalProviderService = new PayPalProviderService( - { - regionService: RegionServiceMock, - }, - { - api_key: "test", - } - ) - - beforeEach(async () => { - jest.clearAllMocks() - }) - - it("updates paypal order", async () => { - result = await paypalProviderService.capturePayment({ - data: { - id: "test", - purchase_units: [ - { - payments: { - authorizations: [ - { - id: "test_auth", - }, - ], - }, - }, - ], - }, - }) - - expect( - PayPalMock.payments.AuthorizationsCaptureRequest - ).toHaveBeenCalledTimes(1) - expect( - PayPalMock.payments.AuthorizationsCaptureRequest - ).toHaveBeenCalledWith("test_auth") - expect(PayPalMock.orders.OrdersGetRequest).toHaveBeenCalledWith("test") - expect(PayPalClientMock.execute).toHaveBeenCalledTimes(2) - - expect(result.id).toEqual("test") - }) - }) - - describe("refundPayment", () => { - let result - const paypalProviderService = new PayPalProviderService( - { - regionService: RegionServiceMock, - }, - { - api_key: "test", - } - ) - - beforeEach(async () => { - jest.clearAllMocks() - }) - - it("refunds payment", async () => { - result = await paypalProviderService.refundPayment( - { - currency_code: "eur", - data: { - id: "test", - purchase_units: [ - { - payments: { - captures: [ - { - id: "test_cap", - }, - ], - }, - }, - ], - }, - }, - 2000 - ) - - expect(PayPalMock.payments.CapturesRefundRequest).toHaveBeenCalledWith( - "test_cap" - ) - expect(PayPalMock.orders.OrdersGetRequest).toHaveBeenCalledWith("test") - expect(PayPalClientMock.execute).toHaveBeenCalledTimes(2) - expect(PayPalClientMock.execute).toHaveBeenCalledWith( - expect.objectContaining({ - body: { - amount: { - currency_code: "EUR", - value: "20.00", - }, - }, - }) - ) - - expect(result.id).toEqual("test") - }) - - it("doesn't refund without captures", async () => { - await expect( - paypalProviderService.refundPayment( - { - currency_code: "eur", - data: { - id: "test", - purchase_units: [ - { - payments: { - captures: [], - }, - }, - ], - }, - }, - 2000 - ) - ).rejects.toThrow("Order not yet captured") - }) - }) - - describe("cancelPayment", () => { - let result - const paypalProviderService = new PayPalProviderService( - { - regionService: RegionServiceMock, - }, - { - api_key: "test", - } - ) - - beforeEach(async () => { - jest.clearAllMocks() - }) - - it("refunds if captured", async () => { - result = await paypalProviderService.cancelPayment({ - captured_at: "true", - currency_code: "eur", - data: { - id: "test", - purchase_units: [ - { - payments: { - captures: [ - { - id: "test_cap", - }, - ], - }, - }, - ], - }, - }) - - expect(PayPalMock.payments.CapturesRefundRequest).toHaveBeenCalledWith( - "test_cap" - ) - expect(PayPalMock.orders.OrdersGetRequest).toHaveBeenCalledWith("test") - expect(PayPalClientMock.execute).toHaveBeenCalledTimes(3) - - expect(result.id).toEqual("test") - }) - - it("voids if not captured", async () => { - result = await paypalProviderService.cancelPayment({ - currency_code: "eur", - data: { - id: "test", - purchase_units: [ - { - payments: { - authorizations: [ - { - id: "test_auth", - }, - ], - }, - }, - ], - }, - }) - - expect( - PayPalMock.payments.AuthorizationsVoidRequest - ).toHaveBeenCalledWith("test_auth") - expect(PayPalMock.orders.OrdersGetRequest).toHaveBeenCalledWith("test") - expect(PayPalClientMock.execute).toHaveBeenCalledTimes(3) - - expect(result.id).toEqual("test") - }) - - it("should return the order if already canceled", async () => { - result = await paypalProviderService.cancelPayment({ - currency_code: "eur", - data: { id: "test-voided" }, - }) - - expect( - PayPalMock.payments.AuthorizationsVoidRequest - ).not.toHaveBeenCalled() - expect(PayPalMock.payments.CapturesRefundRequest).not.toHaveBeenCalled() - expect(PayPalMock.orders.OrdersGetRequest).toHaveBeenCalledWith( - "test-voided" - ) - expect(PayPalClientMock.execute).toHaveBeenCalledTimes(1) - - expect(result.id).toEqual("test-voided") - expect(result.status).toEqual("VOIDED") - }) - - it("should return the order if already fully refund", async () => { - result = await paypalProviderService.cancelPayment({ - currency_code: "eur", - data: { - id: "test-refund", - }, - }) - - expect( - PayPalMock.payments.AuthorizationsVoidRequest - ).not.toHaveBeenCalled() - expect(PayPalMock.payments.CapturesRefundRequest).not.toHaveBeenCalled() - expect(PayPalMock.orders.OrdersGetRequest).toHaveBeenCalledWith( - "test-refund" - ) - expect(PayPalClientMock.execute).toHaveBeenCalledTimes(1) - - expect(result.id).toEqual("test-refund") - expect(result.status).toEqual("COMPLETED") - }) - }) -}) diff --git a/packages/medusa-payment-paypal/src/services/__tests__/paypal-provider.spec.ts b/packages/medusa-payment-paypal/src/services/__tests__/paypal-provider.spec.ts new file mode 100644 index 0000000000..f80ae14675 --- /dev/null +++ b/packages/medusa-payment-paypal/src/services/__tests__/paypal-provider.spec.ts @@ -0,0 +1,500 @@ +import { PaymentIntentDataByStatus } from "../../__fixtures__/data" +import { PaymentProcessorContext, PaymentSessionStatus } from "@medusajs/medusa" +import PaypalProvider from "../paypal-provider" +import { + authorizePaymentSuccessData, + cancelPaymentFailData, + cancelPaymentRefundAlreadyCanceledSuccessData, + cancelPaymentRefundAlreadyCaptureSuccessData, + cancelPaymentSuccessData, + capturePaymentContextFailData, + capturePaymentContextSuccessData, + initiatePaymentContextFail, + initiatePaymentContextSuccess, + refundPaymentFailData, + refundPaymentFailNotYetCapturedData, + refundPaymentSuccessData, + retrievePaymentFailData, + retrievePaymentSuccessData, + updatePaymentFailData, + updatePaymentSuccessData, +} from "../__fixtures__/data" +import axios from "axios" +import { INVOICE_ID, PayPalMock } from "../../core/__mocks__/paypal-sdk" +import { roundToTwo } from "../utils/utils" +import { humanizeAmount } from "medusa-core-utils" + +jest.mock("axios") +const mockedAxios = axios as jest.Mocked + +jest.mock("../../core", () => { + return { + PaypalSdk: jest.fn().mockImplementation(() => PayPalMock), + } +}) + +const container = { + logger: { + error: jest.fn(), + } as any, +} + +const paypalConfig = { + sandbox: true, + client_id: "fake", + client_secret: "fake", +} + +describe("PaypalProvider", () => { + beforeAll(() => { + mockedAxios.create.mockReturnThis() + }) + + describe("getPaymentStatus", function () { + let paypalProvider: PaypalProvider + + beforeAll(async () => { + const scopedContainer = { ...container } + paypalProvider = new PaypalProvider(scopedContainer, paypalConfig) + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("should return the correct status", async () => { + let status: PaymentSessionStatus + + status = await paypalProvider.getPaymentStatus({ + id: PaymentIntentDataByStatus.CREATED.id, + }) + expect(status).toBe(PaymentSessionStatus.PENDING) + + status = await paypalProvider.getPaymentStatus({ + id: PaymentIntentDataByStatus.SAVED.id, + }) + expect(status).toBe(PaymentSessionStatus.REQUIRES_MORE) + + status = await paypalProvider.getPaymentStatus({ + id: PaymentIntentDataByStatus.APPROVED.id, + }) + expect(status).toBe(PaymentSessionStatus.REQUIRES_MORE) + + status = await paypalProvider.getPaymentStatus({ + id: PaymentIntentDataByStatus.PAYER_ACTION_REQUIRED.id, + }) + expect(status).toBe(PaymentSessionStatus.REQUIRES_MORE) + + status = await paypalProvider.getPaymentStatus({ + id: PaymentIntentDataByStatus.VOIDED.id, + }) + expect(status).toBe(PaymentSessionStatus.CANCELED) + + status = await paypalProvider.getPaymentStatus({ + id: PaymentIntentDataByStatus.COMPLETED.id, + }) + expect(status).toBe(PaymentSessionStatus.AUTHORIZED) + + status = await paypalProvider.getPaymentStatus({ + id: "unknown-id", + }) + expect(status).toBe(PaymentSessionStatus.PENDING) + }) + }) + + describe("initiatePayment", function () { + let paypalProvider: PaypalProvider + + beforeAll(async () => { + const scopedContainer = { ...container } + paypalProvider = new PaypalProvider(scopedContainer, paypalConfig) + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("should succeed with an existing customer but no stripe id", async () => { + const result = await paypalProvider.initiatePayment( + initiatePaymentContextSuccess as PaymentProcessorContext + ) + + expect(PayPalMock.createOrder).toHaveBeenCalled() + expect(PayPalMock.createOrder).toHaveBeenCalledWith( + expect.objectContaining({ + intent: "AUTHORIZE", + purchase_units: [ + { + custom_id: initiatePaymentContextSuccess.resource_id, + amount: { + currency_code: + initiatePaymentContextSuccess.currency_code.toUpperCase(), + value: roundToTwo( + humanizeAmount( + initiatePaymentContextSuccess.amount, + initiatePaymentContextSuccess.currency_code + ), + initiatePaymentContextSuccess.currency_code + ), + }, + }, + ], + }) + ) + + expect(result).toEqual({ + session_data: expect.any(Object), + }) + }) + + it("should fail", async () => { + const result = await paypalProvider.initiatePayment( + initiatePaymentContextFail as unknown as PaymentProcessorContext + ) + + expect(PayPalMock.createOrder).toHaveBeenCalled() + + expect(result).toEqual({ + error: "An error occurred in initiatePayment", + code: "", + detail: "Error.", + }) + }) + }) + + describe("authorizePayment", function () { + let paypalProvider: PaypalProvider + + beforeAll(async () => { + const scopedContainer = { ...container } + paypalProvider = new PaypalProvider(scopedContainer, paypalConfig) + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("should succeed", async () => { + const result = await paypalProvider.authorizePayment( + authorizePaymentSuccessData as Record, + {} + ) + + expect(result).toEqual({ + data: { + id: authorizePaymentSuccessData.id, + invoice_id: INVOICE_ID, + status: + PaymentIntentDataByStatus[authorizePaymentSuccessData.id].status, + }, + status: "authorized", + }) + }) + }) + + describe("cancelPayment", function () { + let paypalProvider: PaypalProvider + + beforeAll(async () => { + const scopedContainer = { ...container } + paypalProvider = new PaypalProvider(scopedContainer, paypalConfig) + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("should succeed to void the payment authorization", async () => { + const result = await paypalProvider.cancelPayment( + cancelPaymentSuccessData + ) + + expect(PayPalMock.cancelAuthorizedPayment).toHaveBeenCalledTimes(1) + expect(PayPalMock.cancelAuthorizedPayment).toHaveBeenCalledWith( + cancelPaymentSuccessData.purchase_units[0].payments.authorizations[0].id + ) + + expect(result).toEqual({ + id: cancelPaymentSuccessData.id, + invoice_id: INVOICE_ID, + status: PaymentIntentDataByStatus[cancelPaymentSuccessData.id].status, + }) + }) + + it("should succeed to refund an already captured payment", async () => { + const result = await paypalProvider.cancelPayment( + cancelPaymentRefundAlreadyCaptureSuccessData + ) + + expect(PayPalMock.refundPayment).toHaveBeenCalledTimes(1) + expect(PayPalMock.refundPayment).toHaveBeenCalledWith( + cancelPaymentRefundAlreadyCaptureSuccessData.purchase_units[0].payments + .captures[0].id + ) + + expect(result).toEqual({ + id: cancelPaymentRefundAlreadyCaptureSuccessData.id, + invoice_id: INVOICE_ID, + status: + PaymentIntentDataByStatus[ + cancelPaymentRefundAlreadyCaptureSuccessData.id + ].status, + }) + }) + + it("should succeed to do nothing if already canceled or already fully refund", async () => { + const result = await paypalProvider.cancelPayment( + cancelPaymentRefundAlreadyCanceledSuccessData + ) + + expect(PayPalMock.captureAuthorizedPayment).not.toHaveBeenCalled() + expect(PayPalMock.cancelAuthorizedPayment).not.toHaveBeenCalled() + + expect(result).toEqual({ + id: cancelPaymentRefundAlreadyCanceledSuccessData.id, + invoice_id: INVOICE_ID, + status: + PaymentIntentDataByStatus[ + cancelPaymentRefundAlreadyCanceledSuccessData.id + ].status, + }) + }) + + it("should fail", async () => { + const result = await paypalProvider.cancelPayment(cancelPaymentFailData) + + expect(result).toEqual({ + code: "", + detail: "Error", + error: "An error occurred in retrievePayment", + }) + }) + }) + + describe("capturePayment", function () { + let paypalProvider: PaypalProvider + + beforeAll(async () => { + const scopedContainer = { ...container } + paypalProvider = new PaypalProvider(scopedContainer, paypalConfig) + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("should succeed", async () => { + const result = await paypalProvider.capturePayment( + capturePaymentContextSuccessData.paymentSessionData + ) + + expect(result).toEqual({ + id: capturePaymentContextSuccessData.paymentSessionData.id, + invoice_id: INVOICE_ID, + status: + PaymentIntentDataByStatus[ + capturePaymentContextSuccessData.paymentSessionData.id + ].status, + }) + }) + + it("should fail", async () => { + const result = await paypalProvider.capturePayment( + capturePaymentContextFailData.paymentSessionData + ) + + expect(result).toEqual({ + error: "An error occurred in capturePayment", + code: "", + detail: "Error.", + }) + }) + }) + + describe("refundPayment", function () { + let paypalProvider: PaypalProvider + const refundAmount = 500 + + beforeAll(async () => { + const scopedContainer = { ...container } + paypalProvider = new PaypalProvider(scopedContainer, paypalConfig) + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("should succeed", async () => { + const result = await paypalProvider.refundPayment( + refundPaymentSuccessData, + refundAmount + ) + + expect(PayPalMock.refundPayment).toHaveBeenCalled() + expect(PayPalMock.refundPayment).toHaveBeenCalledWith( + refundPaymentSuccessData.purchase_units[0].payments.captures[0].id, + { + amount: { + currency_code: + refundPaymentSuccessData.purchase_units[0].amount.currency_code, + value: "5.00", + }, + } + ) + + expect(result).toEqual({ + id: refundPaymentSuccessData.id, + invoice_id: INVOICE_ID, + status: PaymentIntentDataByStatus[refundPaymentSuccessData.id].status, + }) + }) + + it("should fail if not already captured", async () => { + const result = await paypalProvider.refundPayment( + refundPaymentFailNotYetCapturedData, + refundAmount + ) + + expect(PayPalMock.refundPayment).not.toHaveBeenCalled() + + expect(result).toEqual({ + code: "", + detail: "Cannot refund an uncaptured payment", + error: "An error occurred in refundPayment", + }) + }) + + it("should fail", async () => { + const result = await paypalProvider.refundPayment( + refundPaymentFailData, + refundAmount + ) + + expect(PayPalMock.refundPayment).toHaveBeenCalled() + expect(PayPalMock.refundPayment).toHaveBeenCalledWith( + refundPaymentFailData.purchase_units[0].payments.captures[0].id, + { + amount: { + currency_code: + refundPaymentFailData.purchase_units[0].amount.currency_code, + value: "5.00", + }, + } + ) + + expect(result).toEqual({ + code: "", + detail: "Error", + error: "An error occurred in retrievePayment", + }) + }) + }) + + describe("retrievePayment", function () { + let paypalProvider + + beforeAll(async () => { + const scopedContainer = { ...container } + paypalProvider = new PaypalProvider(scopedContainer, paypalConfig) + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("should succeed", async () => { + const result = await paypalProvider.retrievePayment( + retrievePaymentSuccessData + ) + + expect(result).toEqual({ + id: retrievePaymentSuccessData.id, + invoice_id: INVOICE_ID, + status: PaymentIntentDataByStatus[retrievePaymentSuccessData.id].status, + }) + }) + + it("should fail on refund creation", async () => { + const result = await paypalProvider.retrievePayment( + retrievePaymentFailData + ) + + expect(result).toEqual({ + error: "An error occurred in retrievePayment", + code: "", + detail: "Error", + }) + }) + }) + + describe("updatePayment", function () { + let paypalProvider: PaypalProvider + + beforeAll(async () => { + const scopedContainer = { ...container } + paypalProvider = new PaypalProvider(scopedContainer, paypalConfig) + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("should succeed", async () => { + const result = await paypalProvider.updatePayment( + updatePaymentSuccessData as unknown as PaymentProcessorContext + ) + + expect(PayPalMock.patchOrder).toHaveBeenCalled() + expect(PayPalMock.patchOrder).toHaveBeenCalledWith( + updatePaymentSuccessData.paymentSessionData.id, + [ + { + op: "replace", + path: "/purchase_units/@reference_id=='default'", + value: { + amount: { + currency_code: updatePaymentSuccessData.currency_code, + value: "10.00", + }, + }, + }, + ] + ) + + expect(result).toEqual( + expect.objectContaining({ + session_data: expect.any(Object), + }) + ) + }) + + it("should fail", async () => { + const result = await paypalProvider.updatePayment( + updatePaymentFailData as unknown as PaymentProcessorContext + ) + + expect(PayPalMock.patchOrder).toHaveBeenCalled() + expect(PayPalMock.patchOrder).toHaveBeenCalledWith( + updatePaymentFailData.paymentSessionData.id, + [ + { + op: "replace", + path: "/purchase_units/@reference_id=='default'", + value: { + amount: { + currency_code: updatePaymentFailData.currency_code, + value: "10.00", + }, + }, + }, + ] + ) + + expect(result).toEqual({ + code: "", + detail: "Error.", + error: "An error occurred in initiatePayment", + }) + }) + }) +}) diff --git a/packages/medusa-payment-paypal/src/services/paypal-provider.js b/packages/medusa-payment-paypal/src/services/paypal-provider.js deleted file mode 100644 index 7e761bc9be..0000000000 --- a/packages/medusa-payment-paypal/src/services/paypal-provider.js +++ /dev/null @@ -1,426 +0,0 @@ -import { humanizeAmount, zeroDecimalCurrencies } from "medusa-core-utils" -import PayPal from "@paypal/checkout-server-sdk" -import { PaymentService } from "medusa-interfaces" - -function roundToTwo(num, currency) { - if (zeroDecimalCurrencies.includes(currency.toLowerCase())) { - return `${num}` - } - return num.toFixed(2) -} - -class PayPalProviderService extends PaymentService { - static identifier = "paypal" - - constructor({ regionService }, options) { - super() - - /** - * Required PayPal options: - * { - * sandbox: [default: false], - * client_id: "CLIENT_ID", REQUIRED - * client_secret: "CLIENT_SECRET", REQUIRED - * auth_webhook_id: REQUIRED for webhook to work - * } - */ - this.options_ = options - - let environment - if (this.options_.sandbox) { - environment = new PayPal.core.SandboxEnvironment( - options.client_id, - options.client_secret - ) - } else { - environment = new PayPal.core.LiveEnvironment( - options.client_id, - options.client_secret - ) - } - - /** @private @const {PayPalHttpClient} */ - this.paypal_ = new PayPal.core.PayPalHttpClient(environment) - - /** @private @const {RegionService} */ - this.regionService_ = regionService - } - - /** - * Fetches an open PayPal order and maps its status to Medusa payment - * statuses. - * @param {object} paymentData - the data stored with the payment - * @returns {Promise} the status of the order - */ - async getStatus(paymentData) { - const order = await this.retrievePayment(paymentData) - - let status = "pending" - - switch (order.status) { - case "CREATED": - return "pending" - case "COMPLETED": - return "authorized" - case "SAVED": - case "APPROVED": - case "PAYER_ACTION_REQUIRED": - return "requires_more" - case "VOIDED": - return "canceled" - default: - return status - } - } - - /** - * Not supported - */ - async retrieveSavedMethods(customer) { - return [] - } - - /** - * Creates a PayPal order, with an Authorize intent. The plugin doesn't - * support shipping details at the moment. - * Reference docs: https://developer.paypal.com/docs/api/orders/v2/ - * @param {object} cart - cart to create a payment for - * @returns {object} the data to be stored with the payment session. - */ - async createPayment(cart) { - let { id, region_id, resource_id, currency_code, total } = cart - - if (!currency_code) { - const region = await this.regionService_.retrieve(region_id) - currency_code = region.currency_code - } - - const amount = total - - const request = new PayPal.orders.OrdersCreateRequest() - request.requestBody({ - intent: "AUTHORIZE", - application_context: { - shipping_preference: "NO_SHIPPING", - }, - purchase_units: [ - { - custom_id: resource_id ?? id, - amount: { - currency_code: currency_code.toUpperCase(), - value: roundToTwo( - humanizeAmount(amount, currency_code), - currency_code - ), - }, - }, - ], - }) - - const res = await this.paypal_.execute(request) - - return { id: res.result.id } - } - - /** - * Retrieves a PayPal order. - * @param {object} data - the data stored with the payment - * @returns {Promise} PayPal order - */ - async retrievePayment(data) { - try { - const request = new PayPal.orders.OrdersGetRequest(data.id) - const res = await this.paypal_.execute(request) - return res.result - } catch (error) { - throw error - } - } - - /** - * Gets the payment data from a payment session - * @param {object} session - the session to fetch payment data for. - * @returns {Promise} the PayPal order object - */ - async getPaymentData(session) { - try { - return this.retrievePayment(session.data) - } catch (error) { - throw error - } - } - - /** - * This method does not call the PayPal authorize function, but fetches the - * status of the payment as it is expected to have been authorized client side. - * @param {object} session - payment session - * @param {object} context - properties relevant to current context - * @returns {Promise<{ status: string, data: object }>} result with data and status - */ - async authorizePayment(session, context = {}) { - const stat = await this.getStatus(session.data) - - try { - return { data: session.data, status: stat } - } catch (error) { - throw error - } - } - - /** - * Updates the data stored with the payment session. - * @param {object} data - the currently stored data. - * @param {object} update - the update data to store. - * @returns {object} the merged data of the two arguments. - */ - async updatePaymentData(data, update) { - try { - return { - ...data, - ...update.data, - } - } catch (error) { - throw error - } - } - - /** - * Updates the PayPal order. - * @param {object} sessionData - payment session data. - * @param {object} cart - the cart to update by. - * @returns {object} the resulting order object. - */ - async updatePayment(sessionData, cart) { - try { - let { currency_code, total, region_id } = cart - - if (!currency_code) { - const region = await this.regionService_.retrieve(region_id) - currency_code = region.currency_code - } - - const request = new PayPal.orders.OrdersPatchRequest(sessionData.id) - request.requestBody([ - { - op: "replace", - path: "/purchase_units/@reference_id=='default'", - value: { - amount: { - currency_code: currency_code.toUpperCase(), - value: roundToTwo( - humanizeAmount(total, currency_code), - currency_code - ), - }, - }, - }, - ]) - - await this.paypal_.execute(request) - - return sessionData - } catch (error) { - return this.createPayment(cart) - } - } - - /** - * Not suported - */ - async deletePayment(payment) { - return - } - - /** - * Captures a previously authorized order. - * @param {object} payment - the payment to capture - * @returns {object} the PayPal order - */ - async capturePayment(payment) { - const { purchase_units } = payment.data - - const id = purchase_units[0].payments.authorizations[0].id - - try { - const request = new PayPal.payments.AuthorizationsCaptureRequest(id) - await this.paypal_.execute(request) - return this.retrievePayment(payment.data) - } catch (error) { - throw error - } - } - - /** - * Refunds a given amount. - * @param {object} payment - payment to refund - * @param {number} amountToRefund - amount to refund - * @returns {Promise} the resulting PayPal order - */ - async refundPayment(payment, amountToRefund) { - const { purchase_units } = payment.data - - try { - const payments = purchase_units[0].payments - if (!(payments && payments.captures.length)) { - throw new Error("Order not yet captured") - } - - const payId = payments.captures[0].id - const request = new PayPal.payments.CapturesRefundRequest(payId) - - request.requestBody({ - amount: { - currency_code: payment.currency_code.toUpperCase(), - value: roundToTwo( - humanizeAmount(amountToRefund, payment.currency_code), - payment.currency_code - ), - }, - }) - - await this.paypal_.execute(request) - - return this.retrievePayment(payment.data) - } catch (error) { - throw error - } - } - - /** - * Cancels payment for paypal payment. - * @param {Payment} payment - payment object - * @returns {Promise} canceled payment intent - */ - async cancelPayment(payment) { - const order = await this.retrievePayment(payment.data) - const isAlreadyCanceled = order.status === "VOIDED" - const isCanceledAndFullyRefund = - order.status === "COMPLETED" && !!order.invoice_id - if (isAlreadyCanceled || isCanceledAndFullyRefund) { - return order - } - - try { - const { purchase_units } = payment.data - if (payment.captured_at) { - const payments = purchase_units[0].payments - - const payId = payments.captures[0].id - const request = new PayPal.payments.CapturesRefundRequest(payId) - await this.paypal_.execute(request) - } else { - const id = purchase_units[0].payments.authorizations[0].id - const request = new PayPal.payments.AuthorizationsVoidRequest(id) - await this.paypal_.execute(request) - } - - return await this.retrievePayment(payment.data) - } catch (error) { - throw error - } - } - - /** - * Given a PayPal authorization object the method will find the order that - * created the authorization, by following the HATEOAS link to the order. - * @param {object} auth - the authorization object. - * @returns {Promise} the PayPal order object - */ - async retrieveOrderFromAuth(auth) { - const link = auth.links.find((l) => l.rel === "up") - const parts = link.href.split("/") - const orderId = parts[parts.length - 1] - const orderReq = new PayPal.orders.OrdersGetRequest(orderId) - const res = await this.paypal_.execute(orderReq) - if (res.result) { - return res.result - } - return null - } - - /** - * Retrieves a PayPal authorization. - * @param {string} id - the id of the authorization. - * @returns {Promise} the authorization. - */ - async retrieveAuthorization(id) { - const authReq = new PayPal.payments.AuthorizationsGetRequest(id) - const res = await this.paypal_.execute(authReq) - if (res.result) { - return res.result - } - return null - } - - /** - * Checks if a webhook is verified. - * @param {object} data - the verficiation data. - * @returns {Promise} the response of the verification request. - */ - async verifyWebhook(data) { - const verifyReq = { - verb: "POST", - path: "/v1/notifications/verify-webhook-signature", - headers: { - "Content-Type": "application/json", - }, - body: { - webhook_id: this.options_.auth_webhook_id, - ...data, - }, - } - - return this.paypal_.execute(verifyReq) - } - - /** - * Upserts a webhook that listens for order authorizations. - */ - async ensureWebhooks() { - if (!this.options_.backend_url) { - return - } - - const webhookReq = { - verb: "GET", - path: "/v1/notifications/webhooks", - } - const webhookRes = await this.paypal_.execute(webhookReq) - - let found - if (webhookRes.result && webhookRes.result.webhooks) { - found = webhookRes.result.webhooks.find((w) => { - const notificationType = w.event_types.find( - (e) => e.name === "PAYMENT.AUTHORIZATION.CREATED" - ) - return ( - !!notificationType && - w.url === `${this.options_.backend_url}/paypal/hooks` - ) - }) - } - - if (!found) { - const whCreateReq = { - verb: "POST", - path: "/v1/notifications/webhooks", - headers: { - "Content-Type": "application/json", - }, - body: { - id: "medusa-auth-notification", - url: `${this.options_.backend_url}/paypal/hooks`, - event_types: [ - { - name: "PAYMENT.AUTHORIZATION.CREATED", - }, - ], - }, - } - - await this.paypal_.execute(whCreateReq) - } - } -} - -export default PayPalProviderService diff --git a/packages/medusa-payment-paypal/src/services/paypal-provider.ts b/packages/medusa-payment-paypal/src/services/paypal-provider.ts new file mode 100644 index 0000000000..501a2e3f92 --- /dev/null +++ b/packages/medusa-payment-paypal/src/services/paypal-provider.ts @@ -0,0 +1,327 @@ +import { EOL } from "os" +import { + AbstractPaymentProcessor, + isPaymentProcessorError, + PaymentProcessorContext, + PaymentProcessorError, + PaymentProcessorSessionResponse, + PaymentSessionStatus, +} from "@medusajs/medusa" +import { + PaypalOptions, + PaypalOrder, + PaypalOrderStatus, + PurchaseUnits, +} from "../types" +import { humanizeAmount } from "medusa-core-utils" +import { roundToTwo } from "./utils/utils" +import { CreateOrder, PaypalSdk } from "../core" +import { Logger } from "@medusajs/types" + +class PayPalProviderService extends AbstractPaymentProcessor { + static identifier = "paypal" + + protected readonly options_: PaypalOptions + protected paypal_: PaypalSdk + protected readonly logger_: Logger | undefined + + constructor({ logger }: { logger?: Logger }, options) { + // @ts-ignore + // eslint-disable-next-line prefer-rest-params + super(...arguments) + + this.logger_ = logger + this.options_ = options + this.init() + } + + protected init(): void { + this.paypal_ = new PaypalSdk({ + ...this.options_, + logger: this.logger_, + }) + } + + async getPaymentStatus( + paymentSessionData: Record + ): Promise { + const order = (await this.retrievePayment( + paymentSessionData + )) as PaypalOrder + + switch (order.status) { + case PaypalOrderStatus.CREATED: + return PaymentSessionStatus.PENDING + case PaypalOrderStatus.SAVED: + case PaypalOrderStatus.APPROVED: + case PaypalOrderStatus.PAYER_ACTION_REQUIRED: + return PaymentSessionStatus.REQUIRES_MORE + case PaypalOrderStatus.VOIDED: + return PaymentSessionStatus.CANCELED + case PaypalOrderStatus.COMPLETED: + return PaymentSessionStatus.AUTHORIZED + default: + return PaymentSessionStatus.PENDING + } + } + + async initiatePayment( + context: PaymentProcessorContext + ): Promise { + const { currency_code, amount, resource_id } = context + + let session_data + + try { + const intent: CreateOrder["intent"] = this.options_.capture + ? "CAPTURE" + : "AUTHORIZE" + + session_data = await this.paypal_.createOrder({ + intent, + purchase_units: [ + { + custom_id: resource_id, + amount: { + currency_code: currency_code.toUpperCase(), + value: roundToTwo( + humanizeAmount(amount, currency_code), + currency_code + ), + }, + }, + ], + }) + } catch (e) { + return this.buildError("An error occurred in initiatePayment", e) + } + + return { + session_data, + } + } + + async authorizePayment( + paymentSessionData: Record, + context: Record + ): Promise< + | PaymentProcessorError + | { + status: PaymentSessionStatus + data: PaymentProcessorSessionResponse["session_data"] + } + > { + try { + const stat = await this.getPaymentStatus(paymentSessionData) + const order = (await this.retrievePayment( + paymentSessionData + )) as PaypalOrder + return { data: order, status: stat } + } catch (error) { + return this.buildError("An error occurred in authorizePayment", error) + } + } + + async cancelPayment( + paymentSessionData: Record + ): Promise< + PaymentProcessorError | PaymentProcessorSessionResponse["session_data"] + > { + const order = (await this.retrievePayment( + paymentSessionData + )) as PaypalOrder + + const isAlreadyCanceled = order.status === PaypalOrderStatus.VOIDED + const isCanceledAndFullyRefund = + order.status === PaypalOrderStatus.COMPLETED && !!order.invoice_id + + if (isAlreadyCanceled || isCanceledAndFullyRefund) { + return order + } + + try { + const { purchase_units } = paymentSessionData as { + purchase_units: PurchaseUnits + } + const isAlreadyCaptured = purchase_units.some( + (pu) => pu.payments.captures?.length + ) + + if (isAlreadyCaptured) { + const payments = purchase_units[0].payments + + const payId = payments.captures[0].id + await this.paypal_.refundPayment(payId) + } else { + const id = purchase_units[0].payments.authorizations[0].id + await this.paypal_.cancelAuthorizedPayment(id) + } + + return (await this.retrievePayment( + paymentSessionData + )) as unknown as PaymentProcessorSessionResponse["session_data"] + } catch (error) { + return this.buildError("An error occurred in cancelPayment", error) + } + } + + async capturePayment( + paymentSessionData: Record + ): Promise< + PaymentProcessorError | PaymentProcessorSessionResponse["session_data"] + > { + const { purchase_units } = paymentSessionData as { + purchase_units: PurchaseUnits + } + + const id = purchase_units[0].payments.authorizations[0].id + + try { + await this.paypal_.captureAuthorizedPayment(id) + return await this.retrievePayment(paymentSessionData) + } catch (error) { + return this.buildError("An error occurred in capturePayment", error) + } + } + + /** + * Paypal does not provide such feature + * @param paymentSessionData + */ + async deletePayment( + paymentSessionData: Record + ): Promise< + PaymentProcessorError | PaymentProcessorSessionResponse["session_data"] + > { + return paymentSessionData + } + + async refundPayment( + paymentSessionData: Record, + refundAmount: number + ): Promise< + PaymentProcessorError | PaymentProcessorSessionResponse["session_data"] + > { + const { purchase_units } = paymentSessionData as { + purchase_units: PurchaseUnits + } + + try { + const purchaseUnit = purchase_units[0] + const payments = purchaseUnit.payments + const isAlreadyCaptured = purchase_units.some( + (pu) => pu.payments.captures?.length + ) + + if (!isAlreadyCaptured) { + throw new Error("Cannot refund an uncaptured payment") + } + + const paymentId = payments.captures[0].id + const currencyCode = purchaseUnit.amount.currency_code + await this.paypal_.refundPayment(paymentId, { + amount: { + currency_code: currencyCode, + value: roundToTwo( + humanizeAmount(refundAmount, currencyCode), + currencyCode + ), + }, + }) + + return await this.retrievePayment(paymentSessionData) + } catch (error) { + return this.buildError("An error occurred in refundPayment", error) + } + } + + async retrievePayment( + paymentSessionData: Record + ): Promise< + PaymentProcessorError | PaymentProcessorSessionResponse["session_data"] + > { + try { + const id = paymentSessionData.id as string + return (await this.paypal_.getOrder( + id + )) as unknown as PaymentProcessorSessionResponse["session_data"] + } catch (e) { + return this.buildError("An error occurred in retrievePayment", e) + } + } + + async updatePayment( + context: PaymentProcessorContext + ): Promise { + try { + const { currency_code, amount } = context + const id = context.paymentSessionData.id as string + + await this.paypal_.patchOrder(id, [ + { + op: "replace", + path: "/purchase_units/@reference_id=='default'", + value: { + amount: { + currency_code: currency_code.toUpperCase(), + value: roundToTwo( + humanizeAmount(amount, currency_code), + currency_code + ), + }, + }, + }, + ]) + return { session_data: context.paymentSessionData } + } catch (error) { + return await this.initiatePayment(context).catch((e) => { + return this.buildError("An error occurred in updatePayment", e) + }) + } + } + + async retrieveOrderFromAuth(authorization) { + const link = authorization.links.find((l) => l.rel === "up") + const parts = link.href.split("/") + const orderId = parts[parts.length - 1] + + if (!orderId) { + return null + } + + return await this.paypal_.getOrder(orderId) + } + + async retrieveAuthorization(id) { + return await this.paypal_.getAuthorizationPayment(id) + } + + protected buildError( + message: string, + e: 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 ?? "", + } + } + + /** + * Checks if a webhook is verified. + * @param {object} data - the verficiation data. + * @returns {Promise} the response of the verification request. + */ + async verifyWebhook(data) { + return await this.paypal_.verifyWebhook({ + webhook_id: this.options_.auth_webhook_id || this.options_.authWebhookId, + ...data, + }) + } +} + +export default PayPalProviderService diff --git a/packages/medusa-payment-paypal/src/services/utils/utils.ts b/packages/medusa-payment-paypal/src/services/utils/utils.ts new file mode 100644 index 0000000000..c91180c32e --- /dev/null +++ b/packages/medusa-payment-paypal/src/services/utils/utils.ts @@ -0,0 +1,8 @@ +import { zeroDecimalCurrencies } from "medusa-core-utils"; + +export function roundToTwo(num: number, currency: string): string { + if (zeroDecimalCurrencies.includes(currency.toLowerCase())) { + return `${num}` + } + return num.toFixed(2) +} \ No newline at end of file diff --git a/packages/medusa-payment-paypal/src/types.ts b/packages/medusa-payment-paypal/src/types.ts new file mode 100644 index 0000000000..28f5e2e0cc --- /dev/null +++ b/packages/medusa-payment-paypal/src/types.ts @@ -0,0 +1,52 @@ +export interface PaypalOptions { + /** + * Indicate if it should run as sandbox, default false + */ + sandbox?: boolean + clientId: string + clientSecret: string + authWebhookId: string + capture?: boolean + + /** + * Backward compatibility options below + */ + + /** + * @deprecated use clientId instead + */ + client_id: string + /** + * @deprecated use clientSecret instead + */ + client_secret: string + /** + * @deprecated use authWebhookId instead + */ + auth_webhook_id: string +} + +export type PaypalOrder = { + status: keyof typeof PaypalOrderStatus + invoice_id: string +} + +export type PurchaseUnits = { + payments: { + captures: { id: string }[] + authorizations: { id: string }[] + } + amount: { + currency_code: string + value: string + } +}[] + +export const PaypalOrderStatus = { + CREATED: "CREATED", + COMPLETED: "COMPLETED", + SAVED: "SAVED", + APPROVED: "APPROVED", + PAYER_ACTION_REQUIRED: "PAYER_ACTION_REQUIRED", + VOIDED: "VOIDED", +} diff --git a/packages/medusa-payment-paypal/tsconfig.json b/packages/medusa-payment-paypal/tsconfig.json new file mode 100644 index 0000000000..5043999ee2 --- /dev/null +++ b/packages/medusa-payment-paypal/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-paypal/tsconfig.spec.json b/packages/medusa-payment-paypal/tsconfig.spec.json new file mode 100644 index 0000000000..9b62409191 --- /dev/null +++ b/packages/medusa-payment-paypal/tsconfig.spec.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src"], + "exclude": ["node_modules"] +} 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 index 276aec919e..be4d8e3592 100644 --- a/packages/medusa-payment-stripe/src/core/__tests__/stripe-base.spec.ts +++ b/packages/medusa-payment-stripe/src/core/__tests__/stripe-base.spec.ts @@ -101,7 +101,6 @@ describe("StripeTest", () => { beforeAll(async () => { const scopedContainer = { ...container } stripeTest = new StripeTest(scopedContainer, { api_key: "test" }) - await stripeTest.init() }) beforeEach(() => { @@ -232,7 +231,6 @@ describe("StripeTest", () => { beforeAll(async () => { const scopedContainer = { ...container } stripeTest = new StripeTest(scopedContainer, { api_key: "test" }) - await stripeTest.init() }) beforeEach(() => { @@ -257,7 +255,6 @@ describe("StripeTest", () => { beforeAll(async () => { const scopedContainer = { ...container } stripeTest = new StripeTest(scopedContainer, { api_key: "test" }) - await stripeTest.init() }) beforeEach(() => { @@ -300,7 +297,6 @@ describe("StripeTest", () => { beforeAll(async () => { const scopedContainer = { ...container } stripeTest = new StripeTest(scopedContainer, { api_key: "test" }) - await stripeTest.init() }) beforeEach(() => { @@ -309,7 +305,7 @@ describe("StripeTest", () => { it("should succeed", async () => { const result = await stripeTest.capturePayment( - capturePaymentContextSuccessData + capturePaymentContextSuccessData.paymentSessionData ) expect(result).toEqual({ @@ -319,7 +315,7 @@ describe("StripeTest", () => { it("should fail on intent capture but still return the intent", async () => { const result = await stripeTest.capturePayment( - capturePaymentContextPartiallyFailData + capturePaymentContextPartiallyFailData.paymentSessionData ) expect(result).toEqual({ @@ -330,11 +326,11 @@ describe("StripeTest", () => { it("should fail on intent capture", async () => { const result = await stripeTest.capturePayment( - capturePaymentContextFailData + capturePaymentContextFailData.paymentSessionData ) expect(result).toEqual({ - error: "An error occurred in deletePayment", + error: "An error occurred in capturePayment", code: "", detail: "Error", }) @@ -347,7 +343,6 @@ describe("StripeTest", () => { beforeAll(async () => { const scopedContainer = { ...container } stripeTest = new StripeTest(scopedContainer, { api_key: "test" }) - await stripeTest.init() }) beforeEach(() => { @@ -391,7 +386,6 @@ describe("StripeTest", () => { beforeAll(async () => { const scopedContainer = { ...container } stripeTest = new StripeTest(scopedContainer, { api_key: "test" }) - await stripeTest.init() }) beforeEach(() => { @@ -429,7 +423,6 @@ describe("StripeTest", () => { beforeAll(async () => { const scopedContainer = { ...container } stripeTest = new StripeTest(scopedContainer, { api_key: "test" }) - await stripeTest.init() }) beforeEach(() => { @@ -464,7 +457,6 @@ describe("StripeTest", () => { beforeAll(async () => { const scopedContainer = { ...container } stripeTest = new StripeTest(scopedContainer, { api_key: "test" }) - await stripeTest.init() }) beforeEach(() => { diff --git a/packages/medusa-payment-stripe/src/core/stripe-base.ts b/packages/medusa-payment-stripe/src/core/stripe-base.ts index 2f6f7089bb..d24df3bb52 100644 --- a/packages/medusa-payment-stripe/src/core/stripe-base.ts +++ b/packages/medusa-payment-stripe/src/core/stripe-base.ts @@ -186,11 +186,11 @@ abstract class StripeBase extends AbstractPaymentProcessor { } async capturePayment( - context: PaymentProcessorContext + paymentSessionData: Record ): Promise< PaymentProcessorError | PaymentProcessorSessionResponse["session_data"] > { - const id = context.paymentSessionData.id as string + const id = paymentSessionData.id as string try { const intent = await this.stripe_.paymentIntents.capture(id) return intent as unknown as PaymentProcessorSessionResponse["session_data"] @@ -201,7 +201,7 @@ abstract class StripeBase extends AbstractPaymentProcessor { } } - return this.buildError("An error occurred in deletePayment", error) + return this.buildError("An error occurred in capturePayment", error) } } diff --git a/packages/medusa-payment-stripe/src/index.ts b/packages/medusa-payment-stripe/src/index.ts new file mode 100644 index 0000000000..5184713378 --- /dev/null +++ b/packages/medusa-payment-stripe/src/index.ts @@ -0,0 +1,8 @@ +export * from "./types" +export * from "./core/stripe-base" +export * from "./services/stripe-blik" +export * from "./services/stripe-bancontact" +export * from "./services/stripe-giropay" +export * from "./services/stripe-ideal" +export * from "./services/stripe-przelewy24" +export * from "./services/stripe-provider" diff --git a/packages/medusa/src/api/routes/store/orders/index.ts b/packages/medusa/src/api/routes/store/orders/index.ts index 679ce0acd9..26e78ce41c 100644 --- a/packages/medusa/src/api/routes/store/orders/index.ts +++ b/packages/medusa/src/api/routes/store/orders/index.ts @@ -101,14 +101,7 @@ export const defaultStoreOrdersFields = [ "currency_code", "tax_rate", "created_at", - "shipping_total", - "discount_total", - "tax_total", "items.refundable", - "refunded_total", - "gift_card_total", - "subtotal", - "total", ] as (keyof Order)[] export const allowedStoreOrdersFields = [ diff --git a/yarn.lock b/yarn.lock index 0c3c475451..5a96a7fd1f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13872,6 +13872,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:^1.3.4": + version: 1.3.4 + resolution: "axios@npm:1.3.4" + dependencies: + follow-redirects: ^1.15.0 + form-data: ^4.0.0 + proxy-from-env: ^1.1.0 + checksum: 39f03d83a9ed5760094f92a677af2533ab159448c8e22bfba98d8957bdef2babe142e117a0a7d9a5aff1d5f28f8ced28eb0471b6a91d33410375c89e49032193 + languageName: node + linkType: hard + "axobject-query@npm:^2.2.0": version: 2.2.0 resolution: "axobject-query@npm:2.2.0" @@ -25020,20 +25031,6 @@ __metadata: languageName: node linkType: hard -"jest-environment-node@npm:25.5.0, jest-environment-node@npm:^25.5.0": - version: 25.5.0 - resolution: "jest-environment-node@npm:25.5.0" - dependencies: - "@jest/environment": ^25.5.0 - "@jest/fake-timers": ^25.5.0 - "@jest/types": ^25.5.0 - jest-mock: ^25.5.0 - jest-util: ^25.5.0 - semver: ^6.3.0 - checksum: 6c5484f828757abc5d9878d77a7c8d76b44d00de51cd056fc37d0817ae6a5d74ec543a8e02bcd2d8e3a433ec98b416f6c2038919487b9d3eca92d9dd223f0115 - languageName: node - linkType: hard - "jest-environment-node@npm:26.6.2, jest-environment-node@npm:^26.6.2": version: 26.6.2 resolution: "jest-environment-node@npm:26.6.2" @@ -25048,6 +25045,20 @@ __metadata: languageName: node linkType: hard +"jest-environment-node@npm:^25.5.0": + version: 25.5.0 + resolution: "jest-environment-node@npm:25.5.0" + dependencies: + "@jest/environment": ^25.5.0 + "@jest/fake-timers": ^25.5.0 + "@jest/types": ^25.5.0 + jest-mock: ^25.5.0 + jest-util: ^25.5.0 + semver: ^6.3.0 + checksum: 6c5484f828757abc5d9878d77a7c8d76b44d00de51cd056fc37d0817ae6a5d74ec543a8e02bcd2d8e3a433ec98b416f6c2038919487b9d3eca92d9dd223f0115 + languageName: node + linkType: hard + "jest-environment-node@npm:^27.5.1": version: 27.5.1 resolution: "jest-environment-node@npm:27.5.1" @@ -28527,29 +28538,17 @@ __metadata: version: 0.0.0-use.local resolution: "medusa-payment-paypal@workspace:packages/medusa-payment-paypal" 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.8.0-rc.0 "@paypal/checkout-server-sdk": ^1.0.3 + "@types/stripe": ^8.0.417 + axios: ^1.3.4 body-parser: ^1.19.0 - client-sessions: ^0.8.0 cross-env: ^5.2.1 express: ^4.17.1 jest: ^25.5.4 - jest-environment-node: 25.5.0 medusa-core-utils: ^1.2.0-rc.0 - medusa-interfaces: ^1.3.7-rc.0 - medusa-test-utils: ^1.1.40-rc.0 peerDependencies: - medusa-interfaces: 1.3.7-rc.0 + "@medusajs/medusa": ^1.7.7 languageName: unknown linkType: soft