feat: adds paypal (#168) (#169)

This commit is contained in:
Sebastian Rindom
2021-02-08 21:40:59 +01:00
committed by GitHub
parent c62dd66911
commit 427ae25016
16 changed files with 612 additions and 6 deletions

View File

@@ -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"]
}
}
}

View File

@@ -0,0 +1,9 @@
{
"plugins": ["prettier"],
"extends": ["prettier"],
"rules": {
"prettier/prettier": "error",
"semi": "error",
"no-unused-expressions": "true"
}
}

View File

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

View File

@@ -0,0 +1,13 @@
/lib
node_modules
.DS_store
.env*
/*.js
!index.js
yarn.lock
src
.gitignore
.eslintrc
.babelrc
.prettierrc

View File

@@ -0,0 +1,7 @@
{
"endOfLine": "lf",
"semi": false,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5"
}

View File

@@ -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
```

View File

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

View File

@@ -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"
}

View File

@@ -0,0 +1,10 @@
import { Router } from "express"
import hooks from "./routes/hooks"
export default (container) => {
const app = Router()
hooks(app)
return app
}

View File

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

View File

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

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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<object>} 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<object>} 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<object>} 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<object>} 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<object>} 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

View File

@@ -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",

View File

@@ -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
})