diff --git a/packages/medusa-payment-paypal/.babelrc b/packages/medusa-payment-paypal/.babelrc new file mode 100644 index 0000000000..75cbf1558b --- /dev/null +++ b/packages/medusa-payment-paypal/.babelrc @@ -0,0 +1,14 @@ +{ + "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/.eslintrc b/packages/medusa-payment-paypal/.eslintrc new file mode 100644 index 0000000000..2a889697f0 --- /dev/null +++ b/packages/medusa-payment-paypal/.eslintrc @@ -0,0 +1,9 @@ +{ + "plugins": ["prettier"], + "extends": ["prettier"], + "rules": { + "prettier/prettier": "error", + "semi": "error", + "no-unused-expressions": "true" + } +} diff --git a/packages/medusa-payment-paypal/.gitignore b/packages/medusa-payment-paypal/.gitignore new file mode 100644 index 0000000000..5b89b55576 --- /dev/null +++ b/packages/medusa-payment-paypal/.gitignore @@ -0,0 +1,16 @@ +/lib +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 new file mode 100644 index 0000000000..73122644c5 --- /dev/null +++ b/packages/medusa-payment-paypal/.npmignore @@ -0,0 +1,13 @@ +/lib +node_modules +.DS_store +.env* +/*.js +!index.js +yarn.lock +src +.gitignore +.eslintrc +.babelrc +.prettierrc + diff --git a/packages/medusa-payment-paypal/.prettierrc b/packages/medusa-payment-paypal/.prettierrc new file mode 100644 index 0000000000..70175ce150 --- /dev/null +++ b/packages/medusa-payment-paypal/.prettierrc @@ -0,0 +1,7 @@ +{ + "endOfLine": "lf", + "semi": false, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5" +} \ No newline at end of file diff --git a/packages/medusa-payment-paypal/README.md b/packages/medusa-payment-paypal/README.md new file mode 100644 index 0000000000..777be3ed58 --- /dev/null +++ b/packages/medusa-payment-paypal/README.md @@ -0,0 +1,11 @@ +# medusa-payment-paypal + +## Options + +``` +sandbox: [default: false], +client_id: "CLIENT_ID", REQUIRED +client_secret: "CLIENT_SECRET", REQUIRED +auth_webhook_id: REQUIRED for webhook to work +``` + diff --git a/packages/medusa-payment-paypal/index.js b/packages/medusa-payment-paypal/index.js new file mode 100644 index 0000000000..172f1ae6a4 --- /dev/null +++ b/packages/medusa-payment-paypal/index.js @@ -0,0 +1 @@ +// noop diff --git a/packages/medusa-payment-paypal/package.json b/packages/medusa-payment-paypal/package.json new file mode 100644 index 0000000000..f43961bf73 --- /dev/null +++ b/packages/medusa-payment-paypal/package.json @@ -0,0 +1,47 @@ +{ + "name": "medusa-payment-paypal", + "version": "1.0.0", + "description": "Paypal Payment provider for Meduas Commerce", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/medusajs/medusa", + "directory": "packages/medusa-payment-paypal" + }, + "author": "Sebastian Rindom", + "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", + "eslint": "^6.8.0", + "jest": "^25.5.2", + "medusa-test-utils": "^1.1.2" + }, + "scripts": { + "build": "babel src -d . --ignore **/__tests__", + "prepare": "cross-env NODE_ENV=production npm run build", + "watch": "babel -w src --out-dir . --ignore **/__tests__", + "test": "jest" + }, + "peerDependencies": { + "medusa-interfaces": "1.x" + }, + "dependencies": { + "@paypal/checkout-server-sdk": "^1.0.2", + "body-parser": "^1.19.0", + "express": "^4.17.1", + "medusa-core-utils": "^1.1.0" + }, + "gitHead": "0646bd395a6056657cb0aa93c13699c4a9dbbcdd" +} diff --git a/packages/medusa-payment-paypal/src/api/index.js b/packages/medusa-payment-paypal/src/api/index.js new file mode 100644 index 0000000000..50feeb7074 --- /dev/null +++ b/packages/medusa-payment-paypal/src/api/index.js @@ -0,0 +1,10 @@ +import { Router } from "express" +import hooks from "./routes/hooks" + +export default (container) => { + const app = Router() + + hooks(app) + + return 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 new file mode 100644 index 0000000000..1c3692b377 --- /dev/null +++ b/packages/medusa-payment-paypal/src/api/middlewares/await-middleware.js @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000000..c784e319a9 --- /dev/null +++ b/packages/medusa-payment-paypal/src/api/middlewares/index.js @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000000..9205bae966 --- /dev/null +++ b/packages/medusa-payment-paypal/src/api/routes/hooks/index.js @@ -0,0 +1,13 @@ +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/paypal.js b/packages/medusa-payment-paypal/src/api/routes/hooks/paypal.js new file mode 100644 index 0000000000..e0ef09b680 --- /dev/null +++ b/packages/medusa-payment-paypal/src/api/routes/hooks/paypal.js @@ -0,0 +1,55 @@ +export default async (req, res) => { + const auth_algo = req.headers["paypal-auth-algo"] + const cert_url = req.headers["paypal-cert-url"] + const transmission_id = req.headers["paypal-transmission-id"] + const transmission_sig = req.headers["paypal-transmission-sig"] + const transmission_time = req.headers["paypal-transmission-time"] + + const paypalService = req.scope.resolve("paypalProviderService") + + try { + await paypalService.verifyWebhook({ + auth_algo, + cert_url, + transmission_id, + transmission_sig, + transmission_time, + webhook_event: req.body, + }) + } catch (err) { + res.sendStatus(401) + return + } + + try { + const authId = req.body.resource.id + const auth = await paypalService.retrieveAuthorization(authId) + + const order = await paypalService.retrieveOrderFromAuth(auth) + + const purchaseUnit = order.purchase_units[0] + const cartId = purchaseUnit.custom_id + + const manager = req.scope.resolve("manager") + const cartService = req.scope.resolve("cartService") + const orderService = req.scope.resolve("orderService") + + await manager.transaction(async (m) => { + const order = await orderService + .withTransaction(m) + .retrieveByCartId(cartId) + .catch((_) => undefined) + + if (!order) { + await cartService.withTransaction(m).setPaymentSession(cartId, "paypal") + await cartService.withTransaction(m).authorizePayment(cartId) + await orderService.withTransaction(m).createFromCart(cartId) + } + }) + + res.sendStatus(200) + } catch (err) { + console.error(err) + res.sendStatus(409) + } +} diff --git a/packages/medusa-payment-paypal/src/services/paypal-provider.js b/packages/medusa-payment-paypal/src/services/paypal-provider.js new file mode 100644 index 0000000000..acb03fa734 --- /dev/null +++ b/packages/medusa-payment-paypal/src/services/paypal-provider.js @@ -0,0 +1,402 @@ +import _ from "lodash" +import PayPal from "@paypal/checkout-server-sdk" +import { PaymentService } from "medusa-interfaces" + +class PayPalProviderService extends PaymentService { + static identifier = "paypal" + + constructor({ customerService, totalsService, 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 {CustomerService} */ + this.customerService_ = customerService + + /** @private @const {RegionService} */ + this.regionService_ = regionService + + /** @private @const {TotalsService} */ + this.totalsService_ = totalsService + } + + /** + * Fetches an open PayPal order and maps its status to Medusa payment + * statuses. + * @param {object} paymentData - the data stored with the payment + * @returns {string} 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" + // return "captured" + default: + return status + } + } + + /** + * Not supported + */ + async retrieveSavedMethods(customer) { + return Promise.resolve([]) + } + + /** + * 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) { + const { customer_id, region_id, email } = cart + const { currency_code } = await this.regionService_.retrieve(region_id) + + const amount = await this.totalsService_.getTotal(cart) + + const request = new PayPal.orders.OrdersCreateRequest() + request.requestBody({ + intent: "AUTHORIZE", + application_context: { + shipping_preference: "NO_SHIPPING", + }, + purchase_units: [ + { + custom_id: cart.id, + amount: { + currency_code: currency_code.toUpperCase(), + value: (amount / 100).toFixed(2), + }, + }, + ], + }) + + 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(paymentSession.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 { + const { region_id } = cart + const { currency_code } = await this.regionService_.retrieve(region_id) + + 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: (cart.total / 100).toFixed(), + }, + }, + }, + ]) + + await this.paypal_.execute(request) + + return sessionData + } catch (error) { + throw error + } + } + + /** + * 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 {string} 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: (amountToRefund / 100).toFixed(), + }, + }) + + await this.paypal_.execute(request) + + return this.retrievePayment(payment.id) + } catch (error) { + throw error + } + } + + /** + * Cancels payment for Stripe payment intent. + * @param {object} paymentData - payment method data from cart + * @returns {object} canceled payment intent + */ + async cancelPayment(payment) { + 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 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) + + console.log(webhookRes.result.webhooks) + 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/src/api/routes/admin/orders/__tests__/get-order.js b/packages/medusa/src/api/routes/admin/orders/__tests__/get-order.js index 6cec0f0b2d..858def2d4b 100644 --- a/packages/medusa/src/api/routes/admin/orders/__tests__/get-order.js +++ b/packages/medusa/src/api/routes/admin/orders/__tests__/get-order.js @@ -21,7 +21,7 @@ const defaultRelations = [ "claims.fulfillments", "claims.claim_items", "claims.claim_items.images", - "claims.claim_items.tags", + // "claims.claim_items.tags", "swaps", "swaps.return_order", "swaps.payment", diff --git a/packages/medusa/src/services/order.js b/packages/medusa/src/services/order.js index aeb0527a5a..19f23a1a95 100644 --- a/packages/medusa/src/services/order.js +++ b/packages/medusa/src/services/order.js @@ -905,11 +905,13 @@ class OrderService extends BaseService { const result = await orderRepo.save(order) - this.eventBus_ - .withTransaction(manager) - .emit(OrderService.Events.PAYMENT_CAPTURED, { - id: result.id, - }) + if (order.payment_status === "captured") { + this.eventBus_ + .withTransaction(manager) + .emit(OrderService.Events.PAYMENT_CAPTURED, { + id: result.id, + }) + } return result })