From b7557db9f99c9d524c6902443d11c8c46fe0c0a6 Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Wed, 8 Apr 2020 09:44:41 +0200 Subject: [PATCH] Adds Regions (#33) Regions are a collection of countries that share some common functionality, e.g., currency, available fulfillment and shipping providers and a taxrate. The store operator can have as many Regions as needed. --- packages/medusa/src/api/routes/admin/index.js | 2 + .../admin/regions/__tests__/add-country.js | 35 + .../__tests__/add-fulfillment-provider.js | 39 + .../regions/__tests__/add-payment-provider.js | 39 + .../admin/regions/__tests__/create-region.js | 43 + .../admin/regions/__tests__/delete-region.js | 31 + .../admin/regions/__tests__/get-region.js | 31 + .../admin/regions/__tests__/list-regions.js | 28 + .../admin/regions/__tests__/remove-country.js | 32 + .../__tests__/remove-fulfillment-provider.js | 38 + .../__tests__/remove-payment-provider.js | 36 + .../admin/regions/__tests__/update-region.js | 47 + .../api/routes/admin/regions/add-country.js | 23 + .../admin/regions/add-fulfillment-provider.js | 23 + .../admin/regions/add-payment-provider.js | 23 + .../api/routes/admin/regions/create-region.js | 25 + .../api/routes/admin/regions/delete-region.js | 17 + .../api/routes/admin/regions/get-region.js | 13 + .../src/api/routes/admin/regions/index.js | 51 + .../api/routes/admin/regions/list-regions.js | 12 + .../routes/admin/regions/remove-country.js | 13 + .../regions/remove-fulfillment-provider.js | 13 + .../admin/regions/remove-payment-provider.js | 13 + .../api/routes/admin/regions/update-region.js | 26 + .../medusa/src/models/__mocks__/region.js | 66 + packages/medusa/src/models/region.js | 17 + .../medusa/src/services/__mocks__/region.js | 15 + .../medusa/src/services/__tests__/region.js | 516 ++++++++ packages/medusa/src/services/region.js | 355 +++++- .../src/utils/{contries.js => countries.js} | 0 packages/medusa/src/utils/currencies.js | 1073 +++++++++++++++++ 31 files changed, 2686 insertions(+), 9 deletions(-) create mode 100644 packages/medusa/src/api/routes/admin/regions/__tests__/add-country.js create mode 100644 packages/medusa/src/api/routes/admin/regions/__tests__/add-fulfillment-provider.js create mode 100644 packages/medusa/src/api/routes/admin/regions/__tests__/add-payment-provider.js create mode 100644 packages/medusa/src/api/routes/admin/regions/__tests__/create-region.js create mode 100644 packages/medusa/src/api/routes/admin/regions/__tests__/delete-region.js create mode 100644 packages/medusa/src/api/routes/admin/regions/__tests__/get-region.js create mode 100644 packages/medusa/src/api/routes/admin/regions/__tests__/list-regions.js create mode 100644 packages/medusa/src/api/routes/admin/regions/__tests__/remove-country.js create mode 100644 packages/medusa/src/api/routes/admin/regions/__tests__/remove-fulfillment-provider.js create mode 100644 packages/medusa/src/api/routes/admin/regions/__tests__/remove-payment-provider.js create mode 100644 packages/medusa/src/api/routes/admin/regions/__tests__/update-region.js create mode 100644 packages/medusa/src/api/routes/admin/regions/add-country.js create mode 100644 packages/medusa/src/api/routes/admin/regions/add-fulfillment-provider.js create mode 100644 packages/medusa/src/api/routes/admin/regions/add-payment-provider.js create mode 100644 packages/medusa/src/api/routes/admin/regions/create-region.js create mode 100644 packages/medusa/src/api/routes/admin/regions/delete-region.js create mode 100644 packages/medusa/src/api/routes/admin/regions/get-region.js create mode 100644 packages/medusa/src/api/routes/admin/regions/index.js create mode 100644 packages/medusa/src/api/routes/admin/regions/list-regions.js create mode 100644 packages/medusa/src/api/routes/admin/regions/remove-country.js create mode 100644 packages/medusa/src/api/routes/admin/regions/remove-fulfillment-provider.js create mode 100644 packages/medusa/src/api/routes/admin/regions/remove-payment-provider.js create mode 100644 packages/medusa/src/api/routes/admin/regions/update-region.js create mode 100644 packages/medusa/src/models/__mocks__/region.js create mode 100644 packages/medusa/src/models/region.js create mode 100644 packages/medusa/src/services/__tests__/region.js rename packages/medusa/src/utils/{contries.js => countries.js} (100%) create mode 100644 packages/medusa/src/utils/currencies.js diff --git a/packages/medusa/src/api/routes/admin/index.js b/packages/medusa/src/api/routes/admin/index.js index 2c5296e6f2..cb31d93ee7 100644 --- a/packages/medusa/src/api/routes/admin/index.js +++ b/packages/medusa/src/api/routes/admin/index.js @@ -3,6 +3,7 @@ import middlewares from "../../middlewares" import authRoutes from "./auth" import productRoutes from "./products" import productVariantRoutes from "./product-variants" +import regionRoutes from "./regions" import shippingOptionRoutes from "./shipping-options" import shippingProfileRoutes from "./shipping-profiles" @@ -18,6 +19,7 @@ export default app => { route.use(middlewares.authenticate()) productRoutes(route) + regionRoutes(route) shippingOptionRoutes(route) shippingProfileRoutes(route) // productVariantRoutes(route) diff --git a/packages/medusa/src/api/routes/admin/regions/__tests__/add-country.js b/packages/medusa/src/api/routes/admin/regions/__tests__/add-country.js new file mode 100644 index 0000000000..7e9b524552 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/__tests__/add-country.js @@ -0,0 +1,35 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { RegionServiceMock } from "../../../../../services/__mocks__/region" + +describe("POST /admin/regions/:region_id/countries", () => { + describe("successful creation", () => { + let subject + + beforeAll(async () => { + const id = IdMap.getId("region") + subject = await request("POST", `/admin/regions/${id}/countries`, { + payload: { + country_code: "se", + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls service addCountry", () => { + expect(RegionServiceMock.addCountry).toHaveBeenCalledTimes(1) + expect(RegionServiceMock.addCountry).toHaveBeenCalledWith( + IdMap.getId("region"), + "se" + ) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/regions/__tests__/add-fulfillment-provider.js b/packages/medusa/src/api/routes/admin/regions/__tests__/add-fulfillment-provider.js new file mode 100644 index 0000000000..7670a850f6 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/__tests__/add-fulfillment-provider.js @@ -0,0 +1,39 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { RegionServiceMock } from "../../../../../services/__mocks__/region" + +describe("POST /admin/regions/:region_id/fulfillment-providers", () => { + describe("successful creation", () => { + let subject + + beforeAll(async () => { + const id = IdMap.getId("region") + subject = await request( + "POST", + `/admin/regions/${id}/fulfillment-providers`, + { + payload: { + provider_id: "default_provider", + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls service addCountry", () => { + expect(RegionServiceMock.addFulfillmentProvider).toHaveBeenCalledTimes(1) + expect(RegionServiceMock.addFulfillmentProvider).toHaveBeenCalledWith( + IdMap.getId("region"), + "default_provider" + ) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/regions/__tests__/add-payment-provider.js b/packages/medusa/src/api/routes/admin/regions/__tests__/add-payment-provider.js new file mode 100644 index 0000000000..327fbfd374 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/__tests__/add-payment-provider.js @@ -0,0 +1,39 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { RegionServiceMock } from "../../../../../services/__mocks__/region" + +describe("POST /admin/regions/:region_id/payment-providers", () => { + describe("successful creation", () => { + let subject + + beforeAll(async () => { + const id = IdMap.getId("region") + subject = await request( + "POST", + `/admin/regions/${id}/payment-providers`, + { + payload: { + provider_id: "default_provider", + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls service addCountry", () => { + expect(RegionServiceMock.addPaymentProvider).toHaveBeenCalledTimes(1) + expect(RegionServiceMock.addPaymentProvider).toHaveBeenCalledWith( + IdMap.getId("region"), + "default_provider" + ) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/regions/__tests__/create-region.js b/packages/medusa/src/api/routes/admin/regions/__tests__/create-region.js new file mode 100644 index 0000000000..d009b81b9b --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/__tests__/create-region.js @@ -0,0 +1,43 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { RegionServiceMock } from "../../../../../services/__mocks__/region" + +describe("POST /admin/regions", () => { + describe("successful creation", () => { + let subject + + beforeAll(async () => { + subject = await request("POST", "/admin/regions", { + payload: { + name: "New Region", + currency_code: "dkk", + countries: ["dk"], + tax_rate: 0.3, + payment_providers: ["default_provider"], + fulfillment_providers: ["default_provider"], + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls service create", () => { + expect(RegionServiceMock.create).toHaveBeenCalledTimes(1) + expect(RegionServiceMock.create).toHaveBeenCalledWith({ + name: "New Region", + currency_code: "dkk", + countries: ["dk"], + tax_rate: 0.3, + payment_providers: ["default_provider"], + fulfillment_providers: ["default_provider"], + }) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/regions/__tests__/delete-region.js b/packages/medusa/src/api/routes/admin/regions/__tests__/delete-region.js new file mode 100644 index 0000000000..1031d00917 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/__tests__/delete-region.js @@ -0,0 +1,31 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { RegionServiceMock } from "../../../../../services/__mocks__/region" + +describe("DELETE /admin/regions/:region_id", () => { + describe("successful deletion", () => { + let subject + + beforeAll(async () => { + const id = IdMap.getId("region") + subject = await request("DELETE", `/admin/regions/${id}`, { + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls service addCountry", () => { + expect(RegionServiceMock.delete).toHaveBeenCalledTimes(1) + expect(RegionServiceMock.delete).toHaveBeenCalledWith( + IdMap.getId("region") + ) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/regions/__tests__/get-region.js b/packages/medusa/src/api/routes/admin/regions/__tests__/get-region.js new file mode 100644 index 0000000000..7d4cb3601d --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/__tests__/get-region.js @@ -0,0 +1,31 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { RegionServiceMock } from "../../../../../services/__mocks__/region" + +describe("GET /admin/regions/:region_id", () => { + describe("successful creation", () => { + let subject + + beforeAll(async () => { + const id = IdMap.getId("region") + subject = await request("GET", `/admin/regions/${id}`, { + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls service addCountry", () => { + expect(RegionServiceMock.retrieve).toHaveBeenCalledTimes(1) + expect(RegionServiceMock.retrieve).toHaveBeenCalledWith( + IdMap.getId("region") + ) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/regions/__tests__/list-regions.js b/packages/medusa/src/api/routes/admin/regions/__tests__/list-regions.js new file mode 100644 index 0000000000..51236cc821 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/__tests__/list-regions.js @@ -0,0 +1,28 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { RegionServiceMock } from "../../../../../services/__mocks__/region" + +describe("GET /admin/regions", () => { + describe("successful creation", () => { + let subject + + beforeAll(async () => { + subject = await request("GET", `/admin/regions`, { + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls service addCountry", () => { + expect(RegionServiceMock.list).toHaveBeenCalledTimes(1) + expect(RegionServiceMock.list).toHaveBeenCalledWith({}) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/regions/__tests__/remove-country.js b/packages/medusa/src/api/routes/admin/regions/__tests__/remove-country.js new file mode 100644 index 0000000000..b82efa0385 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/__tests__/remove-country.js @@ -0,0 +1,32 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { RegionServiceMock } from "../../../../../services/__mocks__/region" + +describe("DELETE /admin/regions/:region_id/countries/:country_code", () => { + describe("successful creation", () => { + let subject + + beforeAll(async () => { + const id = IdMap.getId("region") + subject = await request("DELETE", `/admin/regions/${id}/countries/DK`, { + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls service addCountry", () => { + expect(RegionServiceMock.removeCountry).toHaveBeenCalledTimes(1) + expect(RegionServiceMock.removeCountry).toHaveBeenCalledWith( + IdMap.getId("region"), + "DK" + ) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/regions/__tests__/remove-fulfillment-provider.js b/packages/medusa/src/api/routes/admin/regions/__tests__/remove-fulfillment-provider.js new file mode 100644 index 0000000000..7829d8d5f2 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/__tests__/remove-fulfillment-provider.js @@ -0,0 +1,38 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { RegionServiceMock } from "../../../../../services/__mocks__/region" + +describe("DELETE /admin/regions/:region_id/fulfillment-providers/:provider_id", () => { + describe("successful deletion", () => { + let subject + + beforeAll(async () => { + const id = IdMap.getId("region") + subject = await request( + "DELETE", + `/admin/regions/${id}/fulfillment-providers/default_provider`, + { + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls service addCountry", () => { + expect(RegionServiceMock.removeFulfillmentProvider).toHaveBeenCalledTimes( + 1 + ) + expect(RegionServiceMock.removeFulfillmentProvider).toHaveBeenCalledWith( + IdMap.getId("region"), + "default_provider" + ) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/regions/__tests__/remove-payment-provider.js b/packages/medusa/src/api/routes/admin/regions/__tests__/remove-payment-provider.js new file mode 100644 index 0000000000..de9cdec282 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/__tests__/remove-payment-provider.js @@ -0,0 +1,36 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { RegionServiceMock } from "../../../../../services/__mocks__/region" + +describe("DELETE /admin/regions/:region_id/payment-providers/:provider_id", () => { + describe("successful deletion", () => { + let subject + + beforeAll(async () => { + const id = IdMap.getId("region") + subject = await request( + "DELETE", + `/admin/regions/${id}/payment-providers/default_provider`, + { + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls service addCountry", () => { + expect(RegionServiceMock.removePaymentProvider).toHaveBeenCalledTimes(1) + expect(RegionServiceMock.removePaymentProvider).toHaveBeenCalledWith( + IdMap.getId("region"), + "default_provider" + ) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/regions/__tests__/update-region.js b/packages/medusa/src/api/routes/admin/regions/__tests__/update-region.js new file mode 100644 index 0000000000..23de8a3821 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/__tests__/update-region.js @@ -0,0 +1,47 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { RegionServiceMock } from "../../../../../services/__mocks__/region" + +describe("POST /admin/regions/:region_id", () => { + describe("successful deletion", () => { + let subject + + beforeAll(async () => { + const id = IdMap.getId("region") + subject = await request("POST", `/admin/regions/${id}`, { + payload: { + name: "Updated Region", + currency_code: "dkk", + countries: ["dk"], + tax_rate: 0.3, + payment_providers: ["default_provider"], + fulfillment_providers: ["default_provider"], + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls service addCountry", () => { + expect(RegionServiceMock.update).toHaveBeenCalledTimes(1) + expect(RegionServiceMock.update).toHaveBeenCalledWith( + IdMap.getId("region"), + { + name: "Updated Region", + currency_code: "dkk", + countries: ["dk"], + tax_rate: 0.3, + payment_providers: ["default_provider"], + fulfillment_providers: ["default_provider"], + } + ) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/regions/add-country.js b/packages/medusa/src/api/routes/admin/regions/add-country.js new file mode 100644 index 0000000000..ee40e5c7c7 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/add-country.js @@ -0,0 +1,23 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +export default async (req, res) => { + const { region_id } = req.params + const schema = Validator.object().keys({ + country_code: Validator.string().required(), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const regionService = req.scope.resolve("regionService") + await regionService.addCountry(region_id, value.country_code) + + const data = await regionService.retrieve(region_id) + res.status(200).json(data) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/regions/add-fulfillment-provider.js b/packages/medusa/src/api/routes/admin/regions/add-fulfillment-provider.js new file mode 100644 index 0000000000..56da5089c9 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/add-fulfillment-provider.js @@ -0,0 +1,23 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +export default async (req, res) => { + const { region_id } = req.params + const schema = Validator.object().keys({ + provider_id: Validator.string().required(), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const regionService = req.scope.resolve("regionService") + await regionService.addFulfillmentProvider(region_id, value.provider_id) + + const data = await regionService.retrieve(region_id) + res.status(200).json(data) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/regions/add-payment-provider.js b/packages/medusa/src/api/routes/admin/regions/add-payment-provider.js new file mode 100644 index 0000000000..a4d4ef1403 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/add-payment-provider.js @@ -0,0 +1,23 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +export default async (req, res) => { + const { region_id } = req.params + const schema = Validator.object().keys({ + provider_id: Validator.string().required(), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const regionService = req.scope.resolve("regionService") + await regionService.addPaymentProvider(region_id, value.provider_id) + + const data = await regionService.retrieve(region_id) + res.status(200).json(data) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/regions/create-region.js b/packages/medusa/src/api/routes/admin/regions/create-region.js new file mode 100644 index 0000000000..364541c993 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/create-region.js @@ -0,0 +1,25 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +export default async (req, res) => { + const schema = Validator.object().keys({ + name: Validator.string().required(), + currency_code: Validator.string().required(), + tax_rate: Validator.number().required(), + payment_providers: Validator.array().items(Validator.string()), + fulfillment_providers: Validator.array().items(Validator.string()), + countries: Validator.array().items(Validator.string()), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const regionService = req.scope.resolve("regionService") + const data = await regionService.create(value) + res.status(200).json(data) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/regions/delete-region.js b/packages/medusa/src/api/routes/admin/regions/delete-region.js new file mode 100644 index 0000000000..9879d23e85 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/delete-region.js @@ -0,0 +1,17 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +export default async (req, res) => { + const { region_id } = req.params + try { + const regionService = req.scope.resolve("regionService") + await regionService.delete(region_id) + + res.status(200).json({ + id: region_id, + object: "region", + deleted: true, + }) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/regions/get-region.js b/packages/medusa/src/api/routes/admin/regions/get-region.js new file mode 100644 index 0000000000..c73087f7f1 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/get-region.js @@ -0,0 +1,13 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +export default async (req, res) => { + const { region_id } = req.params + try { + const regionService = req.scope.resolve("regionService") + const data = await regionService.retrieve(region_id) + + res.status(200).json(data) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/regions/index.js b/packages/medusa/src/api/routes/admin/regions/index.js new file mode 100644 index 0000000000..dfe7e8ccfc --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/index.js @@ -0,0 +1,51 @@ +import { Router } from "express" +import middlewares from "../../../middlewares" + +const route = Router() + +export default app => { + app.use("/regions", route) + + route.get("/", middlewares.wrap(require("./list-regions").default)) + route.get("/:region_id", middlewares.wrap(require("./get-region").default)) + + route.post("/", middlewares.wrap(require("./create-region").default)) + route.post( + "/:region_id", + middlewares.wrap(require("./update-region").default) + ) + + route.delete( + "/:region_id", + middlewares.wrap(require("./delete-region").default) + ) + + route.post( + "/:region_id/countries", + middlewares.wrap(require("./add-country").default) + ) + route.delete( + "/:region_id/countries/:country_code", + middlewares.wrap(require("./remove-country").default) + ) + + route.post( + "/:region_id/payment-providers", + middlewares.wrap(require("./add-payment-provider").default) + ) + route.delete( + "/:region_id/payment-providers/:provider_id", + middlewares.wrap(require("./remove-payment-provider").default) + ) + + route.post( + "/:region_id/fulfillment-providers", + middlewares.wrap(require("./add-fulfillment-provider").default) + ) + route.delete( + "/:region_id/fulfillment-providers/:provider_id", + middlewares.wrap(require("./remove-fulfillment-provider").default) + ) + + return app +} diff --git a/packages/medusa/src/api/routes/admin/regions/list-regions.js b/packages/medusa/src/api/routes/admin/regions/list-regions.js new file mode 100644 index 0000000000..f7bb5d4a7d --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/list-regions.js @@ -0,0 +1,12 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +export default async (req, res) => { + try { + const regionService = req.scope.resolve("regionService") + const data = await regionService.list({}) + + res.status(200).json(data) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/regions/remove-country.js b/packages/medusa/src/api/routes/admin/regions/remove-country.js new file mode 100644 index 0000000000..d4cfcd7605 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/remove-country.js @@ -0,0 +1,13 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +export default async (req, res) => { + const { region_id, country_code } = req.params + try { + const regionService = req.scope.resolve("regionService") + await regionService.removeCountry(region_id, country_code) + + res.sendStatus(200) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/regions/remove-fulfillment-provider.js b/packages/medusa/src/api/routes/admin/regions/remove-fulfillment-provider.js new file mode 100644 index 0000000000..cc7ca7a706 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/remove-fulfillment-provider.js @@ -0,0 +1,13 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +export default async (req, res) => { + const { region_id, provider_id } = req.params + try { + const regionService = req.scope.resolve("regionService") + await regionService.removeFulfillmentProvider(region_id, provider_id) + + res.sendStatus(200) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/regions/remove-payment-provider.js b/packages/medusa/src/api/routes/admin/regions/remove-payment-provider.js new file mode 100644 index 0000000000..1799ea32d1 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/remove-payment-provider.js @@ -0,0 +1,13 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +export default async (req, res) => { + const { region_id, provider_id } = req.params + try { + const regionService = req.scope.resolve("regionService") + await regionService.removePaymentProvider(region_id, provider_id) + + res.sendStatus(200) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/api/routes/admin/regions/update-region.js b/packages/medusa/src/api/routes/admin/regions/update-region.js new file mode 100644 index 0000000000..83f5ead386 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/regions/update-region.js @@ -0,0 +1,26 @@ +import { MedusaError, Validator } from "medusa-core-utils" + +export default async (req, res) => { + const { region_id } = req.params + const schema = Validator.object().keys({ + name: Validator.string(), + currency_code: Validator.string(), + tax_rate: Validator.number(), + payment_providers: Validator.array().items(Validator.string()), + fulfillment_providers: Validator.array().items(Validator.string()), + countries: Validator.array().items(Validator.string()), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const regionService = req.scope.resolve("regionService") + const data = await regionService.update(region_id, value) + res.status(200).json(data) + } catch (err) { + throw err + } +} diff --git a/packages/medusa/src/models/__mocks__/region.js b/packages/medusa/src/models/__mocks__/region.js new file mode 100644 index 0000000000..2a396091d2 --- /dev/null +++ b/packages/medusa/src/models/__mocks__/region.js @@ -0,0 +1,66 @@ +import { IdMap } from "medusa-test-utils" + +export const regions = { + testRegion: { + _id: IdMap.getId("testRegion"), + name: "Test Region", + countries: ["DK", "US", "DE"], + tax_rate: 0.25, + payment_providers: ["default_provider", "unregistered"], + fulfillment_providers: ["test_shipper"], + currency_code: "usd", + }, + regionFrance: { + _id: IdMap.getId("region-france"), + name: "France", + countries: ["FR"], + payment_providers: ["default_provider", "france-provider"], + currency_code: "eur", + }, + regionUs: { + _id: IdMap.getId("region-us"), + name: "USA", + countries: ["US"], + currency_code: "usd", + }, + regionGermany: { + _id: IdMap.getId("region-de"), + name: "Germany", + countries: ["DE"], + currency_code: "eur", + }, + regionSweden: { + _id: IdMap.getId("region-se"), + name: "Sweden", + countries: ["SE"], + payment_providers: ["sweden_provider"], + fulfillment_providers: ["sweden_provider"], + currency_code: "SEK", + }, +} + +export const RegionModelMock = { + create: jest.fn().mockReturnValue(Promise.resolve()), + updateOne: jest.fn().mockImplementation((query, update) => {}), + deleteOne: jest.fn().mockReturnValue(Promise.resolve()), + findOne: jest.fn().mockImplementation(query => { + if (query.countries === "SE") { + return Promise.resolve(regions.regionSweden) + } + + switch (query._id) { + case IdMap.getId("testRegion"): + return Promise.resolve(regions.testRegion) + case IdMap.getId("region-france"): + return Promise.resolve(regions.regionFrance) + case IdMap.getId("region-us"): + return Promise.resolve(regions.regionUs) + case IdMap.getId("region-de"): + return Promise.resolve(regions.regionGermany) + case IdMap.getId("region-se"): + return Promise.resolve(regions.regionSweden) + default: + return Promise.resolve(undefined) + } + }), +} diff --git a/packages/medusa/src/models/region.js b/packages/medusa/src/models/region.js new file mode 100644 index 0000000000..aa13126369 --- /dev/null +++ b/packages/medusa/src/models/region.js @@ -0,0 +1,17 @@ +import mongoose from "mongoose" +import { BaseModel } from "medusa-interfaces" + +class RegionModel extends BaseModel { + static modelName = "Region" + static schema = { + name: { type: String, required: true }, + currency_code: { type: String, required: true }, + tax_rate: { type: Number, required: true, default: 0 }, + countries: { type: [String], default: [] }, + payment_providers: { type: [String], default: [] }, + fulfillment_providers: { type: [String], default: [] }, + metadata: { type: mongoose.Schema.Types.Mixed, default: {} }, + } +} + +export default RegionModel diff --git a/packages/medusa/src/services/__mocks__/region.js b/packages/medusa/src/services/__mocks__/region.js index dad28af80c..b05902f4d1 100644 --- a/packages/medusa/src/services/__mocks__/region.js +++ b/packages/medusa/src/services/__mocks__/region.js @@ -57,6 +57,21 @@ export const RegionServiceMock = { } return Promise.resolve(undefined) }), + delete: jest.fn().mockImplementation(data => Promise.resolve()), + create: jest.fn().mockImplementation(data => Promise.resolve()), + addCountry: jest.fn().mockImplementation(data => Promise.resolve()), + addFulfillmentProvider: jest + .fn() + .mockImplementation(data => Promise.resolve()), + addPaymentProvider: jest.fn().mockImplementation(data => Promise.resolve()), + removeCountry: jest.fn().mockImplementation(data => Promise.resolve()), + removeFulfillmentProvider: jest + .fn() + .mockImplementation(data => Promise.resolve()), + removePaymentProvider: jest + .fn() + .mockImplementation(data => Promise.resolve()), + update: jest.fn().mockImplementation(data => Promise.resolve()), list: jest.fn().mockImplementation(data => { return Promise.resolve([ regions.testRegion, diff --git a/packages/medusa/src/services/__tests__/region.js b/packages/medusa/src/services/__tests__/region.js new file mode 100644 index 0000000000..7b4502cab0 --- /dev/null +++ b/packages/medusa/src/services/__tests__/region.js @@ -0,0 +1,516 @@ +import mongoose from "mongoose" +import { IdMap } from "medusa-test-utils" +import RegionService from "../region" +import { RegionModelMock } from "../../models/__mocks__/region" +import { PaymentProviderServiceMock } from "../__mocks__/payment-provider" +import { FulfillmentProviderServiceMock } from "../__mocks__/fulfillment-provider" + +describe("RegionService", () => { + describe("create", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("successfully creates a new region", async () => { + const regionService = new RegionService({ + regionModel: RegionModelMock, + }) + + await regionService.create({ + name: "Denmark", + currency_code: "dkk", + tax_rate: 0.25, + countries: ["DK"], + }) + + expect(RegionModelMock.create).toHaveBeenCalledTimes(1) + expect(RegionModelMock.create).toHaveBeenCalledWith({ + name: "Denmark", + currency_code: "DKK", + tax_rate: 0.25, + countries: ["DK"], + }) + }) + + it("create with payment/fulfillment providers", async () => { + const regionService = new RegionService({ + regionModel: RegionModelMock, + paymentProviderService: PaymentProviderServiceMock, + fulfillmentProviderService: FulfillmentProviderServiceMock, + }) + + await regionService.create({ + name: "Denmark", + currency_code: "dkk", + tax_rate: 0.25, + countries: ["DK"], + payment_providers: ["default_provider"], + fulfillment_providers: ["default_provider"], + }) + + expect(PaymentProviderServiceMock.retrieveProvider).toHaveBeenCalledTimes( + 1 + ) + expect(PaymentProviderServiceMock.retrieveProvider).toHaveBeenCalledWith( + "default_provider" + ) + + expect( + FulfillmentProviderServiceMock.retrieveProvider + ).toHaveBeenCalledTimes(1) + expect( + FulfillmentProviderServiceMock.retrieveProvider + ).toHaveBeenCalledWith("default_provider") + + expect(RegionModelMock.create).toHaveBeenCalledTimes(1) + expect(RegionModelMock.create).toHaveBeenCalledWith({ + name: "Denmark", + currency_code: "DKK", + tax_rate: 0.25, + countries: ["DK"], + payment_providers: ["default_provider"], + fulfillment_providers: ["default_provider"], + }) + }) + }) + + describe("retrieve", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("successfully retrieves a region", async () => { + const regionService = new RegionService({ + regionModel: RegionModelMock, + }) + + await regionService.retrieve(IdMap.getId("region-se")) + + expect(RegionModelMock.findOne).toHaveBeenCalledTimes(1) + expect(RegionModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("region-se"), + }) + }) + }) + + describe("validateFields_", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("throws on invalid currency code", async () => { + const regionService = new RegionService({ + regionModel: RegionModelMock, + paymentProviderService: PaymentProviderServiceMock, + fulfillmentProviderService: FulfillmentProviderServiceMock, + }) + + await expect( + regionService.validateFields_({ currency_code: "1cw" }) + ).rejects.toThrow("Invalid currency code") + }) + + it("throws on invalid country code", async () => { + const regionService = new RegionService({ + regionModel: RegionModelMock, + paymentProviderService: PaymentProviderServiceMock, + fulfillmentProviderService: FulfillmentProviderServiceMock, + }) + + await expect( + regionService.validateFields_({ countries: ["ddd"] }) + ).rejects.toThrow("Invalid country code") + }) + + it("throws on in use country code", async () => { + const regionService = new RegionService({ + regionModel: RegionModelMock, + paymentProviderService: PaymentProviderServiceMock, + fulfillmentProviderService: FulfillmentProviderServiceMock, + }) + + await expect( + regionService.validateFields_({ countries: ["se"] }) + ).rejects.toThrow( + "Sweden already exists in Sweden, delete it in that region before adding it" + ) + }) + + it("throws on invalid tax_rate", async () => { + const regionService = new RegionService({ + regionModel: RegionModelMock, + paymentProviderService: PaymentProviderServiceMock, + fulfillmentProviderService: FulfillmentProviderServiceMock, + }) + + await expect( + regionService.validateFields_({ tax_rate: 12 }) + ).rejects.toThrow("The tax_rate must be between 0 and 1") + }) + + it("throws on metadata", async () => { + const regionService = new RegionService({ + regionModel: RegionModelMock, + paymentProviderService: PaymentProviderServiceMock, + fulfillmentProviderService: FulfillmentProviderServiceMock, + }) + + await expect( + regionService.validateFields_({ metadata: { key: "Valie" } }) + ).rejects.toThrow("Please use setMetadata") + }) + + it("throws on unknown payment providers", async () => { + const regionService = new RegionService({ + regionModel: RegionModelMock, + paymentProviderService: PaymentProviderServiceMock, + fulfillmentProviderService: FulfillmentProviderServiceMock, + }) + + await expect( + regionService.validateFields_({ payment_providers: ["hi"] }) + ).rejects.toThrow("Provider Not Found") + }) + + it("throws on unknown fulfillment providers", async () => { + const regionService = new RegionService({ + regionModel: RegionModelMock, + paymentProviderService: PaymentProviderServiceMock, + fulfillmentProviderService: FulfillmentProviderServiceMock, + }) + + await expect( + regionService.validateFields_({ fulfillment_providers: ["hi"] }) + ).rejects.toThrow("Provider Not Found") + }) + }) + + describe("update", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("successfully updates a region", async () => { + const regionService = new RegionService({ + regionModel: RegionModelMock, + paymentProviderService: PaymentProviderServiceMock, + fulfillmentProviderService: FulfillmentProviderServiceMock, + }) + + await regionService.update(IdMap.getId("region-se"), { + name: "New Name", + currency_code: "gbp", + tax_rate: 0.25, + countries: ["DK", "se"], + payment_providers: ["default_provider"], + fulfillment_providers: ["default_provider"], + }) + + expect(PaymentProviderServiceMock.retrieveProvider).toHaveBeenCalledTimes( + 1 + ) + expect(PaymentProviderServiceMock.retrieveProvider).toHaveBeenCalledWith( + "default_provider" + ) + + expect( + FulfillmentProviderServiceMock.retrieveProvider + ).toHaveBeenCalledTimes(1) + expect( + FulfillmentProviderServiceMock.retrieveProvider + ).toHaveBeenCalledWith("default_provider") + + expect(RegionModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(RegionModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("region-se"), + }, + { + $set: { + name: "New Name", + currency_code: "GBP", + tax_rate: 0.25, + countries: ["DK", "SE"], + payment_providers: ["default_provider"], + fulfillment_providers: ["default_provider"], + }, + } + ) + }) + }) + + describe("delete", () => { + beforeAll(() => { + jest.clearAllMocks() + }) + + it("successfully deletes", async () => { + const regionService = new RegionService({ + regionModel: RegionModelMock, + }) + + await regionService.delete(IdMap.getId("region-se")) + + expect(RegionModelMock.deleteOne).toHaveBeenCalledTimes(1) + expect(RegionModelMock.deleteOne).toHaveBeenCalledWith({ + _id: IdMap.getId("region-se"), + }) + }) + }) + + describe("addCountry", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("successfully adds to the countries array", async () => { + const regionService = new RegionService({ + regionModel: RegionModelMock, + }) + + await regionService.addCountry(IdMap.getId("region-se"), "dk") + + expect(RegionModelMock.findOne).toHaveBeenCalledTimes(2) + expect(RegionModelMock.findOne).toHaveBeenCalledWith({ + countries: "DK", + }) + expect(RegionModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("region-se"), + }) + + expect(RegionModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(RegionModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("region-se"), + }, + { + $push: { countries: "DK" }, + } + ) + }) + + it("resolves if exists", async () => { + const regionService = new RegionService({ + regionModel: RegionModelMock, + }) + + await regionService.addCountry(IdMap.getId("region-se"), "SE") + + expect(RegionModelMock.findOne).toHaveBeenCalledTimes(2) + expect(RegionModelMock.findOne).toHaveBeenCalledWith({ + countries: "SE", + }) + expect(RegionModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("region-se"), + }) + + expect(RegionModelMock.updateOne).toHaveBeenCalledTimes(0) + }) + }) + + describe("removeCountry", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("successfully removes country", async () => { + const regionService = new RegionService({ + regionModel: RegionModelMock, + }) + + await regionService.removeCountry(IdMap.getId("region-se"), "dk") + + expect(RegionModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(RegionModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("region-se"), + }, + { + $pull: { countries: "DK" }, + } + ) + }) + }) + + describe("addPaymentProvider", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("successfully adds to the countries array", async () => { + const regionService = new RegionService({ + regionModel: RegionModelMock, + paymentProviderService: PaymentProviderServiceMock, + }) + + await regionService.addPaymentProvider( + IdMap.getId("region-se"), + "default_provider" + ) + + expect(RegionModelMock.findOne).toHaveBeenCalledTimes(1) + expect(RegionModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("region-se"), + }) + + expect(PaymentProviderServiceMock.retrieveProvider).toHaveBeenCalledTimes( + 1 + ) + expect(PaymentProviderServiceMock.retrieveProvider).toHaveBeenCalledWith( + "default_provider" + ) + + expect(RegionModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(RegionModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("region-se"), + }, + { + $push: { payment_providers: "default_provider" }, + } + ) + }) + + it("resolves if exists", async () => { + const regionService = new RegionService({ + regionModel: RegionModelMock, + paymentProviderService: PaymentProviderServiceMock, + }) + + await regionService.addPaymentProvider( + IdMap.getId("region-se"), + "sweden_provider" + ) + + expect(RegionModelMock.findOne).toHaveBeenCalledTimes(1) + expect(RegionModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("region-se"), + }) + + expect(RegionModelMock.updateOne).toHaveBeenCalledTimes(0) + }) + }) + + describe("addFulfillmentProvider", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("successfully adds to the fulfillment_provider array", async () => { + const regionService = new RegionService({ + regionModel: RegionModelMock, + fulfillmentProviderService: FulfillmentProviderServiceMock, + }) + + await regionService.addFulfillmentProvider( + IdMap.getId("region-se"), + "default_provider" + ) + + expect(RegionModelMock.findOne).toHaveBeenCalledTimes(1) + expect(RegionModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("region-se"), + }) + + expect( + FulfillmentProviderServiceMock.retrieveProvider + ).toHaveBeenCalledTimes(1) + expect( + FulfillmentProviderServiceMock.retrieveProvider + ).toHaveBeenCalledWith("default_provider") + + expect(RegionModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(RegionModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("region-se"), + }, + { + $push: { fulfillment_providers: "default_provider" }, + } + ) + }) + + it("resolves if exists", async () => { + const regionService = new RegionService({ + regionModel: RegionModelMock, + fulfillmentProviderService: FulfillmentProviderServiceMock, + }) + + await regionService.addFulfillmentProvider( + IdMap.getId("region-se"), + "sweden_provider" + ) + + expect(RegionModelMock.findOne).toHaveBeenCalledTimes(1) + expect(RegionModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("region-se"), + }) + + expect(RegionModelMock.updateOne).toHaveBeenCalledTimes(0) + }) + }) + + describe("removePaymentProvider", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("removes payment provider", async () => { + const regionService = new RegionService({ + regionModel: RegionModelMock, + }) + + await regionService.removePaymentProvider( + IdMap.getId("region-se"), + "sweden_provider" + ) + + expect(RegionModelMock.findOne).toHaveBeenCalledTimes(1) + expect(RegionModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("region-se"), + }) + + expect(RegionModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(RegionModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("region-se"), + }, + { + $pull: { payment_providers: "sweden_provider" }, + } + ) + }) + }) + + describe("removeFulfillmentProvider", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("removes fulfillment provider", async () => { + const regionService = new RegionService({ + regionModel: RegionModelMock, + }) + + await regionService.removeFulfillmentProvider( + IdMap.getId("region-se"), + "sweden_provider" + ) + + expect(RegionModelMock.findOne).toHaveBeenCalledTimes(1) + expect(RegionModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("region-se"), + }) + + expect(RegionModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(RegionModelMock.updateOne).toHaveBeenCalledWith( + { + _id: IdMap.getId("region-se"), + }, + { + $pull: { fulfillment_providers: "sweden_provider" }, + } + ) + }) + }) +}) diff --git a/packages/medusa/src/services/region.js b/packages/medusa/src/services/region.js index 69dff6ba86..48a35f60e7 100644 --- a/packages/medusa/src/services/region.js +++ b/packages/medusa/src/services/region.js @@ -1,9 +1,346 @@ -// paymentProviders -// fulfillmentProviders -// setCurrency -// setTaxRate -// putShippingProvider -// putPaymentProvider -// removeShippingProvider -// removePaymentMethod -// listShippingMethods +import _ from "lodash" +import { Validator, MedusaError } from "medusa-core-utils" +import { BaseService } from "medusa-interfaces" +import { countries } from "../utils/countries" +import { currencies } from "../utils/currencies" + +/** + * Provides layer to manipulate regions. + * @implements BaseService + */ +class RegionService extends BaseService { + constructor({ + regionModel, + paymentProviderService, + fulfillmentProviderService, + }) { + super() + + /** @private @const {RegionModel} */ + this.regionModel_ = regionModel + + /** @private @const {PaymentProviderService} */ + this.paymentProviderService_ = paymentProviderService + + /** @private @const {FulfillmentProviderService} */ + this.fulfillmentProviderService_ = fulfillmentProviderService + } + + /** + * Creates a region. + * @param {Region} rawRegion - the unvalidated region + * @return {Region} the newly created region + */ + async create(rawRegion) { + const region = await this.validateFields_(rawRegion) + return this.regionModel_.create(region) + } + + /** + * Updates a region. Note metadata cannot be set with the update function, use + * setMetadata instead. + * @param {string} regionId - the region to update + * @param {object} update - the data to update the region with + * @return {Promise} the result of the update operation + */ + async update(regionId, update) { + const region = await this.validateFields_(update, regionId) + return this.regionModel_.updateOne( + { + _id: regionId, + }, + { + $set: region, + } + ) + } + + /** + * Validates fields for creation and updates. If the region already exisits + * the id can be passed to check that country updates are allowed. + * @param {object} region - the region data to validate + * @param {string?} id - optional id of the region to check against + * @return {object} the validated region data + */ + async validateFields_(region, id = undefined) { + if (region.tax_rate) { + this.validateTaxRate_(region.tax_rate) + } + + if (region.currency_code) { + region.currency_code = region.currency_code.toUpperCase() + this.validateCurrency_(region.currency_code) + } + + if (region.countries) { + region.countries = await Promise.all( + region.countries.map(countryCode => + this.validateCountry_(countryCode, id) + ) + ).catch(err => { + throw err + }) + } + + if (region.metadata) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Please use setMetadata" + ) + } + + if (region.fulfillment_providers) { + // Will throw if we do not find the provider + region.fulfillment_providers.forEach(pId => { + this.fulfillmentProviderService_.retrieveProvider(pId) + }) + } + + if (region.payment_providers) { + // Will throw if we do not find the provider + region.payment_providers.forEach(pId => { + this.paymentProviderService_.retrieveProvider(pId) + }) + } + + return region + } + + /** + * Validates a tax rate. Will throw if the tax rate is not between 0 and 1. + * @param {number} taxRate - a number representing the tax rate of the region + */ + validateTaxRate_(taxRate) { + if (taxRate > 1 || taxRate < 0) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "The tax_rate must be between 0 and 1" + ) + } + } + + /** + * Validates a currency code. Will throw if the currency code doesn't exist. + * @param {string} currencyCode - an ISO currency code + */ + validateCurrency_(currencyCode) { + if (!currencies[currencyCode]) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Invalid currency code" + ) + } + } + + /** + * Validates a country code. Will normalize the code before checking for + * existence. + * @param {string} code - a 2 digit alphanumeric ISO country code + * @param {string} id - the id of the current region to check against + */ + async validateCountry_(code, id) { + const countryCode = code.toUpperCase() + const country = countries.find(c => c.alpha2 === countryCode) + if (!country) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Invalid country code" + ) + } + + const existing = await this.regionModel_.findOne({ countries: countryCode }) + if (existing && existing._id !== id) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `${country.name} already exists in ${existing.name}, delete it in that region before adding it` + ) + } + + return countryCode + } + + /** + * Used to validate region ids. Throws an error if the cast fails + * @param {string} rawId - the raw region id to validate. + * @return {string} the validated id + */ + validateId_(rawId) { + const schema = Validator.objectId() + const { value, error } = schema.validate(rawId) + if (error) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "The regionId could not be casted to an ObjectId" + ) + } + + return value + } + + /** + * Retrieves a region by its id. + * @param {string} regionId - the id of the region to retrieve + * @return {Region} the region + */ + async retrieve(regionId) { + const validatedId = this.validateId_(regionId) + const region = await this.regionModel_.findOne({ _id: validatedId }) + + if (!region) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Region with ${regionId} was not found` + ) + } + return region + } + + /** + * Deletes a region. + * @param {string} regionId - the region to delete + * @return {Promise} the result of the delete operation + */ + delete(regionId) { + return this.regionModel_.deleteOne({ + _id: regionId, + }) + } + + /** + * Adds a country to the region. + * @param {string} regionId - the region to add a country to + * @param {string} code - a 2 digit alphanumeric ISO country code. + * @return {Promise} the result of the update operation + */ + async addCountry(regionId, code) { + const region = await this.retrieve(regionId) + const countryCode = await this.validateCountry_(code, regionId) + + if (region.countries.includes(countryCode)) { + return Promise.resolve() + } + + return this.regionModel_.updateOne( + { + _id: region._id, + }, + { + $push: { countries: countryCode }, + } + ) + } + + /** + * Removes a country from a Region + * @param {string} regionId - the region to remove from + * @param {string} code - a 2 digit alphanumeric ISO country code to remove + * @return {Promise} the result of the update operation + */ + async removeCountry(regionId, code) { + const countryCode = code.toUpperCase() + const region = await this.retrieve(regionId) + + return this.regionModel_.updateOne( + { _id: region._id }, + { + $pull: { + countries: countryCode, + }, + } + ) + } + + /** + * Adds a payment provider that is available in the region. Fails if the + * provider doesn't exist. + * @param {string} regionId - the region to add the provider to + * @param {string} providerId - the provider to add to the region + * @return {Promise} the result of the update operation + */ + async addPaymentProvider(regionId, providerId) { + const region = await this.retrieve(regionId) + + if (region.payment_providers.includes(providerId)) { + return Promise.resolve() + } + + // Will throw if we do not find the provider + this.paymentProviderService_.retrieveProvider(providerId) + + return this.regionModel_.updateOne( + { + _id: region._id, + }, + { + $push: { payment_providers: providerId }, + } + ) + } + + /** + * Adds a fulfillment provider that is available in the region. Fails if the + * provider doesn't exist. + * @param {string} regionId - the region to add the provider to + * @param {string} providerId - the provider to add to the region + * @return {Promise} the result of the update operation + */ + async addFulfillmentProvider(regionId, providerId) { + const region = await this.retrieve(regionId) + + if (region.fulfillment_providers.includes(providerId)) { + return Promise.resolve() + } + + // Will throw if we do not find the provider + this.fulfillmentProviderService_.retrieveProvider(providerId) + + return this.regionModel_.updateOne( + { + _id: region._id, + }, + { + $push: { fulfillment_providers: providerId }, + } + ) + } + + /** + * Removes a payment provider from a region. Is idempotent. + * @param {string} regionId - the region to remove the provider from + * @param {string} providerId - the provider to remove from the region + * @return {Promise} the result of the update operation + */ + async removePaymentProvider(regionId, providerId) { + const region = await this.retrieve(regionId) + + return this.regionModel_.updateOne( + { _id: region._id }, + { + $pull: { + payment_providers: providerId, + }, + } + ) + } + + /** + * Removes a fulfillment provider from a region. Is idempotent. + * @param {string} regionId - the region to remove the provider from + * @param {string} providerId - the provider to remove from the region + * @return {Promise} the result of the update operation + */ + async removeFulfillmentProvider(regionId, providerId) { + const region = await this.retrieve(regionId) + + return this.regionModel_.updateOne( + { _id: region._id }, + { + $pull: { + fulfillment_providers: providerId, + }, + } + ) + } +} + +export default RegionService diff --git a/packages/medusa/src/utils/contries.js b/packages/medusa/src/utils/countries.js similarity index 100% rename from packages/medusa/src/utils/contries.js rename to packages/medusa/src/utils/countries.js diff --git a/packages/medusa/src/utils/currencies.js b/packages/medusa/src/utils/currencies.js new file mode 100644 index 0000000000..bcaa487593 --- /dev/null +++ b/packages/medusa/src/utils/currencies.js @@ -0,0 +1,1073 @@ +export const currencies = { + USD: { + symbol: "$", + name: "US Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "USD", + name_plural: "US dollars", + }, + CAD: { + symbol: "CA$", + name: "Canadian Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "CAD", + name_plural: "Canadian dollars", + }, + EUR: { + symbol: "€", + name: "Euro", + symbol_native: "€", + decimal_digits: 2, + rounding: 0, + code: "EUR", + name_plural: "euros", + }, + AED: { + symbol: "AED", + name: "United Arab Emirates Dirham", + symbol_native: "د.إ.‏", + decimal_digits: 2, + rounding: 0, + code: "AED", + name_plural: "UAE dirhams", + }, + AFN: { + symbol: "Af", + name: "Afghan Afghani", + symbol_native: "؋", + decimal_digits: 0, + rounding: 0, + code: "AFN", + name_plural: "Afghan Afghanis", + }, + ALL: { + symbol: "ALL", + name: "Albanian Lek", + symbol_native: "Lek", + decimal_digits: 0, + rounding: 0, + code: "ALL", + name_plural: "Albanian lekë", + }, + AMD: { + symbol: "AMD", + name: "Armenian Dram", + symbol_native: "դր.", + decimal_digits: 0, + rounding: 0, + code: "AMD", + name_plural: "Armenian drams", + }, + ARS: { + symbol: "AR$", + name: "Argentine Peso", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "ARS", + name_plural: "Argentine pesos", + }, + AUD: { + symbol: "AU$", + name: "Australian Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "AUD", + name_plural: "Australian dollars", + }, + AZN: { + symbol: "man.", + name: "Azerbaijani Manat", + symbol_native: "ман.", + decimal_digits: 2, + rounding: 0, + code: "AZN", + name_plural: "Azerbaijani manats", + }, + BAM: { + symbol: "KM", + name: "Bosnia-Herzegovina Convertible Mark", + symbol_native: "KM", + decimal_digits: 2, + rounding: 0, + code: "BAM", + name_plural: "Bosnia-Herzegovina convertible marks", + }, + BDT: { + symbol: "Tk", + name: "Bangladeshi Taka", + symbol_native: "৳", + decimal_digits: 2, + rounding: 0, + code: "BDT", + name_plural: "Bangladeshi takas", + }, + BGN: { + symbol: "BGN", + name: "Bulgarian Lev", + symbol_native: "лв.", + decimal_digits: 2, + rounding: 0, + code: "BGN", + name_plural: "Bulgarian leva", + }, + BHD: { + symbol: "BD", + name: "Bahraini Dinar", + symbol_native: "د.ب.‏", + decimal_digits: 3, + rounding: 0, + code: "BHD", + name_plural: "Bahraini dinars", + }, + BIF: { + symbol: "FBu", + name: "Burundian Franc", + symbol_native: "FBu", + decimal_digits: 0, + rounding: 0, + code: "BIF", + name_plural: "Burundian francs", + }, + BND: { + symbol: "BN$", + name: "Brunei Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "BND", + name_plural: "Brunei dollars", + }, + BOB: { + symbol: "Bs", + name: "Bolivian Boliviano", + symbol_native: "Bs", + decimal_digits: 2, + rounding: 0, + code: "BOB", + name_plural: "Bolivian bolivianos", + }, + BRL: { + symbol: "R$", + name: "Brazilian Real", + symbol_native: "R$", + decimal_digits: 2, + rounding: 0, + code: "BRL", + name_plural: "Brazilian reals", + }, + BWP: { + symbol: "BWP", + name: "Botswanan Pula", + symbol_native: "P", + decimal_digits: 2, + rounding: 0, + code: "BWP", + name_plural: "Botswanan pulas", + }, + BYN: { + symbol: "Br", + name: "Belarusian Ruble", + symbol_native: "руб.", + decimal_digits: 2, + rounding: 0, + code: "BYN", + name_plural: "Belarusian rubles", + }, + BZD: { + symbol: "BZ$", + name: "Belize Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "BZD", + name_plural: "Belize dollars", + }, + CDF: { + symbol: "CDF", + name: "Congolese Franc", + symbol_native: "FrCD", + decimal_digits: 2, + rounding: 0, + code: "CDF", + name_plural: "Congolese francs", + }, + CHF: { + symbol: "CHF", + name: "Swiss Franc", + symbol_native: "CHF", + decimal_digits: 2, + rounding: 0.05, + code: "CHF", + name_plural: "Swiss francs", + }, + CLP: { + symbol: "CL$", + name: "Chilean Peso", + symbol_native: "$", + decimal_digits: 0, + rounding: 0, + code: "CLP", + name_plural: "Chilean pesos", + }, + CNY: { + symbol: "CN¥", + name: "Chinese Yuan", + symbol_native: "CN¥", + decimal_digits: 2, + rounding: 0, + code: "CNY", + name_plural: "Chinese yuan", + }, + COP: { + symbol: "CO$", + name: "Colombian Peso", + symbol_native: "$", + decimal_digits: 0, + rounding: 0, + code: "COP", + name_plural: "Colombian pesos", + }, + CRC: { + symbol: "₡", + name: "Costa Rican Colón", + symbol_native: "₡", + decimal_digits: 0, + rounding: 0, + code: "CRC", + name_plural: "Costa Rican colóns", + }, + CVE: { + symbol: "CV$", + name: "Cape Verdean Escudo", + symbol_native: "CV$", + decimal_digits: 2, + rounding: 0, + code: "CVE", + name_plural: "Cape Verdean escudos", + }, + CZK: { + symbol: "Kč", + name: "Czech Republic Koruna", + symbol_native: "Kč", + decimal_digits: 2, + rounding: 0, + code: "CZK", + name_plural: "Czech Republic korunas", + }, + DJF: { + symbol: "Fdj", + name: "Djiboutian Franc", + symbol_native: "Fdj", + decimal_digits: 0, + rounding: 0, + code: "DJF", + name_plural: "Djiboutian francs", + }, + DKK: { + symbol: "Dkr", + name: "Danish Krone", + symbol_native: "kr", + decimal_digits: 2, + rounding: 0, + code: "DKK", + name_plural: "Danish kroner", + }, + DOP: { + symbol: "RD$", + name: "Dominican Peso", + symbol_native: "RD$", + decimal_digits: 2, + rounding: 0, + code: "DOP", + name_plural: "Dominican pesos", + }, + DZD: { + symbol: "DA", + name: "Algerian Dinar", + symbol_native: "د.ج.‏", + decimal_digits: 2, + rounding: 0, + code: "DZD", + name_plural: "Algerian dinars", + }, + EEK: { + symbol: "Ekr", + name: "Estonian Kroon", + symbol_native: "kr", + decimal_digits: 2, + rounding: 0, + code: "EEK", + name_plural: "Estonian kroons", + }, + EGP: { + symbol: "EGP", + name: "Egyptian Pound", + symbol_native: "ج.م.‏", + decimal_digits: 2, + rounding: 0, + code: "EGP", + name_plural: "Egyptian pounds", + }, + ERN: { + symbol: "Nfk", + name: "Eritrean Nakfa", + symbol_native: "Nfk", + decimal_digits: 2, + rounding: 0, + code: "ERN", + name_plural: "Eritrean nakfas", + }, + ETB: { + symbol: "Br", + name: "Ethiopian Birr", + symbol_native: "Br", + decimal_digits: 2, + rounding: 0, + code: "ETB", + name_plural: "Ethiopian birrs", + }, + GBP: { + symbol: "£", + name: "British Pound Sterling", + symbol_native: "£", + decimal_digits: 2, + rounding: 0, + code: "GBP", + name_plural: "British pounds sterling", + }, + GEL: { + symbol: "GEL", + name: "Georgian Lari", + symbol_native: "GEL", + decimal_digits: 2, + rounding: 0, + code: "GEL", + name_plural: "Georgian laris", + }, + GHS: { + symbol: "GH₵", + name: "Ghanaian Cedi", + symbol_native: "GH₵", + decimal_digits: 2, + rounding: 0, + code: "GHS", + name_plural: "Ghanaian cedis", + }, + GNF: { + symbol: "FG", + name: "Guinean Franc", + symbol_native: "FG", + decimal_digits: 0, + rounding: 0, + code: "GNF", + name_plural: "Guinean francs", + }, + GTQ: { + symbol: "GTQ", + name: "Guatemalan Quetzal", + symbol_native: "Q", + decimal_digits: 2, + rounding: 0, + code: "GTQ", + name_plural: "Guatemalan quetzals", + }, + HKD: { + symbol: "HK$", + name: "Hong Kong Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "HKD", + name_plural: "Hong Kong dollars", + }, + HNL: { + symbol: "HNL", + name: "Honduran Lempira", + symbol_native: "L", + decimal_digits: 2, + rounding: 0, + code: "HNL", + name_plural: "Honduran lempiras", + }, + HRK: { + symbol: "kn", + name: "Croatian Kuna", + symbol_native: "kn", + decimal_digits: 2, + rounding: 0, + code: "HRK", + name_plural: "Croatian kunas", + }, + HUF: { + symbol: "Ft", + name: "Hungarian Forint", + symbol_native: "Ft", + decimal_digits: 0, + rounding: 0, + code: "HUF", + name_plural: "Hungarian forints", + }, + IDR: { + symbol: "Rp", + name: "Indonesian Rupiah", + symbol_native: "Rp", + decimal_digits: 0, + rounding: 0, + code: "IDR", + name_plural: "Indonesian rupiahs", + }, + ILS: { + symbol: "₪", + name: "Israeli New Sheqel", + symbol_native: "₪", + decimal_digits: 2, + rounding: 0, + code: "ILS", + name_plural: "Israeli new sheqels", + }, + INR: { + symbol: "Rs", + name: "Indian Rupee", + symbol_native: "টকা", + decimal_digits: 2, + rounding: 0, + code: "INR", + name_plural: "Indian rupees", + }, + IQD: { + symbol: "IQD", + name: "Iraqi Dinar", + symbol_native: "د.ع.‏", + decimal_digits: 0, + rounding: 0, + code: "IQD", + name_plural: "Iraqi dinars", + }, + IRR: { + symbol: "IRR", + name: "Iranian Rial", + symbol_native: "﷼", + decimal_digits: 0, + rounding: 0, + code: "IRR", + name_plural: "Iranian rials", + }, + ISK: { + symbol: "Ikr", + name: "Icelandic Króna", + symbol_native: "kr", + decimal_digits: 0, + rounding: 0, + code: "ISK", + name_plural: "Icelandic krónur", + }, + JMD: { + symbol: "J$", + name: "Jamaican Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "JMD", + name_plural: "Jamaican dollars", + }, + JOD: { + symbol: "JD", + name: "Jordanian Dinar", + symbol_native: "د.أ.‏", + decimal_digits: 3, + rounding: 0, + code: "JOD", + name_plural: "Jordanian dinars", + }, + JPY: { + symbol: "¥", + name: "Japanese Yen", + symbol_native: "¥", + decimal_digits: 0, + rounding: 0, + code: "JPY", + name_plural: "Japanese yen", + }, + KES: { + symbol: "Ksh", + name: "Kenyan Shilling", + symbol_native: "Ksh", + decimal_digits: 2, + rounding: 0, + code: "KES", + name_plural: "Kenyan shillings", + }, + KHR: { + symbol: "KHR", + name: "Cambodian Riel", + symbol_native: "៛", + decimal_digits: 2, + rounding: 0, + code: "KHR", + name_plural: "Cambodian riels", + }, + KMF: { + symbol: "CF", + name: "Comorian Franc", + symbol_native: "FC", + decimal_digits: 0, + rounding: 0, + code: "KMF", + name_plural: "Comorian francs", + }, + KRW: { + symbol: "₩", + name: "South Korean Won", + symbol_native: "₩", + decimal_digits: 0, + rounding: 0, + code: "KRW", + name_plural: "South Korean won", + }, + KWD: { + symbol: "KD", + name: "Kuwaiti Dinar", + symbol_native: "د.ك.‏", + decimal_digits: 3, + rounding: 0, + code: "KWD", + name_plural: "Kuwaiti dinars", + }, + KZT: { + symbol: "KZT", + name: "Kazakhstani Tenge", + symbol_native: "тңг.", + decimal_digits: 2, + rounding: 0, + code: "KZT", + name_plural: "Kazakhstani tenges", + }, + LBP: { + symbol: "LB£", + name: "Lebanese Pound", + symbol_native: "ل.ل.‏", + decimal_digits: 0, + rounding: 0, + code: "LBP", + name_plural: "Lebanese pounds", + }, + LKR: { + symbol: "SLRs", + name: "Sri Lankan Rupee", + symbol_native: "SL Re", + decimal_digits: 2, + rounding: 0, + code: "LKR", + name_plural: "Sri Lankan rupees", + }, + LTL: { + symbol: "Lt", + name: "Lithuanian Litas", + symbol_native: "Lt", + decimal_digits: 2, + rounding: 0, + code: "LTL", + name_plural: "Lithuanian litai", + }, + LVL: { + symbol: "Ls", + name: "Latvian Lats", + symbol_native: "Ls", + decimal_digits: 2, + rounding: 0, + code: "LVL", + name_plural: "Latvian lati", + }, + LYD: { + symbol: "LD", + name: "Libyan Dinar", + symbol_native: "د.ل.‏", + decimal_digits: 3, + rounding: 0, + code: "LYD", + name_plural: "Libyan dinars", + }, + MAD: { + symbol: "MAD", + name: "Moroccan Dirham", + symbol_native: "د.م.‏", + decimal_digits: 2, + rounding: 0, + code: "MAD", + name_plural: "Moroccan dirhams", + }, + MDL: { + symbol: "MDL", + name: "Moldovan Leu", + symbol_native: "MDL", + decimal_digits: 2, + rounding: 0, + code: "MDL", + name_plural: "Moldovan lei", + }, + MGA: { + symbol: "MGA", + name: "Malagasy Ariary", + symbol_native: "MGA", + decimal_digits: 0, + rounding: 0, + code: "MGA", + name_plural: "Malagasy Ariaries", + }, + MKD: { + symbol: "MKD", + name: "Macedonian Denar", + symbol_native: "MKD", + decimal_digits: 2, + rounding: 0, + code: "MKD", + name_plural: "Macedonian denari", + }, + MMK: { + symbol: "MMK", + name: "Myanma Kyat", + symbol_native: "K", + decimal_digits: 0, + rounding: 0, + code: "MMK", + name_plural: "Myanma kyats", + }, + MOP: { + symbol: "MOP$", + name: "Macanese Pataca", + symbol_native: "MOP$", + decimal_digits: 2, + rounding: 0, + code: "MOP", + name_plural: "Macanese patacas", + }, + MUR: { + symbol: "MURs", + name: "Mauritian Rupee", + symbol_native: "MURs", + decimal_digits: 0, + rounding: 0, + code: "MUR", + name_plural: "Mauritian rupees", + }, + MXN: { + symbol: "MX$", + name: "Mexican Peso", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "MXN", + name_plural: "Mexican pesos", + }, + MYR: { + symbol: "RM", + name: "Malaysian Ringgit", + symbol_native: "RM", + decimal_digits: 2, + rounding: 0, + code: "MYR", + name_plural: "Malaysian ringgits", + }, + MZN: { + symbol: "MTn", + name: "Mozambican Metical", + symbol_native: "MTn", + decimal_digits: 2, + rounding: 0, + code: "MZN", + name_plural: "Mozambican meticals", + }, + NAD: { + symbol: "N$", + name: "Namibian Dollar", + symbol_native: "N$", + decimal_digits: 2, + rounding: 0, + code: "NAD", + name_plural: "Namibian dollars", + }, + NGN: { + symbol: "₦", + name: "Nigerian Naira", + symbol_native: "₦", + decimal_digits: 2, + rounding: 0, + code: "NGN", + name_plural: "Nigerian nairas", + }, + NIO: { + symbol: "C$", + name: "Nicaraguan Córdoba", + symbol_native: "C$", + decimal_digits: 2, + rounding: 0, + code: "NIO", + name_plural: "Nicaraguan córdobas", + }, + NOK: { + symbol: "Nkr", + name: "Norwegian Krone", + symbol_native: "kr", + decimal_digits: 2, + rounding: 0, + code: "NOK", + name_plural: "Norwegian kroner", + }, + NPR: { + symbol: "NPRs", + name: "Nepalese Rupee", + symbol_native: "नेरू", + decimal_digits: 2, + rounding: 0, + code: "NPR", + name_plural: "Nepalese rupees", + }, + NZD: { + symbol: "NZ$", + name: "New Zealand Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "NZD", + name_plural: "New Zealand dollars", + }, + OMR: { + symbol: "OMR", + name: "Omani Rial", + symbol_native: "ر.ع.‏", + decimal_digits: 3, + rounding: 0, + code: "OMR", + name_plural: "Omani rials", + }, + PAB: { + symbol: "B/.", + name: "Panamanian Balboa", + symbol_native: "B/.", + decimal_digits: 2, + rounding: 0, + code: "PAB", + name_plural: "Panamanian balboas", + }, + PEN: { + symbol: "S/.", + name: "Peruvian Nuevo Sol", + symbol_native: "S/.", + decimal_digits: 2, + rounding: 0, + code: "PEN", + name_plural: "Peruvian nuevos soles", + }, + PHP: { + symbol: "₱", + name: "Philippine Peso", + symbol_native: "₱", + decimal_digits: 2, + rounding: 0, + code: "PHP", + name_plural: "Philippine pesos", + }, + PKR: { + symbol: "PKRs", + name: "Pakistani Rupee", + symbol_native: "₨", + decimal_digits: 0, + rounding: 0, + code: "PKR", + name_plural: "Pakistani rupees", + }, + PLN: { + symbol: "zł", + name: "Polish Zloty", + symbol_native: "zł", + decimal_digits: 2, + rounding: 0, + code: "PLN", + name_plural: "Polish zlotys", + }, + PYG: { + symbol: "₲", + name: "Paraguayan Guarani", + symbol_native: "₲", + decimal_digits: 0, + rounding: 0, + code: "PYG", + name_plural: "Paraguayan guaranis", + }, + QAR: { + symbol: "QR", + name: "Qatari Rial", + symbol_native: "ر.ق.‏", + decimal_digits: 2, + rounding: 0, + code: "QAR", + name_plural: "Qatari rials", + }, + RON: { + symbol: "RON", + name: "Romanian Leu", + symbol_native: "RON", + decimal_digits: 2, + rounding: 0, + code: "RON", + name_plural: "Romanian lei", + }, + RSD: { + symbol: "din.", + name: "Serbian Dinar", + symbol_native: "дин.", + decimal_digits: 0, + rounding: 0, + code: "RSD", + name_plural: "Serbian dinars", + }, + RUB: { + symbol: "RUB", + name: "Russian Ruble", + symbol_native: "₽.", + decimal_digits: 2, + rounding: 0, + code: "RUB", + name_plural: "Russian rubles", + }, + RWF: { + symbol: "RWF", + name: "Rwandan Franc", + symbol_native: "FR", + decimal_digits: 0, + rounding: 0, + code: "RWF", + name_plural: "Rwandan francs", + }, + SAR: { + symbol: "SR", + name: "Saudi Riyal", + symbol_native: "ر.س.‏", + decimal_digits: 2, + rounding: 0, + code: "SAR", + name_plural: "Saudi riyals", + }, + SDG: { + symbol: "SDG", + name: "Sudanese Pound", + symbol_native: "SDG", + decimal_digits: 2, + rounding: 0, + code: "SDG", + name_plural: "Sudanese pounds", + }, + SEK: { + symbol: "Skr", + name: "Swedish Krona", + symbol_native: "kr", + decimal_digits: 2, + rounding: 0, + code: "SEK", + name_plural: "Swedish kronor", + }, + SGD: { + symbol: "S$", + name: "Singapore Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "SGD", + name_plural: "Singapore dollars", + }, + SOS: { + symbol: "Ssh", + name: "Somali Shilling", + symbol_native: "Ssh", + decimal_digits: 0, + rounding: 0, + code: "SOS", + name_plural: "Somali shillings", + }, + SYP: { + symbol: "SY£", + name: "Syrian Pound", + symbol_native: "ل.س.‏", + decimal_digits: 0, + rounding: 0, + code: "SYP", + name_plural: "Syrian pounds", + }, + THB: { + symbol: "฿", + name: "Thai Baht", + symbol_native: "฿", + decimal_digits: 2, + rounding: 0, + code: "THB", + name_plural: "Thai baht", + }, + TND: { + symbol: "DT", + name: "Tunisian Dinar", + symbol_native: "د.ت.‏", + decimal_digits: 3, + rounding: 0, + code: "TND", + name_plural: "Tunisian dinars", + }, + TOP: { + symbol: "T$", + name: "Tongan Paʻanga", + symbol_native: "T$", + decimal_digits: 2, + rounding: 0, + code: "TOP", + name_plural: "Tongan paʻanga", + }, + TRY: { + symbol: "TL", + name: "Turkish Lira", + symbol_native: "TL", + decimal_digits: 2, + rounding: 0, + code: "TRY", + name_plural: "Turkish Lira", + }, + TTD: { + symbol: "TT$", + name: "Trinidad and Tobago Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "TTD", + name_plural: "Trinidad and Tobago dollars", + }, + TWD: { + symbol: "NT$", + name: "New Taiwan Dollar", + symbol_native: "NT$", + decimal_digits: 2, + rounding: 0, + code: "TWD", + name_plural: "New Taiwan dollars", + }, + TZS: { + symbol: "TSh", + name: "Tanzanian Shilling", + symbol_native: "TSh", + decimal_digits: 0, + rounding: 0, + code: "TZS", + name_plural: "Tanzanian shillings", + }, + UAH: { + symbol: "₴", + name: "Ukrainian Hryvnia", + symbol_native: "₴", + decimal_digits: 2, + rounding: 0, + code: "UAH", + name_plural: "Ukrainian hryvnias", + }, + UGX: { + symbol: "USh", + name: "Ugandan Shilling", + symbol_native: "USh", + decimal_digits: 0, + rounding: 0, + code: "UGX", + name_plural: "Ugandan shillings", + }, + UYU: { + symbol: "$U", + name: "Uruguayan Peso", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "UYU", + name_plural: "Uruguayan pesos", + }, + UZS: { + symbol: "UZS", + name: "Uzbekistan Som", + symbol_native: "UZS", + decimal_digits: 0, + rounding: 0, + code: "UZS", + name_plural: "Uzbekistan som", + }, + VEF: { + symbol: "Bs.F.", + name: "Venezuelan Bolívar", + symbol_native: "Bs.F.", + decimal_digits: 2, + rounding: 0, + code: "VEF", + name_plural: "Venezuelan bolívars", + }, + VND: { + symbol: "₫", + name: "Vietnamese Dong", + symbol_native: "₫", + decimal_digits: 0, + rounding: 0, + code: "VND", + name_plural: "Vietnamese dong", + }, + XAF: { + symbol: "FCFA", + name: "CFA Franc BEAC", + symbol_native: "FCFA", + decimal_digits: 0, + rounding: 0, + code: "XAF", + name_plural: "CFA francs BEAC", + }, + XOF: { + symbol: "CFA", + name: "CFA Franc BCEAO", + symbol_native: "CFA", + decimal_digits: 0, + rounding: 0, + code: "XOF", + name_plural: "CFA francs BCEAO", + }, + YER: { + symbol: "YR", + name: "Yemeni Rial", + symbol_native: "ر.ي.‏", + decimal_digits: 0, + rounding: 0, + code: "YER", + name_plural: "Yemeni rials", + }, + ZAR: { + symbol: "R", + name: "South African Rand", + symbol_native: "R", + decimal_digits: 2, + rounding: 0, + code: "ZAR", + name_plural: "South African rand", + }, + ZMK: { + symbol: "ZK", + name: "Zambian Kwacha", + symbol_native: "ZK", + decimal_digits: 0, + rounding: 0, + code: "ZMK", + name_plural: "Zambian kwachas", + }, + ZWL: { + symbol: "ZWL$", + name: "Zimbabwean Dollar", + symbol_native: "ZWL$", + decimal_digits: 0, + rounding: 0, + code: "ZWL", + name_plural: "Zimbabwean Dollar", + }, +}