diff --git a/integration-tests/api/__tests__/admin/order.js b/integration-tests/api/__tests__/admin/order.js index a893ac39b4..9f004944f1 100644 --- a/integration-tests/api/__tests__/admin/order.js +++ b/integration-tests/api/__tests__/admin/order.js @@ -1,5 +1,6 @@ const { dropDatabase } = require("pg-god"); const path = require("path"); +const { ReturnReason } = require("@medusajs/medusa"); const setupServer = require("../../../helpers/setup-server"); const { useApi } = require("../../../helpers/use-api"); @@ -468,6 +469,91 @@ describe("/admin/orders", () => { }); }); + describe("POST /admin/orders/:id/return", () => { + let rrId; + beforeEach(async () => { + try { + await adminSeeder(dbConnection); + await orderSeeder(dbConnection); + + const created = dbConnection.manager.create(ReturnReason, { + value: "too_big", + label: "Too Big", + }); + const result = await dbConnection.manager.save(created); + + rrId = result.id; + } catch (err) { + console.log(err); + throw err; + } + }); + + afterEach(async () => { + const manager = dbConnection.manager; + await manager.query(`DELETE FROM "cart"`); + await manager.query(`DELETE FROM "fulfillment_item"`); + await manager.query(`DELETE FROM "fulfillment"`); + await manager.query(`DELETE FROM "swap"`); + await manager.query(`DELETE FROM "return_item"`); + await manager.query(`DELETE FROM "return_reason"`); + await manager.query(`DELETE FROM "return"`); + await manager.query(`DELETE FROM "claim_image"`); + await manager.query(`DELETE FROM "claim_tag"`); + await manager.query(`DELETE FROM "claim_item"`); + await manager.query(`DELETE FROM "shipping_method"`); + await manager.query(`DELETE FROM "line_item"`); + await manager.query(`DELETE FROM "claim_order"`); + 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_option"`); + await manager.query(`DELETE FROM "discount"`); + await manager.query(`DELETE FROM "payment"`); + await manager.query(`DELETE FROM "order"`); + await manager.query(`DELETE FROM "customer"`); + await manager.query( + `UPDATE "country" SET region_id=NULL WHERE iso_2 = 'us'` + ); + await manager.query(`DELETE FROM "region"`); + await manager.query(`DELETE FROM "user"`); + }); + + it("creates a return", async () => { + const api = useApi(); + + const response = await api.post( + "/admin/orders/test-order/return", + { + items: [ + { + item_id: "test-item", + quantity: 1, + reason_id: rrId, + note: "TOO SMALL", + }, + ], + }, + { + headers: { + authorization: "Bearer test_token", + }, + } + ); + expect(response.status).toEqual(200); + + expect(response.data.order.returns[0].refund_amount).toEqual(7200); + expect(response.data.order.returns[0].items).toEqual([ + expect.objectContaining({ + item_id: "test-item", + quantity: 1, + reason_id: rrId, + note: "TOO SMALL", + }), + ]); + }); + }); + describe("GET /admin/orders", () => { beforeEach(async () => { try { diff --git a/integration-tests/api/__tests__/admin/return-reason.js b/integration-tests/api/__tests__/admin/return-reason.js new file mode 100644 index 0000000000..57857edead --- /dev/null +++ b/integration-tests/api/__tests__/admin/return-reason.js @@ -0,0 +1,170 @@ +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 adminSeeder = require("../../helpers/admin-seeder"); + +jest.setTimeout(30000); + +describe("/admin/return-reasons", () => { + 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/return-reasons", () => { + beforeEach(async () => { + try { + await adminSeeder(dbConnection); + } catch (err) { + console.log(err); + throw err; + } + }); + + afterEach(async () => { + const manager = dbConnection.manager; + await manager.query(`DELETE FROM "return_reason"`); + await manager.query(`DELETE FROM "user"`); + }); + + it("creates a return_reason", async () => { + const api = useApi(); + + const payload = { + label: "Too Big", + description: "Use this if the size was too big", + value: "too_big", + }; + + const response = await api + .post("/admin/return-reasons", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err); + }); + + expect(response.status).toEqual(200); + + expect(response.data.return_reason).toEqual( + expect.objectContaining({ + label: "Too Big", + description: "Use this if the size was too big", + value: "too_big", + }) + ); + }); + + it("update a return reason", async () => { + const api = useApi(); + + const payload = { + label: "Too Big Typo", + description: "Use this if the size was too big", + value: "too_big", + }; + + const response = await api + .post("/admin/return-reasons", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err); + }); + + expect(response.status).toEqual(200); + + expect(response.data.return_reason).toEqual( + expect.objectContaining({ + label: "Too Big Typo", + description: "Use this if the size was too big", + value: "too_big", + }) + ); + + const newResponse = await api + .post( + `/admin/return-reasons/${response.data.return_reason.id}`, + { + label: "Too Big", + description: "new desc", + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + console.log(err); + }); + + expect(newResponse.data.return_reason).toEqual( + expect.objectContaining({ + label: "Too Big", + description: "new desc", + value: "too_big", + }) + ); + }); + + it("list return reasons", async () => { + const api = useApi(); + + const payload = { + label: "Too Big Typo", + description: "Use this if the size was too big", + value: "too_big", + }; + + await api + .post("/admin/return-reasons", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err); + }); + + const response = await api + .get("/admin/return-reasons", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err); + }); + + expect(response.status).toEqual(200); + + console.log(response.data); + + expect(response.data.return_reasons).toEqual([ + expect.objectContaining({ + value: "too_big", + }), + ]); + }); + }); +}); diff --git a/integration-tests/api/__tests__/store/return-reason.js b/integration-tests/api/__tests__/store/return-reason.js new file mode 100644 index 0000000000..76707b5358 --- /dev/null +++ b/integration-tests/api/__tests__/store/return-reason.js @@ -0,0 +1,69 @@ +const { dropDatabase } = require("pg-god"); +const path = require("path"); + +const { ReturnReason } = require("@medusajs/medusa"); + +const setupServer = require("../../../helpers/setup-server"); +const { useApi } = require("../../../helpers/use-api"); +const { initDb } = require("../../../helpers/use-db"); + +jest.setTimeout(30000); + +describe("/store/return-reasons", () => { + 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("GET /store/return-reasons", () => { + let rrId; + + beforeEach(async () => { + try { + const created = dbConnection.manager.create(ReturnReason, { + value: "too_big", + label: "Too Big", + }); + + const result = await dbConnection.manager.save(created); + rrId = result.id; + } catch (err) { + console.log(err); + throw err; + } + }); + + afterEach(async () => { + const manager = dbConnection.manager; + await manager.query(`DELETE FROM "return_reason"`); + }); + + it("list return reasons", async () => { + const api = useApi(); + + const response = await api.get("/store/return-reasons").catch((err) => { + console.log(err); + }); + + expect(response.status).toEqual(200); + + expect(response.data.return_reasons).toEqual([ + expect.objectContaining({ + id: rrId, + value: "too_big", + }), + ]); + }); + }); +}); diff --git a/integration-tests/api/__tests__/store/returns.js b/integration-tests/api/__tests__/store/returns.js index 8721ef5da3..7de4e9463b 100644 --- a/integration-tests/api/__tests__/store/returns.js +++ b/integration-tests/api/__tests__/store/returns.js @@ -2,6 +2,7 @@ const { dropDatabase } = require("pg-god"); const path = require("path"); const { Region, + ReturnReason, Order, Customer, ShippingProfile, @@ -35,6 +36,8 @@ describe("/store/carts", () => { }); describe("POST /store/returns", () => { + let rrId; + beforeEach(async () => { const manager = dbConnection.manager; await manager.query( @@ -110,6 +113,14 @@ describe("/store/carts", () => { amount: 1000, is_return: true, }); + + const created = dbConnection.manager.create(ReturnReason, { + value: "too_big", + label: "Too Big", + }); + const result = await dbConnection.manager.save(created); + + rrId = result.id; }); afterEach(async () => { @@ -117,6 +128,7 @@ describe("/store/carts", () => { await manager.query(`DELETE FROM "shipping_method"`); await manager.query(`DELETE FROM "shipping_option"`); await manager.query(`DELETE FROM "return_item"`); + await manager.query(`DELETE FROM "return_reason"`); await manager.query(`DELETE FROM "return"`); await manager.query(`DELETE FROM "line_item"`); await manager.query(`DELETE FROM "order"`); @@ -174,5 +186,33 @@ describe("/store/carts", () => { expect(response.data.return.refund_amount).toEqual(7000); }); + + it("creates a return with reasons", async () => { + const api = useApi(); + + const response = await api + .post("/store/returns", { + order_id: "order_test", + items: [ + { + reason_id: rrId, + note: "TOO small", + item_id: "test-item", + quantity: 1, + }, + ], + }) + .catch((err) => { + return err.response; + }); + expect(response.status).toEqual(200); + + expect(response.data.return.items).toEqual([ + expect.objectContaining({ + reason_id: rrId, + note: "TOO small", + }), + ]); + }); }); }); diff --git a/integration-tests/api/package.json b/integration-tests/api/package.json index 056de2d8ee..611aaa2247 100644 --- a/integration-tests/api/package.json +++ b/integration-tests/api/package.json @@ -8,15 +8,15 @@ "build": "babel src -d dist --extensions \".ts,.js\"" }, "dependencies": { - "@medusajs/medusa": "1.1.11-dev-1615546159319", - "medusa-interfaces": "1.1.1-dev-1615546159319", + "@medusajs/medusa": "1.1.11-dev-1615882960610", + "medusa-interfaces": "1.1.1-dev-1615882960610", "typeorm": "^0.2.31" }, "devDependencies": { "@babel/cli": "^7.12.10", "@babel/core": "^7.12.10", "@babel/node": "^7.12.10", - "babel-preset-medusa-package": "1.1.0-dev-1615546159319", + "babel-preset-medusa-package": "1.1.0-dev-1615882960610", "jest": "^26.6.3" } } diff --git a/integration-tests/api/yarn.lock b/integration-tests/api/yarn.lock index e8640b53ff..ffaaa33ddd 100644 --- a/integration-tests/api/yarn.lock +++ b/integration-tests/api/yarn.lock @@ -1369,10 +1369,10 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" -"@medusajs/medusa@1.1.11-dev-1615546159319": - version "1.1.11-dev-1615546159319" - resolved "http://localhost:4873/@medusajs%2fmedusa/-/medusa-1.1.11-dev-1615546159319.tgz#88d494cd9f765d69cfa8450aad1c459e3f358c2c" - integrity sha512-0REuafQjwykR6zbjjuIq151S3nLWdHBIFcy7fzKcR7Yhs2XkHMDFToUD01/uY2d/GqmKGvn/HsDLPx2a7Sj4SQ== +"@medusajs/medusa@1.1.11-dev-1615882960610": + version "1.1.11-dev-1615882960610" + resolved "http://localhost:4873/@medusajs%2fmedusa/-/medusa-1.1.11-dev-1615882960610.tgz#805c66ace0750f63ce8d49d89af2b08588455a1b" + integrity sha512-dvkCGMt/lM51ZW6HJoAV70uy6wuuhGS6PAiRvmuofRf331yvHO95KBGMxKbGLtsyL1SAt9Bf1V8Wm4zPYL/BNQ== dependencies: "@babel/plugin-transform-classes" "^7.9.5" "@hapi/joi" "^16.1.8" @@ -1394,8 +1394,8 @@ joi "^17.3.0" joi-objectid "^3.0.1" jsonwebtoken "^8.5.1" - medusa-core-utils "1.1.0-dev-1615546159319" - medusa-test-utils "1.1.3-dev-1615546159319" + medusa-core-utils "1.1.0-dev-1615882960610" + medusa-test-utils "1.1.3-dev-1615882960610" morgan "^1.9.1" multer "^1.4.2" passport "^0.4.0" @@ -1884,10 +1884,10 @@ babel-preset-jest@^26.6.2: babel-plugin-jest-hoist "^26.6.2" babel-preset-current-node-syntax "^1.0.0" -babel-preset-medusa-package@1.1.0-dev-1615546159319: - version "1.1.0-dev-1615546159319" - resolved "http://localhost:4873/babel-preset-medusa-package/-/babel-preset-medusa-package-1.1.0-dev-1615546159319.tgz#eaaa8908d9b04487cbfbd90cd5422c8ade611506" - integrity sha512-IS60RphbSYg+PQA5ywdjba2eL+pyHaHAUm93658fAMezSKnRaDEvtle42DK63EifQC35K1pQc0j7YUbgg8ZODg== +babel-preset-medusa-package@1.1.0-dev-1615882960610: + version "1.1.0-dev-1615882960610" + resolved "http://localhost:4873/babel-preset-medusa-package/-/babel-preset-medusa-package-1.1.0-dev-1615882960610.tgz#1cb3b658a6114759b33bb022a19865d8d5751a9a" + integrity sha512-PF+RAij3oCoXtU7/WbyJVCvassselnk4I4u2NXo7sxqg0Z6V1N9IwEVxTsg7G2PfiXmT2EYaJPPsq9st8lOvuQ== dependencies: "@babel/plugin-proposal-class-properties" "^7.12.1" "@babel/plugin-proposal-decorators" "^7.12.1" @@ -4493,28 +4493,28 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= -medusa-core-utils@1.1.0-dev-1615546159319: - version "1.1.0-dev-1615546159319" - resolved "http://localhost:4873/medusa-core-utils/-/medusa-core-utils-1.1.0-dev-1615546159319.tgz#34147e32463b002c51b815f7329e88881a0fbec2" - integrity sha512-t/QeZmHaFdBmn1wC+StZDq+tJescILybCSNIOFLSUlOaUaOPGwZ0UhYN/MNeMrBwAoQq9ZO5QjJsNNbQK33gWA== +medusa-core-utils@1.1.0-dev-1615882960610: + version "1.1.0-dev-1615882960610" + resolved "http://localhost:4873/medusa-core-utils/-/medusa-core-utils-1.1.0-dev-1615882960610.tgz#5358b5edc3cd571071ed5bf6c3781b397f2674f9" + integrity sha512-v6zvSdkJn97jY7JwEebIpHy5SZzj2KO+m5duIRmRTnBQUI7DWVxOOO7DRqc24W0shwCz/H51PdKjhDwdtSgFwQ== dependencies: joi "^17.3.0" joi-objectid "^3.0.1" -medusa-interfaces@1.1.1-dev-1615546159319: - version "1.1.1-dev-1615546159319" - resolved "http://localhost:4873/medusa-interfaces/-/medusa-interfaces-1.1.1-dev-1615546159319.tgz#a75e2841a9c68b26bc688706d59fccfd655c8f41" - integrity sha512-BqE+ARKvJgmLHABuAGfCSquNa1XPF8+ll/dB6XMMjPAPzlGF9kV/GhQIsjq8fzoZ8ZvrLBD0ak5ndOK/BH/NEA== +medusa-interfaces@1.1.1-dev-1615882960610: + version "1.1.1-dev-1615882960610" + resolved "http://localhost:4873/medusa-interfaces/-/medusa-interfaces-1.1.1-dev-1615882960610.tgz#6062b8c6fadb33e89e7ed53b5627a182af1c4511" + integrity sha512-WdyYK1yqSUIXIJSjLpOCRotVAEIVeggnqNh+AtNuxddmMBJrS0tFUhj2RetkyMkoVsjTuEkSOI/LY9V8fK9xCA== dependencies: - medusa-core-utils "1.1.0-dev-1615546159319" + medusa-core-utils "1.1.0-dev-1615882960610" -medusa-test-utils@1.1.3-dev-1615546159319: - version "1.1.3-dev-1615546159319" - resolved "http://localhost:4873/medusa-test-utils/-/medusa-test-utils-1.1.3-dev-1615546159319.tgz#912b4bf83e03c247bc189ebc315c9db4200f3a71" - integrity sha512-hW8xwrf52a5PAzZDL/kOk9Mlv4Jdpgfx+IPUz/F2D3D8ky3OwikwonXKvPvaRC5JaCdjI/xipPaSWUkJeb1dzg== +medusa-test-utils@1.1.3-dev-1615882960610: + version "1.1.3-dev-1615882960610" + resolved "http://localhost:4873/medusa-test-utils/-/medusa-test-utils-1.1.3-dev-1615882960610.tgz#55326f1884469e5a41221e1a029e792ed3035129" + integrity sha512-cnRbzR4I+R1NkZw1w6e8NwKPDqXodhaK7vAbIc3ogRHt8iXTYIVaU9RJTBCYnmZQ7Y/0zQCSd7NplB+PQ75ssw== dependencies: "@babel/plugin-transform-classes" "^7.9.5" - medusa-core-utils "1.1.0-dev-1615546159319" + medusa-core-utils "1.1.0-dev-1615882960610" randomatic "^3.1.1" merge-descriptors@1.0.1: diff --git a/packages/medusa/src/api/routes/admin/index.js b/packages/medusa/src/api/routes/admin/index.js index 7ea852addf..9f86c67aad 100644 --- a/packages/medusa/src/api/routes/admin/index.js +++ b/packages/medusa/src/api/routes/admin/index.js @@ -11,6 +11,7 @@ import shippingProfileRoutes from "./shipping-profiles" import discountRoutes from "./discounts" import giftCardRoutes from "./gift-cards" import orderRoutes from "./orders" +import returnReasonRoutes from "./return-reasons" import storeRoutes from "./store" import uploadRoutes from "./uploads" import customerRoutes from "./customers" @@ -64,6 +65,7 @@ export default (app, container, config) => { variantRoutes(route) collectionRoutes(route) notificationRoutes(route) + returnReasonRoutes(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 eaa0fc0136..d9f6077294 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 @@ -13,6 +13,8 @@ const defaultRelations = [ "fulfillments.tracking_links", "fulfillments.items", "returns", + "returns.items", + "returns.items.reason", "gift_cards", "gift_card_transactions", "claims", diff --git a/packages/medusa/src/api/routes/admin/orders/index.js b/packages/medusa/src/api/routes/admin/orders/index.js index 1cb30ea871..16f6518799 100644 --- a/packages/medusa/src/api/routes/admin/orders/index.js +++ b/packages/medusa/src/api/routes/admin/orders/index.js @@ -195,6 +195,8 @@ export const defaultRelations = [ "fulfillments.tracking_links", "fulfillments.items", "returns", + "returns.items", + "returns.items.reason", "gift_cards", "gift_card_transactions", "claims", diff --git a/packages/medusa/src/api/routes/admin/orders/request-return.js b/packages/medusa/src/api/routes/admin/orders/request-return.js index 187dd31600..24cb7f084c 100644 --- a/packages/medusa/src/api/routes/admin/orders/request-return.js +++ b/packages/medusa/src/api/routes/admin/orders/request-return.js @@ -21,6 +21,12 @@ import { defaultRelations, defaultFields } from "./" * item_id: * description: The id of the Line Item. * type: string + * reason_id: + * description: The id of the Return Reason to use. + * type: string + * note: + * description: An optional note with information about the Return. + * type: string * quantity: * description: The quantity of the Line Item. * type: integer @@ -60,6 +66,8 @@ export default async (req, res) => { .items({ item_id: Validator.string().required(), quantity: Validator.number().required(), + reason_id: Validator.string().optional(), + note: Validator.string().optional(), }) .required(), return_shipping: Validator.object() diff --git a/packages/medusa/src/api/routes/admin/return-reasons/create-reason.js b/packages/medusa/src/api/routes/admin/return-reasons/create-reason.js new file mode 100644 index 0000000000..42b9eb8760 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/return-reasons/create-reason.js @@ -0,0 +1,66 @@ +import { MedusaError, Validator } from "medusa-core-utils" +import { defaultRelations, defaultFields } from "./" + +/** + * @oas [post] /return-reasons + * operationId: "PostReturnReasons" + * summary: "Create a Return Reason" + * description: "Creates a Return Reason" + * requestBody: + * content: + * application/json: + * schema: + * properties: + * label: + * description: "The label to display to the Customer." + * type: string + * value: + * description: "The value that the Return Reason will be identified by. Must be unique." + * type: string + * description: + * description: "An optional description to for the Reason." + * type: string + * metadata: + * description: An optional set of key-value pairs with additional information. + * type: object + * tags: + * - Return Reason + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * return_reason: + * $ref: "#/components/schemas/return_reason" + */ +export default async (req, res) => { + const schema = Validator.object().keys({ + value: Validator.string().required(), + label: Validator.string().required(), + description: Validator.string() + .optional() + .allow(""), + metadata: Validator.object().optional(), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const returnReasonService = req.scope.resolve("returnReasonService") + const result = await returnReasonService.create(value) + + const reason = await returnReasonService.retrieve(result.id, { + select: defaultFields, + relations: defaultRelations, + }) + + res.status(200).json({ return_reason: reason }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/return-reasons/get-reason.js b/packages/medusa/src/api/routes/admin/return-reasons/get-reason.js new file mode 100644 index 0000000000..53a1cf40dd --- /dev/null +++ b/packages/medusa/src/api/routes/admin/return-reasons/get-reason.js @@ -0,0 +1,37 @@ +import { MedusaError, Validator } from "medusa-core-utils" +import { defaultRelations, defaultFields } from "./" + +/** + * @oas [get] /return-reasons/{id} + * operationId: "GetReturnReasonsReason" + * summary: "Retrieve a Return Reason" + * description: "Retrieves a Return Reason." + * parameters: + * - (path) id=* {string} The id of the Return Reason. + * tags: + * - Return Reason + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * return_reason: + * $ref: "#/components/schemas/return_reason" + */ +export default async (req, res) => { + const { id } = req.params + try { + const returnReasonService = req.scope.resolve("returnReasonService") + + const data = await returnReasonService.retrieve(id, { + select: defaultFields, + relations: defaultRelations, + }) + + res.status(200).json({ return_reason: data }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/return-reasons/index.js b/packages/medusa/src/api/routes/admin/return-reasons/index.js new file mode 100644 index 0000000000..7ad111137f --- /dev/null +++ b/packages/medusa/src/api/routes/admin/return-reasons/index.js @@ -0,0 +1,42 @@ +import { Router } from "express" +import middlewares from "../../../middlewares" + +const route = Router() + +export default app => { + app.use("/return-reasons", route) + + /** + * List reasons + */ + route.get("/", middlewares.wrap(require("./list-reasons").default)) + + /** + * Retrieve reason + */ + route.get("/:id", middlewares.wrap(require("./get-reason").default)) + + /** + * Create a reason + */ + route.post("/", middlewares.wrap(require("./create-reason").default)) + + /** + * Update a reason + */ + route.post("/:id", middlewares.wrap(require("./update-reason").default)) + + return app +} + +export const defaultFields = [ + "id", + "value", + "label", + "description", + "created_at", + "updated_at", + "deleted_at", +] + +export const defaultRelations = [] diff --git a/packages/medusa/src/api/routes/admin/return-reasons/list-reasons.js b/packages/medusa/src/api/routes/admin/return-reasons/list-reasons.js new file mode 100644 index 0000000000..367a2fb651 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/return-reasons/list-reasons.js @@ -0,0 +1,37 @@ +import { MedusaError, Validator } from "medusa-core-utils" +import { defaultRelations, defaultFields } from "./" + +/** + * @oas [get] /return-reasons + * operationId: "GetReturnReasons" + * summary: "List Return Reasons" + * description: "Retrieves a list of Return Reasons." + * tags: + * - Return Reason + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * return_reasons: + * type: array + * items: + * $ref: "#/components/schemas/return_reason" + */ +export default async (req, res) => { + try { + const returnReasonService = req.scope.resolve("returnReasonService") + + const query = {} + const data = await returnReasonService.list(query, { + select: defaultFields, + relations: defaultRelations, + }) + + res.status(200).json({ return_reasons: data }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/return-reasons/update-reason.js b/packages/medusa/src/api/routes/admin/return-reasons/update-reason.js new file mode 100644 index 0000000000..e756533dda --- /dev/null +++ b/packages/medusa/src/api/routes/admin/return-reasons/update-reason.js @@ -0,0 +1,70 @@ +import { MedusaError, Validator } from "medusa-core-utils" +import { defaultRelations, defaultFields } from "./" + +/** + * @oas [post] /return-reasons/:id + * operationId: "PostReturnReasonsReason" + * summary: "Update a Return Reason" + * description: "Updates a Return Reason" + * parameters: + * - (path) id=* {string} The id of the Return Reason. + * requestBody: + * content: + * application/json: + * schema: + * properties: + * label: + * description: "The label to display to the Customer." + * type: string + * value: + * description: "The value that the Return Reason will be identified by. Must be unique." + * type: string + * description: + * description: "An optional description to for the Reason." + * type: string + * metadata: + * description: An optional set of key-value pairs with additional information. + * type: object + * tags: + * - Return Reason + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * return_reason: + * $ref: "#/components/schemas/return_reason" + */ +export default async (req, res) => { + const { id } = req.params + + const schema = Validator.object().keys({ + label: Validator.string().optional(), + description: Validator.string() + .optional() + .allow(""), + metadata: Validator.object().optional(), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const returnReasonService = req.scope.resolve("returnReasonService") + + await returnReasonService.update(id, value) + + const reason = await returnReasonService.retrieve(id, { + select: defaultFields, + relations: defaultRelations, + }) + + res.status(200).json({ return_reason: reason }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/store/index.js b/packages/medusa/src/api/routes/store/index.js index 335fa6660b..413ea3237b 100644 --- a/packages/medusa/src/api/routes/store/index.js +++ b/packages/medusa/src/api/routes/store/index.js @@ -11,6 +11,7 @@ import customerRoutes from "./customers" import shippingOptionRoutes from "./shipping-options" import regionRoutes from "./regions" import returnRoutes from "./returns" +import returnReasonRoutes from "./return-reasons" import swapRoutes from "./swaps" import variantRoutes from "./variants" import giftCardRoutes from "./gift-cards" @@ -41,6 +42,7 @@ export default (app, container, config) => { variantRoutes(route) returnRoutes(route) giftCardRoutes(route) + returnReasonRoutes(route) return app } diff --git a/packages/medusa/src/api/routes/store/return-reasons/get-reason.js b/packages/medusa/src/api/routes/store/return-reasons/get-reason.js new file mode 100644 index 0000000000..53a1cf40dd --- /dev/null +++ b/packages/medusa/src/api/routes/store/return-reasons/get-reason.js @@ -0,0 +1,37 @@ +import { MedusaError, Validator } from "medusa-core-utils" +import { defaultRelations, defaultFields } from "./" + +/** + * @oas [get] /return-reasons/{id} + * operationId: "GetReturnReasonsReason" + * summary: "Retrieve a Return Reason" + * description: "Retrieves a Return Reason." + * parameters: + * - (path) id=* {string} The id of the Return Reason. + * tags: + * - Return Reason + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * return_reason: + * $ref: "#/components/schemas/return_reason" + */ +export default async (req, res) => { + const { id } = req.params + try { + const returnReasonService = req.scope.resolve("returnReasonService") + + const data = await returnReasonService.retrieve(id, { + select: defaultFields, + relations: defaultRelations, + }) + + res.status(200).json({ return_reason: data }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/store/return-reasons/index.js b/packages/medusa/src/api/routes/store/return-reasons/index.js new file mode 100644 index 0000000000..4590bb5c6a --- /dev/null +++ b/packages/medusa/src/api/routes/store/return-reasons/index.js @@ -0,0 +1,32 @@ +import { Router } from "express" +import middlewares from "../../../middlewares" + +const route = Router() + +export default app => { + app.use("/return-reasons", route) + + /** + * List reasons + */ + route.get("/", middlewares.wrap(require("./list-reasons").default)) + + /** + * Retrieve reason + */ + route.get("/:id", middlewares.wrap(require("./get-reason").default)) + + return app +} + +export const defaultFields = [ + "id", + "value", + "label", + "description", + "created_at", + "updated_at", + "deleted_at", +] + +export const defaultRelations = [] diff --git a/packages/medusa/src/api/routes/store/return-reasons/list-reasons.js b/packages/medusa/src/api/routes/store/return-reasons/list-reasons.js new file mode 100644 index 0000000000..367a2fb651 --- /dev/null +++ b/packages/medusa/src/api/routes/store/return-reasons/list-reasons.js @@ -0,0 +1,37 @@ +import { MedusaError, Validator } from "medusa-core-utils" +import { defaultRelations, defaultFields } from "./" + +/** + * @oas [get] /return-reasons + * operationId: "GetReturnReasons" + * summary: "List Return Reasons" + * description: "Retrieves a list of Return Reasons." + * tags: + * - Return Reason + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * return_reasons: + * type: array + * items: + * $ref: "#/components/schemas/return_reason" + */ +export default async (req, res) => { + try { + const returnReasonService = req.scope.resolve("returnReasonService") + + const query = {} + const data = await returnReasonService.list(query, { + select: defaultFields, + relations: defaultRelations, + }) + + res.status(200).json({ return_reasons: data }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/store/returns/create-return.js b/packages/medusa/src/api/routes/store/returns/create-return.js index ef1d7c4060..3fb61ec8d7 100644 --- a/packages/medusa/src/api/routes/store/returns/create-return.js +++ b/packages/medusa/src/api/routes/store/returns/create-return.js @@ -8,6 +8,8 @@ export default async (req, res) => { .items({ item_id: Validator.string().required(), quantity: Validator.number().required(), + reason_id: Validator.string().optional(), + note: Validator.string().optional(), }) .required(), return_shipping: Validator.object() @@ -109,13 +111,14 @@ export default async (req, res) => { const { key, error } = await idempotencyKeyService.workStage( idempotencyKey.idempotency_key, async manager => { - let order = await orderService - .withTransaction(manager) - .retrieve(value.order_id, { relations: ["returns"] }) - - let ret = await returnService.withTransaction(manager).list({ - idempotency_key: idempotencyKey.idempotency_key, - }) + let ret = await returnService.withTransaction(manager).list( + { + idempotency_key: idempotencyKey.idempotency_key, + }, + { + relations: ["items", "items.reason"], + } + ) if (!ret.length) { throw new MedusaError( MedusaError.Types.INVALID_DATA, diff --git a/packages/medusa/src/index.js b/packages/medusa/src/index.js index 8c0e3d8984..748791d2b6 100644 --- a/packages/medusa/src/index.js +++ b/packages/medusa/src/index.js @@ -41,3 +41,4 @@ export { StagedJob } from "./models/staged-job" export { Store } from "./models/store" export { Swap } from "./models/swap" export { User } from "./models/user" +export { ReturnReason } from "./models/return-reason" diff --git a/packages/medusa/src/migrations/1615891636559-return_reason.ts b/packages/medusa/src/migrations/1615891636559-return_reason.ts new file mode 100644 index 0000000000..c1c73cb8ac --- /dev/null +++ b/packages/medusa/src/migrations/1615891636559-return_reason.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class returnReason1615891636559 implements MigrationInterface { + name = "returnReason1615891636559" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "return_reason" ("id" character varying NOT NULL, "value" character varying NOT NULL, "label" character varying NOT NULL, "description" character varying, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deleted_at" TIMESTAMP WITH TIME ZONE, "metadata" jsonb, CONSTRAINT "PK_95fd1172973165790903e65660a" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_00605f9d662c06b81c1b60ce24" ON "return_reason" ("value") ` + ) + await queryRunner.query( + `ALTER TABLE "return_item" ADD "reason_id" character varying` + ) + await queryRunner.query( + `ALTER TABLE "return_item" ADD "note" character varying` + ) + await queryRunner.query( + `ALTER TABLE "return_item" ADD CONSTRAINT "FK_d742532378a65022e7ceb328828" FOREIGN KEY ("reason_id") REFERENCES "return_reason"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "return_item" DROP CONSTRAINT "FK_d742532378a65022e7ceb328828"` + ) + await queryRunner.query(`ALTER TABLE "return_item" DROP COLUMN "note"`) + await queryRunner.query(`ALTER TABLE "return_item" DROP COLUMN "reason_id"`) + await queryRunner.query(`DROP INDEX "IDX_00605f9d662c06b81c1b60ce24"`) + await queryRunner.query(`DROP TABLE "return_reason"`) + } +} diff --git a/packages/medusa/src/models/return-item.ts b/packages/medusa/src/models/return-item.ts index 28c89ea70c..1e1db9b5e7 100644 --- a/packages/medusa/src/models/return-item.ts +++ b/packages/medusa/src/models/return-item.ts @@ -16,6 +16,7 @@ import { JoinTable, } from "typeorm" +import { ReturnReason } from "./return-reason" import { Return } from "./return" import { LineItem } from "./line-item" @@ -47,6 +48,16 @@ export class ReturnItem { @Column({ type: "int", nullable: true }) received_quantity: number + @Column({ nullable: true }) + reason_id: string + + @ManyToOne(() => ReturnReason, { eager: true }) + @JoinColumn({ name: "reason_id" }) + reason: ReturnReason + + @Column({ nullable: true }) + note: string + @Column({ type: "jsonb", nullable: true }) metadata: any } @@ -79,6 +90,13 @@ export class ReturnItem { * recieved_quantity: * description: "The quantity that was received in the warehouse." * type: integer + * reason: + * description: "The reason for returning the item." + * anyOf: + * - $ref: "#/components/schemas/return_reason" + * note: + * description: "An optional note with additional details about the Return." + * type: string * metadata: * description: "An optional key-value map with additional information." * type: object diff --git a/packages/medusa/src/models/return-reason.ts b/packages/medusa/src/models/return-reason.ts new file mode 100644 index 0000000000..d1c37ffa88 --- /dev/null +++ b/packages/medusa/src/models/return-reason.ts @@ -0,0 +1,81 @@ +import { + Entity, + Index, + BeforeInsert, + Column, + DeleteDateColumn, + CreateDateColumn, + UpdateDateColumn, + PrimaryColumn, +} from "typeorm" +import { ulid } from "ulid" + +@Entity() +export class ReturnReason { + @PrimaryColumn() + id: string + + @Index({ unique: true }) + @Column() + value: string + + @Column() + label: string + + @Column({ nullable: true }) + description: string + + @CreateDateColumn({ type: "timestamptz" }) + created_at: Date + + @UpdateDateColumn({ type: "timestamptz" }) + updated_at: Date + + @DeleteDateColumn({ type: "timestamptz" }) + deleted_at: Date + + @Column({ type: "jsonb", nullable: true }) + metadata: any + + @BeforeInsert() + private beforeInsert() { + if (this.id) return + const id = ulid() + this.id = `rr_${id}` + } +} + +/** + * @schema return_reason + * title: "Return Reason" + * description: "A Reason for why a given product is returned. A Return Reason can be used on Return Items in order to indicate why a Line Item was returned." + * x-resourceId: return_reason + * properties: + * id: + * description: "The id of the Return Reason will start with `rr_`." + * type: string + * description: + * description: "A description of the Reason." + * type: string + * label: + * description: "A text that can be displayed to the Customer as a reason." + * type: string + * value: + * description: "The value to identify the reason by." + * type: string + * created_at: + * description: "The date with timezone at which the resource was created." + * type: string + * format: date-time + * updated_at: + * description: "The date with timezone at which the resource was last updated." + * type: string + * format: date-time + * deleted_at: + * description: "The date with timezone at which the resource was deleted." + * type: string + * format: date-time + * metadata: + * description: "An optional key-value map with additional information." + * type: object + */ diff --git a/packages/medusa/src/repositories/return-reason.ts b/packages/medusa/src/repositories/return-reason.ts new file mode 100644 index 0000000000..c66869fcfb --- /dev/null +++ b/packages/medusa/src/repositories/return-reason.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from "typeorm" +import { ReturnReason } from "../models/return-reason" + +@EntityRepository(ReturnReason) +export class ReturnReasonRepository extends Repository {} diff --git a/packages/medusa/src/services/return-reason.js b/packages/medusa/src/services/return-reason.js new file mode 100644 index 0000000000..34784c78e9 --- /dev/null +++ b/packages/medusa/src/services/return-reason.js @@ -0,0 +1,97 @@ +import _ from "lodash" +import { Validator, MedusaError } from "medusa-core-utils" +import { BaseService } from "medusa-interfaces" + +class ReturnReasonService extends BaseService { + constructor({ manager, returnReasonRepository }) { + super() + + /** @private @constant {EntityManager} */ + this.manager_ = manager + + /** @private @constant {ReturnReasonRepository} */ + this.retReasonRepo_ = returnReasonRepository + } + + withTransaction(manager) { + if (!manager) { + return this + } + + const cloned = new ReturnReasonService({ + manager, + returnReasonRepository: this.retReasonRepo_, + }) + + cloned.transactionManager_ = manager + + return cloned + } + + create(data) { + return this.atomicPhase_(async manager => { + const rrRepo = manager.getCustomRepository(this.retReasonRepo_) + + const created = rrRepo.create(data) + + const result = await rrRepo.save(created) + return result + }) + } + + update(id, data) { + return this.atomicPhase_(async manager => { + const rrRepo = manager.getCustomRepository(this.retReasonRepo_) + const reason = await this.retrieve(id) + + if ("description" in data) { + reason.description = data.description + } + + if ("label" in data) { + reason.label = data.label + } + + await rrRepo.save(reason) + + return reason + }) + } + + /** + * @param {Object} selector - the query object for find + * @return {Promise} the result of the find operation + */ + async list( + selector, + config = { skip: 0, take: 50, order: { created_at: "DESC" } } + ) { + const rrRepo = this.manager_.getCustomRepository(this.retReasonRepo_) + const query = this.buildQuery_(selector, config) + return rrRepo.find(query) + } + + /** + * Gets an order by id. + * @param {string} orderId - id of order to retrieve + * @return {Promise} the order document + */ + async retrieve(id, config = {}) { + const rrRepo = this.manager_.getCustomRepository(this.retReasonRepo_) + const validatedId = this.validateId_(id) + + const query = this.buildQuery_({ id: validatedId }, config) + const item = await rrRepo.findOne(query) + + if (!item) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Return Reason with id: ${id} was not found.` + ) + } + + return item + } +} + +export default ReturnReasonService diff --git a/packages/medusa/src/services/return.js b/packages/medusa/src/services/return.js index b68ca05b09..13509dc9fc 100644 --- a/packages/medusa/src/services/return.js +++ b/packages/medusa/src/services/return.js @@ -14,6 +14,7 @@ class ReturnService extends BaseService { returnRepository, returnItemRepository, shippingOptionService, + returnReasonService, fulfillmentProviderService, }) { super() @@ -38,6 +39,8 @@ class ReturnService extends BaseService { /** @private @const {FulfillmentProviderService} */ this.fulfillmentProviderService_ = fulfillmentProviderService + + this.returnReasonService_ = returnReasonService } withTransaction(transactionManager) { @@ -53,6 +56,7 @@ class ReturnService extends BaseService { returnItemRepository: this.returnItemRepository_, shippingOptionService: this.shippingOptionService_, fulfillmentProviderService: this.fulfillmentProviderService_, + returnReasonService: this.returnReasonService_, }) cloned.transactionManager_ = transactionManager @@ -72,9 +76,9 @@ class ReturnService extends BaseService { */ async getFulfillmentItems_(order, items, transformer) { const toReturn = await Promise.all( - items.map(async ({ item_id, quantity }) => { - const item = order.items.find(i => i.id === item_id) - return transformer(item, quantity) + items.map(async data => { + const item = order.items.find(i => i.id === data.item_id) + return transformer(item, data.quantity, data) }) ) @@ -127,10 +131,11 @@ class ReturnService extends BaseService { * @param {LineItem?} item - the line item to check has sufficient returnable * quantity. * @param {number} quantity - the quantity that is requested to be returned. + * @param {object} additional - the quantity that is requested to be returned. * @return {LineItem} a line item where the quantity is set to the requested * return quantity. */ - validateReturnLineItem_(item, quantity) { + validateReturnLineItem_(item, quantity, additional) { if (!item) { throw new MedusaError( MedusaError.Types.INVALID_DATA, @@ -146,10 +151,20 @@ class ReturnService extends BaseService { ) } - return { + const toReturn = { ...item, quantity, } + + if ("reason_id" in additional) { + toReturn.reason_id = additional.reason_id + } + + if ("note" in additional) { + toReturn.note = additional.note + } + + return toReturn } /** @@ -288,6 +303,8 @@ class ReturnService extends BaseService { item_id: i.id, quantity: i.quantity, requested_quantity: i.quantity, + reason_id: i.reason_id, + note: i.note, metadata: i.metadata, }) )