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 <rmthamir@gmail.com>

* fix unit tests

* update changeset

---------

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>
Co-authored-by: Riqwan Thamir <rmthamir@gmail.com>
This commit is contained in:
Adrien de Peretti
2023-03-28 13:49:09 +02:00
committed by GitHub
parent 5f41cd9a67
commit 5307408894
44 changed files with 2272 additions and 1037 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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`],
}

View File

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

View File

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

View File

@@ -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":
OrdersGetRequest: jest.fn().mockImplementation((paymentId) => {
if (paymentId === FAIL_INTENT_ID) {
throw new Error("Error")
}
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",
},
}
result: Object.values(PaymentIntentDataByStatus).find(value => {
return value.id === paymentId
}) ?? {}
}
}),
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<typeof axios>
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}`,
},
})
)
})
})

View File

@@ -0,0 +1,2 @@
export * from "./paypal-sdk"
export * from "./types"

View File

@@ -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<T, TResponse>({
url,
data,
method,
}: {
url: string
data?: T
method?: Method
}): Promise<TResponse> {
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<T = unknown>(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
}
}

View File

@@ -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<CreateOrderResponse> {
const url = PaypalApiPath.CREATE_ORDER
return await this.httpClient_.request({ url, data })
}
/**
* Retrieve an order.
* @param orderId
*/
async getOrder(orderId: string): Promise<GetOrderResponse> {
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<void> {
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<CreateOrderResponse> {
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<CapturesRefundResponse> {
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<void> {
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<CapturesAuthorizationResponse> {
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<GetAuthorizationPaymentResponse> {
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 })
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
import { Links, PaymentSource, PurchaseUnit } from "./common"
export interface CreateOrder {
intent: "CAPTURE" | "AUTHORIZE"
purchase_units: Array<PurchaseUnit>
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<PurchaseUnit>
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<string, unknown>
}

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export * from "./types"
export * from "./services/paypal-provider"

View File

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

View File

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

View File

@@ -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<typeof axios>
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<string, unknown>,
{}
)
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",
})
})
})
})

View File

@@ -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<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"
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<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(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<Object>} 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<object>} 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<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)
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

@@ -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<string, unknown>
): Promise<PaymentSessionStatus> {
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<PaymentProcessorError | PaymentProcessorSessionResponse> {
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<string, unknown>,
context: Record<string, unknown>
): 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<string, unknown>
): 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<string, unknown>
): 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<string, unknown>
): Promise<
PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]
> {
return paymentSessionData
}
async refundPayment(
paymentSessionData: Record<string, unknown>,
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<string, unknown>
): 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<PaymentProcessorError | PaymentProcessorSessionResponse | void> {
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<object>} 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(() => {

View File

@@ -186,11 +186,11 @@ abstract class StripeBase extends AbstractPaymentProcessor {
}
async capturePayment(
context: PaymentProcessorContext
paymentSessionData: Record<string, unknown>
): 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)
}
}

View File

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

View File

@@ -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 = [

View File

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