From f8fcd5a3ddac8496d9f224028da3625156a03402 Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Tue, 18 Feb 2020 14:58:11 +0100 Subject: [PATCH] Adds a payment provider service that can easily retrieve payment providers --- .../medusa-interfaces/src/payment-service.js | 4 + packages/medusa/README.md | 19 +++ .../services/__mocks__/payment-provider.js | 28 ++++ .../medusa/src/services/__mocks__/region.js | 4 +- .../medusa/src/services/__tests__/cart.js | 148 ++++++++++++++++++ packages/medusa/src/services/cart.js | 81 +++++++++- .../medusa/src/services/payment-provider.js | 33 ++++ 7 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 packages/medusa/src/services/__mocks__/payment-provider.js create mode 100644 packages/medusa/src/services/payment-provider.js diff --git a/packages/medusa-interfaces/src/payment-service.js b/packages/medusa-interfaces/src/payment-service.js index 6b783b18a9..e998d02d22 100644 --- a/packages/medusa-interfaces/src/payment-service.js +++ b/packages/medusa-interfaces/src/payment-service.js @@ -42,6 +42,10 @@ class BasePaymentService extends BaseService { throw Error("updatePayment must be overridden by the child class") } + getStatus() { + throw Error("getStatus must be overridden by the child class") + } + authorizePayment() { throw Error("authorizePayment must be overridden by the child class") } diff --git a/packages/medusa/README.md b/packages/medusa/README.md index 17adccb01b..958c4099ae 100644 --- a/packages/medusa/README.md +++ b/packages/medusa/README.md @@ -20,4 +20,23 @@ This is where events live. Want to perform a certain task whenever something els The core will look for files in the folders listed above, and inject the custom code. +# Checkout flow + +To create an order from a cart the customer must have filled in: +- their details (shipping/billing address, email) +- shipping method +- payment method + +The steps can be done in any order. The standard path would probably be: + +1. submit details (PUT /cart/shipping-address, PUT /cart/email) +2. select shipping method (PUT /cart/shipping-method) +3. enter payment details (PUT /cart/payment-method) +4. complete order (POST /order) + +Assuming that shipping methods are static within each region we can display all shipping methods at checkout time. If shipping is dynamically calculated the price of the shipping method may change, we will ask the fulfillment provider for new rates. + +Payment details can be entered at any point as long as the final amount is known. If the final amount changes afer the payment details are entered the payment method may therefore be invalidated. + +Within the store UI you could imagine each step being taken care of by a single button click, which calls all endpoints. diff --git a/packages/medusa/src/services/__mocks__/payment-provider.js b/packages/medusa/src/services/__mocks__/payment-provider.js new file mode 100644 index 0000000000..94110e27c1 --- /dev/null +++ b/packages/medusa/src/services/__mocks__/payment-provider.js @@ -0,0 +1,28 @@ +export const DefaultProviderMock = { + getStatus: jest.fn().mockImplementation(data => { + if (data.money_id === "success") { + return Promise.resolve("authorized") + } + + if (data.money_id === "fail") { + return Promise.resolve("fail") + } + + return Promise.resolve("initial") + }), +} + +export const PaymentProviderServiceMock = { + retrieveProvider: jest.fn().mockImplementation(providerId => { + if (providerId === "default_provider") { + return DefaultProviderMock + } + return undefined + }), +} + +const mock = jest.fn().mockImplementation(() => { + return PaymentProviderServiceMock +}) + +export default mock diff --git a/packages/medusa/src/services/__mocks__/region.js b/packages/medusa/src/services/__mocks__/region.js index 3f7d71bbcb..dd72b97bb5 100644 --- a/packages/medusa/src/services/__mocks__/region.js +++ b/packages/medusa/src/services/__mocks__/region.js @@ -6,8 +6,8 @@ export const regions = { name: "Test Region", countries: ["DK", "US", "DE"], tax_rate: 0.25, - payment_providers: ["default_provider"], - shipping_providers: ["test_shipper"], + payment_providers: ["default_provider", "unregistered"], + fulfillment_providers: ["test_shipper"], currency_code: "usd", }, regionFrance: { diff --git a/packages/medusa/src/services/__tests__/cart.js b/packages/medusa/src/services/__tests__/cart.js index 634910629a..7d897f6b47 100644 --- a/packages/medusa/src/services/__tests__/cart.js +++ b/packages/medusa/src/services/__tests__/cart.js @@ -1,6 +1,10 @@ import mongoose from "mongoose" import { IdMap } from "medusa-test-utils" import CartService from "../cart" +import { + PaymentProviderServiceMock, + DefaultProviderMock, +} from "../__mocks__/payment-provider" import { ProductVariantServiceMock } from "../__mocks__/product-variant" import { RegionServiceMock } from "../__mocks__/region" import { CartModelMock, carts } from "../../models/__mocks__/cart" @@ -588,4 +592,148 @@ describe("CartService", () => { ) }) }) + + describe("setPaymentMethod", () => { + const cartService = new CartService({ + cartModel: CartModelMock, + regionService: RegionServiceMock, + paymentProviderService: PaymentProviderServiceMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("successfully sets a payment method", async () => { + const paymentMethod = { + provider_id: "default_provider", + data: { + money_id: "success", + }, + } + + await cartService.setPaymentMethod( + IdMap.getId("cartWithLine"), + paymentMethod + ) + + expect(RegionServiceMock.retrieve).toHaveBeenCalledTimes(1) + expect(RegionServiceMock.retrieve).toHaveBeenCalledWith( + IdMap.getId("testRegion") + ) + + expect(PaymentProviderServiceMock.retrieveProvider).toHaveBeenCalledTimes( + 1 + ) + expect(PaymentProviderServiceMock.retrieveProvider).toHaveBeenCalledWith( + "default_provider" + ) + expect(DefaultProviderMock.getStatus).toHaveBeenCalledTimes(1) + expect(DefaultProviderMock.getStatus).toHaveBeenCalledWith({ + money_id: "success", + }) + + expect(CartModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(CartModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("cartWithLine"), + }, + { + $set: { payment_method: paymentMethod }, + } + ) + }) + + it("fails if the region does not contain the provider_id", async () => { + const paymentMethod = { + provider_id: "unknown_provider", + data: { + money_id: "success", + }, + } + + try { + await cartService.setPaymentMethod( + IdMap.getId("cartWithLine"), + paymentMethod + ) + } catch (err) { + expect(RegionServiceMock.retrieve).toHaveBeenCalledTimes(1) + expect(RegionServiceMock.retrieve).toHaveBeenCalledWith( + IdMap.getId("testRegion") + ) + + expect(err.message).toEqual( + `The payment method is not available in this region` + ) + } + }) + + it("fails if the payment provider is not registered", async () => { + const paymentMethod = { + provider_id: "unregistered", + data: { + money_id: "success", + }, + } + + try { + await cartService.setPaymentMethod( + IdMap.getId("cartWithLine"), + paymentMethod + ) + } catch (err) { + expect(RegionServiceMock.retrieve).toHaveBeenCalledTimes(1) + expect(RegionServiceMock.retrieve).toHaveBeenCalledWith( + IdMap.getId("testRegion") + ) + + expect( + PaymentProviderServiceMock.retrieveProvider + ).toHaveBeenCalledTimes(1) + expect( + PaymentProviderServiceMock.retrieveProvider + ).toHaveBeenCalledWith("unregistered") + + expect(err.message).toEqual( + `The payment provider for the payment method was not found` + ) + } + }) + + it("fails if the payment is not authorized", async () => { + const paymentMethod = { + provider_id: "default_provider", + data: { + money_id: "fail", + }, + } + + try { + await cartService.setPaymentMethod( + IdMap.getId("cartWithLine"), + paymentMethod + ) + } catch (err) { + expect(RegionServiceMock.retrieve).toHaveBeenCalledTimes(1) + expect(RegionServiceMock.retrieve).toHaveBeenCalledWith( + IdMap.getId("testRegion") + ) + + expect( + PaymentProviderServiceMock.retrieveProvider + ).toHaveBeenCalledTimes(1) + expect( + PaymentProviderServiceMock.retrieveProvider + ).toHaveBeenCalledWith("default_provider") + + expect(DefaultProviderMock.getStatus).toHaveBeenCalledTimes(1) + expect(DefaultProviderMock.getStatus).toHaveBeenCalledWith({ + money_id: "fail", + }) + + expect(err.message).toEqual(`The payment method was not authorized`) + } + }) + }) }) diff --git a/packages/medusa/src/services/cart.js b/packages/medusa/src/services/cart.js index 81d04af642..777460ab6a 100644 --- a/packages/medusa/src/services/cart.js +++ b/packages/medusa/src/services/cart.js @@ -9,10 +9,11 @@ import { BaseService } from "medusa-interfaces" class CartService extends BaseService { constructor({ cartModel, - regionService, + eventBusService, + paymentProviderService, productService, productVariantService, - eventBusService, + regionService, }) { super() @@ -30,6 +31,9 @@ class CartService extends BaseService { /** @private @const {RegionService} */ this.regionService_ = regionService + + /** @private @const {PaymentProviderService} */ + this.paymentProviderService_ = paymentProviderService } /** @@ -381,6 +385,79 @@ class CartService extends BaseService { ) } + /** + * @typedef {object} PaymentMethod + * @property {string} provider_id - the identifier of the payment method's + * provider + * @property {object} data - the data associated with the payment method + */ + + /** + * Sets a payment method for a cart. + * @param {string} cartId - the id of the cart to add payment method to + * @param {PaymentMethod} paymentMethod - the method to be set to the cart + * @returns {Promise} result of update operation + */ + async setPaymentMethod(cartId, paymentMethod) { + const cart = await this.retrieve(cartId) + if (!cart) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + "The cart was not found" + ) + } + + const region = await this.regionService_.retrieve(cart.region_id) + if (!region) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `The cart does not have a region associated` + ) + } + + // The region must have the provider id in its providers array + if ( + !( + region.payment_providers.length && + region.payment_providers.includes(paymentMethod.provider_id) + ) + ) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `The payment method is not available in this region` + ) + } + + // Check if the payment method has been authorized. + const provider = this.paymentProviderService_.retrieveProvider( + paymentMethod.provider_id + ) + if (!provider) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `The payment provider for the payment method was not found` + ) + } + + const status = await provider.getStatus(paymentMethod.data) + if (status !== "authorized") { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `The payment method was not authorized` + ) + } + + // At this point we can register the payment method. + return this.cartModel_.updateOne( + { + _id: cart._id, + }, + { + $set: { payment_method: paymentMethod }, + } + ) + } + /** * Set's the region of a cart. * @param {string} cartId - the id of the cart to set region on diff --git a/packages/medusa/src/services/payment-provider.js b/packages/medusa/src/services/payment-provider.js new file mode 100644 index 0000000000..5e58221dbf --- /dev/null +++ b/packages/medusa/src/services/payment-provider.js @@ -0,0 +1,33 @@ +import { MedusaError } from "medusa-core-utils" + +/** + * Helps retrive payment providers + */ +class PaymentProviderService { + constructor(container) { + /** @private {logger} */ + this.container_ = container + } + + /** + * Handles incoming jobs. + * @param job {{ eventName: (string), data: (any) }} + * eventName - the name of the event to process + * data - data to send to the subscriber + * + * @returns {PaymentService} the payment provider + */ + retrieveProvider(provider_id) { + try { + const provider = this.container_.resolve(`pp_${provider_id}`) + return provider + } catch (err) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Could not find a payment provider with id: ${provider_id}` + ) + } + } +} + +export default PaymentProviderService