From 362c5c605518d5001ae442500508385aa305b182 Mon Sep 17 00:00:00 2001 From: olivermrbl Date: Tue, 16 Feb 2021 16:53:55 +0100 Subject: [PATCH] adds endpoints for draft orders --- .../api/__tests__/admin/draft-order.js | 344 ++++++++++++++ .../api/__tests__/store/draft-order.js | 88 ++++ .../api/helpers/draft-order-seeder.js | 200 ++++++++ .../api/src/services/test-pay.js | 2 +- .../routes/admin/customers/get-customer.js | 2 +- .../routes/admin/customers/list-customers.js | 8 +- .../admin/draft-orders/create-draft-order.js | 73 +++ .../admin/draft-orders/create-line-item.js | 76 +++ .../admin/draft-orders/delete-line-item.js | 43 ++ .../admin/draft-orders/get-draft-order.js | 44 ++ .../draft-orders/get-shipping-options.js | 21 + .../api/routes/admin/draft-orders/index.js | 85 ++++ .../admin/draft-orders/list-draft-orders.js | 34 ++ .../admin/draft-orders/register-payment.js | 24 + .../admin/draft-orders/update-draft-order.js | 65 +++ packages/medusa/src/api/routes/admin/index.js | 2 + .../admin/orders/__tests__/get-order.js | 2 +- .../src/api/routes/admin/orders/index.js | 2 + packages/medusa/src/index.js | 1 + .../migrations/1613384784316-draft_orders.ts | 46 ++ packages/medusa/src/models/cart.ts | 1 + packages/medusa/src/models/draft-order.ts | 70 --- packages/medusa/src/models/line-item.ts | 11 - packages/medusa/src/models/payment.ts | 8 - packages/medusa/src/models/shipping-method.ts | 8 - .../src/services/__tests__/draft-order.js | 335 +++++++++++++ packages/medusa/src/services/cart.js | 2 + packages/medusa/src/services/customer.js | 28 ++ packages/medusa/src/services/draft-order.js | 449 ++++++++++++------ packages/medusa/src/services/order.js | 1 - .../src/services/system-payment-provider.js | 47 ++ 31 files changed, 1871 insertions(+), 251 deletions(-) create mode 100644 integration-tests/api/__tests__/admin/draft-order.js create mode 100644 integration-tests/api/__tests__/store/draft-order.js create mode 100644 integration-tests/api/helpers/draft-order-seeder.js create mode 100644 packages/medusa/src/api/routes/admin/draft-orders/create-draft-order.js create mode 100644 packages/medusa/src/api/routes/admin/draft-orders/create-line-item.js create mode 100644 packages/medusa/src/api/routes/admin/draft-orders/delete-line-item.js create mode 100644 packages/medusa/src/api/routes/admin/draft-orders/get-draft-order.js create mode 100644 packages/medusa/src/api/routes/admin/draft-orders/get-shipping-options.js create mode 100644 packages/medusa/src/api/routes/admin/draft-orders/index.js create mode 100644 packages/medusa/src/api/routes/admin/draft-orders/list-draft-orders.js create mode 100644 packages/medusa/src/api/routes/admin/draft-orders/register-payment.js create mode 100644 packages/medusa/src/api/routes/admin/draft-orders/update-draft-order.js create mode 100644 packages/medusa/src/migrations/1613384784316-draft_orders.ts create mode 100644 packages/medusa/src/services/__tests__/draft-order.js create mode 100644 packages/medusa/src/services/system-payment-provider.js diff --git a/integration-tests/api/__tests__/admin/draft-order.js b/integration-tests/api/__tests__/admin/draft-order.js new file mode 100644 index 0000000000..7c67cb118e --- /dev/null +++ b/integration-tests/api/__tests__/admin/draft-order.js @@ -0,0 +1,344 @@ +const { dropDatabase } = require("pg-god"); +const path = require("path"); + +const setupServer = require("../../../helpers/setup-server"); +const { useApi } = require("../../../helpers/use-api"); +const { initDb } = require("../../../helpers/use-db"); + +const draftOrderSeeder = require("../../helpers/draft-order-seeder"); +const adminSeeder = require("../../helpers/admin-seeder"); + +jest.setTimeout(30000); + +describe("/admin/draft-orders", () => { + let medusaProcess; + let dbConnection; + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")); + dbConnection = await initDb({ cwd }); + medusaProcess = await setupServer({ cwd }); + }); + + afterAll(async () => { + await dbConnection.close(); + await dropDatabase({ databaseName: "medusa-integration" }); + + medusaProcess.kill(); + }); + + describe("POST /admin/draft-orders", () => { + beforeEach(async () => { + try { + await adminSeeder(dbConnection); + await draftOrderSeeder(dbConnection); + } catch (err) { + console.log(err); + throw err; + } + }); + + afterEach(async () => { + const manager = dbConnection.manager; + await manager.query(`DELETE FROM "line_item"`); + await manager.query(`DELETE FROM "money_amount"`); + await manager.query(`DELETE FROM "product_variant"`); + await manager.query(`DELETE FROM "product"`); + await manager.query(`DELETE FROM "shipping_method"`); + await manager.query(`DELETE FROM "shipping_option"`); + await manager.query(`DELETE FROM "discount"`); + await manager.query(`DELETE FROM "payment_provider"`); + await manager.query(`DELETE FROM "payment_session"`); + await manager.query(`UPDATE "payment" SET order_id=NULL`); + await manager.query(`DELETE FROM "order"`); + await manager.query(`UPDATE "draft_order" SET order_id=NULL`); + await manager.query(`DELETE FROM "draft_order"`); + await manager.query(`DELETE FROM "cart"`); + await manager.query(`DELETE FROM "payment"`); + await manager.query(`DELETE FROM "customer"`); + await manager.query(`DELETE FROM "address"`); + + await manager.query( + `UPDATE "country" SET region_id=NULL WHERE iso_2 = 'us'` + ); + await manager.query( + `UPDATE "country" SET region_id=NULL WHERE iso_2 = 'de'` + ); + await manager.query(`DELETE FROM "region"`); + await manager.query(`DELETE FROM "user"`); + }); + + it("creates a draft order cart", async () => { + const api = useApi(); + + const payload = { + email: "oli@test.dk", + shipping_address_id: "oli-shipping", + items: [ + { + variant_id: "test-variant", + quantity: 2, + metadata: {}, + }, + ], + region_id: "test-region", + customer_id: "oli-test", + shipping_methods: [ + { + option_id: "test-option", + }, + ], + }; + + const response = await api + .post("/admin/draft-orders", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err); + }); + expect(response.status).toEqual(200); + }); + + it("creates a draft order with custom item", async () => { + const api = useApi(); + + const payload = { + email: "oli@test.dk", + shipping_address_id: "oli-shipping", + items: [ + { + variant_id: "test-variant", + quantity: 2, + metadata: {}, + }, + { + quantity: 1, + metadata: {}, + unit_price: 10000, + }, + ], + region_id: "test-region", + customer_id: "oli-test", + shipping_methods: [ + { + option_id: "test-option", + }, + ], + }; + + const response = await api + .post("/admin/draft-orders", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err); + }); + expect(response.status).toEqual(200); + }); + + it("creates a draft order with created shipping address", async () => { + const api = useApi(); + + const payload = { + email: "oli@test.dk", + shipping_address: { + first_name: "new", + last_name: "one", + address_1: "New place 1", + city: "Copenhagen", + country_code: "us", + postal_code: "2100", + }, + items: [ + { + variant_id: "test-variant", + quantity: 2, + metadata: {}, + }, + { + quantity: 1, + metadata: {}, + unit_price: 10000, + }, + ], + region_id: "test-region", + customer_id: "oli-test", + shipping_methods: [ + { + option_id: "test-option", + }, + ], + }; + + const response = await api + .post("/admin/draft-orders", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err); + }); + expect(response.status).toEqual(200); + }); + + it("creates a draft order and registers manual payment", async () => { + const api = useApi(); + + // register system payment for draft order + const orderResponse = await api.post( + `/admin/draft-orders/test-draft-order/register-payment`, + {}, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ); + + const createdOrder = await api.get( + `/admin/orders/${orderResponse.data.order.id}`, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ); + + const updatedDraftOrder = await api.get( + `/admin/draft-orders/test-draft-order`, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ); + + expect(orderResponse.status).toEqual(200); + // expect newly created order to have id of draft order and system payment + expect(createdOrder.data.order.draft_order_id).toEqual( + "test-draft-order" + ); + expect(createdOrder.data.order.payments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ provider_id: "system" }), + ]) + ); + // expect draft order to be complete + expect(updatedDraftOrder.data.draft_order.status).toEqual("completed"); + }); + }); + describe("GET /admin/draft-orders", () => { + beforeEach(async () => { + try { + await adminSeeder(dbConnection); + await draftOrderSeeder(dbConnection); + } catch (err) { + console.log(err); + throw err; + } + }); + + afterEach(async () => { + const manager = dbConnection.manager; + await manager.query(`DELETE FROM "line_item"`); + await manager.query(`DELETE FROM "money_amount"`); + await manager.query(`DELETE FROM "product_variant"`); + await manager.query(`DELETE FROM "product"`); + await manager.query(`DELETE FROM "shipping_method"`); + await manager.query(`DELETE FROM "shipping_option"`); + await manager.query(`DELETE FROM "discount"`); + await manager.query(`DELETE FROM "payment_provider"`); + await manager.query(`DELETE FROM "payment_session"`); + await manager.query(`UPDATE "payment" SET order_id=NULL`); + await manager.query(`DELETE FROM "order"`); + await manager.query(`UPDATE "draft_order" SET order_id=NULL`); + await manager.query(`DELETE FROM "draft_order"`); + await manager.query(`DELETE FROM "cart"`); + await manager.query(`DELETE FROM "payment"`); + await manager.query(`DELETE FROM "customer"`); + await manager.query(`DELETE FROM "address"`); + + await manager.query( + `UPDATE "country" SET region_id=NULL WHERE iso_2 = 'us'` + ); + await manager.query( + `UPDATE "country" SET region_id=NULL WHERE iso_2 = 'de'` + ); + await manager.query(`DELETE FROM "region"`); + await manager.query(`DELETE FROM "user"`); + }); + + it("lists draft orders", async () => { + const api = useApi(); + + const response = await api + .get("/admin/draft-orders", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err); + }); + + expect(response.status).toEqual(200); + + expect(response.data.draft_orders).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "test-draft-order" }), + ]) + ); + }); + + it("lists draft orders with query", async () => { + const api = useApi(); + + const response = await api + .get("/admin/draft-orders?q=oli@test", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err); + }); + + expect(response.status).toEqual(200); + + expect(response.data.draft_orders).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + cart: expect.objectContaining({ email: "oli@test.dk" }), + }), + ]) + ); + }); + + it("lists no draft orders on query for non-existing email", async () => { + const api = useApi(); + + const response = await api + .get("/admin/draft-orders?q=heyo@heyo.dk", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err); + }); + + expect(response.status).toEqual(200); + + console.log(response.data.draft_orders); + + expect(response.data.draft_orders).toEqual([]); + expect(response.data.count).toEqual(0); + }); + }); +}); diff --git a/integration-tests/api/__tests__/store/draft-order.js b/integration-tests/api/__tests__/store/draft-order.js new file mode 100644 index 0000000000..94e769219d --- /dev/null +++ b/integration-tests/api/__tests__/store/draft-order.js @@ -0,0 +1,88 @@ +const { dropDatabase } = require("pg-god"); +const path = require("path"); + +const setupServer = require("../../../helpers/setup-server"); +const { useApi } = require("../../../helpers/use-api"); +const { initDb } = require("../../../helpers/use-db"); + +const draftOrderSeeder = require("../../helpers/draft-order-seeder"); +const { create } = require("domain"); + +jest.setTimeout(30000); + +describe("/store/carts (draft-orders)", () => { + let medusaProcess; + let dbConnection; + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")); + dbConnection = await initDb({ cwd }); + medusaProcess = await setupServer({ cwd }); + }); + + afterAll(async () => { + await dbConnection.close(); + await dropDatabase({ databaseName: "medusa-integration" }); + + medusaProcess.kill(); + }); + + describe("POST /admin/draft-order", () => { + beforeEach(async () => { + try { + await draftOrderSeeder(dbConnection); + } catch (err) { + console.log(err); + throw err; + } + }); + + afterEach(async () => { + const manager = dbConnection.manager; + await manager.query(`DELETE FROM "line_item"`); + await manager.query(`DELETE FROM "money_amount"`); + await manager.query(`DELETE FROM "product_variant"`); + await manager.query(`DELETE FROM "product"`); + await manager.query(`DELETE FROM "shipping_method"`); + await manager.query(`DELETE FROM "shipping_option"`); + await manager.query(`DELETE FROM "discount"`); + await manager.query(`DELETE FROM "draft_order"`); + await manager.query(`DELETE FROM "payment_provider"`); + await manager.query(`DELETE FROM "payment_session"`); + await manager.query(`UPDATE "payment" SET order_id=NULL`); + await manager.query(`DELETE FROM "order"`); + await manager.query(`DELETE FROM "cart"`); + await manager.query(`DELETE FROM "payment"`); + await manager.query(`DELETE FROM "customer"`); + await manager.query(`DELETE FROM "address"`); + + await manager.query( + `UPDATE "country" SET region_id=NULL WHERE iso_2 = 'us'` + ); + await manager.query( + `UPDATE "country" SET region_id=NULL WHERE iso_2 = 'de'` + ); + await manager.query(`DELETE FROM "region"`); + }); + + it("completes a cart for a draft order thereby creating an order for the draft order", async () => { + const api = useApi(); + + const response = await api + .post("/store/carts/test-cart/complete-cart", {}) + .catch((err) => { + console.log(err); + }); + + expect(response.status).toEqual(200); + + const createdOrder = await api + .get(`/store/orders/${response.data.data.id}`, {}) + .catch((err) => { + console.log(err); + }); + + expect(createdOrder.data.order.cart_id).toEqual("test-cart"); + }); + }); +}); diff --git a/integration-tests/api/helpers/draft-order-seeder.js b/integration-tests/api/helpers/draft-order-seeder.js new file mode 100644 index 0000000000..ddbd597a4a --- /dev/null +++ b/integration-tests/api/helpers/draft-order-seeder.js @@ -0,0 +1,200 @@ +const { + ShippingProfile, + Customer, + MoneyAmount, + ShippingOption, + Product, + ProductVariant, + Region, + Address, + Cart, + PaymentSession, + DraftOrder, +} = require("@medusajs/medusa"); + +module.exports = async (connection, data = {}) => { + const manager = connection.manager; + + const defaultProfile = await manager.findOne(ShippingProfile, { + type: "default", + }); + + await manager.insert(Product, { + id: "test-product", + title: "test product", + profile_id: defaultProfile.id, + options: [{ id: "test-option", title: "Size" }], + }); + + await manager.insert(Address, { + id: "oli-shipping", + first_name: "oli", + last_name: "test", + country_code: "us", + }); + + await manager.insert(Product, { + id: "test-product-2", + title: "test product 2", + profile_id: defaultProfile.id, + options: [{ id: "test-option-color", title: "Color" }], + }); + + await manager.insert(ProductVariant, { + id: "test-variant", + title: "test variant", + product_id: "test-product", + inventory_quantity: 1, + options: [ + { + option_id: "test-option", + value: "Size", + }, + ], + }); + + await manager.insert(ProductVariant, { + id: "test-variant-2", + title: "test variant-2", + product_id: "test-product-2", + inventory_quantity: 4, + options: [ + { + option_id: "test-option-color", + value: "Color", + }, + ], + }); + + const ma = manager.create(MoneyAmount, { + variant_id: "test-variant", + currency_code: "usd", + amount: 8000, + }); + await manager.save(ma); + + const ma2 = manager.create(MoneyAmount, { + variant_id: "test-variant-2", + currency_code: "usd", + amount: 10000, + }); + + await manager.save(ma2); + + await manager.insert(Region, { + id: "test-region", + name: "Test Region", + currency_code: "usd", + tax_rate: 0, + payment_providers: [ + { + id: "test-pay", + is_installed: true, + }, + ], + }); + + await manager.insert(Region, { + id: "test-region-2", + name: "Test Region 2", + currency_code: "eur", + tax_rate: 0, + payment_providers: [ + { + id: "test-pay", + is_installed: true, + }, + ], + }); + + await manager.query( + `UPDATE "country" SET region_id='test-region' WHERE iso_2 = 'us'` + ); + + await manager.query( + `UPDATE "country" SET region_id='test-region-2' WHERE iso_2 = 'de'` + ); + + await manager.insert(Customer, { + id: "oli-test", + email: "oli@test.dk", + }); + + await manager.insert(Customer, { + id: "lebron-james", + email: "lebron@james.com", + }); + + await manager.insert(ShippingOption, { + id: "test-option", + name: "test-option", + provider_id: "test-ful", + region_id: "test-region", + profile_id: defaultProfile.id, + price_type: "flat_rate", + amount: 1000, + data: {}, + }); + + const c = manager.create(Cart, { + id: "test-cart", + customer_id: "oli-test", + email: "oli@test.dk", + shipping_address_id: "oli-shipping", + region_id: "test-region", + currency_code: "usd", + payment_sessions: [], + items: [ + { + id: "test-item", + fulfilled_quantity: 1, + title: "Line Item", + description: "Line Item Desc", + thumbnail: "https://test.js/1234", + unit_price: 8000, + quantity: 1, + variant_id: "test-variant", + }, + ], + type: "draft_order", + metadata: { draft_order_id: "test-draft-order" }, + }); + + await manager.save(c); + + await manager.insert(PaymentSession, { + id: "test-session", + cart_id: "test-cart", + provider_id: "test-pay", + is_selected: true, + data: {}, + status: "pending", + }); + + // await manager.save(cart); + + const draftOrder = manager.create(DraftOrder, { + id: "test-draft-order", + status: "awaiting", + display_id: 4, + cart_id: "test-cart", + customer_id: "oli-test", + items: [ + { + id: "test-item", + fulfilled_quantity: 1, + title: "Line Item", + description: "Line Item Desc", + thumbnail: "https://test.js/1234", + unit_price: 8000, + quantity: 1, + variant_id: "test-variant", + }, + ], + email: "oli@test.dk", + region_id: "test-region", + discounts: [], + }); + + await manager.save(draftOrder); +}; diff --git a/integration-tests/api/src/services/test-pay.js b/integration-tests/api/src/services/test-pay.js index 481261b3a0..22725965ab 100644 --- a/integration-tests/api/src/services/test-pay.js +++ b/integration-tests/api/src/services/test-pay.js @@ -28,7 +28,7 @@ class TestPayService extends PaymentService { } async authorizePayment(sessionData, context = {}) { - return {}; + return { data: {}, status: "authorized" }; } async updatePaymentData(sessionData, update) { diff --git a/packages/medusa/src/api/routes/admin/customers/get-customer.js b/packages/medusa/src/api/routes/admin/customers/get-customer.js index 65841fb91b..a86f42f290 100644 --- a/packages/medusa/src/api/routes/admin/customers/get-customer.js +++ b/packages/medusa/src/api/routes/admin/customers/get-customer.js @@ -3,7 +3,7 @@ export default async (req, res) => { try { const customerService = req.scope.resolve("customerService") const customer = await customerService.retrieve(id, { - relations: ["orders"], + relations: ["orders", "shipping_addresses"], }) res.json({ customer }) diff --git a/packages/medusa/src/api/routes/admin/customers/list-customers.js b/packages/medusa/src/api/routes/admin/customers/list-customers.js index 6683f80e06..566fa61c23 100644 --- a/packages/medusa/src/api/routes/admin/customers/list-customers.js +++ b/packages/medusa/src/api/routes/admin/customers/list-customers.js @@ -5,13 +5,19 @@ export default async (req, res) => { const limit = parseInt(req.query.limit) || 10 const offset = parseInt(req.query.offset) || 0 + const selector = {} + + if ("q" in req.query) { + selector.q = req.query.q + } + const listConfig = { relations: [], skip: offset, take: limit, } - const customers = await customerService.list({}, listConfig) + const customers = await customerService.list(selector, listConfig) res.json({ customers, count: customers.length, offset, limit }) } catch (error) { diff --git a/packages/medusa/src/api/routes/admin/draft-orders/create-draft-order.js b/packages/medusa/src/api/routes/admin/draft-orders/create-draft-order.js new file mode 100644 index 0000000000..4c09d54845 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/draft-orders/create-draft-order.js @@ -0,0 +1,73 @@ +import { MedusaError, Validator } from "medusa-core-utils" +import { defaultFields, defaultRelations } from "." + +export default async (req, res) => { + const schema = Validator.object().keys({ + status: Validator.string() + .valid("open", "awaiting", "completed") + .optional(), + email: Validator.string() + .email() + .required(), + billing_address: Validator.address().optional(), + shipping_address: Validator.address().optional(), + billing_address_id: Validator.string().optional(), + shipping_address_id: Validator.string().optional(), + items: Validator.array() + .items({ + variant_id: Validator.string().optional(), + unit_price: Validator.number().optional(), + title: Validator.string().optional(), + quantity: Validator.number().required(), + metadata: Validator.object().default({}), + }) + .required(), + region_id: Validator.string().required(), + discounts: Validator.array().optional(), + customer_id: Validator.string().optional(), + customer: Validator.string().optional(), + shipping_methods: Validator.array() + .items({ + option_id: Validator.string().required(), + data: Validator.object().optional(), + price: Validator.number() + .integer() + .integer() + .allow(0) + .optional(), + }) + .required(), + metadata: Validator.object().optional(), + requires_shipping: Validator.boolean().default(true), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const draftOrderService = req.scope.resolve("draftOrderService") + const entityManager = req.scope.resolve("manager") + + await entityManager.transaction(async manager => { + const requiresShipping = value.requires_shipping + delete value.requires_shipping + + let draftOrder = await draftOrderService + .withTransaction(manager) + .create(value, requiresShipping) + + draftOrder = await draftOrderService + .withTransaction(manager) + .retrieve(draftOrder.id, { + relations: defaultRelations, + select: defaultFields, + }) + + res.status(200).json({ draft_order: draftOrder }) + }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/draft-orders/create-line-item.js b/packages/medusa/src/api/routes/admin/draft-orders/create-line-item.js new file mode 100644 index 0000000000..0ec64ef24e --- /dev/null +++ b/packages/medusa/src/api/routes/admin/draft-orders/create-line-item.js @@ -0,0 +1,76 @@ +import { MedusaError, Validator } from "medusa-core-utils" +import { defaultCartFields, defaultCartRelations, defaultFields } from "." + +export default async (req, res) => { + const { id } = req.params + + const schema = Validator.object().keys({ + title: Validator.string().optional(), + unit_price: Validator.number().optional(), + variant_id: Validator.string().optional(), + quantity: Validator.number().required(), + metadata: Validator.object().optional(), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const draftOrderService = req.scope.resolve("draftOrderService") + const cartService = req.scope.resolve("cartService") + const lineItemService = req.scope.resolve("lineItemService") + const entityManager = req.scope.resolve("manager") + + await entityManager.transaction(async manager => { + const draftOrder = await draftOrderService + .withTransaction(manager) + .retrieve(id, { select: defaultFields, relations: ["cart"] }) + + if ( + draftOrder.status === "completed" || + draftOrder.status === "awaiting" + ) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "You are only allowed to update open draft orders" + ) + } + + if (value.variant_id) { + const line = await lineItemService.generate( + value.variant_id, + draftOrder.cart.region_id, + value.quantity, + value.metadata + ) + + await cartService + .withTransaction(manager) + .addLineItem(draftOrder.cart_id, line) + } else { + // custom line items can be added to a draft order + await lineItemService.withTransaction(manager).create({ + cart_id: draftOrder.cart_id, + has_shipping: true, + title: value.title || "Custom item", + allow_discounts: false, + unit_price: value.unit_price || 0, + quantity: value.quantity, + }) + } + + draftOrder.cart = await cartService + .withTransaction(manager) + .retrieve(draftOrder.cart_id, { + relations: defaultCartRelations, + select: defaultCartFields, + }) + + res.status(200).json({ draft_order: draftOrder }) + }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/draft-orders/delete-line-item.js b/packages/medusa/src/api/routes/admin/draft-orders/delete-line-item.js new file mode 100644 index 0000000000..c6fba46165 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/draft-orders/delete-line-item.js @@ -0,0 +1,43 @@ +import { MedusaError, Validator } from "medusa-core-utils" +import { defaultCartFields, defaultCartRelations, defaultFields } from "." + +export default async (req, res) => { + const { id, line_id } = req.params + + try { + const draftOrderService = req.scope.resolve("draftOrderService") + const cartService = req.scope.resolve("cartService") + const entityManager = req.scope.resolve("manager") + + await entityManager.transaction(async manager => { + const draftOrder = await draftOrderService + .withTransaction(manager) + .retrieve(id, { select: defaultFields }) + + if ( + draftOrder.status === "completed" || + draftOrder.status === "awaiting" + ) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "You are only allowed to update open draft orders" + ) + } + + await cartService + .withTransaction(manager) + .removeLineItem(draftOrder.cart_id, line_id) + + draftOrder.cart = await cartService + .withTransaction(manager) + .retrieve(draftOrder.cart_id, { + relations: defaultCartRelations, + select: defaultCartFields, + }) + + res.status(200).json({ draft_order: draftOrder }) + }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/draft-orders/get-draft-order.js b/packages/medusa/src/api/routes/admin/draft-orders/get-draft-order.js new file mode 100644 index 0000000000..71097d677c --- /dev/null +++ b/packages/medusa/src/api/routes/admin/draft-orders/get-draft-order.js @@ -0,0 +1,44 @@ +import { defaultRelations, defaultFields } from "." + +export default async (req, res) => { + const { id } = req.params + + try { + const draftOrderService = req.scope.resolve("draftOrderService") + const cartService = req.scope.resolve("cartService") + + const draftOrder = await draftOrderService.retrieve(id, { + select: defaultFields, + relations: defaultRelations, + }) + + draftOrder.cart = await cartService.retrieve(draftOrder.cart_id, { + relations: [ + "gift_cards", + "region", + "items", + "payment", + "shipping_address", + "billing_address", + "region.countries", + "region.payment_providers", + "shipping_methods", + "payment_sessions", + "shipping_methods.shipping_option", + "discounts", + ], + select: [ + "subtotal", + "tax_total", + "shipping_total", + "discount_total", + "gift_card_total", + "total", + ], + }) + + res.json({ draft_order: draftOrder }) + } catch (error) { + throw error + } +} diff --git a/packages/medusa/src/api/routes/admin/draft-orders/get-shipping-options.js b/packages/medusa/src/api/routes/admin/draft-orders/get-shipping-options.js new file mode 100644 index 0000000000..bf8a410021 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/draft-orders/get-shipping-options.js @@ -0,0 +1,21 @@ +import { Validator, MedusaError } from "medusa-core-utils" + +export default async (req, res) => { + const { id } = req.params + + try { + const draftOrderService = req.scope.resolve("draftOrderService") + const shippingProfileService = req.scope.resolve("shippingProfileService") + + const draftOrder = await cartService.retrieve(value.cart_id, { + select: ["subtotal"], + relations: ["region", "items", "items.variant", "items.variant.product"], + }) + + const options = await shippingProfileService.fetchCartOptions(cart) + + res.status(200).json({ shipping_options: options }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/draft-orders/index.js b/packages/medusa/src/api/routes/admin/draft-orders/index.js new file mode 100644 index 0000000000..ee4798f0d6 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/draft-orders/index.js @@ -0,0 +1,85 @@ +import { Router } from "express" +import middlewares from "../../../middlewares" + +const route = Router() + +export default app => { + app.use("/draft-orders", route) + + route.get("/", middlewares.wrap(require("./list-draft-orders").default)) + + route.get("/:id", middlewares.wrap(require("./get-draft-order").default)) + + route.post("/", middlewares.wrap(require("./create-draft-order").default)) + + route.delete( + "/:id/line-items/:line_id", + middlewares.wrap(require("./delete-line-item").default) + ) + + route.post( + "/:id/line-items", + middlewares.wrap(require("./create-line-item").default) + ) + + route.post("/", middlewares.wrap(require("./create-draft-order").default)) + + route.post( + "/:id/register-payment", + middlewares.wrap(require("./register-payment").default) + ) + + return app +} + +export const defaultRelations = [] + +export const defaultCartRelations = [ + "region", + "items", + "payment", + "shipping_address", + "billing_address", + "region.payment_providers", + "shipping_methods", + "payment_sessions", + "shipping_methods.shipping_option", + "discounts", +] + +export const defaultCartFields = [ + "region", + "items", + "payment", + "shipping_address", + "billing_address", + "region.payment_providers", + "shipping_methods", + "payment_sessions", + "shipping_methods.shipping_option", + "discounts", +] + +export const defaultFields = [ + "id", + "status", + "display_id", + "cart_id", + "canceled_at", + "created_at", + "updated_at", + "metadata", +] + +export const allowedFields = [ + "id", + "status", + "display_id", + "cart_id", + "canceled_at", + "created_at", + "updated_at", + "metadata", +] + +export const allowedRelations = ["cart"] diff --git a/packages/medusa/src/api/routes/admin/draft-orders/list-draft-orders.js b/packages/medusa/src/api/routes/admin/draft-orders/list-draft-orders.js new file mode 100644 index 0000000000..8caf133838 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/draft-orders/list-draft-orders.js @@ -0,0 +1,34 @@ +import _ from "lodash" +import { defaultRelations, defaultFields } from "./" + +export default async (req, res) => { + try { + const draftOrderService = req.scope.resolve("draftOrderService") + + const limit = parseInt(req.query.limit) || 50 + const offset = parseInt(req.query.offset) || 0 + + let selector = {} + + if ("q" in req.query) { + selector.q = req.query.q + } + + const listConfig = { + select: defaultFields, + relations: defaultRelations, + skip: offset, + take: limit, + order: { created_at: "DESC" }, + } + + const [draftOrders, count] = await draftOrderService.listAndCount( + selector, + listConfig + ) + + res.json({ draft_orders: draftOrders, count, offset, limit }) + } catch (error) { + throw error + } +} diff --git a/packages/medusa/src/api/routes/admin/draft-orders/register-payment.js b/packages/medusa/src/api/routes/admin/draft-orders/register-payment.js new file mode 100644 index 0000000000..a48cc190d4 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/draft-orders/register-payment.js @@ -0,0 +1,24 @@ +import { + defaultFields as defaultOrderFields, + defaultRelations as defaultOrderRelations, +} from "../orders/index" + +export default async (req, res) => { + const { id } = req.params + + try { + const draftOrderService = req.scope.resolve("draftOrderService") + const orderService = req.scope.resolve("orderService") + + const createdOrder = await draftOrderService.registerSystemPayment(id) + + const order = await orderService.retrieve(createdOrder.id, { + relations: defaultOrderRelations, + select: defaultOrderFields, + }) + + res.status(200).json({ order }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/draft-orders/update-draft-order.js b/packages/medusa/src/api/routes/admin/draft-orders/update-draft-order.js new file mode 100644 index 0000000000..e214d64439 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/draft-orders/update-draft-order.js @@ -0,0 +1,65 @@ +import { MedusaError, Validator } from "medusa-core-utils" +import { defaultCartFields, defaultCartRelations, defaultFields } from "." + +export default async (req, res) => { + const { id } = req.params + + const schema = Validator.object().keys({ + region_id: Validator.string().optional(), + country_code: Validator.string().optional(), + email: Validator.string() + .email() + .optional(), + billing_address: Validator.object().optional(), + shipping_address: Validator.object().optional(), + + discounts: Validator.array() + .items({ + code: Validator.string(), + }) + .optional(), + customer_id: Validator.string().optional(), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const draftOrderService = req.scope.resolve("draftOrderService") + const cartService = req.scope.resolve("cartService") + const entityManager = req.scope.resolve("manager") + + await entityManager.transaction(async manager => { + const draftOrder = await draftOrderService + .withTransaction(manager) + .retrieve(id, { select: defaultFields }) + + if ( + draftOrder.status === "completed" || + draftOrder.status === "awaiting" + ) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "You are only allowed to update open draft orders" + ) + } + + await cartService + .withTransaction(manager) + .update(draftOrder.cart_id, value) + + draftOrder.cart = await cartService + .withTransaction(manager) + .retrieve(draftOrder.cart_id, { + relations: defaultCartRelations, + select: defaultCartFields, + }) + + res.status(200).json({ draft_order: draftOrder }) + }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/index.js b/packages/medusa/src/api/routes/admin/index.js index 433575eff5..796aeeb31b 100644 --- a/packages/medusa/src/api/routes/admin/index.js +++ b/packages/medusa/src/api/routes/admin/index.js @@ -18,6 +18,7 @@ import appRoutes from "./apps" import swapRoutes from "./swaps" import returnRoutes from "./returns" import variantRoutes from "./variants" +import draftOrderRoutes from "./draft-orders" const route = Router() @@ -60,6 +61,7 @@ export default (app, container, config) => { swapRoutes(route) returnRoutes(route) variantRoutes(route) + draftOrderRoutes(route) return app } diff --git a/packages/medusa/src/api/routes/admin/orders/__tests__/get-order.js b/packages/medusa/src/api/routes/admin/orders/__tests__/get-order.js index 6cec0f0b2d..3e0e879926 100644 --- a/packages/medusa/src/api/routes/admin/orders/__tests__/get-order.js +++ b/packages/medusa/src/api/routes/admin/orders/__tests__/get-order.js @@ -21,7 +21,6 @@ const defaultRelations = [ "claims.fulfillments", "claims.claim_items", "claims.claim_items.images", - "claims.claim_items.tags", "swaps", "swaps.return_order", "swaps.payment", @@ -38,6 +37,7 @@ const defaultFields = [ "payment_status", "display_id", "cart_id", + "draft_order_id", "customer_id", "email", "region_id", diff --git a/packages/medusa/src/api/routes/admin/orders/index.js b/packages/medusa/src/api/routes/admin/orders/index.js index 0a22df479b..54648cecf7 100644 --- a/packages/medusa/src/api/routes/admin/orders/index.js +++ b/packages/medusa/src/api/routes/admin/orders/index.js @@ -208,6 +208,7 @@ export const defaultFields = [ "payment_status", "display_id", "cart_id", + "draft_order_id", "customer_id", "email", "region_id", @@ -235,6 +236,7 @@ export const allowedFields = [ "payment_status", "display_id", "cart_id", + "draft_order_id", "customer_id", "email", "region_id", diff --git a/packages/medusa/src/index.js b/packages/medusa/src/index.js index 866f6a511e..290b05f02d 100644 --- a/packages/medusa/src/index.js +++ b/packages/medusa/src/index.js @@ -37,3 +37,4 @@ export { StagedJob } from "./models/staged-job" export { Store } from "./models/store" export { Swap } from "./models/swap" export { User } from "./models/user" +export { DraftOrder } from "./models/draft-order" diff --git a/packages/medusa/src/migrations/1613384784316-draft_orders.ts b/packages/medusa/src/migrations/1613384784316-draft_orders.ts new file mode 100644 index 0000000000..8bb3a13576 --- /dev/null +++ b/packages/medusa/src/migrations/1613384784316-draft_orders.ts @@ -0,0 +1,46 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class draftOrders1613384784316 implements MigrationInterface { + name = 'draftOrders1613384784316' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "draft_order_status_enum" AS ENUM('open', 'awaiting', 'completed')`); + await queryRunner.query(`CREATE TABLE "draft_order" ("id" character varying NOT NULL, "status" "draft_order_status_enum" NOT NULL DEFAULT 'open', "display_id" SERIAL NOT NULL, "cart_id" character varying, "order_id" character varying, "canceled_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "metadata" jsonb, "idempotency_key" character varying, CONSTRAINT "REL_5bd11d0e2a9628128e2c26fd0a" UNIQUE ("cart_id"), CONSTRAINT "REL_8f6dd6c49202f1466ebf21e77d" UNIQUE ("order_id"), CONSTRAINT "PK_f478946c183d98f8d88a94cfcd7" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_e87cc617a22ef4edce5601edab" ON "draft_order" ("display_id") `); + await queryRunner.query(`CREATE INDEX "IDX_5bd11d0e2a9628128e2c26fd0a" ON "draft_order" ("cart_id") `); + await queryRunner.query(`CREATE INDEX "IDX_8f6dd6c49202f1466ebf21e77d" ON "draft_order" ("order_id") `); + await queryRunner.query(`ALTER TABLE "order" ADD "draft_order_id" character varying`); + await queryRunner.query(`ALTER TABLE "order" ADD CONSTRAINT "UQ_727b872f86c7378474a8fa46147" UNIQUE ("draft_order_id")`); + await queryRunner.query(`ALTER TYPE "public"."cart_type_enum" RENAME TO "cart_type_enum_old"`); + await queryRunner.query(`CREATE TYPE "cart_type_enum" AS ENUM('default', 'swap', 'draft_order', 'payment_link')`); + await queryRunner.query(`ALTER TABLE "cart" ALTER COLUMN "type" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "cart" ALTER COLUMN "type" TYPE "cart_type_enum" USING "type"::"text"::"cart_type_enum"`); + await queryRunner.query(`ALTER TABLE "cart" ALTER COLUMN "type" SET DEFAULT 'default'`); + await queryRunner.query(`DROP TYPE "cart_type_enum_old"`); + await queryRunner.query(`COMMENT ON COLUMN "cart"."type" IS NULL`); + await queryRunner.query(`ALTER TABLE "draft_order" ADD CONSTRAINT "FK_5bd11d0e2a9628128e2c26fd0a6" FOREIGN KEY ("cart_id") REFERENCES "cart"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "draft_order" ADD CONSTRAINT "FK_8f6dd6c49202f1466ebf21e77da" FOREIGN KEY ("order_id") REFERENCES "order"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "order" ADD CONSTRAINT "FK_727b872f86c7378474a8fa46147" FOREIGN KEY ("draft_order_id") REFERENCES "draft_order"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "order" DROP CONSTRAINT "FK_727b872f86c7378474a8fa46147"`); + await queryRunner.query(`ALTER TABLE "draft_order" DROP CONSTRAINT "FK_8f6dd6c49202f1466ebf21e77da"`); + await queryRunner.query(`ALTER TABLE "draft_order" DROP CONSTRAINT "FK_5bd11d0e2a9628128e2c26fd0a6"`); + await queryRunner.query(`COMMENT ON COLUMN "cart"."type" IS NULL`); + await queryRunner.query(`CREATE TYPE "cart_type_enum_old" AS ENUM('default', 'swap', 'payment_link')`); + await queryRunner.query(`ALTER TABLE "cart" ALTER COLUMN "type" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "cart" ALTER COLUMN "type" TYPE "cart_type_enum_old" USING "type"::"text"::"cart_type_enum_old"`); + await queryRunner.query(`ALTER TABLE "cart" ALTER COLUMN "type" SET DEFAULT 'default'`); + await queryRunner.query(`DROP TYPE "cart_type_enum"`); + await queryRunner.query(`ALTER TYPE "cart_type_enum_old" RENAME TO "cart_type_enum"`); + await queryRunner.query(`ALTER TABLE "order" DROP CONSTRAINT "UQ_727b872f86c7378474a8fa46147"`); + await queryRunner.query(`ALTER TABLE "order" DROP COLUMN "draft_order_id"`); + await queryRunner.query(`DROP INDEX "IDX_8f6dd6c49202f1466ebf21e77d"`); + await queryRunner.query(`DROP INDEX "IDX_5bd11d0e2a9628128e2c26fd0a"`); + await queryRunner.query(`DROP INDEX "IDX_e87cc617a22ef4edce5601edab"`); + await queryRunner.query(`DROP TABLE "draft_order"`); + await queryRunner.query(`DROP TYPE "draft_order_status_enum"`); + } + +} diff --git a/packages/medusa/src/models/cart.ts b/packages/medusa/src/models/cart.ts index 0e9e0cfe07..9d21ec8208 100644 --- a/packages/medusa/src/models/cart.ts +++ b/packages/medusa/src/models/cart.ts @@ -32,6 +32,7 @@ import { ShippingMethod } from "./shipping-method" export enum CartType { DEFAULT = "default", SWAP = "swap", + DRAFT_ORDER = "draft_order", PAYMENT_LINK = "payment_link", } diff --git a/packages/medusa/src/models/draft-order.ts b/packages/medusa/src/models/draft-order.ts index 0228289b90..5e63751cfd 100644 --- a/packages/medusa/src/models/draft-order.ts +++ b/packages/medusa/src/models/draft-order.ts @@ -61,76 +61,6 @@ export class DraftOrder { @JoinColumn({ name: "order_id" }) order: Order - @Index() - @Column() - customer_id: string - - @ManyToOne(() => Customer, { cascade: ["insert"] }) - @JoinColumn({ name: "customer_id" }) - customer: Customer - - @OneToMany( - () => LineItem, - lineItem => lineItem.draft_order, - { cascade: ["insert", "remove"] } - ) - items: LineItem[] - - @Column() - email: string - - @Index() - @Column({ nullable: true }) - billing_address_id: string - - @ManyToOne(() => Address, { cascade: ["insert"] }) - @JoinColumn({ name: "billing_address_id" }) - billing_address: Address - - @Index() - @Column({ nullable: true }) - shipping_address_id: string - - @ManyToOne(() => Address, { cascade: ["insert"] }) - @JoinColumn({ name: "shipping_address_id" }) - shipping_address: Address - - @Index() - @Column() - region_id: string - - @ManyToOne(() => Region) - @JoinColumn({ name: "region_id" }) - region: Region - - @ManyToMany(() => Discount, { cascade: ["insert"] }) - @JoinTable({ - name: "draft_order_discounts", - joinColumn: { - name: "draft_order_id", - referencedColumnName: "id", - }, - inverseJoinColumn: { - name: "discount_id", - referencedColumnName: "id", - }, - }) - discounts: Discount[] - - @OneToMany( - () => ShippingMethod, - m => m.draft_order, - { cascade: ["soft-remove", "remove"] } - ) - shipping_methods: ShippingMethod[] - - @OneToMany( - () => Payment, - p => p.draft_order, - { cascade: ["insert"] } - ) - payments: Payment[] - @Column({ nullable: true, type: "timestamptz" }) canceled_at: Date diff --git a/packages/medusa/src/models/line-item.ts b/packages/medusa/src/models/line-item.ts index f5d0099b0e..bafc0942b5 100644 --- a/packages/medusa/src/models/line-item.ts +++ b/packages/medusa/src/models/line-item.ts @@ -49,17 +49,6 @@ export class LineItem { ) @JoinColumn({ name: "order_id" }) order: Order - - @Index() - @Column({ nullable: true }) - draft_order_id: string - - @ManyToOne( - () => DraftOrder, - dorder => dorder.items - ) - @JoinColumn({ name: "draft_order_id" }) - draft_order: DraftOrder @Index() @Column({ nullable: true }) diff --git a/packages/medusa/src/models/payment.ts b/packages/medusa/src/models/payment.ts index 0f8b8e9be0..134bf5ef3b 100644 --- a/packages/medusa/src/models/payment.ts +++ b/packages/medusa/src/models/payment.ts @@ -50,14 +50,6 @@ export class Payment { @JoinColumn({ name: "order_id" }) order: Order - @Index() - @Column({ nullable: true }) - draft_order_id: string - - @OneToOne(() => DraftOrder) - @JoinColumn({ name: "draft_order_id" }) - draft_order: DraftOrder - @Column({ type: "int" }) amount: number diff --git a/packages/medusa/src/models/shipping-method.ts b/packages/medusa/src/models/shipping-method.ts index d385585a8b..08a6223858 100644 --- a/packages/medusa/src/models/shipping-method.ts +++ b/packages/medusa/src/models/shipping-method.ts @@ -68,14 +68,6 @@ export class ShippingMethod { @Column({ nullable: true }) return_id: string - @Index() - @Column({ nullable: true }) - draft_order_id: string - - @ManyToOne(() => DraftOrder) - @JoinColumn({ name: "draft_order_id" }) - draft_order: DraftOrder - @OneToOne( () => Return, ret => ret.shipping_method diff --git a/packages/medusa/src/services/__tests__/draft-order.js b/packages/medusa/src/services/__tests__/draft-order.js new file mode 100644 index 0000000000..3972e8b17c --- /dev/null +++ b/packages/medusa/src/services/__tests__/draft-order.js @@ -0,0 +1,335 @@ +import _ from "lodash" +import { IdMap, MockRepository, MockManager } from "medusa-test-utils" +import DraftOrderService from "../draft-order" + +const eventBusService = { + emit: jest.fn(), + withTransaction: function() { + return this + }, +} + +describe("DraftOrderService", () => { + const totalsService = { + getTotal: o => { + return o.total || 0 + }, + getSubtotal: o => { + return o.subtotal || 0 + }, + getTaxTotal: o => { + return o.tax_total || 0 + }, + getDiscountTotal: o => { + return o.discount_total || 0 + }, + getShippingTotal: o => { + return o.shipping_total || 0 + }, + getGiftCardTotal: o => { + return o.gift_card_total || 0 + }, + } + + describe("create", () => { + let result + + const regionService = { + retrieve: () => + Promise.resolve({ id: "test-region", countries: [{ iso_2: "dk" }] }), + withTransaction: function() { + return this + }, + } + + const shippingOptionService = { + createShippingMethod: jest.fn().mockImplementation(() => + Promise.resolve({ + shipping_option: { + profile_id: "test-profile", + }, + }) + ), + withTransaction: function() { + return this + }, + } + + const lineItemService = { + generate: jest.fn().mockImplementation(() => + Promise.resolve({ + title: "test-item", + variant_id: "test-variant", + }) + ), + create: jest.fn(), + withTransaction: function() { + return this + }, + } + + const productVariantService = { + retrieve: () => + Promise.resolve({ + id: "test-variant", + product: { + profile_id: "test-profile", + }, + }), + withTransaction: function() { + return this + }, + } + + const cartService = { + create: jest.fn().mockImplementation(data => + Promise.resolve({ + id: "test-cart", + ...data, + }) + ), + withTransaction: function() { + return this + }, + } + + const testOrder = { + region_id: "test-region", + shipping_address_id: "test-shipping", + billing_address_id: "test-billing", + customer_id: "test-customer", + items: [{ variant_id: "test-variant", quantity: 2, metadata: {} }], + shipping_methods: [ + { + option_id: "test-option", + data: {}, + }, + ], + } + + const addressRepository = MockRepository({ + create: addr => ({ + ...addr, + }), + }) + + const draftOrderRepository = MockRepository({ + create: d => ({ + ...d, + }), + save: d => ({ + id: "test-draft-order", + ...d, + }), + }) + + const draftOrderService = new DraftOrderService({ + manager: MockManager, + regionService, + cartService, + shippingOptionService, + lineItemService, + productVariantService, + draftOrderRepository, + addressRepository, + eventBusService, + }) + + beforeEach(async () => { + jest.clearAllMocks() + }) + + it("creates a draft order", async () => { + await draftOrderService.create(testOrder) + + expect(draftOrderRepository.create).toHaveBeenCalledTimes(1) + expect(draftOrderRepository.create).toHaveBeenCalledWith({ + cart_id: "test-cart", + }) + + expect(cartService.create).toHaveBeenCalledTimes(1) + expect(cartService.create).toHaveBeenCalledWith({ + region_id: "test-region", + shipping_address_id: "test-shipping", + billing_address_id: "test-billing", + customer_id: "test-customer", + }) + + expect(shippingOptionService.createShippingMethod).toHaveBeenCalledTimes( + 1 + ) + expect(shippingOptionService.createShippingMethod).toHaveBeenCalledWith( + "test-option", + {}, + { + cart: { + id: "test-cart", + region_id: "test-region", + shipping_address_id: "test-shipping", + billing_address_id: "test-billing", + customer_id: "test-customer", + }, + } + ) + + expect(lineItemService.generate).toHaveBeenCalledTimes(1) + expect(lineItemService.generate).toHaveBeenCalledWith( + "test-variant", + "test-region", + 2, + {} + ) + + expect(lineItemService.create).toHaveBeenCalledTimes(1) + expect(lineItemService.create).toHaveBeenCalledWith({ + cart_id: "test-cart", + has_shipping: true, + title: "test-item", + variant_id: "test-variant", + }) + }) + + it("fails on missing region", async () => { + try { + await draftOrderService.create({ + items: [], + }) + } catch (error) { + expect(error.message).toEqual( + `region_id is required to create a draft order` + ) + } + }) + + it("fails on missing items", async () => { + try { + await draftOrderService.create({ + region_id: "test-region", + items: [], + }) + } catch (error) { + expect(error.message).toEqual( + `Items are required to create a draft order` + ) + } + }) + }) + + describe("registerSystemPayment", () => { + let result + + const shippingOptionService = { + update: jest.fn(), + withTransaction: function() { + return this + }, + } + + const lineItemService = { + update: jest.fn(), + withTransaction: function() { + return this + }, + } + + const cartService = { + retrieve: jest.fn().mockImplementation(data => + Promise.resolve({ + id: "test-cart", + total: 1000, + region_id: "test-region", + region: { + id: "test-region", + currency_code: "usd", + tax_rate: 0, + }, + items: [{ id: "test-item" }], + discounts: [], + email: "oli@test.dk", + customer_id: "test-customer", + draft_order_id: "test-draft-order", + metadata: {}, + }) + ), + withTransaction: function() { + return this + }, + } + + const draftOrderRepository = MockRepository({ + findOne: () => + Promise.resolve({ id: "test-draft-order", cart_id: "test-cart" }), + }) + const orderRepository = MockRepository({ + create: d => ({ id: "test-order", ...d }), + }) + const paymentRepository = MockRepository({ + create: d => ({ + ...d, + }), + }) + + const draftOrderService = new DraftOrderService({ + manager: MockManager, + cartService, + shippingOptionService, + lineItemService, + paymentRepository, + draftOrderRepository, + orderRepository, + eventBusService, + }) + + beforeEach(async () => { + jest.clearAllMocks() + }) + + it("registers system payment", async () => { + await draftOrderService.registerSystemPayment("test-draft-order") + + expect(cartService.retrieve).toHaveBeenCalledTimes(1) + expect(cartService.retrieve).toHaveBeenCalledWith("test-cart", { + relations: ["discounts", "shipping_methods", "region", "items"], + select: ["total"], + }) + + expect(paymentRepository.create).toHaveBeenCalledTimes(1) + expect(paymentRepository.create).toHaveBeenCalledWith({ + provider_id: "system", + amount: 1000, + currency_code: "usd", + data: {}, + }) + + expect(orderRepository.create).toHaveBeenCalledTimes(1) + expect(orderRepository.create).toHaveBeenCalledWith({ + cart_id: "test-cart", + discounts: [], + region_id: "test-region", + discounts: [], + email: "oli@test.dk", + customer_id: "test-customer", + draft_order_id: "test-draft-order", + tax_rate: 0, + payment_status: "awaiting", + currency_code: "usd", + metadata: {}, + payments: [ + { + provider_id: "system", + amount: 1000, + currency_code: "usd", + data: {}, + }, + ], + }) + + expect(draftOrderRepository.save).toHaveBeenCalledTimes(1) + expect(draftOrderRepository.save).toHaveBeenCalledWith({ + id: "test-draft-order", + cart_id: "test-cart", + status: "completed", + }) + }) + }) +}) diff --git a/packages/medusa/src/services/cart.js b/packages/medusa/src/services/cart.js index cd9bf09ced..9220392927 100644 --- a/packages/medusa/src/services/cart.js +++ b/packages/medusa/src/services/cart.js @@ -958,6 +958,8 @@ class CartService extends BaseService { relations: ["payment_sessions"], }) + console.log("YELLO: ", session.status) + if (session.status === "authorized") { const payment = await this.paymentProviderService_ .withTransaction(manager) diff --git a/packages/medusa/src/services/customer.js b/packages/medusa/src/services/customer.js index e5ffc6bd47..c99550acf2 100644 --- a/packages/medusa/src/services/customer.js +++ b/packages/medusa/src/services/customer.js @@ -3,6 +3,7 @@ import Scrypt from "scrypt-kdf" import _ from "lodash" import { Validator, MedusaError } from "medusa-core-utils" import { BaseService } from "medusa-interfaces" +import { Brackets } from "typeorm" /** * Provides layer to manipulate customers. @@ -127,7 +128,34 @@ class CustomerService extends BaseService { this.customerRepository_ ) + let q + if ("q" in selector) { + q = selector.q + delete selector.q + } + const query = this.buildQuery_(selector, config) + + if (q) { + const where = query.where + + delete where.email + delete where.first_name + delete where.last_name + + query.where = qb => { + qb.where(where) + + qb.andWhere( + new Brackets(qb => { + qb.where(`email ILIKE :q`, { q: `%${q}%` }) + .orWhere(`first_name ILIKE :q`, { q: `%${q}%` }) + .orWhere(`last_name ILIKE :q`, { q: `%${q}%` }) + }) + ) + } + } + return customerRepo.find(query) } diff --git a/packages/medusa/src/services/draft-order.js b/packages/medusa/src/services/draft-order.js index f12895cb75..6f50984b54 100644 --- a/packages/medusa/src/services/draft-order.js +++ b/packages/medusa/src/services/draft-order.js @@ -1,6 +1,7 @@ import _ from "lodash" import { BaseService } from "medusa-interfaces" -import { MedusaError } from "medusa-core-utils" +import { MedusaError, Validator } from "medusa-core-utils" +import { Brackets } from "typeorm" /** * Handles swaps @@ -14,11 +15,15 @@ class DraftOrderService extends BaseService { constructor({ manager, draftOrderRepository, + paymentRepository, + orderRepository, eventBusService, addressRepository, cartService, + customerService, totalsService, lineItemService, + paymentProviderService, productVariantService, shippingOptionService, regionService, @@ -28,9 +33,15 @@ class DraftOrderService extends BaseService { /** @private @const {EntityManager} */ this.manager_ = manager - /** @private @const {SwapModel} */ + /** @private @const {DraftOrderRepository} */ this.draftOrderRepository_ = draftOrderRepository + /** @private @const {PaymentRepository} */ + this.paymentRepository_ = paymentRepository + + /** @private @const {OrderRepository} */ + this.orderRepository_ = orderRepository + /** @private @const {TotalsService} */ this.totalsService_ = totalsService @@ -40,15 +51,18 @@ class DraftOrderService extends BaseService { /** @private @const {LineItemService} */ this.lineItemService_ = lineItemService - /** @private @const {ReturnService} */ - this.returnService_ = returnService - /** @private @const {CartService} */ this.cartService_ = cartService + /** @private @const {CustomerService} */ + this.customerService_ = customerService + /** @private @const {RegionService} */ this.regionService_ = regionService + /** @private @const {PaymentProviderService} */ + this.paymentProviderService_ = paymentProviderService + /** @private @const {ProductVariantService} */ this.productVariantService_ = productVariantService @@ -67,10 +81,16 @@ class DraftOrderService extends BaseService { const cloned = new DraftOrderService({ manager: transactionManager, draftOrderRepository: this.draftOrderRepository_, + orderRepository: this.orderRepository_, + paymentRepository: this.paymentRepository_, + paymentProviderService: this.paymentProviderService_, + regionService: this.regionService_, eventBusService: this.eventBus_, cartService: this.cartService_, totalsService: this.totalsService_, productVariantService: this.productVariantService_, + addressRepository: this.addressRepository_, + shippingOptionService: this.shippingOptionService_, lineItemService: this.lineItemService_, }) @@ -79,6 +99,68 @@ class DraftOrderService extends BaseService { return cloned } + transformQueryForTotals_(config) { + let { select, relations } = config + + if (!select) { + return { + select, + relations, + totalsToSelect: [], + } + } + + const totalFields = [ + "subtotal", + "tax_total", + "shipping_total", + "discount_total", + "total", + ] + + const totalsToSelect = select.filter(v => totalFields.includes(v)) + if (totalsToSelect.length > 0) { + const relationSet = new Set(relations) + relationSet.add("items") + relationSet.add("discounts") + relationSet.add("shipping_methods") + relationSet.add("region") + relations = [...relationSet] + + select = select.filter(v => !totalFields.includes(v)) + } + + return { + relations, + select, + totalsToSelect, + } + } + + decorateTotals_(draftOrder, totalsFields = []) { + if (totalsFields.includes("shipping_total")) { + draftOrder.shipping_total = this.totalsService_.getShippingTotal( + draftOrder + ) + } + if (totalsFields.includes("discount_total")) { + draftOrder.discount_total = this.totalsService_.getDiscountTotal( + draftOrder + ) + } + if (totalsFields.includes("tax_total")) { + draftOrder.tax_total = this.totalsService_.getTaxTotal(draftOrder) + } + if (totalsFields.includes("subtotal")) { + draftOrder.subtotal = this.totalsService_.getSubtotal(draftOrder) + } + if (totalsFields.includes("total")) { + draftOrder.total = this.totalsService_.getTotal(draftOrder) + } + + return draftOrder + } + /** * Retrieves a draft order with the given id. * @param {string} id - id of the draft order to retrieve @@ -91,17 +173,32 @@ class DraftOrderService extends BaseService { const validatedId = this.validateId_(id) - const query = this.buildQuery_({ id: validatedId }, config) + const { select, relations, totalsToSelect } = this.transformQueryForTotals_( + config + ) - const draftOrder = await draftOrderRepo.findOne(query) + const query = { + where: { id: validatedId }, + } - if (!draftOrder) { + if (relations && relations.length > 0) { + query.relations = relations + } + + if (select && select.length > 0) { + query.select = select + } + + const raw = await draftOrderRepo.findOne(query) + + if (!raw) { throw new MedusaError( MedusaError.Types.NOT_FOUND, - `Draft order with id: ${id} was not found` + `Draft order with ${id} was not found` ) } + const draftOrder = this.decorateTotals_(raw, totalsToSelect) return draftOrder } @@ -132,6 +229,65 @@ class DraftOrderService extends BaseService { return draftOrder } + async listAndCount( + selector, + config = { skip: 0, take: 50, order: { created_at: "DESC" } } + ) { + const draftOrderRepository = this.manager_.getCustomRepository( + this.draftOrderRepository_ + ) + + let q + if ("q" in selector) { + q = selector.q + delete selector.q + } + + const query = this.buildQuery_(selector, config) + + if (q) { + const where = query.where + + delete where.display_id + + query.join = { + alias: "draftOrder", + innerJoin: { + cart: "draftOrder.cart", + }, + } + + query.where = qb => { + qb.where(where) + + qb.andWhere( + new Brackets(qb => { + qb.where(`cart.email ILIKE :q`, { + q: `%${q}%`, + }).orWhere(`display_id::varchar(255) ILIKE :dId`, { dId: `${q}` }) + }) + ) + } + } + + const { select, relations, totalsToSelect } = this.transformQueryForTotals_( + config + ) + + if (select && select.length) { + query.select = select + } + + if (relations && relations.length) { + query.relations = relations + } + + const [raw, count] = await draftOrderRepository.findAndCount(query) + const draftOrders = raw.map(r => this.decorateTotals_(r, totalsToSelect)) + + return [draftOrders, count] + } + /** * Lists draft orders * @param {Object} selector - query object for find @@ -151,31 +307,6 @@ class DraftOrderService extends BaseService { return draftOrderRepo.find(query) } - async setAddress_(region, address) { - const addressRepo = this.manager_.getCustomRepository( - this.addressRepository_ - ) - - const regCountries = region.countries.map(({ iso_2 }) => iso_2) - - if (!address.country_code) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Address is missing country code` - ) - } - - if (!regCountries.includes(address.country_code)) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Country ${address.country_code} is not in region ${region.name}` - ) - } - - const created = addressRepo.create(adress) - return created - } - /** * Confirms if the contents of a line item is covered by the inventory. * To be covered a variant must either not have its inventory managed or it @@ -197,86 +328,13 @@ class DraftOrderService extends BaseService { return this.productVariantService_.canCoverQuantity(variantId, quantity) } - async addLineItem(doId, lineItem) { - return this.atomicPhase_(async manager => { - const draftOrder = await this.retrieve(doId, { - relations: [ - "shipping_methods", - "items", - "payment_sessions", - "items.variant", - "items.variant.product", - ], - }) - - if (draftOrder.status !== "open") { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "You are not allowed to add items to a draft order with status awaiting or completed" - ) - } - - let currentItem - if (lineItem.should_merge) { - currentItem = draftOrder.items.find(line => { - if (line.should_merge && line.variant_id === lineItem.variant_id) { - return _.isEqual(line.metadata, lineItem.metadata) - } - }) - } - - // If content matches one of the line items currently in the cart we can - // simply update the quantity of the existing line item - if (currentItem) { - const newQuantity = currentItem.quantity + lineItem.quantity - - // Confirm inventory - const hasInventory = await this.confirmInventory_( - lineItem.variant_id, - newQuantity - ) - - if (!hasInventory) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "Inventory doesn't cover the desired quantity" - ) - } - - await this.lineItemService_ - .withTransaction(manager) - .update(currentItem.id, { - quantity: newQuantity, - }) - } else { - // Confirm inventory - const hasInventory = await this.confirmInventory_( - lineItem.variant_id, - lineItem.quantity - ) - - if (!hasInventory) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "Inventory doesn't cover the desired quantity" - ) - } - - await this.lineItemService_.withTransaction(manager).create({ - ...lineItem, - has_shipping: false, - cart_id: cartId, - }) - } - }) - } - /** * Creates a draft order. - * @param {Object} data - data to create draft order from + * @param {object} data - data to create draft order from + * @param {boolean} shippingRequired - needs shipping flag * @return {Promise} the created draft order */ - async create(data) { + async create(data, shippingRequired = true) { return this.atomicPhase_(async manager => { const draftOrderRepo = manager.getCustomRepository( this.draftOrderRepository_ @@ -289,36 +347,20 @@ class DraftOrderService extends BaseService { ) } - const region = await this.regionService_ - .withTransaction(manager) - .retrieve(data.region_id, { - relations: ["countries"], - }) - - if (!data.shipping_address && !data.shipping_address_id) { + if (!data.items || !data.items.length) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - `Shipping addresss is required to create a draft order` - ) - } - - if (data.shipping_address && !data.shipping_address_id) { - data.shipping_address = await this.setAddress_( - region, - data.shipping_address - ) - } - - if (data.billing_address && !data.billing_address_id) { - data.billing_address = await this.setAddress_( - region, - data.billing_address + `Items are required to create a draft order` ) } const { items, shipping_methods, ...rest } = data - const draftOrder = draftOrderRepo.create(rest) + const createdCart = await this.cartService_ + .withTransaction(manager) + .create({ type: "draft_order", ...rest }) + + const draftOrder = draftOrderRepo.create({ cart_id: createdCart.id }) const result = await draftOrderRepo.save(draftOrder) await this.eventBus_ @@ -328,31 +370,140 @@ class DraftOrderService extends BaseService { }) let shippingMethods = [] - for (const method of shipping_methods) { - const m = await this.shippingOptionService_ - .withTransaction(manager) - .createShippingMethod(method.option_id, method.data, { - draft_order_id: draftOrder.id, - }) + let profiles = [] + if (shippingRequired) { + for (const method of shipping_methods) { + const m = await this.shippingOptionService_ + .withTransaction(manager) + .createShippingMethod(method.option_id, method.data, { + cart: createdCart, + }) - shippingMethods.push(m) + shippingMethods.push(m) + } + + profiles = shippingMethods.map( + ({ shipping_option }) => shipping_option.profile_id + ) } for (const item of items) { - const line = await this.lineItemService_ - .withTransaction(manager) - .generate( - item.variant_id, - cart.region_id, - item.quantity, - item.metadata - ) + if (item.variant_id) { + const line = await this.lineItemService_ + .withTransaction(manager) + .generate( + item.variant_id, + data.region_id, + item.quantity, + item.metadata + ) + const variant = await this.productVariantService_ + .withTransaction(manager) + .retrieve(item.variant_id) + const itemProfile = variant.product.profile_id + + let hasShipping = true + + // if shipping is required, ensure items can be shipped + if (shippingRequired) { + hasShipping = profiles.includes(itemProfile) + } + + await this.lineItemService_.withTransaction(manager).create({ + cart_id: createdCart.id, + has_shipping: hasShipping, + ...line, + }) + } else { + // custom line items can be added to a draft order + await this.lineItemService_.withTransaction(manager).create({ + cart_id: createdCart.id, + has_shipping: true, + title: item.title || "Custom item", + allow_discounts: false, + unit_price: item.unit_price || 0, + quantity: item.quantity, + }) + } + } + + return result + }) + } + + async registerSystemPayment(doId) { + return this.atomicPhase_(async manager => { + const draftOrder = await this.retrieve(doId) + + const draftOrderCart = await this.cartService_ + .withTransaction(manager) + .retrieve(draftOrder.cart_id, { + select: ["total"], + relations: ["discounts", "shipping_methods", "region", "items"], + }) + + const orderRepo = manager.getCustomRepository(this.orderRepository_) + const draftOrderRepo = manager.getCustomRepository( + this.draftOrderRepository_ + ) + + const paymentRepo = manager.getCustomRepository(this.paymentRepository_) + + const created = paymentRepo.create({ + provider_id: "system", + amount: draftOrderCart.total, + currency_code: draftOrderCart.region.currency_code, + data: {}, + }) + + const toCreate = { + payment_status: "awaiting", + discounts: draftOrderCart.discounts, + region_id: draftOrderCart.region_id, + email: draftOrderCart.email, + customer_id: draftOrderCart.customer_id, + draft_order_id: draftOrder.id, + cart_id: draftOrderCart.id, + tax_rate: draftOrderCart.region.tax_rate, + currency_code: draftOrderCart.region.currency_code, + metadata: draftOrderCart.metadata || {}, + payments: [created], + } + + if (draftOrderCart.shipping_address_id) { + toCreate.shipping_address_id = draftOrderCart.shipping_address_id + } + + if (draftOrderCart.billing_address_id) { + toCreate.billing_address_id = draftOrderCart.billing_address_id + } + + if (draftOrderCart.shipping_methods) { + toCreate.shipping_methods = draftOrderCart.shipping_methods + } + + const o = orderRepo.create(toCreate) + + const result = await orderRepo.save(o) + + if (draftOrderCart.shipping_methods) { + for (const method of draftOrderCart.shipping_methods) { + await this.shippingOptionService_ + .withTransaction(manager) + .updateShippingMethod(method.id, { order_id: result.id }) + } + } + + for (const item of draftOrderCart.items) { await this.lineItemService_ .withTransaction(manager) - .create({ draft_order_id: draftOrder.id, ...line }) + .update(item.id, { order_id: result.id }) } + draftOrder.status = "completed" + await draftOrderRepo.save(draftOrder) + return result }) } diff --git a/packages/medusa/src/services/order.js b/packages/medusa/src/services/order.js index aeb0527a5a..bb14da7249 100644 --- a/packages/medusa/src/services/order.js +++ b/packages/medusa/src/services/order.js @@ -484,7 +484,6 @@ class OrderService extends BaseService { payment_status: "awaiting", discounts: cart.discounts, gift_cards: cart.gift_cards, - payment_status: "awaiting", shipping_methods: cart.shipping_methods, shipping_address_id: cart.shipping_address_id, billing_address_id: cart.billing_address_id, diff --git a/packages/medusa/src/services/system-payment-provider.js b/packages/medusa/src/services/system-payment-provider.js new file mode 100644 index 0000000000..4e75535a98 --- /dev/null +++ b/packages/medusa/src/services/system-payment-provider.js @@ -0,0 +1,47 @@ +import { BaseService } from "medusa-interfaces" + +class SystemProviderService extends BaseService { + static identifier = "system" + + constructor({}) { + super() + } + + async createPayment(_) { + return {} + } + + async getPaymentData(_) { + return {} + } + + async authorizePayment(_) { + return { data: {}, status: "authorized" } + } + + async updatePaymentData(_) { + return {} + } + + async updatePayment(_) { + return {} + } + + async deletePayment(_) { + return {} + } + + async capturePayment(_) { + return {} + } + + async refundPayment(_) { + return {} + } + + async cancelPayment(_) { + return {} + } +} + +export default SystemProviderService