Adds a payment provider service that can easily retrieve payment providers

This commit is contained in:
Sebastian Rindom
2020-02-18 14:58:11 +01:00
parent d114b806d6
commit f8fcd5a3dd
7 changed files with 313 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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