Replaces MongoDB support with PostgreSQL (#151)

- All schemas have been rewritten to a relational model
- All services have been rewritten to accommodate the new data model
- Adds idempotency keys to core endpoints allowing you to retry requests with no additional side effects
- Adds staged jobs to avoid putting jobs in the queue when transactions abort
- Adds atomic transactions to all methods with access to the data layer

Co-authored-by: Oliver Windall Juhl <oliver@mrbltech.com>
This commit is contained in:
Sebastian Rindom
2021-01-26 10:26:14 +01:00
co-authored by Oliver Windall Juhl
parent 5f819486fc
commit f1baca3cbd
499 changed files with 25909 additions and 16128 deletions
@@ -2,13 +2,17 @@ import { IdMap } from "medusa-test-utils"
export const carts = {
frCart: {
_id: IdMap.getId("fr-cart"),
id: IdMap.getId("fr-cart"),
email: "lebron@james.com",
title: "test",
region: {
tax_rate: 2500,
currency_code: "eur",
},
region_id: IdMap.getId("region-france"),
items: [
{
_id: IdMap.getId("line"),
id: IdMap.getId("line"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
@@ -16,20 +20,20 @@ export const carts = {
{
unit_price: 8,
variant: {
_id: IdMap.getId("eur-8-us-10"),
id: IdMap.getId("eur-8-us-10"),
},
product: {
_id: IdMap.getId("product"),
id: IdMap.getId("product"),
},
quantity: 1,
},
{
unit_price: 10,
variant: {
_id: IdMap.getId("eur-10-us-12"),
id: IdMap.getId("eur-10-us-12"),
},
product: {
_id: IdMap.getId("product"),
id: IdMap.getId("product"),
},
quantity: 1,
},
@@ -37,17 +41,17 @@ export const carts = {
quantity: 10,
},
{
_id: IdMap.getId("existingLine"),
id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 10,
variant: {
_id: IdMap.getId("eur-10-us-12"),
id: IdMap.getId("eur-10-us-12"),
},
product: {
_id: IdMap.getId("product"),
id: IdMap.getId("product"),
},
quantity: 1,
},
@@ -56,13 +60,16 @@ export const carts = {
],
shipping_methods: [
{
_id: IdMap.getId("freeShipping"),
id: IdMap.getId("freeShipping"),
data: {
name: "test",
},
profile_id: "default_profile",
},
],
shipping_options: [
{
_id: IdMap.getId("freeShipping"),
id: IdMap.getId("freeShipping"),
profile_id: "default_profile",
},
],
@@ -87,7 +94,7 @@ export const carts = {
discounts: [
{
code: "MEDUSA_FREE",
discount_rule: {
rule: {
type: "percent",
value: 20,
allocation: "item",
@@ -3,38 +3,107 @@ export default async (req, res) => {
const { shipping_address, merchant_data } = req.body
try {
const manager = req.scope.resolve("manager")
const cartService = req.scope.resolve("cartService")
const klarnaProviderService = req.scope.resolve("pp_klarna")
const shippingProfileService = req.scope.resolve("shippingProfileService")
const cart = await cartService.retrieve(merchant_data)
const result = await manager.transaction("SERIALIZABLE", async (m) => {
const cart = await cartService.retrieve(merchant_data, {
select: ["subtotal"],
relations: [
"shipping_address",
"billing_address",
"region",
"items",
"shipping_methods",
"shipping_methods.shipping_option",
"items.variant",
"items.variant.product",
],
})
if (shipping_address) {
const updatedAddress = {
first_name: shipping_address.given_name,
last_name: shipping_address.family_name,
address_1: shipping_address.street_address,
address_2: shipping_address.street_address2,
city: shipping_address.city,
country_code: shipping_address.country.toUpperCase(),
postal_code: shipping_address.postal_code,
phone: shipping_address.phone
if (shipping_address) {
const shippingAddress = {
...cart.shipping_address,
first_name: shipping_address.given_name,
last_name: shipping_address.family_name,
address_1: shipping_address.street_address,
address_2: shipping_address.street_address2,
city: shipping_address.city,
country_code: shipping_address.country,
postal_code: shipping_address.postal_code,
phone: shipping_address.phone,
}
let billingAddress = {
first_name: shipping_address.given_name,
last_name: shipping_address.family_name,
address_1: shipping_address.street_address,
address_2: shipping_address.street_address2,
city: shipping_address.city,
country_code: shipping_address.country,
postal_code: shipping_address.postal_code,
phone: shipping_address.phone,
}
if (cart.billing_address) {
billingAddress = {
...billingAddress,
...cart.billing_address,
}
}
await cartService.update(cart.id, {
shipping_address: shippingAddress,
billing_address: billingAddress,
email: shipping_address.email,
})
const shippingOptions = await shippingProfileService.fetchCartOptions(
cart
)
if (shippingOptions?.length) {
const option = shippingOptions.find(
(o) => o.data && !o.data.require_drop_point
)
await cartService
.withTransaction(m)
.addShippingMethod(cart.id, option.id, option.data)
}
// Fetch and return updated Klarna order
const updatedCart = await cartService
.withTransaction(m)
.retrieve(cart.id, {
select: [
"gift_card_total",
"subtotal",
"total",
"tax_total",
"discount_total",
"subtotal",
],
relations: [
"shipping_address",
"billing_address",
"region",
"shipping_methods",
"shipping_methods.shipping_option",
"items",
"items.variant",
"items.variant.product",
],
})
return klarnaProviderService.cartToKlarnaOrder(updatedCart)
} else {
return null
}
await cartService.updateShippingAddress(cart._id, updatedAddress)
await cartService.updateBillingAddress(cart._id, updatedAddress)
await cartService.updateEmail(cart._id, shipping_address.email)
})
const shippingOptions = await shippingProfileService.fetchCartOptions(cart)
if (shippingOptions.length === 1) {
const option = shippingOptions[0]
await cartService.addShippingMethod(cart._id, option._id, option.data)
}
// Fetch and return updated Klarna order
const updatedCart = await cartService.retrieve(cart._id)
const order = await klarnaProviderService.cartToKlarnaOrder(updatedCart)
res.json(order)
if (result) {
res.json(result)
return
} else {
res.sendStatus(400)
@@ -4,7 +4,6 @@ export default async (req, res) => {
const { klarna_order_id } = req.query
try {
const cartService = req.scope.resolve("cartService")
const orderService = req.scope.resolve("orderService")
const klarnaProviderService = req.scope.resolve("pp_klarna")
@@ -13,22 +12,9 @@ export default async (req, res) => {
)
const cartId = klarnaOrder.merchant_data
try {
const order = await orderService.retrieveByCartId(cartId)
await klarnaProviderService.acknowledgeOrder(
klarnaOrder.order_id,
order._id
)
} catch (err) {
if (err.type === MedusaError.Types.NOT_FOUND) {
const cart = await cartService.retrieve(cartId)
const order = await orderService.createFromCart(cart)
await klarnaProviderService.acknowledgeOrder(
klarnaOrder.order_id,
order._id
)
}
}
const order = await orderService.retrieveByCartId(cartId)
await klarnaProviderService.acknowledgeOrder(klarnaOrder.order_id, order.id)
res.sendStatus(200)
} catch (error) {
@@ -7,19 +7,57 @@ export default async (req, res) => {
const klarnaProviderService = req.scope.resolve("pp_klarna")
const shippingProfileService = req.scope.resolve("shippingProfileService")
const cart = await cartService.retrieve(merchant_data)
const shippingOptions = await shippingProfileService.fetchCartOptions(cart)
const cart = await cartService.retrieve(merchant_data, {
select: ["subtotal"],
relations: [
"shipping_address",
"billing_address",
"region",
"shipping_methods",
"shipping_methods.shipping_option",
"items",
"items.variant",
"items.variant.product",
],
})
let shippingOptions = await shippingProfileService.fetchCartOptions(cart)
shippingOptions = shippingOptions.filter(
(so) => !so.data?.require_drop_point
)
const ids = selected_shipping_option.id.split(".")
await Promise.all(ids.map(async id => {
const option = shippingOptions.find(({ _id }) => _id.equals(id))
await Promise.all(
ids.map(async (id) => {
const option = shippingOptions.find((so) => so.id === id)
if (option) {
await cartService.addShippingMethod(cart._id, option._id, option.data)
}
}))
if (option) {
await cartService.addShippingMethod(cart.id, option.id, option.data)
}
})
)
const newCart = await cartService.retrieve(cart.id, {
select: [
"gift_card_total",
"subtotal",
"total",
"tax_total",
"discount_total",
"subtotal",
],
relations: [
"shipping_address",
"billing_address",
"shipping_methods",
"shipping_methods.shipping_option",
"region",
"items",
"items.variant",
"items.variant.product",
],
})
const newCart = await cartService.retrieve(cart._id)
const order = await klarnaProviderService.cartToKlarnaOrder(newCart)
res.json(order)
} catch (error) {
@@ -1,3 +1,5 @@
jest.unmock("axios")
import axios from "axios"
import MockAdapter from "axios-mock-adapter"
import KlarnaProviderService from "../klarna-provider"
@@ -151,7 +153,6 @@ describe("KlarnaProviderService", () => {
expect(result).toEqual({
order_id: "123456789",
order_amount: 1000,
})
})
})
@@ -176,15 +177,19 @@ describe("KlarnaProviderService", () => {
mockServer
.onPost("/ordermanagement/v1/orders/123456789/cancel")
.reply(() => {
return [200]
return [200, { order_id: "123456789" }]
})
it("returns order id", async () => {
mockServer.onGet("/ordermanagement/v1/orders/123456789").reply(() => {
return [200, { order_id: "123456789" }]
})
it("returns order", async () => {
result = await klarnaProviderService.cancelPayment({
order_id: "123456789",
data: { order_id: "123456789" },
})
expect(result).toEqual("123456789")
expect(result).toEqual({ order_id: "123456789" })
})
})
@@ -289,15 +294,19 @@ describe("KlarnaProviderService", () => {
mockServer
.onPost("/ordermanagement/v1/orders/123456789/captures")
.reply(() => {
return [200]
return [200, { order_id: "123456789" }]
})
it("returns order id", async () => {
mockServer.onGet("/ordermanagement/v1/orders/123456789").reply(() => {
return [200, { order_id: "123456789" }]
})
it("returns order", async () => {
result = await klarnaProviderService.capturePayment({
order_id: "123456789",
data: { order_id: "123456789" },
})
expect(result).toEqual("123456789")
expect(result).toEqual({ order_id: "123456789" })
})
})
@@ -322,18 +331,22 @@ describe("KlarnaProviderService", () => {
mockServer
.onPost("/ordermanagement/v1/orders/123456789/refunds")
.reply(() => {
return [200]
return [200, { order_id: "123456789" }]
})
it("returns order id", async () => {
mockServer.onGet("/ordermanagement/v1/orders/123456789").reply(() => {
return [200, { order_id: "123456789" }]
})
it("returns order", async () => {
result = await klarnaProviderService.refundPayment(
{
order_id: "123456789",
data: { order_id: "123456789" },
},
1000
)
expect(result).toEqual("123456789")
expect(result).toEqual({ order_id: "123456789" })
})
})
})
@@ -5,14 +5,26 @@ import { PaymentService } from "medusa-interfaces"
class KlarnaProviderService extends PaymentService {
static identifier = "klarna"
constructor(
{ shippingProfileService, totalsService, regionService },
options
) {
constructor({ shippingProfileService }, options) {
super()
/**
* Required Klarna options:
* {
* backend_url: "",
* url: "",
* user: "",
* password: "",
* merchant_urls: {
* terms: ``,
* checkout: ``,
* confirmation: ``,
* }
* }
*/
this.options_ = options
/** @private @const {Klarna} */
this.klarna_ = axios.create({
baseURL: options.url,
auth: {
@@ -27,54 +39,36 @@ class KlarnaProviderService extends PaymentService {
this.backendUrl_ = options.backend_url
this.totalsService_ = totalsService
this.regionService_ = regionService
/** @private @const {ShippingProfileService} */
this.shippingProfileService_ = shippingProfileService
}
async lineItemsToOrderLines_(cart, taxRate) {
let order_lines = []
const tax = taxRate / 100
cart.items.forEach((item) => {
// For bundles, we create an order line for each item in the bundle
if (Array.isArray(item.content)) {
item.content.forEach((c) => {
const total_amount = c.unit_price * c.quantity * (taxRate + 1)
const total_tax_amount = total_amount * taxRate
// Withdraw discount from the total item amount
const quantity = item.quantity
const unit_price = item.unit_price * (tax + 1)
const total_amount = unit_price * quantity
const total_tax_amount = total_amount * (tax / (1 + tax))
order_lines.push({
name: item.title,
unit_price: c.unit_price,
quantity: c.quantity,
tax_rate: taxRate * 10000,
total_amount,
total_tax_amount,
})
})
} else {
// Withdraw discount from the total item amount
const quantity = item.quantity
const unit_price = item.content.unit_price * 100 * (taxRate + 1)
const total_amount = unit_price * quantity
const total_tax_amount = total_amount * (taxRate / (1 + taxRate))
order_lines.push({
name: item.title,
tax_rate: taxRate * 10000,
quantity,
unit_price,
total_amount,
total_tax_amount,
})
}
order_lines.push({
name: item.title,
tax_rate: tax * 10000,
quantity,
unit_price,
total_amount,
total_tax_amount,
})
})
if (cart.shipping_methods.length) {
const { name, price } = cart.shipping_methods.reduce(
(acc, next) => {
acc.name = [...acc.name, next.name]
acc.name = [...acc.name, next.data.name]
acc.price += next.price
return acc
},
@@ -85,10 +79,10 @@ class KlarnaProviderService extends PaymentService {
name: name.join(" + "),
quantity: 1,
type: "shipping_fee",
unit_price: price * (1 + taxRate) * 100,
tax_rate: taxRate * 10000,
total_amount: price * (1 + taxRate) * 100,
total_tax_amount: price * taxRate * 100,
unit_price: price * (1 + tax),
tax_rate: tax * 10000,
total_amount: price * (1 + tax),
total_tax_amount: price * tax,
})
}
@@ -98,28 +92,39 @@ class KlarnaProviderService extends PaymentService {
async cartToKlarnaOrder(cart) {
let order = {
// Cart id is stored, such that we can use it for hooks
merchant_data: cart._id,
// TODO: Investigate if other locales are needed
merchant_data: cart.id,
locale: "en-US",
}
const { tax_rate, currency_code } = await this.regionService_.retrieve(
cart.region_id
)
const { region, gift_card_total, discount_total, tax_total, total } = cart
order.order_lines = await this.lineItemsToOrderLines_(cart, tax_rate)
const taxRate = region.tax_rate / 100
const discount = (await this.totalsService_.getDiscountTotal(cart)) * 100
if (discount) {
order.order_lines = await this.lineItemsToOrderLines_(cart, region.tax_rate)
if (discount_total) {
order.order_lines.push({
name: `Discount`,
quantity: 1,
type: "discount",
unit_price: 0,
total_discount_amount: discount * (1 + tax_rate),
tax_rate: tax_rate * 10000,
total_amount: -discount * (1 + tax_rate),
total_tax_amount: -discount * tax_rate,
total_discount_amount: discount_total * (1 + taxRate),
tax_rate: taxRate * 10000,
total_amount: -discount_total * (1 + taxRate),
total_tax_amount: -discount_total * taxRate,
})
}
if (gift_card_total) {
order.order_lines.push({
name: `Gift Card`,
quantity: 1,
type: "gift_card",
unit_price: 0,
total_discount_amount: gift_card_total * (1 + taxRate),
tax_rate: taxRate * 10000,
total_amount: -gift_card_total * (1 + taxRate),
total_tax_amount: -gift_card_total * taxRate,
})
}
@@ -134,20 +139,19 @@ class KlarnaProviderService extends PaymentService {
}
}
// TODO: Check if country matches ISO
if (
!_.isEmpty(cart.shipping_address) &&
cart.shipping_address.country_code
) {
order.purchase_country = cart.shipping_address.country_code
const hasCountry =
!_.isEmpty(cart.shipping_address) && cart.shipping_address.country_code
if (hasCountry) {
order.purchase_country = cart.shipping_address.country_code.toUpperCase()
} else {
// Defaults to Sweden
order.purchase_country = "SE"
}
order.order_amount = (await this.totalsService_.getTotal(cart)) * 100
order.order_tax_amount = (await this.totalsService_.getTaxTotal(cart)) * 100
// TODO: Check if currency matches ISO
order.purchase_currency = currency_code
order.order_amount = total
order.order_tax_amount = tax_total
order.purchase_currency = region.currency_code.toUpperCase()
order.merchant_urls = {
terms: this.options_.merchant_urls.terms,
@@ -159,20 +163,24 @@ class KlarnaProviderService extends PaymentService {
}
if (cart.shipping_address && cart.shipping_address.first_name) {
const shippingOptions = await this.shippingProfileService_.fetchCartOptions(
let shippingOptions = await this.shippingProfileService_.fetchCartOptions(
cart
)
shippingOptions = shippingOptions.filter(
(so) => !so.data?.require_drop_point
)
// If the cart does not have shipping methods yet, preselect one from
// shipping_options and set the selected shipping method
if (cart.shipping_methods.length) {
const shipping_method = cart.shipping_methods[0]
order.selected_shipping_option = {
id: shipping_method._id,
name: shipping_method.name,
price: shipping_method.price * (1 + tax_rate) * 100,
tax_amount: shipping_method.price * tax_rate * 100,
tax_rate: tax_rate * 10000,
id: shipping_method.shipping_option.id,
name: shipping_method.shipping_option.name,
price: shipping_method.price * (1 + taxRate),
tax_amount: shipping_method.price * taxRate,
tax_rate: taxRate * 10000,
}
}
@@ -185,20 +193,25 @@ class KlarnaProviderService extends PaymentService {
return acc
}, {})
let f = (a, b) =>
[].concat(...a.map((a) => b.map((b) => [].concat(a, b))))
let cartesian = (a, b, ...c) => (b ? cartesian(f(a, b), ...c) : a)
// Helper function that calculates the cartesian product of multiple arrays
// Don't touch :D
// From: https://stackoverflow.com/questions/12303989/cartesian-product-of-multiple-arrays-in-javascript
const f = (a, b) =>
[].concat(...a.map((d) => b.map((e) => [].concat(d, e))))
const cartesian = (a, b, ...c) => (b ? cartesian(f(a, b), ...c) : a)
const methods = Object.keys(partitioned).map((k) => partitioned[k])
const combinations = cartesian(...methods)
// Use the cartesian product of shipping methods to generate correct
// format for the Klarna Widget
order.shipping_options = combinations.map((combination) => {
combination = Array.isArray(combination) ? combination : [combination]
const details = combination.reduce(
(acc, next) => {
acc.id = [...acc.id, next._id]
acc.id = [...acc.id, next.id]
acc.name = [...acc.name, next.name]
acc.price += next.price
acc.price += next.amount
return acc
},
{ id: [], name: [], price: 0 }
@@ -207,10 +220,9 @@ class KlarnaProviderService extends PaymentService {
return {
id: details.id.join("."),
name: details.name.join(" + "),
price: details.price * (1 + tax_rate) * 100,
tax_amount: details.price * tax_rate * 100,
tax_rate: tax_rate * 10000,
preselected: combinations.length === 1,
price: details.price * (1 + taxRate),
tax_amount: details.price * taxRate,
tax_rate: taxRate * 10000,
}
})
}
@@ -224,20 +236,18 @@ class KlarnaProviderService extends PaymentService {
* @returns {string} the status of the Klarna order
*/
async getStatus(paymentData) {
try {
const { order_id } = paymentData
const { data: order } = await this.klarna_.get(
`${this.klarnaOrderUrl_}/${order_id}`
)
const { order_id } = paymentData
const { data: order } = await this.klarna_.get(
`${this.klarnaOrderUrl_}/${order_id}`
)
let status = "initial"
if (order.status === "checkout_complete") {
status = "authorized"
}
return status
} catch (error) {
throw error
let status = "pending"
if (order.status === "checkout_complete") {
status = "authorized"
}
return status
}
/**
@@ -249,9 +259,12 @@ class KlarnaProviderService extends PaymentService {
async createPayment(cart) {
try {
const order = await this.cartToKlarnaOrder(cart)
return this.klarna_
const klarnaPayment = await this.klarna_
.post(this.klarnaOrderUrl_, order)
.then(({ data }) => data)
return klarnaPayment
} catch (error) {
throw error
}
@@ -272,6 +285,21 @@ class KlarnaProviderService extends PaymentService {
}
}
/**
* Gets a Klarna payment objec.
* @param {object} sessionData - the data of the payment to retrieve
* @returns {Promise<object>} Stripe payment intent
*/
async getPaymentData(sessionData) {
try {
return this.klarna_
.get(`${this.klarnaOrderUrl_}/${sessionData.data.order_id}`)
.then(({ data }) => data)
} catch (error) {
throw error
}
}
/**
* Retrieves completed Klarna Order.
* @param {string} klarnaOrderId - id of the order to retrieve
@@ -287,6 +315,23 @@ class KlarnaProviderService extends PaymentService {
}
}
/**
* Authorizes Klarna payment by simply returning the status for the payment
* in use.
* @param {object} sessionData - payment session data
* @param {object} context - properties relevant to current context
* @returns {Promise<{ status: string, data: object }>} result with data and status
*/
async authorizePayment(sessionData, context = {}) {
try {
const paymentStatus = await this.getStatus(sessionData.data)
return { data: sessionData.data, status: paymentStatus }
} catch (error) {
throw error
}
}
/**
* Acknowledges a Klarna order as part of the order completion process
* @param {string} klarnaOrderId - id of the order to acknowledge
@@ -332,6 +377,14 @@ class KlarnaProviderService extends PaymentService {
}
}
async updatePaymentData(sessionData, update) {
try {
return { ...sessionData, ...update }
} catch (error) {
throw error
}
}
/**
* Updates Klarna order.
* @param {string} order - the order to update
@@ -339,14 +392,14 @@ class KlarnaProviderService extends PaymentService {
* @returns {Object} updated order
*/
async updatePayment(paymentData, cart) {
try {
const order = await this.cartToKlarnaOrder(cart, true)
if (cart.total !== paymentData.order_amount) {
const order = await this.cartToKlarnaOrder(cart)
return this.klarna_
.post(`${this.klarnaOrderUrl_}/${paymentData.order_id}`, order)
.then(({ data }) => data)
} catch (error) {
throw error
}
return paymentData
}
/**
@@ -354,9 +407,9 @@ class KlarnaProviderService extends PaymentService {
* @param {Object} paymentData - payment method data from cart
* @returns {string} id of captured order
*/
async capturePayment(paymentData) {
async capturePayment(payment) {
const { order_id } = payment.data
try {
const { order_id } = paymentData
const { data: order } = await this.klarna_.get(
`${this.klarnaOrderManagementUrl_}/${order_id}`
)
@@ -368,7 +421,8 @@ class KlarnaProviderService extends PaymentService {
captured_amount: order_amount,
}
)
return order_id
return this.retrieveCompletedOrder(order_id)
} catch (error) {
throw error
}
@@ -379,16 +433,17 @@ class KlarnaProviderService extends PaymentService {
* @param {Object} paymentData - payment method data from cart
* @returns {string} id of refunded order
*/
async refundPayment(paymentData, amount) {
async refundPayment(payment, amountToRefund) {
const { order_id } = payment.data
try {
const { order_id } = paymentData
await this.klarna_.post(
`${this.klarnaOrderManagementUrl_}/${order_id}/refunds`,
{
refunded_amount: amount * 100,
refunded_amount: amountToRefund,
}
)
return order_id
return this.retrieveCompletedOrder(order_id)
} catch (error) {
throw error
}
@@ -399,17 +454,22 @@ class KlarnaProviderService extends PaymentService {
* @param {Object} paymentData - payment method data from cart
* @returns {string} id of cancelled order
*/
async cancelPayment(paymentData) {
async cancelPayment(payment) {
const { order_id } = payment.data
try {
const { order_id } = paymentData
await this.klarna_.post(
`${this.klarnaOrderManagementUrl_}/${order_id}/cancel`
)
return order_id
return this.retrieveCompletedOrder(order_id)
} catch (error) {
throw error
}
}
async deletePayment(_) {
return Promise.resolve()
}
}
export default KlarnaProviderService