From 5300926db87b61ccd38541eea529c838e7f065ea Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Fri, 18 Mar 2022 15:18:50 +0100 Subject: [PATCH] feat: Implement PriceList and extend MoneyAmount (#1152) * init * added buld id validation to repo * admin done * updated price reqs * intial implementation of PriceList * integration tests for price lists * updated admin/product integration tests * update updateVariantPrices method * remove comment from error handler * add integration test for batch deleting prices associated with price list * make update to prices through variant service limited to default prices * update store/products.js snapshot * add api unit tests and update product integration tests to validate that prices from Price List are ignored * fix product test * requested changes * cascade * ensure delete variant cascades to MoneyAmount * addresses PR feedback * removed unused endpoint * update mock * fix failing store integration tests * remove medusajs ressource * re add env.template * Update integration-tests/api/__tests__/admin/price-list.js Co-authored-by: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> * Update integration-tests/api/__tests__/admin/price-list.js Co-authored-by: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> * fix: update snapshots Co-authored-by: Sebastian Rindom Co-authored-by: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> --- .../admin/__snapshots__/price-list.js.snap | 293 +++++++ .../admin/__snapshots__/product.js.snap | 339 +++++++- .../api/__tests__/admin/price-list.js | 758 ++++++++++++++++++ .../api/__tests__/admin/product.js | 512 ++++++++---- .../api/__tests__/admin/store.js | 4 +- .../__snapshots__/product-variants.js.snap | 12 +- .../store/__snapshots__/products.js.snap | 16 +- .../api/__tests__/store/product-variants.js | 8 +- .../api/__tests__/store/products.js | 12 +- .../api/helpers/customer-seeder.js | 12 +- .../api/helpers/price-list-seeder.js | 53 ++ integration-tests/api/package.json | 6 +- integration-tests/api/yarn.lock | 70 +- packages/medusa/src/api/routes/admin/index.js | 41 +- .../price-lists/__tests__/add-prices-batch.ts | 105 +++ .../__tests__/create-price-list.ts | 119 +++ .../__tests__/delete-price-list.ts | 38 + .../__tests__/delete-prices-batch.ts | 76 ++ .../price-lists/__tests__/get-price-list.ts | 36 + .../price-lists/__tests__/list-price-lists.ts | 45 ++ .../__tests__/update-price-list.ts | 37 + .../admin/price-lists/add-prices-batch.ts | 99 +++ .../admin/price-lists/create-price-list.ts | 134 ++++ .../admin/price-lists/delete-price-list.ts | 41 + .../admin/price-lists/delete-prices-batch.ts | 63 ++ .../admin/price-lists/get-price-list.ts | 37 + .../src/api/routes/admin/price-lists/index.ts | 67 ++ .../admin/price-lists/list-price-lists.ts | 106 +++ .../admin/price-lists/update-price-list.ts | 155 ++++ .../routes/admin/products/create-product.ts | 23 +- .../routes/admin/products/create-variant.ts | 30 +- .../src/api/routes/admin/products/index.ts | 6 +- .../routes/admin/products/update-product.ts | 22 +- .../routes/admin/products/update-variant.ts | 33 +- packages/medusa/src/index.js | 1 + ...0108-update_money_amount_add_price_list.ts | 38 + packages/medusa/src/models/customer-group.ts | 34 +- packages/medusa/src/models/money-amount.ts | 38 +- packages/medusa/src/models/price-list.ts | 127 +++ .../repositories/__mocks__/money-amount.js | 31 + .../src/repositories/__mocks__/price-list.js | 22 + .../medusa/src/repositories/money-amount.ts | 74 +- .../medusa/src/repositories/price-list.ts | 5 + .../src/services/__mocks__/price-list.js | 43 + .../src/services/__mocks__/product-variant.js | 16 +- .../src/services/__tests__/price-list.js | 121 +++ .../src/services/__tests__/product-variant.js | 215 ++--- packages/medusa/src/services/discount.js | 4 +- packages/medusa/src/services/price-list.ts | 281 +++++++ .../medusa/src/services/product-variant.ts | 70 +- packages/medusa/src/types/price-list.ts | 151 ++++ packages/medusa/src/types/product-variant.ts | 60 +- 52 files changed, 4234 insertions(+), 505 deletions(-) create mode 100644 integration-tests/api/__tests__/admin/__snapshots__/price-list.js.snap create mode 100644 integration-tests/api/__tests__/admin/price-list.js create mode 100644 integration-tests/api/helpers/price-list-seeder.js create mode 100644 packages/medusa/src/api/routes/admin/price-lists/__tests__/add-prices-batch.ts create mode 100644 packages/medusa/src/api/routes/admin/price-lists/__tests__/create-price-list.ts create mode 100644 packages/medusa/src/api/routes/admin/price-lists/__tests__/delete-price-list.ts create mode 100644 packages/medusa/src/api/routes/admin/price-lists/__tests__/delete-prices-batch.ts create mode 100644 packages/medusa/src/api/routes/admin/price-lists/__tests__/get-price-list.ts create mode 100644 packages/medusa/src/api/routes/admin/price-lists/__tests__/list-price-lists.ts create mode 100644 packages/medusa/src/api/routes/admin/price-lists/__tests__/update-price-list.ts create mode 100644 packages/medusa/src/api/routes/admin/price-lists/add-prices-batch.ts create mode 100644 packages/medusa/src/api/routes/admin/price-lists/create-price-list.ts create mode 100644 packages/medusa/src/api/routes/admin/price-lists/delete-price-list.ts create mode 100644 packages/medusa/src/api/routes/admin/price-lists/delete-prices-batch.ts create mode 100644 packages/medusa/src/api/routes/admin/price-lists/get-price-list.ts create mode 100644 packages/medusa/src/api/routes/admin/price-lists/index.ts create mode 100644 packages/medusa/src/api/routes/admin/price-lists/list-price-lists.ts create mode 100644 packages/medusa/src/api/routes/admin/price-lists/update-price-list.ts create mode 100644 packages/medusa/src/migrations/1646915480108-update_money_amount_add_price_list.ts create mode 100644 packages/medusa/src/models/price-list.ts create mode 100644 packages/medusa/src/repositories/__mocks__/money-amount.js create mode 100644 packages/medusa/src/repositories/__mocks__/price-list.js create mode 100644 packages/medusa/src/repositories/price-list.ts create mode 100644 packages/medusa/src/services/__mocks__/price-list.js create mode 100644 packages/medusa/src/services/__tests__/price-list.js create mode 100644 packages/medusa/src/services/price-list.ts create mode 100644 packages/medusa/src/types/price-list.ts diff --git a/integration-tests/api/__tests__/admin/__snapshots__/price-list.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/price-list.js.snap new file mode 100644 index 0000000000..c2685e78ac --- /dev/null +++ b/integration-tests/api/__tests__/admin/__snapshots__/price-list.js.snap @@ -0,0 +1,293 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`/admin/price-lists GET /admin/price-lists returns a price list by :id 1`] = ` +Object { + "created_at": Any, + "customer_groups": Array [], + "deleted_at": null, + "description": "Winter sale for VIP customers. 25% off selected items.", + "ends_at": "2022-07-31T00:00:00.000Z", + "id": Any, + "name": "VIP winter sale", + "prices": Array [ + Object { + "amount": 100, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": Any, + "max_quantity": 100, + "min_quantity": 1, + "price_list_id": "pl_no_customer_groups", + "region_id": null, + "updated_at": Any, + "variant_id": "test-variant", + }, + Object { + "amount": 80, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": Any, + "max_quantity": 500, + "min_quantity": 101, + "price_list_id": "pl_no_customer_groups", + "region_id": null, + "updated_at": Any, + "variant_id": "test-variant", + }, + Object { + "amount": 50, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": Any, + "max_quantity": 1000, + "min_quantity": 501, + "price_list_id": "pl_no_customer_groups", + "region_id": null, + "updated_at": Any, + "variant_id": "test-variant", + }, + ], + "starts_at": "2022-07-01T00:00:00.000Z", + "status": "active", + "type": "sale", + "updated_at": Any, +} +`; + +exports[`/admin/price-lists POST /admin/price-lists/:id updates a price list 1`] = ` +Object { + "created_at": Any, + "customer_groups": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": "customer-group-1", + "metadata": null, + "name": "vip-customers", + "updated_at": Any, + }, + ], + "deleted_at": null, + "description": "Winter sale for our most loyal customers", + "ends_at": "2022-12-31T00:00:00.000Z", + "id": "pl_no_customer_groups", + "name": "Loyalty Reward - Winter Sale", + "prices": Array [ + Object { + "amount": 100, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": Any, + "max_quantity": 100, + "min_quantity": 1, + "price_list_id": "pl_no_customer_groups", + "region_id": null, + "updated_at": Any, + "variant_id": "test-variant", + }, + Object { + "amount": 80, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": Any, + "max_quantity": 500, + "min_quantity": 101, + "price_list_id": "pl_no_customer_groups", + "region_id": null, + "updated_at": Any, + "variant_id": "test-variant", + }, + Object { + "amount": 50, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": Any, + "max_quantity": 1000, + "min_quantity": 501, + "price_list_id": "pl_no_customer_groups", + "region_id": null, + "updated_at": Any, + "variant_id": "test-variant", + }, + Object { + "amount": 85, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": Any, + "max_quantity": null, + "min_quantity": null, + "price_list_id": "pl_no_customer_groups", + "region_id": null, + "updated_at": Any, + "variant_id": "test-variant_1", + }, + Object { + "amount": 10, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": Any, + "max_quantity": null, + "min_quantity": null, + "price_list_id": "pl_no_customer_groups", + "region_id": null, + "updated_at": Any, + "variant_id": "test-variant", + }, + ], + "starts_at": "2022-09-01T00:00:00.000Z", + "status": "draft", + "type": "sale", + "updated_at": Any, +} +`; + +exports[`/admin/price-lists POST /admin/price-lists/:id updates the amount and currency of a price in the price list 1`] = ` +Object { + "amount": 250, + "created_at": Any, + "currency_code": "eur", + "deleted_at": null, + "id": "ma_test_1", + "max_quantity": 100, + "min_quantity": 1, + "price_list_id": "pl_no_customer_groups", + "region_id": null, + "updated_at": Any, + "variant_id": "test-variant", +} +`; + +exports[`/admin/price-lists POST /admin/price-lists/:id/prices/batch Adds a batch of new prices to a price list overriding existing prices 1`] = ` +Array [ + Object { + "amount": 45, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": Any, + "max_quantity": 2000, + "min_quantity": 1001, + "price_list_id": "pl_no_customer_groups", + "region_id": null, + "updated_at": Any, + "variant_id": "test-variant", + }, + Object { + "amount": 35, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": Any, + "max_quantity": 3000, + "min_quantity": 2001, + "price_list_id": "pl_no_customer_groups", + "region_id": null, + "updated_at": Any, + "variant_id": "test-variant", + }, + Object { + "amount": 25, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": Any, + "max_quantity": 4000, + "min_quantity": 3001, + "price_list_id": "pl_no_customer_groups", + "region_id": null, + "updated_at": Any, + "variant_id": "test-variant", + }, +] +`; + +exports[`/admin/price-lists POST /admin/price-lists/:id/prices/batch Adds a batch of new prices to a price list without overriding existing prices 1`] = ` +Array [ + Object { + "amount": 100, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": Any, + "max_quantity": 100, + "min_quantity": 1, + "price_list_id": "pl_no_customer_groups", + "region_id": null, + "updated_at": Any, + "variant_id": "test-variant", + }, + Object { + "amount": 80, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": Any, + "max_quantity": 500, + "min_quantity": 101, + "price_list_id": "pl_no_customer_groups", + "region_id": null, + "updated_at": Any, + "variant_id": "test-variant", + }, + Object { + "amount": 50, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": Any, + "max_quantity": 1000, + "min_quantity": 501, + "price_list_id": "pl_no_customer_groups", + "region_id": null, + "updated_at": Any, + "variant_id": "test-variant", + }, + Object { + "amount": 45, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": Any, + "max_quantity": 2000, + "min_quantity": 1001, + "price_list_id": "pl_no_customer_groups", + "region_id": null, + "updated_at": Any, + "variant_id": "test-variant", + }, + Object { + "amount": 35, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": Any, + "max_quantity": 3000, + "min_quantity": 2001, + "price_list_id": "pl_no_customer_groups", + "region_id": null, + "updated_at": Any, + "variant_id": "test-variant", + }, + Object { + "amount": 25, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": Any, + "max_quantity": 4000, + "min_quantity": 3001, + "price_list_id": "pl_no_customer_groups", + "region_id": null, + "updated_at": Any, + "variant_id": "test-variant", + }, +] +`; diff --git a/integration-tests/api/__tests__/admin/__snapshots__/product.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/product.js.snap index f7fd34fc6a..d712a71ee3 100644 --- a/integration-tests/api/__tests__/admin/__snapshots__/product.js.snap +++ b/integration-tests/api/__tests__/admin/__snapshots__/product.js.snap @@ -110,8 +110,10 @@ Array [ "currency_code": "usd", "deleted_at": null, "id": StringMatching /\\^test-price\\*/, + "max_quantity": null, + "min_quantity": null, + "price_list_id": null, "region_id": null, - "sale_amount": null, "updated_at": Any, "variant_id": StringMatching /\\^test-variant\\*/, }, @@ -159,8 +161,10 @@ Array [ "currency_code": "usd", "deleted_at": null, "id": StringMatching /\\^test-price\\*/, + "max_quantity": null, + "min_quantity": null, + "price_list_id": null, "region_id": null, - "sale_amount": null, "updated_at": Any, "variant_id": StringMatching /\\^test-variant\\*/, }, @@ -208,8 +212,10 @@ Array [ "currency_code": "usd", "deleted_at": null, "id": StringMatching /\\^test-price\\*/, + "max_quantity": null, + "min_quantity": null, + "price_list_id": null, "region_id": null, - "sale_amount": null, "updated_at": Any, "variant_id": StringMatching /\\^test-variant\\*/, }, @@ -315,8 +321,10 @@ Array [ "currency_code": "usd", "deleted_at": null, "id": StringMatching /\\^test-price\\*/, + "max_quantity": null, + "min_quantity": null, + "price_list_id": null, "region_id": null, - "sale_amount": null, "updated_at": Any, "variant_id": StringMatching /\\^test-variant\\*/, }, @@ -364,8 +372,10 @@ Array [ "currency_code": "usd", "deleted_at": null, "id": StringMatching /\\^test-price\\*/, + "max_quantity": null, + "min_quantity": null, + "price_list_id": null, "region_id": "test-region", - "sale_amount": null, "updated_at": Any, "variant_id": StringMatching /\\^test-variant\\*/, }, @@ -561,8 +571,10 @@ Array [ "currency_code": "usd", "deleted_at": null, "id": Any, + "max_quantity": null, + "min_quantity": null, + "price_list_id": null, "region_id": null, - "sale_amount": null, "updated_at": Any, "variant_id": StringMatching /\\^variant_\\*/, }, @@ -581,3 +593,318 @@ Array [ }, ] `; + +exports[`/admin/products POST /admin/products creates a product 1`] = ` +Object { + "collection": Object { + "created_at": Any, + "deleted_at": null, + "handle": "test-collection", + "id": "test-collection", + "metadata": null, + "title": "Test collection", + "updated_at": Any, + }, + "collection_id": "test-collection", + "created_at": Any, + "deleted_at": null, + "description": "test-product-description", + "discountable": true, + "external_id": null, + "handle": "test", + "height": null, + "hs_code": null, + "id": StringMatching /\\^prod_\\*/, + "images": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": Any, + "metadata": null, + "updated_at": Any, + "url": "test-image.png", + }, + Object { + "created_at": Any, + "deleted_at": null, + "id": Any, + "metadata": null, + "updated_at": Any, + "url": "test-image-2.png", + }, + ], + "is_giftcard": false, + "length": null, + "material": null, + "metadata": null, + "mid_code": null, + "options": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^opt_\\*/, + "metadata": null, + "product_id": StringMatching /\\^prod_\\*/, + "title": "size", + "updated_at": Any, + }, + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^opt_\\*/, + "metadata": null, + "product_id": StringMatching /\\^prod_\\*/, + "title": "color", + "updated_at": Any, + }, + ], + "origin_country": null, + "profile_id": StringMatching /\\^sp_\\*/, + "status": "draft", + "subtitle": null, + "tags": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": Any, + "metadata": null, + "updated_at": Any, + "value": "123", + }, + Object { + "created_at": Any, + "deleted_at": null, + "id": Any, + "metadata": null, + "updated_at": Any, + "value": "456", + }, + ], + "thumbnail": "test-image.png", + "title": "Test", + "type": Object { + "created_at": Any, + "deleted_at": null, + "id": "test-type", + "metadata": null, + "updated_at": Any, + "value": "test-type", + }, + "type_id": "test-type", + "updated_at": Any, + "variants": Array [ + Object { + "allow_backorder": false, + "barcode": null, + "created_at": Any, + "deleted_at": null, + "ean": null, + "height": null, + "hs_code": null, + "id": StringMatching /\\^variant_\\*/, + "inventory_quantity": 10, + "length": null, + "manage_inventory": true, + "material": null, + "metadata": null, + "mid_code": null, + "options": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^optval_\\*/, + "metadata": null, + "option_id": StringMatching /\\^opt_\\*/, + "updated_at": Any, + "value": "large", + "variant_id": StringMatching /\\^variant_\\*/, + }, + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^optval_\\*/, + "metadata": null, + "option_id": StringMatching /\\^opt_\\*/, + "updated_at": Any, + "value": "green", + "variant_id": StringMatching /\\^variant_\\*/, + }, + ], + "origin_country": null, + "prices": Array [ + Object { + "amount": 100, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": StringMatching /\\^ma_\\*/, + "max_quantity": null, + "min_quantity": null, + "price_list_id": null, + "region_id": null, + "updated_at": Any, + "variant_id": StringMatching /\\^variant_\\*/, + }, + Object { + "amount": 45, + "created_at": Any, + "currency_code": "eur", + "deleted_at": null, + "id": StringMatching /\\^ma_\\*/, + "max_quantity": null, + "min_quantity": null, + "price_list_id": null, + "region_id": null, + "updated_at": Any, + "variant_id": StringMatching /\\^variant_\\*/, + }, + Object { + "amount": 30, + "created_at": Any, + "currency_code": "dkk", + "deleted_at": null, + "id": StringMatching /\\^ma_\\*/, + "max_quantity": null, + "min_quantity": null, + "price_list_id": null, + "region_id": null, + "updated_at": Any, + "variant_id": StringMatching /\\^variant_\\*/, + }, + ], + "product_id": StringMatching /\\^prod_\\*/, + "sku": null, + "title": "Test variant", + "upc": null, + "updated_at": Any, + "weight": null, + "width": null, + }, + ], + "weight": null, + "width": null, +} +`; + +exports[`/admin/products POST /admin/products updates a product (update prices, tags, update status, delete collection, delete type, replaces images) 1`] = ` +Object { + "collection": null, + "collection_id": null, + "created_at": Any, + "deleted_at": null, + "description": "test-product-description", + "discountable": true, + "external_id": null, + "handle": "test-product", + "height": null, + "hs_code": null, + "id": "test-product", + "images": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^img_\\*/, + "metadata": null, + "updated_at": Any, + "url": "test-image-2.png", + }, + ], + "is_giftcard": false, + "length": null, + "material": null, + "metadata": null, + "mid_code": null, + "options": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": "test-option", + "metadata": null, + "product_id": "test-product", + "title": "test-option", + "updated_at": Any, + }, + ], + "origin_country": null, + "profile_id": StringMatching /\\^sp_\\*/, + "status": "published", + "subtitle": null, + "tags": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": "tag1", + "metadata": null, + "updated_at": Any, + "value": "123", + }, + ], + "thumbnail": "test-image-2.png", + "title": "Test product", + "type": Object { + "created_at": Any, + "deleted_at": null, + "id": StringMatching /\\^ptyp_\\*/, + "metadata": null, + "updated_at": Any, + "value": "test-type-2", + }, + "type_id": StringMatching /\\^ptyp_\\*/, + "updated_at": Any, + "variants": Array [ + Object { + "allow_backorder": false, + "barcode": "test-barcode", + "created_at": Any, + "deleted_at": null, + "ean": "test-ean", + "height": null, + "hs_code": null, + "id": "test-variant", + "inventory_quantity": 10, + "length": null, + "manage_inventory": true, + "material": null, + "metadata": null, + "mid_code": null, + "options": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": "test-variant-option", + "metadata": null, + "option_id": "test-option", + "updated_at": Any, + "value": "Default variant", + "variant_id": "test-variant", + }, + ], + "origin_country": null, + "prices": Array [ + Object { + "amount": 75, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": "test-price", + "max_quantity": null, + "min_quantity": null, + "price_list_id": null, + "region_id": null, + "updated_at": Any, + "variant_id": "test-variant", + }, + ], + "product_id": "test-product", + "sku": "test-sku", + "title": "Test variant", + "upc": "test-upc", + "updated_at": Any, + "weight": null, + "width": null, + }, + ], + "weight": null, + "width": null, +} +`; diff --git a/integration-tests/api/__tests__/admin/price-list.js b/integration-tests/api/__tests__/admin/price-list.js new file mode 100644 index 0000000000..4ae8e35268 --- /dev/null +++ b/integration-tests/api/__tests__/admin/price-list.js @@ -0,0 +1,758 @@ +const path = require("path") + +const setupServer = require("../../../helpers/setup-server") +const { useApi } = require("../../../helpers/use-api") +const { useDb, initDb } = require("../../../helpers/use-db") + +const adminSeeder = require("../../helpers/admin-seeder") +const customerSeeder = require("../../helpers/customer-seeder") +const priceListSeeder = require("../../helpers/price-list-seeder") +const productSeeder = require("../../helpers/product-seeder") + +jest.setTimeout(30000) + +describe("/admin/price-lists", () => { + let medusaProcess + let dbConnection + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + medusaProcess = await setupServer({ cwd }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + medusaProcess.kill() + }) + + describe("POST /admin/price-list", () => { + beforeEach(async () => { + try { + await adminSeeder(dbConnection) + await customerSeeder(dbConnection) + await productSeeder(dbConnection) + } catch (err) { + console.log(err) + throw err + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("creates a price list", async () => { + const api = useApi() + + const payload = { + name: "VIP Summer sale", + description: "Summer sale for VIP customers. 25% off selected items.", + type: "sale", + status: "active", + starts_at: "2022-07-01T00:00:00.000Z", + ends_at: "2022-07-31T00:00:00.000Z", + customer_groups: [ + { + id: "customer-group-1", + }, + ], + prices: [ + { + amount: 85, + currency_code: "usd", + variant_id: "test-variant", + }, + ], + } + + const response = await api + .post("/admin/price-lists", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.warn(err.response.data) + }) + + expect(response.status).toEqual(200) + expect(response.data.price_list).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "VIP Summer sale", + description: "Summer sale for VIP customers. 25% off selected items.", + type: "sale", + status: "active", + starts_at: "2022-07-01T00:00:00.000Z", + ends_at: "2022-07-31T00:00:00.000Z", + customer_groups: [ + expect.objectContaining({ + id: "customer-group-1", + }), + ], + prices: [ + expect.objectContaining({ + id: expect.any(String), + amount: 85, + currency_code: "usd", + variant_id: "test-variant", + }), + ], + }) + ) + }) + }) + + describe("GET /admin/price-lists", () => { + beforeEach(async () => { + try { + await adminSeeder(dbConnection) + await productSeeder(dbConnection) + await priceListSeeder(dbConnection) + } catch (err) { + console.log(err) + throw err + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("returns a price list by :id", async () => { + const api = useApi() + + const response = await api + .get("/admin/price-lists/pl_no_customer_groups", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.warn(err.response.data) + }) + + expect(response.status).toEqual(200) + expect(response.data.price_list).toMatchSnapshot({ + id: expect.any(String), + name: "VIP winter sale", + description: "Winter sale for VIP customers. 25% off selected items.", + type: "sale", + status: "active", + starts_at: "2022-07-01T00:00:00.000Z", + ends_at: "2022-07-31T00:00:00.000Z", + prices: [ + { + id: expect.any(String), + amount: 100, + currency_code: "usd", + min_quantity: 1, + max_quantity: 100, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + created_at: expect.any(String), + updated_at: expect.any(String), + }, + { + id: expect.any(String), + amount: 80, + currency_code: "usd", + min_quantity: 101, + max_quantity: 500, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + created_at: expect.any(String), + updated_at: expect.any(String), + }, + { + id: expect.any(String), + amount: 50, + currency_code: "usd", + min_quantity: 501, + max_quantity: 1000, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + created_at: expect.any(String), + updated_at: expect.any(String), + }, + ], + created_at: expect.any(String), + updated_at: expect.any(String), + }) + }) + + it("returns a list of price lists", async () => { + const api = useApi() + + const response = await api + .get("/admin/price-lists", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.warn(err.response.data) + }) + + expect(response.status).toEqual(200) + expect(response.data.price_lists).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "pl_no_customer_groups", + }), + ]) + ) + }) + }) + + describe("POST /admin/price-lists/:id", () => { + beforeEach(async () => { + try { + await adminSeeder(dbConnection) + await customerSeeder(dbConnection) + await productSeeder(dbConnection) + await priceListSeeder(dbConnection) + } catch (err) { + console.log(err) + throw err + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("updates a price list", async () => { + const api = useApi() + + const payload = { + name: "Loyalty Reward - Winter Sale", + description: "Winter sale for our most loyal customers", + type: "sale", + status: "draft", + starts_at: "2022-09-01T00:00:00.000Z", + ends_at: "2022-12-31T00:00:00.000Z", + customer_groups: [ + { + id: "customer-group-1", + }, + ], + prices: [ + { + amount: 85, + currency_code: "usd", + variant_id: "test-variant_1", + }, + { + amount: 10, + currency_code: "usd", + variant_id: "test-variant", + }, + ], + } + + const response = await api + .post("/admin/price-lists/pl_no_customer_groups", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.warn(err.response.data) + }) + + expect(response.status).toEqual(200) + expect(response.data.price_list).toMatchSnapshot({ + id: "pl_no_customer_groups", + name: "Loyalty Reward - Winter Sale", + description: "Winter sale for our most loyal customers", + type: "sale", + status: "draft", + starts_at: "2022-09-01T00:00:00.000Z", + ends_at: "2022-12-31T00:00:00.000Z", + prices: [ + { + id: expect.any(String), + amount: 100, + currency_code: "usd", + min_quantity: 1, + max_quantity: 100, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + region_id: null, + created_at: expect.any(String), + updated_at: expect.any(String), + }, + { + id: expect.any(String), + amount: 80, + currency_code: "usd", + min_quantity: 101, + max_quantity: 500, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + region_id: null, + created_at: expect.any(String), + updated_at: expect.any(String), + }, + { + id: expect.any(String), + amount: 50, + currency_code: "usd", + min_quantity: 501, + max_quantity: 1000, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + region_id: null, + created_at: expect.any(String), + updated_at: expect.any(String), + }, + { + id: expect.any(String), + amount: 85, + currency_code: "usd", + variant_id: "test-variant_1", + price_list_id: "pl_no_customer_groups", + min_quantity: null, + max_quantity: null, + region_id: null, + created_at: expect.any(String), + updated_at: expect.any(String), + deleted_at: null, + }, + { + id: expect.any(String), + amount: 10, + currency_code: "usd", + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + min_quantity: null, + max_quantity: null, + region_id: null, + created_at: expect.any(String), + updated_at: expect.any(String), + deleted_at: null, + }, + ], + customer_groups: [ + { + id: "customer-group-1", + created_at: expect.any(String), + updated_at: expect.any(String), + }, + ], + created_at: expect.any(String), + updated_at: expect.any(String), + }) + }) + + it("updates the amount and currency of a price in the price list", async () => { + const api = useApi() + + const payload = { + prices: [ + { + id: "ma_test_1", + amount: 250, + currency_code: "eur", + variant_id: "test-variant", + }, + ], + } + + const response = await api + .post("/admin/price-lists/pl_no_customer_groups", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.warn(err.response.data) + }) + + expect(response.status).toEqual(200) + + const updatedPrice = response.data.price_list.prices.find( + (p) => p.id === "ma_test_1" + ) + + expect(updatedPrice).toMatchSnapshot({ + id: "ma_test_1", + amount: 250, + currency_code: "eur", + min_quantity: 1, + max_quantity: 100, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + region_id: null, + created_at: expect.any(String), + updated_at: expect.any(String), + }) + }) + }) + + describe("POST /admin/price-lists/:id/prices/batch", () => { + beforeEach(async () => { + try { + await adminSeeder(dbConnection) + await productSeeder(dbConnection) + await priceListSeeder(dbConnection) + } catch (err) { + console.log(err) + throw err + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("Adds a batch of new prices to a price list without overriding existing prices", async () => { + const api = useApi() + + const payload = { + prices: [ + { + amount: 45, + currency_code: "usd", + variant_id: "test-variant", + min_quantity: 1001, + max_quantity: 2000, + }, + { + amount: 35, + currency_code: "usd", + variant_id: "test-variant", + min_quantity: 2001, + max_quantity: 3000, + }, + { + amount: 25, + currency_code: "usd", + variant_id: "test-variant", + min_quantity: 3001, + max_quantity: 4000, + }, + ], + } + + const response = await api + .post( + "/admin/price-lists/pl_no_customer_groups/prices/batch", + payload, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + console.warn(err.response.data) + }) + + expect(response.status).toEqual(200) + expect(response.data.price_list.prices.length).toEqual(6) + expect(response.data.price_list.prices).toMatchSnapshot([ + { + id: expect.any(String), + price_list_id: "pl_no_customer_groups", + amount: 100, + currency_code: "usd", + min_quantity: 1, + max_quantity: 100, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + created_at: expect.any(String), + updated_at: expect.any(String), + }, + { + id: expect.any(String), + price_list_id: "pl_no_customer_groups", + amount: 80, + currency_code: "usd", + min_quantity: 101, + max_quantity: 500, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + created_at: expect.any(String), + updated_at: expect.any(String), + }, + { + id: expect.any(String), + price_list_id: "pl_no_customer_groups", + amount: 50, + currency_code: "usd", + min_quantity: 501, + max_quantity: 1000, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + created_at: expect.any(String), + updated_at: expect.any(String), + }, + { + id: expect.any(String), + price_list_id: "pl_no_customer_groups", + amount: 45, + currency_code: "usd", + variant_id: "test-variant", + min_quantity: 1001, + max_quantity: 2000, + created_at: expect.any(String), + updated_at: expect.any(String), + }, + { + id: expect.any(String), + price_list_id: "pl_no_customer_groups", + amount: 35, + currency_code: "usd", + variant_id: "test-variant", + min_quantity: 2001, + max_quantity: 3000, + created_at: expect.any(String), + updated_at: expect.any(String), + }, + { + id: expect.any(String), + price_list_id: "pl_no_customer_groups", + amount: 25, + currency_code: "usd", + variant_id: "test-variant", + min_quantity: 3001, + max_quantity: 4000, + created_at: expect.any(String), + updated_at: expect.any(String), + }, + ]) + }) + + it("Adds a batch of new prices to a price list overriding existing prices", async () => { + const api = useApi() + + const payload = { + prices: [ + { + amount: 45, + currency_code: "usd", + variant_id: "test-variant", + min_quantity: 1001, + max_quantity: 2000, + }, + { + amount: 35, + currency_code: "usd", + variant_id: "test-variant", + min_quantity: 2001, + max_quantity: 3000, + }, + { + amount: 25, + currency_code: "usd", + variant_id: "test-variant", + min_quantity: 3001, + max_quantity: 4000, + }, + ], + override: true, + } + + const response = await api + .post( + "/admin/price-lists/pl_no_customer_groups/prices/batch", + payload, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + console.warn(err.response.data) + }) + + expect(response.status).toEqual(200) + expect(response.data.price_list.prices.length).toEqual(3) + expect(response.data.price_list.prices).toMatchSnapshot([ + { + id: expect.any(String), + price_list_id: "pl_no_customer_groups", + amount: 45, + currency_code: "usd", + variant_id: "test-variant", + min_quantity: 1001, + max_quantity: 2000, + created_at: expect.any(String), + updated_at: expect.any(String), + }, + { + id: expect.any(String), + price_list_id: "pl_no_customer_groups", + amount: 35, + currency_code: "usd", + variant_id: "test-variant", + min_quantity: 2001, + max_quantity: 3000, + created_at: expect.any(String), + updated_at: expect.any(String), + }, + { + id: expect.any(String), + price_list_id: "pl_no_customer_groups", + amount: 25, + currency_code: "usd", + variant_id: "test-variant", + min_quantity: 3001, + max_quantity: 4000, + created_at: expect.any(String), + updated_at: expect.any(String), + }, + ]) + }) + }) + + describe("DELETE /admin/price-lists/:id", () => { + beforeEach(async () => { + try { + await adminSeeder(dbConnection) + await productSeeder(dbConnection) + await priceListSeeder(dbConnection) + } catch (err) { + console.log(err) + throw err + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("Deletes a price list", async () => { + const api = useApi() + + const response = await api + .delete("/admin/price-lists/pl_no_customer_groups", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.warn(err.response.data) + }) + + expect(response.status).toEqual(200) + expect(response.data).toEqual({ + id: "pl_no_customer_groups", + object: "price-list", + deleted: true, + }) + }) + }) + + describe("tests cascade on delete", () => { + beforeEach(async () => { + try { + await adminSeeder(dbConnection) + await productSeeder(dbConnection) + await priceListSeeder(dbConnection) + } catch (err) { + console.log(err) + throw err + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("Deletes a variant and ensures that prices associated with the variant are deleted from PriceList", async () => { + const api = useApi() + + const deleteResponse = await api + .delete("/admin/products/test-product/variants/test-variant", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.warn(err.response.data) + }) + + + const response = await api.get( + "/admin/price-lists/pl_no_customer_groups", + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + + + expect(response.status).toEqual(200) + expect(response.data.price_list.prices.length).toEqual(0) + }) + }) + + describe("DELETE /admin/price-lists/:id/prices/batch", () => { + beforeEach(async () => { + try { + await adminSeeder(dbConnection) + await productSeeder(dbConnection) + await priceListSeeder(dbConnection) + } catch (err) { + console.log(err) + throw err + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("Deletes several prices associated with a price list", async () => { + const api = useApi() + + const response = await api + .delete("/admin/price-lists/pl_no_customer_groups/prices/batch", { + headers: { + Authorization: "Bearer test_token", + }, + data: { + price_ids: ["ma_test_1", "ma_test_2"], + }, + }) + .catch((err) => { + console.warn(err.response.data) + }) + + const getPriceListResponse = await api + .get("/admin/price-lists/pl_no_customer_groups", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.warn(err.response.data) + }) + + expect(response.status).toEqual(200) + expect(response.data).toEqual({ + ids: ["ma_test_1", "ma_test_2"], + object: "money-amount", + deleted: true, + }) + expect(getPriceListResponse.data.price_list.prices.length).toEqual(1) + expect(getPriceListResponse.data.price_list.prices[0].id).toEqual( + "ma_test_3" + ) + }) + }) +}) diff --git a/integration-tests/api/__tests__/admin/product.js b/integration-tests/api/__tests__/admin/product.js index 8c9b4576e0..fd53816501 100644 --- a/integration-tests/api/__tests__/admin/product.js +++ b/integration-tests/api/__tests__/admin/product.js @@ -7,6 +7,7 @@ const { initDb, useDb } = require("../../../helpers/use-db") const adminSeeder = require("../../helpers/admin-seeder") const productSeeder = require("../../helpers/product-seeder") const { ProductVariant } = require("@medusajs/medusa") +const priceListSeeder = require("../../helpers/price-list-seeder") jest.setTimeout(50000) @@ -773,7 +774,20 @@ describe("/admin/products", () => { { title: "Test variant", inventory_quantity: 10, - prices: [{ currency_code: "usd", amount: 100 }], + prices: [ + { + currency_code: "usd", + amount: 100, + }, + { + currency_code: "eur", + amount: 45, + }, + { + currency_code: "dkk", + amount: 30, + }, + ], options: [{ value: "large" }, { value: "green" }], }, ], @@ -790,66 +804,126 @@ describe("/admin/products", () => { }) expect(response.status).toEqual(200) - expect(response.data.product).toEqual( - expect.objectContaining({ - title: "Test", - discountable: true, - is_giftcard: false, - handle: "test", - status: "draft", - images: expect.arrayContaining([ - expect.objectContaining({ - url: "test-image.png", - }), - expect.objectContaining({ - url: "test-image-2.png", - }), - ]), - thumbnail: "test-image.png", - tags: [ - expect.objectContaining({ - value: "123", - }), - expect.objectContaining({ - value: "456", - }), - ], - type: expect.objectContaining({ - value: "test-type", - }), - collection: expect.objectContaining({ - id: "test-collection", - title: "Test collection", - }), - options: [ - expect.objectContaining({ - title: "size", - }), - expect.objectContaining({ - title: "color", - }), - ], - variants: [ - expect.objectContaining({ - title: "Test variant", - prices: [ - expect.objectContaining({ - currency_code: "usd", - amount: 100, - }), - ], - options: [ - expect.objectContaining({ - value: "large", - }), - expect.objectContaining({ - value: "green", - }), - ], - }), - ], - }) - ) + expect(response.data.product).toMatchSnapshot({ + id: expect.stringMatching(/^prod_*/), + title: "Test", + discountable: true, + is_giftcard: false, + handle: "test", + status: "draft", + created_at: expect.any(String), + updated_at: expect.any(String), + profile_id: expect.stringMatching(/^sp_*/), + images: [ + { + id: expect.any(String), + url: "test-image.png", + created_at: expect.any(String), + updated_at: expect.any(String), + }, + { + id: expect.any(String), + url: "test-image-2.png", + created_at: expect.any(String), + updated_at: expect.any(String), + }, + ], + thumbnail: "test-image.png", + tags: [ + { + id: expect.any(String), + value: "123", + created_at: expect.any(String), + updated_at: expect.any(String), + }, + { + id: expect.any(String), + value: "456", + created_at: expect.any(String), + updated_at: expect.any(String), + }, + ], + type: { + value: "test-type", + created_at: expect.any(String), + updated_at: expect.any(String), + }, + collection: { + id: "test-collection", + title: "Test collection", + created_at: expect.any(String), + updated_at: expect.any(String), + }, + options: [ + { + id: expect.stringMatching(/^opt_*/), + product_id: expect.stringMatching(/^prod_*/), + title: "size", + created_at: expect.any(String), + updated_at: expect.any(String), + }, + { + id: expect.stringMatching(/^opt_*/), + product_id: expect.stringMatching(/^prod_*/), + title: "color", + created_at: expect.any(String), + updated_at: expect.any(String), + }, + ], + variants: [ + { + id: expect.stringMatching(/^variant_*/), + product_id: expect.stringMatching(/^prod_*/), + updated_at: expect.any(String), + created_at: expect.any(String), + title: "Test variant", + prices: [ + { + id: expect.stringMatching(/^ma_*/), + currency_code: "usd", + amount: 100, + created_at: expect.any(String), + updated_at: expect.any(String), + variant_id: expect.stringMatching(/^variant_*/), + }, + { + id: expect.stringMatching(/^ma_*/), + currency_code: "eur", + amount: 45, + created_at: expect.any(String), + updated_at: expect.any(String), + variant_id: expect.stringMatching(/^variant_*/), + }, + { + id: expect.stringMatching(/^ma_*/), + currency_code: "dkk", + amount: 30, + created_at: expect.any(String), + updated_at: expect.any(String), + variant_id: expect.stringMatching(/^variant_*/), + }, + ], + options: [ + { + value: "large", + created_at: expect.any(String), + updated_at: expect.any(String), + variant_id: expect.stringMatching(/^variant_*/), + option_id: expect.stringMatching(/^opt_*/), + id: expect.stringMatching(/^optval_*/), + }, + { + value: "green", + created_at: expect.any(String), + updated_at: expect.any(String), + variant_id: expect.stringMatching(/^variant_*/), + option_id: expect.stringMatching(/^opt_*/), + id: expect.stringMatching(/^optval_*/), + }, + ], + }, + ], + }) }) it("creates a product that is not discountable", async () => { @@ -1006,8 +1080,7 @@ describe("/admin/products", () => { prices: [ { currency_code: "usd", - amount: 100, - sale_amount: 75, + amount: 75, }, ], }, @@ -1030,36 +1103,92 @@ describe("/admin/products", () => { expect(response.status).toEqual(200) - expect(response.data.product).toEqual( - expect.objectContaining({ - images: expect.arrayContaining([ - expect.objectContaining({ - url: "test-image-2.png", - }), - ]), - thumbnail: "test-image-2.png", - tags: [ - expect.objectContaining({ - value: "123", - }), - ], - variants: [ - expect.objectContaining({ - prices: [ - expect.objectContaining({ - sale_amount: 75, - amount: 100, - }), - ], - }), - ], - status: "published", - collection: null, - type: expect.objectContaining({ - value: "test-type-2", - }), - }) - ) + expect(response.data.product).toMatchSnapshot({ + id: "test-product", + created_at: expect.any(String), + description: "test-product-description", + discountable: true, + handle: "test-product", + images: [ + { + created_at: expect.any(String), + deleted_at: null, + id: expect.stringMatching(/^img_*/), + metadata: null, + updated_at: expect.any(String), + url: "test-image-2.png", + }, + ], + is_giftcard: false, + options: [ + { + created_at: expect.any(String), + id: "test-option", + product_id: "test-product", + title: "test-option", + updated_at: expect.any(String), + }, + ], + profile_id: expect.stringMatching(/^sp_*/), + status: "published", + tags: [ + { + created_at: expect.any(String), + id: "tag1", + updated_at: expect.any(String), + value: "123", + }, + ], + thumbnail: "test-image-2.png", + title: "Test product", + type: { + created_at: expect.any(String), + id: expect.stringMatching(/^ptyp_*/), + updated_at: expect.any(String), + value: "test-type-2", + }, + type_id: expect.stringMatching(/^ptyp_*/), + updated_at: expect.any(String), + variants: [ + { + allow_backorder: false, + barcode: "test-barcode", + created_at: expect.any(String), + ean: "test-ean", + id: "test-variant", + inventory_quantity: 10, + manage_inventory: true, + options: [ + { + created_at: expect.any(String), + deleted_at: null, + id: "test-variant-option", + metadata: null, + option_id: "test-option", + updated_at: expect.any(String), + value: "Default variant", + variant_id: "test-variant", + }, + ], + origin_country: null, + prices: [ + { + amount: 75, + created_at: expect.any(String), + currency_code: "usd", + id: "test-price", + updated_at: expect.any(String), + variant_id: "test-variant", + }, + ], + product_id: "test-product", + sku: "test-sku", + title: "Test variant", + upc: "test-upc", + updated_at: expect.any(String), + }, + ], + }) }) it("updates product (removes images when empty array included)", async () => { @@ -1189,11 +1318,12 @@ describe("/admin/products", () => { }) }) - describe("updates a variant's prices", () => { + describe("updates a variant's default prices (ignores prices associated with a Price List)", () => { beforeEach(async () => { try { await productSeeder(dbConnection) await adminSeeder(dbConnection) + await priceListSeeder(dbConnection) } catch (err) { console.log(err) throw err @@ -1205,7 +1335,7 @@ describe("/admin/products", () => { await db.teardown() }) - it("successfully updates a variant's prices by changing an existing price (currency_code)", async () => { + it("successfully updates a variant's default prices by changing an existing price (currency_code)", async () => { const api = useApi() const data = { prices: [ @@ -1239,6 +1369,33 @@ describe("/admin/products", () => { amount: 1500, currency_code: "usd", }), + expect.objectContaining({ + id: "ma_test_1", + amount: 100, + currency_code: "usd", + min_quantity: 1, + max_quantity: 100, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + }), + expect.objectContaining({ + id: "ma_test_2", + amount: 80, + currency_code: "usd", + min_quantity: 101, + max_quantity: 500, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + }), + expect.objectContaining({ + id: "ma_test_3", + amount: 50, + currency_code: "usd", + min_quantity: 501, + max_quantity: 1000, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + }), ]), }), ]), @@ -1316,26 +1473,55 @@ describe("/admin/products", () => { expect(response.status).toEqual(200) - expect(response.data).toEqual({ - product: expect.objectContaining({ - id: "test-product", - variants: expect.arrayContaining([ - expect.objectContaining({ - id: "test-variant", - prices: [ - expect.objectContaining({ - amount: 100, - currency_code: "usd", - }), - expect.objectContaining({ - amount: 4500, - currency_code: "eur", - }), - ], - }), - ]), - }), - }) + expect(response.data).toEqual( + expect.objectContaining({ + product: expect.objectContaining({ + id: "test-product", + variants: expect.arrayContaining([ + expect.objectContaining({ + id: "test-variant", + prices: expect.arrayContaining([ + expect.objectContaining({ + amount: 100, + currency_code: "usd", + }), + expect.objectContaining({ + amount: 4500, + currency_code: "eur", + }), + expect.objectContaining({ + id: "ma_test_1", + amount: 100, + currency_code: "usd", + min_quantity: 1, + max_quantity: 100, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + }), + expect.objectContaining({ + id: "ma_test_2", + amount: 80, + currency_code: "usd", + min_quantity: 101, + max_quantity: 500, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + }), + expect.objectContaining({ + id: "ma_test_3", + amount: 50, + currency_code: "usd", + min_quantity: 501, + max_quantity: 1000, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + }), + ]), + }), + ]), + }), + }) + ) }) it("successfully updates a variant's prices by replacing a price", async () => { @@ -1343,7 +1529,7 @@ describe("/admin/products", () => { const data = { prices: [ { - currency_code: "eur", + currency_code: "usd", amount: 4500, }, ], @@ -1362,14 +1548,43 @@ describe("/admin/products", () => { expect(response.status).toEqual(200) expect(response.data.product.variants[0].prices.length).toEqual( - data.prices.length + 4 // 3 prices from Price List + 1 default price + ) + expect(response.data.product.variants[0].prices).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + amount: 4500, + currency_code: "usd", + }), + expect.objectContaining({ + id: "ma_test_1", + amount: 100, + currency_code: "usd", + min_quantity: 1, + max_quantity: 100, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + }), + expect.objectContaining({ + id: "ma_test_2", + amount: 80, + currency_code: "usd", + min_quantity: 101, + max_quantity: 500, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + }), + expect.objectContaining({ + id: "ma_test_3", + amount: 50, + currency_code: "usd", + min_quantity: 501, + max_quantity: 1000, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + }), + ]) ) - expect(response.data.product.variants[0].prices).toEqual([ - expect.objectContaining({ - amount: 4500, - currency_code: "eur", - }), - ]) }) it("successfully updates a variant's prices by deleting a price and adding another price", async () => { @@ -1400,18 +1615,47 @@ describe("/admin/products", () => { expect(response.status).toEqual(200) expect(response.data.product.variants[0].prices.length).toEqual( - data.prices.length + 5 // 2 default prices + 3 prices from Price List + ) + expect(response.data.product.variants[0].prices).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + amount: 8000, + currency_code: "dkk", + }), + expect.objectContaining({ + amount: 900, + currency_code: "eur", + }), + expect.objectContaining({ + id: "ma_test_1", + amount: 100, + currency_code: "usd", + min_quantity: 1, + max_quantity: 100, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + }), + expect.objectContaining({ + id: "ma_test_2", + amount: 80, + currency_code: "usd", + min_quantity: 101, + max_quantity: 500, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + }), + expect.objectContaining({ + id: "ma_test_3", + amount: 50, + currency_code: "usd", + min_quantity: 501, + max_quantity: 1000, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + }), + ]) ) - expect(response.data.product.variants[0].prices).toEqual([ - expect.objectContaining({ - amount: 8000, - currency_code: "dkk", - }), - expect.objectContaining({ - amount: 900, - currency_code: "eur", - }), - ]) }) it("successfully updates a variant's prices by updating an existing price (using region_id) and adding another price", async () => { diff --git a/integration-tests/api/__tests__/admin/store.js b/integration-tests/api/__tests__/admin/store.js index fca3c7a244..1e8d558d16 100644 --- a/integration-tests/api/__tests__/admin/store.js +++ b/integration-tests/api/__tests__/admin/store.js @@ -32,8 +32,8 @@ describe("/admin/store", () => { afterEach(async () => { const db = useDb() - db.teardown() - medusaProcess.kill() + await db.teardown() + await medusaProcess.kill() }) it("has created store with default currency", async () => { diff --git a/integration-tests/api/__tests__/store/__snapshots__/product-variants.js.snap b/integration-tests/api/__tests__/store/__snapshots__/product-variants.js.snap index 8ae969e513..d8417366b6 100644 --- a/integration-tests/api/__tests__/store/__snapshots__/product-variants.js.snap +++ b/integration-tests/api/__tests__/store/__snapshots__/product-variants.js.snap @@ -37,8 +37,10 @@ Object { "currency_code": "usd", "deleted_at": null, "id": "test-price", + "max_quantity": null, + "min_quantity": null, + "price_list_id": null, "region_id": null, - "sale_amount": null, "updated_at": Any, "variant_id": "test-variant", }, @@ -93,8 +95,10 @@ Object { "currency_code": "usd", "deleted_at": null, "id": "test-price", + "max_quantity": null, + "min_quantity": null, + "price_list_id": null, "region_id": null, - "sale_amount": null, "updated_at": Any, "variant_id": "test-variant", }, @@ -150,8 +154,10 @@ Object { "currency_code": "usd", "deleted_at": null, "id": Any, + "max_quantity": null, + "min_quantity": null, + "price_list_id": null, "region_id": null, - "sale_amount": null, "updated_at": Any, "variant_id": Any, }, diff --git a/integration-tests/api/__tests__/store/__snapshots__/products.js.snap b/integration-tests/api/__tests__/store/__snapshots__/products.js.snap index 300a0ebee5..9037ba8821 100644 --- a/integration-tests/api/__tests__/store/__snapshots__/products.js.snap +++ b/integration-tests/api/__tests__/store/__snapshots__/products.js.snap @@ -162,8 +162,10 @@ Object { "currency_code": "usd", "deleted_at": null, "id": "test-price", + "max_quantity": null, + "min_quantity": null, + "price_list_id": null, "region_id": null, - "sale_amount": null, "updated_at": Any, "variant_id": "test-variant", }, @@ -211,8 +213,10 @@ Object { "currency_code": "usd", "deleted_at": null, "id": "test-price2", + "max_quantity": null, + "min_quantity": null, + "price_list_id": null, "region_id": null, - "sale_amount": null, "updated_at": Any, "variant_id": "test-variant_2", }, @@ -260,8 +264,10 @@ Object { "currency_code": "usd", "deleted_at": null, "id": "test-price1", + "max_quantity": null, + "min_quantity": null, + "price_list_id": null, "region_id": null, - "sale_amount": null, "updated_at": Any, "variant_id": "test-variant_1", }, @@ -314,8 +320,10 @@ Object { "currency_code": "usd", "deleted_at": null, "id": Any, + "max_quantity": null, + "min_quantity": null, + "price_list_id": null, "region_id": null, - "sale_amount": null, "updated_at": Any, "variant_id": Any, }, diff --git a/integration-tests/api/__tests__/store/product-variants.js b/integration-tests/api/__tests__/store/product-variants.js index 22afde0fff..5534c2dc7c 100644 --- a/integration-tests/api/__tests__/store/product-variants.js +++ b/integration-tests/api/__tests__/store/product-variants.js @@ -99,7 +99,9 @@ describe("/store/variants", () => { deleted_at: null, id: "test-price", region_id: null, - sale_amount: null, + min_quantity: null, + max_quantity: null, + price_list_id: null, variant_id: "test-variant", }, ], @@ -193,7 +195,9 @@ describe("/store/variants", () => { deleted_at: null, id: "test-price", region_id: null, - sale_amount: null, + min_quantity: null, + max_quantity: null, + price_list_id: null, variant_id: "test-variant", }, ], diff --git a/integration-tests/api/__tests__/store/products.js b/integration-tests/api/__tests__/store/products.js index 50dd847917..32a285713b 100644 --- a/integration-tests/api/__tests__/store/products.js +++ b/integration-tests/api/__tests__/store/products.js @@ -353,7 +353,9 @@ describe("/store/products", () => { deleted_at: null, id: "test-price", region_id: null, - sale_amount: null, + min_quantity: null, + max_quantity: null, + price_list_id: null, updated_at: expect.any(String), variant_id: "test-variant", }, @@ -396,7 +398,9 @@ describe("/store/products", () => { deleted_at: null, id: "test-price2", region_id: null, - sale_amount: null, + min_quantity: null, + max_quantity: null, + price_list_id: null, variant_id: "test-variant_2", }, ], @@ -438,7 +442,9 @@ describe("/store/products", () => { deleted_at: null, id: "test-price1", region_id: null, - sale_amount: null, + min_quantity: null, + max_quantity: null, + price_list_id: null, updated_at: expect.any(String), variant_id: "test-variant_1", }, diff --git a/integration-tests/api/helpers/customer-seeder.js b/integration-tests/api/helpers/customer-seeder.js index 2878bdfc49..f131bca512 100644 --- a/integration-tests/api/helpers/customer-seeder.js +++ b/integration-tests/api/helpers/customer-seeder.js @@ -36,17 +36,17 @@ module.exports = async (connection, data = {}) => { has_account: true, }) - const customer5 = manager.create(Customer, { + const customer5 = await manager.create(Customer, { id: "test-customer-5", email: "test5@email.com", }) - const customer6 = manager.create(Customer, { + const customer6 = await manager.create(Customer, { id: "test-customer-6", email: "test6@email.com", }) - const customer7 = manager.create(Customer, { + const customer7 = await manager.create(Customer, { id: "test-customer-7", email: "test7@email.com", }) @@ -78,13 +78,13 @@ module.exports = async (connection, data = {}) => { name: "test-group-4", }) - const c_group_5 = manager.create(CustomerGroup, { + const c_group_5 = await manager.create(CustomerGroup, { id: "test-group-5", name: "test-group-5", }) await manager.save(c_group_5) - const c_group_6 = manager.create(CustomerGroup, { + const c_group_6 = await manager.create(CustomerGroup, { id: "test-group-6", name: "test-group-6", }) @@ -99,7 +99,7 @@ module.exports = async (connection, data = {}) => { customer7.groups = [c_group_5, c_group_6] await manager.save(customer7) - const c_group_delete = manager.create(CustomerGroup, { + const c_group_delete = await manager.create(CustomerGroup, { id: "test-group-delete", name: "test-group-delete", }) diff --git a/integration-tests/api/helpers/price-list-seeder.js b/integration-tests/api/helpers/price-list-seeder.js new file mode 100644 index 0000000000..984db87a88 --- /dev/null +++ b/integration-tests/api/helpers/price-list-seeder.js @@ -0,0 +1,53 @@ +const { PriceList, MoneyAmount } = require("@medusajs/medusa") + +module.exports = async (connection, data = {}) => { + const manager = connection.manager + + const priceListNoCustomerGroups = await manager.create(PriceList, { + id: "pl_no_customer_groups", + name: "VIP winter sale", + description: "Winter sale for VIP customers. 25% off selected items.", + type: "sale", + status: "active", + starts_at: "2022-07-01T00:00:00.000Z", + ends_at: "2022-07-31T00:00:00.000Z", + }) + + await manager.save(priceListNoCustomerGroups) + + const moneyAmount1 = await manager.create(MoneyAmount, { + id: "ma_test_1", + amount: 100, + currency_code: "usd", + min_quantity: 1, + max_quantity: 100, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + }) + + await manager.save(moneyAmount1) + + const moneyAmount2 = await manager.create(MoneyAmount, { + id: "ma_test_2", + amount: 80, + currency_code: "usd", + min_quantity: 101, + max_quantity: 500, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + }) + + await manager.save(moneyAmount2) + + const moneyAmount3 = await manager.create(MoneyAmount, { + id: "ma_test_3", + amount: 50, + currency_code: "usd", + min_quantity: 501, + max_quantity: 1000, + variant_id: "test-variant", + price_list_id: "pl_no_customer_groups", + }) + + await manager.save(moneyAmount3) +} diff --git a/integration-tests/api/package.json b/integration-tests/api/package.json index b87084cc94..4b0b8d3704 100644 --- a/integration-tests/api/package.json +++ b/integration-tests/api/package.json @@ -8,16 +8,16 @@ "build": "babel src -d dist --extensions \".ts,.js\"" }, "dependencies": { - "@medusajs/medusa": "1.2.0-dev-1646213107704", + "@medusajs/medusa": "1.2.0-dev-1647336201011", "faker": "^5.5.3", - "medusa-interfaces": "1.2.0-dev-1646213107704", + "medusa-interfaces": "1.2.0-dev-1647336201011", "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.19-dev-1646213107704", + "babel-preset-medusa-package": "1.1.19-dev-1647336201011", "jest": "^26.6.3" } } diff --git a/integration-tests/api/yarn.lock b/integration-tests/api/yarn.lock index 5262e28718..e72fa6dcf6 100644 --- a/integration-tests/api/yarn.lock +++ b/integration-tests/api/yarn.lock @@ -1256,10 +1256,10 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" -"@medusajs/medusa-cli@1.2.0-dev-1646213107704": - version "1.2.0-dev-1646213107704" - resolved "http://localhost:4873/@medusajs%2fmedusa-cli/-/medusa-cli-1.2.0-dev-1646213107704.tgz#b84f9143450f3c03e732277b6c5bbeb0df761b2e" - integrity sha512-U1BqPe167vxpzf2YfopVpNTq0qcBkX3jkWFCNduHEjkBbuOXchlB/MheKtxIsxjHV1hzmjTr9uw3owu93pDYCQ== +"@medusajs/medusa-cli@1.2.0-dev-1647336201011": + version "1.2.0-dev-1647336201011" + resolved "http://localhost:4873/@medusajs%2fmedusa-cli/-/medusa-cli-1.2.0-dev-1647336201011.tgz#9842f8d2f53d5a7d2207ec23d7d4b18a33e65c54" + integrity sha512-wVqcWMh0+8ko/Db66GDrp68XpaVkvPrjrc4mn7KdfD9DnIgFCOoFakuYsQUxHDNak9EA96idZSF8/hsHALlOqg== dependencies: "@babel/polyfill" "^7.8.7" "@babel/runtime" "^7.9.6" @@ -1277,8 +1277,8 @@ is-valid-path "^0.1.1" joi-objectid "^3.0.1" meant "^1.0.1" - medusa-core-utils "1.1.31-dev-1646213107704" - medusa-telemetry "0.0.11-dev-1646213107704" + medusa-core-utils "1.1.31-dev-1647336201011" + medusa-telemetry "0.0.11-dev-1647336201011" netrc-parser "^3.1.6" open "^8.0.6" ora "^5.4.1" @@ -1292,13 +1292,13 @@ winston "^3.3.3" yargs "^15.3.1" -"@medusajs/medusa@1.2.0-dev-1646213107704": - version "1.2.0-dev-1646213107704" - resolved "http://localhost:4873/@medusajs%2fmedusa/-/medusa-1.2.0-dev-1646213107704.tgz#40ed0efe4f8e242c620eb1a984f0904465576920" - integrity sha512-pZnoM/U6WF4IdKriX7lKaxlmt9nlK4WoQ4PtzLjvZemjNHKriLjsg8ugvuBOuuzZJe66O8XEAXcoPh2o/SmAeg== +"@medusajs/medusa@1.2.0-dev-1647336201011": + version "1.2.0-dev-1647336201011" + resolved "http://localhost:4873/@medusajs%2fmedusa/-/medusa-1.2.0-dev-1647336201011.tgz#76c0c3b93adf55b463ec091d2e144616dc1293bf" + integrity sha512-t0eWo60Y5KsyN4ucWDjPyuI+4oOHrwqlrzegg4oogGiAfvq2rLDQFwxrfvzKlhgYtJUA58dvxMpZqG5n1NOJLQ== dependencies: "@hapi/joi" "^16.1.8" - "@medusajs/medusa-cli" "1.2.0-dev-1646213107704" + "@medusajs/medusa-cli" "1.2.0-dev-1647336201011" "@types/lodash" "^4.14.168" awilix "^4.2.3" body-parser "^1.19.0" @@ -1322,8 +1322,8 @@ joi "^17.3.0" joi-objectid "^3.0.1" jsonwebtoken "^8.5.1" - medusa-core-utils "1.1.31-dev-1646213107704" - medusa-test-utils "1.1.37-dev-1646213107704" + medusa-core-utils "1.1.31-dev-1647336201011" + medusa-test-utils "1.1.37-dev-1647336201011" morgan "^1.9.1" multer "^1.4.2" passport "^0.4.0" @@ -1947,10 +1947,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.19-dev-1646213107704: - version "1.1.19-dev-1646213107704" - resolved "http://localhost:4873/babel-preset-medusa-package/-/babel-preset-medusa-package-1.1.19-dev-1646213107704.tgz#820201a6abfbdb95af623f1cae8981bb5ccddb8d" - integrity sha512-gTkFWvA/i+UmPSK8O0J4HBXE1tqQSD/D2p3ernvcvoaaXYhiLhNUrRRe9F8YXdkFT4tVXy2DKz/eQyUG1dsnnA== +babel-preset-medusa-package@1.1.19-dev-1647336201011: + version "1.1.19-dev-1647336201011" + resolved "http://localhost:4873/babel-preset-medusa-package/-/babel-preset-medusa-package-1.1.19-dev-1647336201011.tgz#52a3e7ad80f216cdcae3732c22d9edb3d2504a92" + integrity sha512-oYbWdvcXHvGc14p6N4vyGjPK0gs/zO4gVMahrDHPcMGfmGOajkVBMnuMfiEzOaDHaiBchIsWCv9LYLFLcv8JjA== dependencies: "@babel/plugin-proposal-class-properties" "^7.12.1" "@babel/plugin-proposal-decorators" "^7.12.1" @@ -5140,25 +5140,25 @@ 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.31-dev-1646213107704: - version "1.1.31-dev-1646213107704" - resolved "http://localhost:4873/medusa-core-utils/-/medusa-core-utils-1.1.31-dev-1646213107704.tgz#40ca7c1a4b290b1af9cf855cfc3320c23bbf3c53" - integrity sha512-P3D/jVSnx0YYEU8hTVqEow4Xht/BAHnHyc/HSbU5YT7PtCwEd+MY7+bvSAkhbVXNDCVsxyGTDA2nuofPS/c4RQ== +medusa-core-utils@1.1.31-dev-1647336201011: + version "1.1.31-dev-1647336201011" + resolved "http://localhost:4873/medusa-core-utils/-/medusa-core-utils-1.1.31-dev-1647336201011.tgz#6b6d9c9708d8377d1bddc8ef9d26723b550312c9" + integrity sha512-/qYyS0vE5lqHhNZfhNM2iTE5Sh8qYockVyHn2dapolBsmYE9f7IjhOsB3bz2o0b1J33L3se6iG0wSCQeN3INXA== dependencies: joi "^17.3.0" joi-objectid "^3.0.1" -medusa-interfaces@1.2.0-dev-1646213107704: - version "1.2.0-dev-1646213107704" - resolved "http://localhost:4873/medusa-interfaces/-/medusa-interfaces-1.2.0-dev-1646213107704.tgz#1a5310484afb5c1d127ba5775e08e3c0502e1488" - integrity sha512-3+Ve8f5a+dvdcRafOzAuisXk/vwY9fBXgshof8xYFgtDso2INwMHTNIEgUQrjMd8yXt2+EO8l9wlBeAr02eQ0A== +medusa-interfaces@1.2.0-dev-1647336201011: + version "1.2.0-dev-1647336201011" + resolved "http://localhost:4873/medusa-interfaces/-/medusa-interfaces-1.2.0-dev-1647336201011.tgz#f6a25ef007c8ffe2828b4e2f64f23bd60929f4f5" + integrity sha512-8PvQpLfwB0YGTQU0qh/miDhJaOczoyCFs1bvlMbe3cOsn7ua7UKeUj+TLQQwh6ykbezjtggUgafEH+fKOmLUWg== dependencies: - medusa-core-utils "1.1.31-dev-1646213107704" + medusa-core-utils "1.1.31-dev-1647336201011" -medusa-telemetry@0.0.11-dev-1646213107704: - version "0.0.11-dev-1646213107704" - resolved "http://localhost:4873/medusa-telemetry/-/medusa-telemetry-0.0.11-dev-1646213107704.tgz#7dc191fd263774a31795c2696ffe015a2b5f7443" - integrity sha512-BLKdzaU1sE9Eqnt3lmE6wkocOuC7wMT0l2OWc0aW6YTRRWDOffxOuANku1bZCIdVtbJ1q25UpeY2dwuD7lXhZw== +medusa-telemetry@0.0.11-dev-1647336201011: + version "0.0.11-dev-1647336201011" + resolved "http://localhost:4873/medusa-telemetry/-/medusa-telemetry-0.0.11-dev-1647336201011.tgz#d8a33d114a5bd4e6d2bdef112a07eed96a5b2f8d" + integrity sha512-am/sacr0XhCyc2OT6w8QCLuI7AXioJvaXxPzB4P1n5OiEGOn3IJIHZJY22MQzdY/dJZ7Rb4XuFlXdZInbc4woA== dependencies: axios "^0.21.1" axios-retry "^3.1.9" @@ -5170,13 +5170,13 @@ medusa-telemetry@0.0.11-dev-1646213107704: remove-trailing-slash "^0.1.1" uuid "^8.3.2" -medusa-test-utils@1.1.37-dev-1646213107704: - version "1.1.37-dev-1646213107704" - resolved "http://localhost:4873/medusa-test-utils/-/medusa-test-utils-1.1.37-dev-1646213107704.tgz#5fdb8298ac482f1d89475115d7727dd5227ac866" - integrity sha512-S42x4NaioWXPwQluO+iOYDwV0LUmr7q58XClFJA8j7Nw07IFKJU2PPHAAPrbpWQKpa2pdCBYdR0FUID4/VOfXw== +medusa-test-utils@1.1.37-dev-1647336201011: + version "1.1.37-dev-1647336201011" + resolved "http://localhost:4873/medusa-test-utils/-/medusa-test-utils-1.1.37-dev-1647336201011.tgz#6a1cb877abf79210db0065095b1baa0427eb93d1" + integrity sha512-K6J7FlUm3q3jL94Ye01gpk3rcan9JEH1SDRoUm1zqZ0XAX3tl49R7VQtHO7kWcO05o2JUJt8/+LvNv6suth4ow== dependencies: "@babel/plugin-transform-classes" "^7.9.5" - medusa-core-utils "1.1.31-dev-1646213107704" + medusa-core-utils "1.1.31-dev-1647336201011" 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 b5e4107b7c..bdc4cd808d 100644 --- a/packages/medusa/src/api/routes/admin/index.js +++ b/packages/medusa/src/api/routes/admin/index.js @@ -1,33 +1,33 @@ -import { Router } from "express" import cors from "cors" - +import { Router } from "express" import middlewares from "../../middlewares" +import appRoutes from "./apps" import authRoutes from "./auth" -import productRoutes from "./products" -import userRoutes, { unauthenticatedUserRoutes } from "./users" +import collectionRoutes from "./collections" +import customerGroupRoutes from "./customer-groups" +import customerRoutes from "./customers" +import discountRoutes from "./discounts" +import draftOrderRoutes from "./draft-orders" +import giftCardRoutes from "./gift-cards" import inviteRoutes, { unauthenticatedInviteRoutes } from "./invites" +import noteRoutes from "./notes" +import notificationRoutes from "./notifications" +import orderRoutes from "./orders" +import priceListRoutes from "./price-lists" +import productTagRoutes from "./product-tags" +import productTypesRoutes from "./product-types" +import productRoutes from "./products" import regionRoutes from "./regions" +import returnReasonRoutes from "./return-reasons" +import returnRoutes from "./returns" import shippingOptionRoutes from "./shipping-options" 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" -import appRoutes from "./apps" import swapRoutes from "./swaps" -import returnRoutes from "./returns" -import variantRoutes from "./variants" -import draftOrderRoutes from "./draft-orders" -import collectionRoutes from "./collections" -import productTagRoutes from "./product-tags" -import notificationRoutes from "./notifications" -import noteRoutes from "./notes" import taxRateRoutes from "./tax-rates" -import productTypesRoutes from "./product-types" -import customerGroupRoutes from "./customer-groups" +import uploadRoutes from "./uploads" +import userRoutes, { unauthenticatedUserRoutes } from "./users" +import variantRoutes from "./variants" const route = Router() @@ -86,6 +86,7 @@ export default (app, container, config) => { inviteRoutes(route) taxRateRoutes(route) customerGroupRoutes(route) + priceListRoutes(route) return app } diff --git a/packages/medusa/src/api/routes/admin/price-lists/__tests__/add-prices-batch.ts b/packages/medusa/src/api/routes/admin/price-lists/__tests__/add-prices-batch.ts new file mode 100644 index 0000000000..a24d133b56 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/price-lists/__tests__/add-prices-batch.ts @@ -0,0 +1,105 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { PriceListServiceMock } from "../../../../../services/__mocks__/price-list" + +describe("POST /price-lists/:id/prices/batch", () => { + describe("successfully adds several new prices to a price list", () => { + let subject + + beforeAll(async () => { + subject = await request( + "POST", + `/admin/price-lists/pl_1234/prices/batch`, + { + payload: { + prices: [ + { + currency_code: "usd", + amount: 500, + min_quantity: 10, + max_quantity: 20, + variant_id: "variant_12", + }, + { + currency_code: "usd", + amount: 430, + min_quantity: 21, + max_quantity: 40, + variant_id: "variant_12", + }, + ], + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls PriceListService addPrices", () => { + expect(PriceListServiceMock.addPrices).toHaveBeenCalledTimes(1) + expect(PriceListServiceMock.addPrices).toHaveBeenCalledWith( + "pl_1234", + [ + { + currency_code: "usd", + amount: 500, + min_quantity: 10, + max_quantity: 20, + variant_id: "variant_12", + }, + { + currency_code: "usd", + amount: 430, + min_quantity: 21, + max_quantity: 40, + variant_id: "variant_12", + }, + ], + undefined + ) + }) + }) + + describe("fails if no prices were provided", () => { + let subject + + beforeAll(async () => { + subject = await request( + "POST", + `/admin/price-lists/pl_1234/prices/batch`, + { + payload: {}, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("returns 400", () => { + expect(subject.status).toEqual(400) + }) + + it("returns descriptive error that name is missing", () => { + expect(subject.body.type).toEqual("invalid_data") + expect(subject.body.message).toEqual("prices must be an array") + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/price-lists/__tests__/create-price-list.ts b/packages/medusa/src/api/routes/admin/price-lists/__tests__/create-price-list.ts new file mode 100644 index 0000000000..87c07b7703 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/price-lists/__tests__/create-price-list.ts @@ -0,0 +1,119 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { PriceListServiceMock } from "../../../../../services/__mocks__/price-list" + +describe("POST /price-lists", () => { + describe("successfully creates a price list", () => { + let subject + + beforeAll(async () => { + subject = await request("POST", `/admin/price-lists`, { + payload: { + name: "My Price List", + description: "testing", + ends_at: "2022-03-14T08:28:38.551Z", + starts_at: "2022-03-14T08:28:38.551Z", + customer_groups: [ + { + id: "gc_123", + }, + ], + type: "sale", + prices: [ + { + currency_code: "usd", + amount: 500, + min_quantity: 10, + max_quantity: 20, + variant_id: "variant_12", + }, + { + currency_code: "usd", + amount: 430, + min_quantity: 21, + max_quantity: 40, + variant_id: "variant_12", + }, + ], + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls PriceListService addPrices", () => { + expect(PriceListServiceMock.create).toHaveBeenCalledTimes(1) + expect(PriceListServiceMock.create).toHaveBeenCalledWith({ + name: "My Price List", + description: "testing", + ends_at: "2022-03-14T08:28:38.551Z", + starts_at: "2022-03-14T08:28:38.551Z", + customer_groups: [ + { + id: "gc_123", + }, + ], + type: "sale", + prices: [ + { + currency_code: "usd", + amount: 500, + min_quantity: 10, + max_quantity: 20, + variant_id: "variant_12", + }, + { + currency_code: "usd", + amount: 430, + min_quantity: 21, + max_quantity: 40, + variant_id: "variant_12", + }, + ], + }) + }) + }) + + describe("fails if required fields are missing", () => { + let subject + + beforeAll(async () => { + subject = await request("POST", `/admin/price-lists`, { + payload: { + description: "bad payload", + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("returns 400", () => { + expect(subject.status).toEqual(400) + }) + + it("returns descriptive error that several fields are missing", () => { + expect(subject.body.type).toEqual("invalid_data") + expect(subject.body.message).toEqual( + "name must be a string, type must be a valid enum value, prices must be an array" + ) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/price-lists/__tests__/delete-price-list.ts b/packages/medusa/src/api/routes/admin/price-lists/__tests__/delete-price-list.ts new file mode 100644 index 0000000000..f78ed99c48 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/price-lists/__tests__/delete-price-list.ts @@ -0,0 +1,38 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { PriceListServiceMock } from "../../../../../services/__mocks__/price-list" + +describe("POST /price-lists/:id/prices/batch", () => { + describe("successfully adds several new prices to a price list", () => { + let subject + + beforeAll(async () => { + subject = await request("DELETE", `/admin/price-lists/pl_1234`, { + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls PriceListService addPrices", () => { + expect(PriceListServiceMock.delete).toHaveBeenCalledTimes(1) + expect(PriceListServiceMock.delete).toHaveBeenCalledWith("pl_1234") + + expect(subject.body).toEqual({ + id: "pl_1234", + object: "price-list", + deleted: true, + }) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/price-lists/__tests__/delete-prices-batch.ts b/packages/medusa/src/api/routes/admin/price-lists/__tests__/delete-prices-batch.ts new file mode 100644 index 0000000000..da8c330bbd --- /dev/null +++ b/packages/medusa/src/api/routes/admin/price-lists/__tests__/delete-prices-batch.ts @@ -0,0 +1,76 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { PriceListServiceMock } from "../../../../../services/__mocks__/price-list" + +describe("DELETE /price-lists/:id/prices/batch", () => { + describe("successfully adds several new prices to a price list", () => { + let subject + + beforeAll(async () => { + subject = await request( + "DELETE", + `/admin/price-lists/pl_1234/prices/batch`, + { + payload: { + price_ids: ["price_1234", "price_1235", "price_1236", "price_1237"], + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls PriceListService addPrices", () => { + expect(PriceListServiceMock.deletePrices).toHaveBeenCalledTimes(1) + expect(PriceListServiceMock.deletePrices).toHaveBeenCalledWith( + "pl_1234", + ["price_1234", "price_1235", "price_1236", "price_1237"] + ) + }) + }) + + describe("fails if no prices were provided", () => { + let subject + + beforeAll(async () => { + subject = await request( + "DELETE", + `/admin/price-lists/pl_1234/prices/batch`, + { + payload: {}, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("returns 400", () => { + expect(subject.status).toEqual(400) + }) + + it("returns descriptive error that price_ids is missing", () => { + expect(subject.body.type).toEqual("invalid_data") + expect(subject.body.message).toEqual( + "each value in price_ids must be a string, price_ids should not be empty" + ) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/price-lists/__tests__/get-price-list.ts b/packages/medusa/src/api/routes/admin/price-lists/__tests__/get-price-list.ts new file mode 100644 index 0000000000..44cd274120 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/price-lists/__tests__/get-price-list.ts @@ -0,0 +1,36 @@ +import { IdMap } from "medusa-test-utils" +import { defaultAdminPriceListFields, defaultAdminPriceListRelations } from ".." +import { request } from "../../../../../helpers/test-request" +import { PriceListServiceMock } from "../../../../../services/__mocks__/price-list" + +describe("GET /price-lists/:id", () => { + describe("", () => { + let subject + + beforeAll(async () => { + subject = await request("GET", `/admin/price-lists/pl_1234`, { + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls PriceListService retrieve", () => { + expect(PriceListServiceMock.retrieve).toHaveBeenCalledTimes(1) + expect(PriceListServiceMock.retrieve).toHaveBeenCalledWith("pl_1234", { + relations: defaultAdminPriceListRelations, + select: defaultAdminPriceListFields, + }) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/price-lists/__tests__/list-price-lists.ts b/packages/medusa/src/api/routes/admin/price-lists/__tests__/list-price-lists.ts new file mode 100644 index 0000000000..2cffd14636 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/price-lists/__tests__/list-price-lists.ts @@ -0,0 +1,45 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { PriceListServiceMock } from "../../../../../services/__mocks__/price-list" + +describe("GET /price-lists", () => { + describe("successfully lists price lists", () => { + let subject + + beforeAll(async () => { + subject = await request("GET", `/admin/price-lists`, { + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls PriceListService listAndCount", () => { + expect(PriceListServiceMock.listAndCount).toHaveBeenCalledTimes(1) + expect(PriceListServiceMock.listAndCount).toHaveBeenCalledWith( + { + created_at: undefined, + deleted_at: undefined, + description: undefined, + id: undefined, + name: undefined, + q: undefined, + status: undefined, + type: undefined, + updated_at: undefined, + }, + { order: { created_at: "DESC" }, relations: [], skip: 0, take: 10 } + ) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/price-lists/__tests__/update-price-list.ts b/packages/medusa/src/api/routes/admin/price-lists/__tests__/update-price-list.ts new file mode 100644 index 0000000000..3d43fd4211 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/price-lists/__tests__/update-price-list.ts @@ -0,0 +1,37 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { PriceListServiceMock } from "../../../../../services/__mocks__/price-list" + +describe("POST /price-lists/:id", () => { + describe("successfully updates a price list", () => { + let subject + + beforeAll(async () => { + subject = await request("POST", `/admin/price-lists/pl_1234`, { + payload: { + description: "new description", + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls PriceListService update", () => { + expect(PriceListServiceMock.update).toHaveBeenCalledTimes(1) + expect(PriceListServiceMock.update).toHaveBeenCalledWith("pl_1234", { + description: "new description", + }) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/price-lists/add-prices-batch.ts b/packages/medusa/src/api/routes/admin/price-lists/add-prices-batch.ts new file mode 100644 index 0000000000..02863f1c40 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/price-lists/add-prices-batch.ts @@ -0,0 +1,99 @@ +import { Type } from "class-transformer" +import { IsArray, IsBoolean, IsOptional, ValidateNested } from "class-validator" +import { defaultAdminPriceListFields, defaultAdminPriceListRelations } from "." +import { PriceList } from "../../../.." +import PriceListService from "../../../../services/price-list" +import { AdminPriceListPricesUpdateReq } from "../../../../types/price-list" +import { validator } from "../../../../utils/validator" + +/** + * @oas [post] /price-lists/{id}/prices/batch + * operationId: "PostPriceListsPriceListPricesBatch" + * summary: "Batch update prices for a Price List" + * description: "Batch update prices for a Price List" + * x-authenticated: true + * parameters: + * - (path) id=* {string} The id of the Price List to update prices for. + * requestBody: + * content: + * application/json: + * schema: + * properties: + * prices: + * description: The prices to update or add. + * type: array + * items: + * properties: + * id: + * description: The id of the price. + * type: string + * status: + * description: The status of the Price List. + * type: string + * enum: + * - active + * - draft + * region_id: + * description: The id of the Region for which the price is used. + * type: string + * currency_code: + * description: The 3 character ISO currency code for which the price will be used. + * type: string + * amount: + * description: The amount of the price. + * type: number + * min_quantity: + * description: The minimum quantity for which the price will be used. + * type: number + * max_quantity: + * description: The maximum quantity for which the price will be used. + * type: number + * override: + * description: "If true the prices will replace all existing prices associated with the Price List." + * type: boolean + * tags: + * - Price List + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * id: + * type: string + * description: The id of the deleted Price List. + * object: + * type: string + * description: The type of the object that was deleted. + * deleted: + * type: boolean + */ +export default async (req, res) => { + const { id } = req.params + + const validated = await validator(AdminPostPriceListPricesPricesReq, req.body) + + const priceListService: PriceListService = + req.scope.resolve("priceListService") + + await priceListService.addPrices(id, validated.prices, validated.override) + + const priceList = await priceListService.retrieve(id, { + select: defaultAdminPriceListFields as (keyof PriceList)[], + relations: defaultAdminPriceListRelations, + }) + + res.json({ price_list: priceList }) +} + +export class AdminPostPriceListPricesPricesReq { + @IsArray() + @Type(() => AdminPriceListPricesUpdateReq) + @ValidateNested({ each: true }) + prices: AdminPriceListPricesUpdateReq[] + + @IsOptional() + @IsBoolean() + override?: boolean +} diff --git a/packages/medusa/src/api/routes/admin/price-lists/create-price-list.ts b/packages/medusa/src/api/routes/admin/price-lists/create-price-list.ts new file mode 100644 index 0000000000..5372b3c81c --- /dev/null +++ b/packages/medusa/src/api/routes/admin/price-lists/create-price-list.ts @@ -0,0 +1,134 @@ +import { Type } from "class-transformer" +import { + IsArray, + IsEnum, + IsOptional, + IsString, + ValidateNested, +} from "class-validator" +import PriceListService from "../../../../services/price-list" +import { + AdminPriceListPricesCreateReq, + PriceListStatus, + PriceListType, +} from "../../../../types/price-list" +import { validator } from "../../../../utils/validator" + +/** + * @oas [post] /price_lists + * operationId: "PostPriceListsPriceList" + * summary: "Creates a Price List" + * description: "Creates a Price List" + * x-authenticated: true + * requestBody: + * content: + * application/json: + * schema: + * properties: + * name: + * description: "The name of the Price List" + * type: string + * description: + * description: "A description of the Price List." + * type: string + * type: + * description: The type of the Price List. + * type: string + * enum: + * - sale + * - override + * status: + * description: The status of the Price List. + * type: string + * enum: + * - active + * - draft + * prices: + * description: The prices of the Price List. + * type: array + * items: + * properties: + * region_id: + * description: The id of the Region for which the price is used. + * type: string + * currency_code: + * description: The 3 character ISO currency code for which the price will be used. + * type: string + * amount: + * description: The amount to charge for the Product Variant. + * type: integer + * min_quantity: + * description: The minimum quantity for which the price will be used. + * type: integer + * max_quantity: + * description: The maximum quantity for which the price will be used. + * type: integer + * customer_groups: + * type: array + * description: A list of customer groups that the Price List applies to. + * items: + * required: + * - id + * properties: + * id: + * description: The id of a customer group + * type: string + * tags: + * - Price List + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * product: + * $ref: "#/components/schemas/price_list" + */ +export default async (req, res) => { + const validated = await validator(AdminPostPriceListsPriceListReq, req.body) + + const priceListService: PriceListService = + req.scope.resolve("priceListService") + + const priceList = await priceListService.create(validated) + + res.json({ price_list: priceList }) +} + +class CustomerGroup { + @IsString() + id: string +} + +export class AdminPostPriceListsPriceListReq { + @IsString() + name: string + + @IsString() + description: string + + @IsOptional() + starts_at?: Date + + @IsOptional() + ends_at?: Date + + @IsOptional() + @IsEnum(PriceListStatus) + status?: PriceListStatus + + @IsEnum(PriceListType) + type: PriceListType + + @IsArray() + @Type(() => AdminPriceListPricesCreateReq) + @ValidateNested({ each: true }) + prices: AdminPriceListPricesCreateReq[] + + @IsOptional() + @IsArray() + @Type(() => CustomerGroup) + @ValidateNested({ each: true }) + customer_groups?: CustomerGroup[] +} diff --git a/packages/medusa/src/api/routes/admin/price-lists/delete-price-list.ts b/packages/medusa/src/api/routes/admin/price-lists/delete-price-list.ts new file mode 100644 index 0000000000..3da319d55c --- /dev/null +++ b/packages/medusa/src/api/routes/admin/price-lists/delete-price-list.ts @@ -0,0 +1,41 @@ +import PriceListService from "../../../../services/price-list" + +/** + * @oas [delete] /price-lists/{id} + * operationId: "DeletePriceListsPriceList" + * summary: "Delete a Price List" + * description: "Deletes a Price List" + * x-authenticated: true + * parameters: + * - (path) id=* {string} The id of the Price List to delete. + * tags: + * - Price List + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * id: + * type: string + * description: The id of the deleted Price List. + * object: + * type: string + * description: The type of the object that was deleted. + * deleted: + * type: boolean + */ +export default async (req, res) => { + const { id } = req.params + + const priceListService: PriceListService = + req.scope.resolve("priceListService") + await priceListService.delete(id) + + res.json({ + id, + object: "price-list", + deleted: true, + }) +} diff --git a/packages/medusa/src/api/routes/admin/price-lists/delete-prices-batch.ts b/packages/medusa/src/api/routes/admin/price-lists/delete-prices-batch.ts new file mode 100644 index 0000000000..5784281363 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/price-lists/delete-prices-batch.ts @@ -0,0 +1,63 @@ +import { ArrayNotEmpty, IsString } from "class-validator" +import PriceListService from "../../../../services/price-list" +import { validator } from "../../../../utils/validator" + +/** + * @oas [delete] /price-lists/{id}/prices/batch + * operationId: "DeletePriceListsPriceListPricesBatch" + * summary: "Batch delete prices that belongs to a Price List" + * description: "Batch delete prices that belongs to a Price List" + * x-authenticated: true + * parameters: + * - (path) id=* {string} The id of the Price List that the Money Amounts that will be deleted belongs to. + * requestBody: + * content: + * application/json: + * schema: + * properties: + * price_ids: + * description: The price id's of the Money Amounts to delete. + * type: array + * items: + * type: string + * tags: + * - Price List + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * ids: + * type: array + * items: + * type: string + * description: The id of the deleted Money Amount. + * object: + * type: string + * description: The type of the object that was deleted. + * deleted: + * type: boolean + */ +export default async (req, res) => { + const { id } = req.params + + const validated = await validator( + AdminDeletePriceListPricesPricesReq, + req.body + ) + + const priceListService: PriceListService = + req.scope.resolve("priceListService") + + await priceListService.deletePrices(id, validated.price_ids) + + res.json({ ids: validated.price_ids, object: "money-amount", deleted: true }) +} + +export class AdminDeletePriceListPricesPricesReq { + @ArrayNotEmpty() + @IsString({ each: true }) + price_ids: string[] +} diff --git a/packages/medusa/src/api/routes/admin/price-lists/get-price-list.ts b/packages/medusa/src/api/routes/admin/price-lists/get-price-list.ts new file mode 100644 index 0000000000..68571c9cc5 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/price-lists/get-price-list.ts @@ -0,0 +1,37 @@ +import { defaultAdminPriceListFields, defaultAdminPriceListRelations } from "." +import { PriceList } from "../../../.." +import PriceListService from "../../../../services/price-list" + +/** + * @oas [get] /price-lists/{id} + * operationId: "GetPriceListsPriceList" + * summary: "Retrieve a Price List" + * description: "Retrieves a Price List." + * x-authenticated: true + * parameters: + * - (path) id=* {string} The id of the Price List. + * tags: + * - Price List + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * price_list: + * $ref: "#/components/schemas/price_list" + */ +export default async (req, res) => { + const { id } = req.params + + const priceListService: PriceListService = + req.scope.resolve("priceListService") + + const priceList = await priceListService.retrieve(id, { + select: defaultAdminPriceListFields as (keyof PriceList)[], + relations: defaultAdminPriceListRelations, + }) + + res.status(200).json({ price_list: priceList }) +} diff --git a/packages/medusa/src/api/routes/admin/price-lists/index.ts b/packages/medusa/src/api/routes/admin/price-lists/index.ts new file mode 100644 index 0000000000..5c9b8ccaa9 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/price-lists/index.ts @@ -0,0 +1,67 @@ +import { Router } from "express" +import "reflect-metadata" +import { PriceList } from "../../../.." +import { DeleteResponse, PaginatedResponse } from "../../../../types/common" +import middlewares from "../../../middlewares" + +const route = Router() + +export default (app) => { + app.use("/price-lists", route) + + route.get("/:id", middlewares.wrap(require("./get-price-list").default)) + + route.get("/", middlewares.wrap(require("./list-price-lists").default)) + + route.post("/", middlewares.wrap(require("./create-price-list").default)) + + route.post("/:id", middlewares.wrap(require("./update-price-list").default)) + + route.delete("/:id", middlewares.wrap(require("./delete-price-list").default)) + + route.delete( + "/:id/prices/batch", + middlewares.wrap(require("./delete-prices-batch").default) + ) + + route.post( + "/:id/prices/batch", + middlewares.wrap(require("./add-prices-batch").default) + ) + + return app +} + +export const defaultAdminPriceListFields = [ + "id", + "name", + "description", + "type", + "status", + "starts_at", + "ends_at", + "created_at", + "updated_at", + "deleted_at", +] + +export const defaultAdminPriceListRelations = ["prices", "customer_groups"] + +export const allowedAdminPriceListFields = ["prices", "customer_groups"] + +export type AdminPriceListRes = { + price_list: PriceList +} + +export type AdminPriceListDeleteRes = DeleteResponse + +export type AdminPriceListsListRes = PaginatedResponse & { + price_lists: PriceList[] +} + +export * from "./add-prices-batch" +export * from "./create-price-list" +export * from "./delete-price-list" +export * from "./get-price-list" +export * from "./list-price-lists" +export * from "./update-price-list" diff --git a/packages/medusa/src/api/routes/admin/price-lists/list-price-lists.ts b/packages/medusa/src/api/routes/admin/price-lists/list-price-lists.ts new file mode 100644 index 0000000000..3a83b10fb0 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/price-lists/list-price-lists.ts @@ -0,0 +1,106 @@ +import { Type } from "class-transformer" +import { IsNumber, IsOptional, IsString } from "class-validator" +import omit from "lodash/omit" +import { PriceList } from "../../../.." +import PriceListService from "../../../../services/price-list" +import { FindConfig } from "../../../../types/common" +import { FilterablePriceListProps } from "../../../../types/price-list" +import { validator } from "../../../../utils/validator" +/** + * @oas [get] /price-lists + * operationId: "GetPriceLists" + * summary: "List Price Lists" + * description: "Retrieves a list of Price Lists." + * x-authenticated: true + * tags: + * - Price List + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * price_lists: + * type: array + * items: + * $ref: "#/components/schemas/price_list" + * count: + * description: The number of Price Lists. + * type: integer + * offset: + * description: The offset of the Price List query. + * type: integer + * limit: + * description: The limit of the Price List query. + * type: integer + */ +export default async (req, res) => { + const validated = await validator( + AdminGetPriceListPaginationParams, + req.query + ) + + const priceListService: PriceListService = + req.scope.resolve("priceListService") + + let expandFields: string[] = [] + if (validated.expand) { + expandFields = validated.expand.split(",") + } + + const listConfig: FindConfig = { + relations: expandFields, + skip: validated.offset, + take: validated.limit, + order: { created_at: "DESC" } as { [k: string]: "DESC" }, + } + + if (typeof validated.order !== "undefined") { + if (validated.order.startsWith("-")) { + const [, field] = validated.order.split("-") + listConfig.order = { [field]: "DESC" } + } else { + listConfig.order = { [validated.order]: "ASC" } + } + } + + const filterableFields = omit(validated, [ + "limit", + "offset", + "expand", + "order", + ]) + + const [price_lists, count] = await priceListService.listAndCount( + filterableFields, + listConfig + ) + + res.json({ + price_lists, + count, + offset: validated.offset, + limit: validated.limit, + }) +} + +export class AdminGetPriceListPaginationParams extends FilterablePriceListProps { + @IsNumber() + @IsOptional() + @Type(() => Number) + offset?: number = 0 + + @IsNumber() + @IsOptional() + @Type(() => Number) + limit?: number = 10 + + @IsString() + @IsOptional() + expand?: string + + @IsString() + @IsOptional() + order?: string +} diff --git a/packages/medusa/src/api/routes/admin/price-lists/update-price-list.ts b/packages/medusa/src/api/routes/admin/price-lists/update-price-list.ts new file mode 100644 index 0000000000..b6bccb0b12 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/price-lists/update-price-list.ts @@ -0,0 +1,155 @@ +import { Type } from "class-transformer" +import { + IsArray, + IsEnum, + IsOptional, + IsString, + ValidateNested, +} from "class-validator" +import { defaultAdminPriceListFields, defaultAdminPriceListRelations } from "." +import { PriceList } from "../../../.." +import PriceListService from "../../../../services/price-list" +import { + AdminPriceListPricesUpdateReq, + PriceListStatus, + PriceListType, +} from "../../../../types/price-list" +import { validator } from "../../../../utils/validator" + +/** + * @oas [post] /price_lists/{id} + * operationId: "PostPriceListsPriceListPriceList" + * summary: "Update a Price List" + * description: "Updates a Price List" + * x-authenticated: true + * parameters: + * - (path) id=* {string} The id of the Price List. + * requestBody: + * content: + * application/json: + * schema: + * properties: + * name: + * description: "The name of the Price List" + * type: string + * description: + * description: "A description of the Price List." + * type: string + * type: + * description: The type of the Price List. + * type: string + * enum: + * - sale + * - override + * status: + * description: The status of the Price List. + * type: string + * enum: + * - active + * - draft + * prices: + * description: The prices of the Price List. + * type: array + * items: + * properties: + * id: + * description: The id of the price. + * type: string + * region_id: + * description: The id of the Region for which the price is used. + * type: string + * currency_code: + * description: The 3 character ISO currency code for which the price will be used. + * type: string + * amount: + * description: The amount to charge for the Product Variant. + * type: integer + * min_quantity: + * description: The minimum quantity for which the price will be used. + * type: integer + * max_quantity: + * description: The maximum quantity for which the price will be used. + * type: integer + * customer_groups: + * type: array + * description: A list of customer groups that the Price List applies to. + * items: + * required: + * - id + * properties: + * id: + * description: The id of a customer group + * type: string + * tags: + * - Price List + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * product: + * $ref: "#/components/schemas/price_list" + */ +export default async (req, res) => { + const { id } = req.params + + const validated = await validator( + AdminPostPriceListsPriceListPriceListReq, + req.body + ) + + const priceListService: PriceListService = + req.scope.resolve("priceListService") + + await priceListService.update(id, validated) + + const priceList = await priceListService.retrieve(id, { + select: defaultAdminPriceListFields as (keyof PriceList)[], + relations: defaultAdminPriceListRelations, + }) + + res.json({ price_list: priceList }) +} + +class CustomerGroup { + @IsString() + id: string +} + +export class AdminPostPriceListsPriceListPriceListReq { + @IsString() + @IsOptional() + name?: string + + @IsString() + @IsOptional() + description?: string + + @IsOptional() + starts_at?: Date + + @IsOptional() + ends_at?: Date + + @IsOptional() + @IsEnum(PriceListStatus) + status?: PriceListStatus + + @IsOptional() + @IsEnum(PriceListType) + type?: PriceListType + + @IsOptional() + @IsArray() + @Type(() => AdminPriceListPricesUpdateReq) + @ValidateNested({ each: true }) + prices: AdminPriceListPricesUpdateReq[] + + @IsOptional() + @IsArray() + @Type(() => CustomerGroup) + @ValidateNested({ each: true }) + customer_groups: CustomerGroup[] +} diff --git a/packages/medusa/src/api/routes/admin/products/create-product.ts b/packages/medusa/src/api/routes/admin/products/create-product.ts index 1d6d6abb90..ef82b7a49d 100644 --- a/packages/medusa/src/api/routes/admin/products/create-product.ts +++ b/packages/medusa/src/api/routes/admin/products/create-product.ts @@ -3,12 +3,10 @@ import { IsArray, IsBoolean, IsEnum, - IsInt, IsNumber, IsObject, IsOptional, IsString, - Validate, ValidateNested, } from "class-validator" import { EntityManager } from "typeorm" @@ -19,7 +17,7 @@ import { ShippingProfileService, } from "../../../../services" import { ProductStatus } from "../../../../types/product" -import { XorConstraint } from "../../../../types/validators/xor" +import { ProductVariantPricesCreateReq } from "../../../../types/product-variant" import { validator } from "../../../../utils/validator" /** @@ -308,21 +306,6 @@ class ProductOptionReq { title: string } -class ProductVariantPricesReq { - @Validate(XorConstraint, ["currency_code"]) - region_id?: string - - @Validate(XorConstraint, ["region_id"]) - currency_code?: string - - @IsInt() - amount: number - - @IsOptional() - @IsInt() - sale_amount?: number -} - class ProductVariantReq { @IsString() title: string @@ -393,8 +376,8 @@ class ProductVariantReq { @IsArray() @ValidateNested({ each: true }) - @Type(() => ProductVariantPricesReq) - prices: ProductVariantPricesReq[] + @Type(() => ProductVariantPricesCreateReq) + prices: ProductVariantPricesCreateReq[] @IsOptional() @Type(() => ProductVariantOptionReq) diff --git a/packages/medusa/src/api/routes/admin/products/create-variant.ts b/packages/medusa/src/api/routes/admin/products/create-variant.ts index 07a86e6e9d..05afc6e662 100644 --- a/packages/medusa/src/api/routes/admin/products/create-variant.ts +++ b/packages/medusa/src/api/routes/admin/products/create-variant.ts @@ -2,17 +2,15 @@ import { Type } from "class-transformer" import { IsArray, IsBoolean, - IsInt, IsNumber, IsObject, IsOptional, IsString, - Validate, ValidateNested, } from "class-validator" import { defaultAdminProductFields, defaultAdminProductRelations } from "." import { ProductService, ProductVariantService } from "../../../../services" -import { XorConstraint } from "../../../../types/validators/xor" +import { ProductVariantPricesCreateReq } from "../../../../types/product-variant" import { validator } from "../../../../utils/validator" /** @@ -96,8 +94,11 @@ import { validator } from "../../../../utils/validator" * amount: * description: The amount to charge for the Product Variant. * type: integer - * sale_amount: - * description: The sale amount to charge for the Product Variant. + * min_quantity: + * description: The minimum quantity for which the price will be used. + * type: integer + * max_quantity: + * description: The maximum quantity for which the price will be used. * type: integer * options: * type: array @@ -152,21 +153,6 @@ class ProductVariantOptionReq { option_id: string } -class ProductVariantPricesReq { - @Validate(XorConstraint, ["currency_code"]) - region_id?: string - - @Validate(XorConstraint, ["region_id"]) - currency_code?: string - - @IsInt() - amount: number - - @IsOptional() - @IsInt() - sale_amount?: number -} - export class AdminPostProductsProductVariantsReq { @IsString() title: string @@ -237,8 +223,8 @@ export class AdminPostProductsProductVariantsReq { @IsArray() @ValidateNested({ each: true }) - @Type(() => ProductVariantPricesReq) - prices: ProductVariantPricesReq[] + @Type(() => ProductVariantPricesCreateReq) + prices: ProductVariantPricesCreateReq[] @IsOptional() @Type(() => ProductVariantOptionReq) diff --git a/packages/medusa/src/api/routes/admin/products/index.ts b/packages/medusa/src/api/routes/admin/products/index.ts index 2199dffd8a..e0cbcde0af 100644 --- a/packages/medusa/src/api/routes/admin/products/index.ts +++ b/packages/medusa/src/api/routes/admin/products/index.ts @@ -1,8 +1,8 @@ import { Router } from "express" -import { Product, ProductTag, ProductType } from "../../../.." -import { DeleteResponse, PaginatedResponse } from "../../../../types/common" -import middlewares from "../../../middlewares" import "reflect-metadata" +import { Product, ProductTag, ProductType } from "../../../.." +import { PaginatedResponse } from "../../../../types/common" +import middlewares from "../../../middlewares" const route = Router() diff --git a/packages/medusa/src/api/routes/admin/products/update-product.ts b/packages/medusa/src/api/routes/admin/products/update-product.ts index 59383516b7..d80e986e74 100644 --- a/packages/medusa/src/api/routes/admin/products/update-product.ts +++ b/packages/medusa/src/api/routes/admin/products/update-product.ts @@ -9,7 +9,6 @@ import { IsOptional, IsString, NotEquals, - Validate, ValidateIf, ValidateNested, } from "class-validator" @@ -19,7 +18,7 @@ import { ProductStatus, } from "." import { ProductService } from "../../../../services" -import { XorConstraint } from "../../../../types/validators/xor" +import { ProductVariantPricesUpdateReq } from "../../../../types/product-variant" import { validator } from "../../../../utils/validator" /** @@ -248,21 +247,6 @@ class ProductVariantOptionReq { option_id: string } -class ProductVariantPricesReq { - @Validate(XorConstraint, ["currency_code"]) - region_id?: string - - @Validate(XorConstraint, ["region_id"]) - currency_code?: string - - @IsInt() - amount: number - - @IsOptional() - @IsInt() - sale_amount?: number -} - class ProductVariantReq { @IsString() @IsOptional() @@ -339,8 +323,8 @@ class ProductVariantReq { @IsArray() @IsOptional() @ValidateNested({ each: true }) - @Type(() => ProductVariantPricesReq) - prices: ProductVariantPricesReq[] + @Type(() => ProductVariantPricesUpdateReq) + prices: ProductVariantPricesUpdateReq[] @IsOptional() @Type(() => ProductVariantOptionReq) diff --git a/packages/medusa/src/api/routes/admin/products/update-variant.ts b/packages/medusa/src/api/routes/admin/products/update-variant.ts index 91bd89d16b..c0629bb9e8 100644 --- a/packages/medusa/src/api/routes/admin/products/update-variant.ts +++ b/packages/medusa/src/api/routes/admin/products/update-variant.ts @@ -2,17 +2,15 @@ import { Type } from "class-transformer" import { IsArray, IsBoolean, - IsInt, IsNumber, IsObject, IsOptional, IsString, - Validate, ValidateNested, } from "class-validator" import { defaultAdminProductFields, defaultAdminProductRelations } from "." import { ProductService, ProductVariantService } from "../../../../services" -import { XorConstraint } from "../../../../types/validators/xor" +import { ProductVariantPricesUpdateReq } from "../../../../types/product-variant" import { validator } from "../../../../utils/validator" /** @@ -84,6 +82,9 @@ import { validator } from "../../../../utils/validator" * type: array * items: * properties: + * id: + * description: The id of the price. + * type: string * region_id: * description: The id of the Region for which the price is used. * type: string @@ -93,8 +94,11 @@ import { validator } from "../../../../utils/validator" * amount: * description: The amount to charge for the Product Variant. * type: integer - * sale_amount: - * description: The sale amount to charge for the Product Variant. + * min_quantity: + * description: The minimum quantity for which the price will be used. + * type: integer + * max_quantity: + * description: The maximum quantity for which the price will be used. * type: integer * options: * type: array @@ -152,21 +156,6 @@ class ProductVariantOptionReq { option_id: string } -class ProductVariantPricesReq { - @Validate(XorConstraint, ["currency_code"]) - region_id?: string - - @Validate(XorConstraint, ["region_id"]) - currency_code?: string - - @IsInt() - amount: number - - @IsOptional() - @IsInt() - sale_amount?: number -} - export class AdminPostProductsProductVariantsVariantReq { @IsString() @IsOptional() @@ -238,8 +227,8 @@ export class AdminPostProductsProductVariantsVariantReq { @IsArray() @ValidateNested({ each: true }) - @Type(() => ProductVariantPricesReq) - prices: ProductVariantPricesReq[] + @Type(() => ProductVariantPricesUpdateReq) + prices: ProductVariantPricesUpdateReq[] @Type(() => ProductVariantOptionReq) @ValidateNested({ each: true }) diff --git a/packages/medusa/src/index.js b/packages/medusa/src/index.js index e3d92b9cda..ff62eeaf68 100644 --- a/packages/medusa/src/index.js +++ b/packages/medusa/src/index.js @@ -42,6 +42,7 @@ export * from "./models/order" export * from "./models/payment" export * from "./models/payment-provider" export * from "./models/payment-session" +export * from "./models/price-list" export * from "./models/product" export * from "./models/product-collection" export * from "./models/product-option" diff --git a/packages/medusa/src/migrations/1646915480108-update_money_amount_add_price_list.ts b/packages/medusa/src/migrations/1646915480108-update_money_amount_add_price_list.ts new file mode 100644 index 0000000000..df5bfe87a1 --- /dev/null +++ b/packages/medusa/src/migrations/1646915480108-update_money_amount_add_price_list.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class updateMoneyAmountAddPriceList1646915480108 implements MigrationInterface { + name = 'updateMoneyAmountAddPriceList1646915480108' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "price_list_type_enum" AS ENUM('sale', 'override')`); + await queryRunner.query(`CREATE TYPE "price_list_status_enum" AS ENUM('active', 'draft')`); + await queryRunner.query(`CREATE TABLE "price_list" ("id" character varying NOT NULL, "name" character varying NOT NULL, "description" character varying NOT NULL, "type" "price_list_type_enum" NOT NULL DEFAULT 'sale', "status" "price_list_status_enum" NOT NULL DEFAULT 'draft', "starts_at" TIMESTAMP WITH TIME ZONE, "ends_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deleted_at" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_52ea7826468b1c889cb2c28df03" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "price_list_customer_groups" ("price_list_id" character varying NOT NULL, "customer_group_id" character varying NOT NULL, CONSTRAINT "PK_1afcbe15cc8782dc80c05707df9" PRIMARY KEY ("price_list_id", "customer_group_id"))`); + await queryRunner.query(`CREATE INDEX "IDX_52875734e9dd69064f0041f4d9" ON "price_list_customer_groups" ("price_list_id") `); + await queryRunner.query(`CREATE INDEX "IDX_c5516f550433c9b1c2630d787a" ON "price_list_customer_groups" ("customer_group_id") `); + await queryRunner.query(`ALTER TABLE "money_amount" DROP COLUMN "sale_amount"`); + await queryRunner.query(`ALTER TABLE "money_amount" ADD "min_quantity" integer`); + await queryRunner.query(`ALTER TABLE "money_amount" ADD "max_quantity" integer`); + await queryRunner.query(`ALTER TABLE "money_amount" ADD "price_list_id" character varying`); + await queryRunner.query(`ALTER TABLE "money_amount" ADD CONSTRAINT "FK_f249976b079375499662eb80c40" FOREIGN KEY ("price_list_id") REFERENCES "price_list"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "price_list_customer_groups" ADD CONSTRAINT "FK_52875734e9dd69064f0041f4d92" FOREIGN KEY ("price_list_id") REFERENCES "price_list"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "price_list_customer_groups" ADD CONSTRAINT "FK_c5516f550433c9b1c2630d787a7" FOREIGN KEY ("customer_group_id") REFERENCES "customer_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "price_list_customer_groups" DROP CONSTRAINT "FK_c5516f550433c9b1c2630d787a7"`); + await queryRunner.query(`ALTER TABLE "price_list_customer_groups" DROP CONSTRAINT "FK_52875734e9dd69064f0041f4d92"`); + await queryRunner.query(`ALTER TABLE "money_amount" DROP CONSTRAINT "FK_f249976b079375499662eb80c40"`); + await queryRunner.query(`ALTER TABLE "money_amount" DROP COLUMN "price_list_id"`); + await queryRunner.query(`ALTER TABLE "money_amount" DROP COLUMN "max_quantity"`); + await queryRunner.query(`ALTER TABLE "money_amount" DROP COLUMN "min_quantity"`); + await queryRunner.query(`ALTER TABLE "money_amount" ADD "sale_amount" integer`); + await queryRunner.query(`DROP INDEX "IDX_c5516f550433c9b1c2630d787a"`); + await queryRunner.query(`DROP INDEX "IDX_52875734e9dd69064f0041f4d9"`); + await queryRunner.query(`DROP TABLE "price_list_customer_groups"`); + await queryRunner.query(`DROP TABLE "price_list"`); + await queryRunner.query(`DROP TYPE "price_list_status_enum"`); + await queryRunner.query(`DROP TYPE "price_list_type_enum"`); + } + +} diff --git a/packages/medusa/src/models/customer-group.ts b/packages/medusa/src/models/customer-group.ts index 20ed261cc9..b2bc1ad77f 100644 --- a/packages/medusa/src/models/customer-group.ts +++ b/packages/medusa/src/models/customer-group.ts @@ -5,14 +5,14 @@ import { DeleteDateColumn, Entity, Index, - JoinTable, ManyToMany, PrimaryColumn, - UpdateDateColumn, + UpdateDateColumn } from "typeorm" import { ulid } from "ulid" -import { Customer } from ".." import { DbAwareColumn, resolveDbType } from "../utils/db-aware-column" +import { Customer } from "./customer" +import { PriceList } from "./price-list" @Entity() export class CustomerGroup { @@ -23,22 +23,22 @@ export class CustomerGroup { @Column() name: string - @ManyToMany(() => Customer, (customer) => customer.groups, { - onDelete: "CASCADE", - }) - @JoinTable({ - name: "customer_group_customers", - joinColumn: { - name: "customer_group_id", - referencedColumnName: "id", - }, - inverseJoinColumn: { - name: "customer_id", - referencedColumnName: "id", - }, - }) + @ManyToMany( + () => Customer, + (customer) => customer.groups, + { + onDelete: "CASCADE", + } + ) customers: Customer[] + @ManyToMany( + () => PriceList, + (priceList) => priceList.customer_groups, + { onDelete: "CASCADE" } + ) + price_lists: PriceList[] + @CreateDateColumn({ type: resolveDbType("timestamptz") }) created_at: Date diff --git a/packages/medusa/src/models/money-amount.ts b/packages/medusa/src/models/money-amount.ts index d234279794..2b208b9330 100644 --- a/packages/medusa/src/models/money-amount.ts +++ b/packages/medusa/src/models/money-amount.ts @@ -8,11 +8,12 @@ import { JoinColumn, ManyToOne, PrimaryColumn, - UpdateDateColumn, + UpdateDateColumn } from "typeorm" import { ulid } from "ulid" import { resolveDbType } from "../utils/db-aware-column" import { Currency } from "./currency" +import { PriceList } from "./price-list" import { ProductVariant } from "./product-variant" import { Region } from "./region" @@ -31,16 +32,34 @@ export class MoneyAmount { @Column({ type: "int" }) amount: number - @Column({ type: "int", nullable: true, default: null }) - sale_amount?: number + @Column({ type: "int", nullable: true }) + min_quantity: number | null + + @Column({ type: "int", nullable: true }) + max_quantity: number | null + + @Column({ nullable: true }) + price_list_id: string | null + + @ManyToOne( + () => PriceList, + (priceList) => priceList.prices, + { cascade: true, onDelete: "CASCADE" } + ) + @JoinColumn({ name: "price_list_id" }) + price_list: PriceList | null @Index() @Column({ nullable: true }) variant_id: string - @ManyToOne(() => ProductVariant, (variant) => variant.prices, { - onDelete: "CASCADE", - }) + @ManyToOne( + () => ProductVariant, + (variant) => variant.prices, + { + onDelete: "CASCADE", + } + ) @JoinColumn({ name: "variant_id" }) variant: ProductVariant @@ -86,8 +105,11 @@ export class MoneyAmount { * amount: * description: "The amount in the smallest currecny unit (e.g. cents 100 cents to charge $1) that the Product Variant will cost." * type: integer - * sale_amount: - * description: "An optional sale amount that the Product Variant will be available for when defined." + * min_quantity: + * description: "The minimum quantity that the Money Amount applies to. If this value is not set, the Money Amount applies to all quantities." + * type: integer + * max_quantity: + * description: "The maximum quantity that the Money Amount applies to. If this value is not set, the Money Amount applies to all quantities." * type: integer * variant_id: * description: "The id of the Product Variant that the Money Amount belongs to." diff --git a/packages/medusa/src/models/price-list.ts b/packages/medusa/src/models/price-list.ts new file mode 100644 index 0000000000..f01a9340a8 --- /dev/null +++ b/packages/medusa/src/models/price-list.ts @@ -0,0 +1,127 @@ +import { + BeforeInsert, + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinTable, + ManyToMany, + OneToMany, + PrimaryColumn, + UpdateDateColumn +} from "typeorm" +import { ulid } from "ulid" +import { PriceListStatus, PriceListType } from "../types/price-list" +import { DbAwareColumn, resolveDbType } from "../utils/db-aware-column" +import { CustomerGroup } from "./customer-group" +import { MoneyAmount } from "./money-amount" + +@Entity() +export class PriceList { + @PrimaryColumn() + id: string + + @Column() + name: string + + @Column() + description: string + + @DbAwareColumn({ type: "enum", enum: PriceListType, default: "sale" }) + type: PriceListType + + @DbAwareColumn({ type: "enum", enum: PriceListStatus, default: "draft" }) + status: PriceListStatus + + @Column({ + type: resolveDbType("timestamptz"), + nullable: true, + }) + starts_at: Date + + @Column({ type: resolveDbType("timestamptz"), nullable: true }) + ends_at: Date + + @JoinTable({ + name: "price_list_customer_groups", + joinColumn: { + name: "price_list_id", + referencedColumnName: "id", + }, + inverseJoinColumn: { + name: "customer_group_id", + referencedColumnName: "id", + }, + }) + @ManyToMany( + () => CustomerGroup, + (cg) => cg.price_lists, + { onDelete: "CASCADE" } + ) + customer_groups: CustomerGroup[] + + @OneToMany( + () => MoneyAmount, + (moneyAmount) => moneyAmount.price_list, + { onDelete: "CASCADE" } + ) + prices: MoneyAmount[] + + @CreateDateColumn({ type: resolveDbType("timestamptz") }) + created_at: Date + + @UpdateDateColumn({ type: resolveDbType("timestamptz") }) + updated_at: Date + + @DeleteDateColumn({ type: resolveDbType("timestamptz") }) + deleted_at: Date + + @BeforeInsert() + private beforeInsert(): undefined | void { + if (this.id) { + return + } + const id = ulid() + this.id = `pl_${id}` + } +} + +/** + * @schema price_list + * title: "Price List" + * description: "Price Lists represents a set of prices that overrides the default price for one or more product variants." + * x-resourceId: price_list + * properties: + * id: + * description: "The id of the Price List. This value will be prefixed by `pl_`." + * type: string + * type: + * description: "The type of Price List. This can be one of either `sale` or `override`." + * type: string + * enum: + * - sale + * - override + * starts_at: + * description: "The date with timezone that the Price List starts being valid." + * type: date-time + * ends_at: + * description: "The date with timezone that the Price List stops being valid." + * type: date-time + * customer_groups: + * description: "The Customer Groups that the Price List applies to." + * type: array + * items: + * $ref: "#/components/schemas/customer_group" + * 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 + */ diff --git a/packages/medusa/src/repositories/__mocks__/money-amount.js b/packages/medusa/src/repositories/__mocks__/money-amount.js new file mode 100644 index 0000000000..892d8e745a --- /dev/null +++ b/packages/medusa/src/repositories/__mocks__/money-amount.js @@ -0,0 +1,31 @@ +import { IdMap } from "medusa-test-utils" + +export const moneyAmounts = { + amountOne: { + id: IdMap.getId("amountOne"), + currency_code: "USD", + amount: 1, + min_quantity: 1, + max_quantity: 10, + price_list_id: null, + } +} + +export const MoneyAmountModelMock = { + create: jest.fn().mockReturnValue(Promise.resolve()), + findOne: jest.fn().mockImplementation(query => { + if (query._id === IdMap.getId("amountOne")) { + return Promise.resolve(moneyAmounts.amountOne) + } + return Promise.resolve(undefined) + }), + addToPriceList: jest.fn().mockImplementation((priceListId, prices, overrideExisting) => { + return Promise.resolve() + }), + deletePriceListPrices: jest.fn().mockImplementation((priceListId, moneyAmountIds) => { + return Promise.resolve() + }), + updatePriceListPrices: jest.fn().mockImplementation((priceListId, updates) => { + return Promise.resolve() + }), +} diff --git a/packages/medusa/src/repositories/__mocks__/price-list.js b/packages/medusa/src/repositories/__mocks__/price-list.js new file mode 100644 index 0000000000..702ea4da46 --- /dev/null +++ b/packages/medusa/src/repositories/__mocks__/price-list.js @@ -0,0 +1,22 @@ +export const PriceListModelMock = { + create: jest.fn().mockReturnValue(Promise.resolve()), + updateOne: jest.fn().mockImplementation((query, update) => { + return Promise.resolve() + }), + deleteOne: jest.fn().mockReturnValue(Promise.resolve()), + findOne: jest.fn().mockImplementation(query => { + if (query.email === "oliver@medusa.com") { + return Promise.resolve(customers.testCustomer) + } + if (query.phone === "12345678") { + return Promise.resolve(customers.customerWithPhone) + } + if (query._id === IdMap.getId("testCustomer")) { + return Promise.resolve(customers.testCustomer) + } + if (query._id === IdMap.getId("deleteId")) { + return Promise.resolve(customers.deleteCustomer) + } + return Promise.resolve(undefined) + }), +} \ No newline at end of file diff --git a/packages/medusa/src/repositories/money-amount.ts b/packages/medusa/src/repositories/money-amount.ts index 6f2030f985..6f15979fd4 100644 --- a/packages/medusa/src/repositories/money-amount.ts +++ b/packages/medusa/src/repositories/money-amount.ts @@ -1,19 +1,20 @@ +import partition from "lodash/partition" import { Brackets, EntityRepository, In, IsNull, Not, - Repository, + Repository } from "typeorm" import { MoneyAmount } from "../models/money-amount" +import { PriceListPriceCreateInput, PriceListPriceUpdateInput } from "../types/price-list" type Price = Partial< - Pick< - MoneyAmount, - "currency_code" | "region_id" | "sale_amount" | "currency_code" - > -> & { amount: number } + Omit +> & { + amount: number +} @EntityRepository(MoneyAmount) export class MoneyAmountRepository extends Repository { @@ -21,6 +22,7 @@ export class MoneyAmountRepository extends Repository { const pricesNotInPricesPayload = await this.createQueryBuilder() .where({ variant_id: variantId, + price_list_id: IsNull(), }) .andWhere( new Brackets((qb) => { @@ -33,12 +35,13 @@ export class MoneyAmountRepository extends Repository { return pricesNotInPricesPayload } - public async upsertCurrencyPrice(variantId: string, price: Price) { + public async upsertVariantCurrencyPrice(variantId: string, price: Price) { let moneyAmount = await this.findOne({ where: { currency_code: price.currency_code, variant_id: variantId, region_id: IsNull(), + price_list_id: IsNull(), }, }) @@ -50,9 +53,64 @@ export class MoneyAmountRepository extends Repository { }) } else { moneyAmount.amount = price.amount - moneyAmount.sale_amount = price.sale_amount } return await this.save(moneyAmount) } + + public async addPriceListPrices( + priceListId: string, + prices: PriceListPriceCreateInput[], + overrideExisting: boolean = false + ): Promise { + const toInsert = prices.map((price) => (this.create({ + ...price, + price_list_id: priceListId, + }))) + const insertResult = await this.createQueryBuilder() + .insert() + .orIgnore(true) + .into(MoneyAmount) + .values(toInsert) + .execute() + + if (overrideExisting) { + await this.createQueryBuilder() + .delete() + .from(MoneyAmount) + .where({ + price_list_id: priceListId, + id: Not(In(insertResult.identifiers.map((ma) => ma.id))), + }) + .execute() + } + + return await this.manager + .createQueryBuilder(MoneyAmount, "ma") + .select() + .where(insertResult.identifiers) + .getMany() + } + + public async deletePriceListPrices( + priceListId: string, + moneyAmountIds: string[] + ): Promise { + await this.createQueryBuilder() + .delete() + .from(MoneyAmount) + .where({ price_list_id: priceListId, id: In(moneyAmountIds) }) + .execute() + } + + public async updatePriceListPrices( + priceListId: string, + updates: PriceListPriceUpdateInput[] + ): Promise { + const [existingPrices, newPrices] = partition(updates, (update) => update.id) + + const newPriceEntities = newPrices.map((price) => (this.create({ ...price, price_list_id: priceListId }))) + + return await this.save([...existingPrices, ...newPriceEntities]) + } } diff --git a/packages/medusa/src/repositories/price-list.ts b/packages/medusa/src/repositories/price-list.ts new file mode 100644 index 0000000000..bcf37926bf --- /dev/null +++ b/packages/medusa/src/repositories/price-list.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from "typeorm" +import { PriceList } from "../models/price-list" + +@EntityRepository(PriceList) +export class PriceListRepository extends Repository {} diff --git a/packages/medusa/src/services/__mocks__/price-list.js b/packages/medusa/src/services/__mocks__/price-list.js new file mode 100644 index 0000000000..214a34bc4a --- /dev/null +++ b/packages/medusa/src/services/__mocks__/price-list.js @@ -0,0 +1,43 @@ +export const PriceListServiceMock = { + withTransaction: function() { + return this + }, + + create: jest.fn().mockImplementation((f) => { + return Promise.resolve(f) + }), + + retrieve: jest.fn().mockImplementation((f) => { + return Promise.resolve(f) + }), + + update: jest.fn().mockImplementation((id, update) => { + return Promise.resolve({ id, ...update }) + }), + + delete: jest.fn().mockImplementation((id) => { + return Promise.resolve(id) + }), + + addPrices: jest.fn().mockImplementation((id, prices) => { + return Promise.resolve({ id, prices }) + }), + + deletePrices: jest.fn().mockImplementation((id, prices) => { + return Promise.resolve({ id, prices }) + }), + + listAndCount: jest.fn().mockImplementation((fields, config) => { + return Promise.resolve([[{ id: "pl_1" }, { id: "pl_2" }], 2]) + }), + + upsertCustomerGroups_: jest.fn().mockImplementation((id, groups) => { + return Promise.resolve() + }), +} + +const mock = jest.fn().mockImplementation(() => { + return PriceListServiceMock +}) + +export default mock diff --git a/packages/medusa/src/services/__mocks__/product-variant.js b/packages/medusa/src/services/__mocks__/product-variant.js index 7576cbae13..8e1f695355 100644 --- a/packages/medusa/src/services/__mocks__/product-variant.js +++ b/packages/medusa/src/services/__mocks__/product-variant.js @@ -98,7 +98,9 @@ const variantWithPrices = { id: "price_1", currency_code: "usd", amount: 100, - sale_amount: null, + price_list_id: null, + min_quantity: 1, + max_quantity: 10, variant_id: "variant_with_prices", region_id: null, created_at: "2021-03-16T21:24:13.657Z", @@ -109,7 +111,9 @@ const variantWithPrices = { id: "price_2", currency_code: "dk", amount: 100, - sale_amount: null, + price_list_id: null, + min_quantity: 1, + max_quantity: 10, variant_id: "variant_with_prices", region_id: null, created_at: "2021-03-16T21:24:13.657Z", @@ -285,8 +289,12 @@ export const ProductVariantServiceMock = { }), delete: jest.fn().mockReturnValue(Promise.resolve()), update: jest.fn().mockReturnValue(Promise.resolve()), - setCurrencyPrice: jest.fn().mockReturnValue(Promise.resolve()), - setRegionPrice: jest.fn().mockReturnValue(Promise.resolve()), + updateVariantPrices: jest.fn().mockImplementation((variantId, prices) => { + return Promise.resolve({}) + }), + deleteVariantPrices: jest.fn().mockImplementation((variantId, priceIds) => { + return Promise.resolve({}) + }), updateOptionValue: jest.fn().mockReturnValue(Promise.resolve()), addOptionValue: jest.fn().mockImplementation((variantId, optionId, value) => { return Promise.resolve({}) diff --git a/packages/medusa/src/services/__tests__/price-list.js b/packages/medusa/src/services/__tests__/price-list.js new file mode 100644 index 0000000000..1fe9bc8495 --- /dev/null +++ b/packages/medusa/src/services/__tests__/price-list.js @@ -0,0 +1,121 @@ +import { MedusaError } from "medusa-core-utils" +import { IdMap, MockManager, MockRepository } from "medusa-test-utils" +import PriceListService from "../price-list" + +describe("PriceListService", () => { + const priceListRepository = MockRepository({ + findOne: (q) => { + if (q === IdMap.getId("batman")) { + return Promise.resolve(undefined) + } + return Promise.resolve({ id: IdMap.getId("ironman") }) + }, + create: (data) => { + return Promise.resolve({ id: IdMap.getId("ironman"), ...data }) + }, + save: (data) => Promise.resolve(data), + }) + + const customerGroupService = { + retrieve: jest.fn((id) => { + if (id === IdMap.getId("group")) { + return Promise.resolve({ id: IdMap.getId("group") }) + } + + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `CustomerGroup with id ${id} was not found` + ) + }), + } + + const moneyAmountRepository = MockRepository() + + moneyAmountRepository.addPriceListPrices = jest.fn(() => Promise.resolve()) + moneyAmountRepository.removePriceListPrices = jest.fn(() => Promise.resolve()) + moneyAmountRepository.updatePriceListPrices = jest.fn(() => Promise.resolve()) + + const defaultRelations = ["prices", "customer_groups"] + + const priceListService = new PriceListService({ + manager: MockManager, + customerGroupService, + priceListRepository, + moneyAmountRepository, + }) + + beforeEach(async () => { + jest.clearAllMocks() + }) + + describe("retrieve", () => { + it("successfully retrieves a price list", async () => { + const result = await priceListService.retrieve(IdMap.getId("ironman")) + + expect(priceListRepository.findOne).toHaveBeenCalledTimes(1) + expect(priceListRepository.findOne).toHaveBeenCalledWith({ + where: { id: IdMap.getId("ironman") }, + }) + + expect(result.id).toEqual(IdMap.getId("ironman")) + }) + + it("fails on non-existing product variant id", async () => { + try { + await priceListService.retrieve(IdMap.getId("batman")) + } catch (error) { + expect(error.message).toBe( + `Price list with id: ${IdMap.getId("batman")} was not found` + ) + } + }) + }) + + describe("create", () => { + it("creates a new Price List", async () => { + const result = await priceListService.create({ + name: "VIP winter sale", + description: "Winter sale for VIP customers. 25% off selected items.", + type: "sale", + status: "active", + starts_at: "2022-07-01T00:00:00.000Z", + ends_at: "2022-07-31T00:00:00.000Z", + customer_groups: [{ id: IdMap.getId("group") }], + prices: [ + { + amount: 100, + currency_code: "usd", + min_quantity: 1, + max_quantity: 100, + }, + ], + }) + + expect(priceListRepository.create).toHaveBeenCalledTimes(1) + expect(priceListRepository.create).toHaveBeenCalledWith({ + name: "VIP winter sale", + description: "Winter sale for VIP customers. 25% off selected items.", + type: "sale", + status: "active", + starts_at: "2022-07-01T00:00:00.000Z", + ends_at: "2022-07-31T00:00:00.000Z", + }) + expect(customerGroupService.retrieve).toHaveBeenCalledTimes(1) + expect(customerGroupService.retrieve).toHaveBeenCalledWith( + IdMap.getId("group") + ) + expect(moneyAmountRepository.addPriceListPrices).toHaveBeenCalledTimes(1) + expect(moneyAmountRepository.addPriceListPrices).toHaveBeenCalledWith( + IdMap.getId("ironman"), + [ + { + amount: 100, + currency_code: "usd", + min_quantity: 1, + max_quantity: 100, + }, + ] + ) + }) + }) +}) diff --git a/packages/medusa/src/services/__tests__/product-variant.js b/packages/medusa/src/services/__tests__/product-variant.js index f71199bb3b..41dc539b8d 100644 --- a/packages/medusa/src/services/__tests__/product-variant.js +++ b/packages/medusa/src/services/__tests__/product-variant.js @@ -1,5 +1,4 @@ -import { IdMap, MockRepository, MockManager } from "medusa-test-utils" -import idMap from "medusa-test-utils/dist/id-map" +import { IdMap, MockManager, MockRepository } from "medusa-test-utils" import ProductVariantService from "../product-variant" const eventBusService = { @@ -376,7 +375,6 @@ describe("ProductVariantService", () => { { currency_code: "dkk", amount: 1000, - sale_amount: 750, }, ], }) @@ -388,7 +386,6 @@ describe("ProductVariantService", () => { { currency_code: "dkk", amount: 1000, - sale_amount: 750, }, ] ) @@ -435,6 +432,10 @@ describe("ProductVariantService", () => { .fn() .mockImplementation(() => Promise.resolve(oldPrices)) + moneyAmountRepository.deleteVariantPrices = jest + .fn() + .mockImplementation((variant_id, price_ids) => Promise.resolve({})) + const productVariantRepository = MockRepository({ findOne: (query) => Promise.resolve({ id: IdMap.getId("ironman") }), }) @@ -456,114 +457,9 @@ describe("ProductVariantService", () => { .fn() .mockReturnValue(() => Promise.resolve()) - productVariantService.setCurrencyPrice = jest - .fn() - .mockReturnValue(() => Promise.resolve()) - - productVariantService.setRegionPrice = jest - .fn() - .mockReturnValue(() => Promise.resolve()) - beforeEach(async () => { jest.clearAllMocks() }) - - it("successfully removes obsolete prices and calls setCurrencyPrice on new/existing prices", async () => { - await productVariantService.updateVariantPrices("ironman", [ - { - currency_code: "usd", - amount: 4000, - }, - ]) - - expect( - moneyAmountRepository.findVariantPricesNotIn - ).toHaveBeenCalledTimes(1) - - expect(productVariantService.setCurrencyPrice).toHaveBeenCalledTimes(1) - expect(productVariantService.setCurrencyPrice).toHaveBeenCalledWith( - "ironman", - { - currency_code: "usd", - amount: 4000, - } - ) - - expect(moneyAmountRepository.remove).toHaveBeenCalledTimes(1) - expect(moneyAmountRepository.remove).toHaveBeenCalledWith(oldPrices) - }) - - it("successfully removes obsolete prices and calls setRegionPrice on new/existing prices", async () => { - await productVariantService.updateVariantPrices("ironman", [ - { - region_id: "test-region", - amount: 4000, - sale_amount: 2000, - }, - ]) - - expect( - moneyAmountRepository.findVariantPricesNotIn - ).toHaveBeenCalledTimes(1) - - expect(productVariantService.setRegionPrice).toHaveBeenCalledTimes(1) - expect(productVariantService.setRegionPrice).toHaveBeenCalledWith( - "ironman", - { - region_id: "test-region", - amount: 4000, - sale_amount: 2000, - } - ) - - expect(moneyAmountRepository.remove).toHaveBeenCalledTimes(1) - expect(moneyAmountRepository.remove).toHaveBeenCalledWith(oldPrices) - }) - }) - - describe("setCurrencyPrice", () => { - const productVariantRepository = MockRepository({ - findOne: (query) => Promise.resolve({ id: IdMap.getId("ironman") }), - }) - - const moneyAmountRepository = MockRepository() - - moneyAmountRepository.upsertCurrencyPrice = jest - .fn() - .mockImplementation((variantId, price) => { - return Promise.resolve({ - id: IdMap.getId("test-amount"), - variant_id: IdMap.getId(variantId), - ...price, - }) - }) - - const productVariantService = new ProductVariantService({ - manager: MockManager, - eventBusService, - moneyAmountRepository, - productVariantRepository, - }) - - beforeEach(async () => { - jest.clearAllMocks() - }) - - it("calls upsert price with given currency", async () => { - await productVariantService.setCurrencyPrice(IdMap.getId("ironman"), { - currency_code: "usd", - amount: 100, - }) - - expect(moneyAmountRepository.upsertCurrencyPrice).toHaveBeenCalledTimes(1) - expect(moneyAmountRepository.upsertCurrencyPrice).toHaveBeenCalledWith( - IdMap.getId("ironman"), - { - currency_code: "usd", - amount: 100, - } - ) - }) }) describe("getRegionPrice", () => { @@ -590,7 +486,6 @@ describe("ProductVariantService", () => { currency_code: "dkk", region_id: IdMap.getId("california"), amount: 1000, - sale_amount: 750, }) } return Promise.resolve({ @@ -629,7 +524,8 @@ describe("ProductVariantService", () => { IdMap.getId("california") ) - expect(result).toBe(750) + // TODO: Update once PriceStrategy is implemented + expect(result).toBe(1000) }) it("fails if no price is found", async () => { @@ -646,7 +542,7 @@ describe("ProductVariantService", () => { }) }) - describe("setRegionPrice", () => { + describe("updateVariantPrices", () => { const moneyAmountRepository = MockRepository({ findOne: (query) => { if (query.where.region_id === IdMap.getId("cali")) { @@ -657,14 +553,47 @@ describe("ProductVariantService", () => { variant_id: IdMap.getId("ironman"), currency_code: "dkk", amount: 750, - sale_amount: 500, }) }, + create: (p) => p, + remove: () => Promise.resolve(), }) + const oldPrices = [ + { + currency_code: "dkk", + amount: 1000, + variant_id: "ironman", + region_id: null, + }, + ] + + moneyAmountRepository.findVariantPricesNotIn = jest + .fn() + .mockImplementation(() => Promise.resolve(oldPrices)) + + moneyAmountRepository.upsertVariantCurrencyPrice = jest + .fn() + .mockImplementation(() => Promise.resolve()) + + const regionService = { + list: jest.fn().mockImplementation((config) => { + const idOrIds = config.id + + if (Array.isArray(idOrIds)) { + return Promise.resolve( + idOrIds.map((id) => ({ id, currency_code: "usd" })) + ) + } else { + return Promise.resolve([{ id: idOrIds, currency_code: "usd" }]) + } + }), + } + const productVariantService = new ProductVariantService({ manager: MockManager, eventBusService, + regionService, moneyAmountRepository, }) @@ -672,17 +601,43 @@ describe("ProductVariantService", () => { jest.clearAllMocks() }) - it("successfully creates a price if none exist with given region id", async () => { - await productVariantService.setRegionPrice(IdMap.getId("ironman"), { + it("successfully removes obsolete prices and calls save on new/existing prices", async () => { + await productVariantService.updateVariantPrices("ironman", [ + { + currency_code: "usd", + amount: 4000, + }, + ]) + + expect( + moneyAmountRepository.findVariantPricesNotIn + ).toHaveBeenCalledTimes(1) + + expect( + moneyAmountRepository.upsertVariantCurrencyPrice + ).toHaveBeenCalledTimes(1) + expect( + moneyAmountRepository.upsertVariantCurrencyPrice + ).toHaveBeenCalledWith("ironman", { currency_code: "usd", - amount: 100, - region_id: IdMap.getId("cali"), + amount: 4000, }) + expect(moneyAmountRepository.remove).toHaveBeenCalledTimes(1) + expect(moneyAmountRepository.remove).toHaveBeenCalledWith(oldPrices) + }) + + it("successfully creates new a region price", async () => { + await productVariantService.updateVariantPrices(IdMap.getId("ironman"), [ + { + amount: 100, + region_id: IdMap.getId("cali"), + }, + ]) + expect(moneyAmountRepository.create).toHaveBeenCalledTimes(1) expect(moneyAmountRepository.create).toHaveBeenCalledWith({ variant_id: IdMap.getId("ironman"), - currency_code: "usd", region_id: IdMap.getId("cali"), amount: 100, }) @@ -690,22 +645,26 @@ describe("ProductVariantService", () => { expect(moneyAmountRepository.save).toHaveBeenCalledTimes(1) }) - it("successfully updates a price", async () => { - await productVariantService.setRegionPrice(IdMap.getId("ironman"), { - currency_code: "dkk", - amount: 750, - sale_amount: 500, - }) + it("successfully creates a currency price", async () => { + await productVariantService.updateVariantPrices(IdMap.getId("ironman"), [ + { + id: IdMap.getId("dkk"), + currency_code: "dkk", + amount: 750, + }, + ]) expect(moneyAmountRepository.create).toHaveBeenCalledTimes(0) - expect(moneyAmountRepository.save).toHaveBeenCalledTimes(1) - expect(moneyAmountRepository.save).toHaveBeenCalledWith({ - variant_id: IdMap.getId("ironman"), + expect( + moneyAmountRepository.upsertVariantCurrencyPrice + ).toHaveBeenCalledTimes(1) + expect( + moneyAmountRepository.upsertVariantCurrencyPrice + ).toHaveBeenCalledWith(IdMap.getId("ironman"), { id: IdMap.getId("dkk"), currency_code: "dkk", amount: 750, - sale_amount: 500, }) }) }) diff --git a/packages/medusa/src/services/discount.js b/packages/medusa/src/services/discount.js index b0996ec788..80e88bb397 100644 --- a/packages/medusa/src/services/discount.js +++ b/packages/medusa/src/services/discount.js @@ -1,6 +1,6 @@ -import { BaseService } from "medusa-interfaces" -import { Validator, MedusaError } from "medusa-core-utils" import { parse, toSeconds } from "iso8601-duration" +import { MedusaError, Validator } from "medusa-core-utils" +import { BaseService } from "medusa-interfaces" import { Brackets, ILike } from "typeorm" import { formatException } from "../utils/exception-formatter" diff --git a/packages/medusa/src/services/price-list.ts b/packages/medusa/src/services/price-list.ts new file mode 100644 index 0000000000..0312b6e81e --- /dev/null +++ b/packages/medusa/src/services/price-list.ts @@ -0,0 +1,281 @@ +import { MedusaError } from "medusa-core-utils" +import { BaseService } from "medusa-interfaces" +import { EntityManager } from "typeorm" +import { CustomerGroupService } from "." +import { CustomerGroup } from "../models/customer-group" +import { PriceList } from "../models/price-list" +import { MoneyAmountRepository } from "../repositories/money-amount" +import { PriceListRepository } from "../repositories/price-list" +import { FindConfig } from "../types/common" +import { + CreatePriceListInput, + FilterablePriceListProps, + PriceListPriceCreateInput, + UpdatePriceListInput, +} from "../types/price-list" +import { formatException } from "../utils/exception-formatter" + +type PriceListConstructorProps = { + manager: EntityManager + customerGroupService: CustomerGroupService + priceListRepository: typeof PriceListRepository + moneyAmountRepository: typeof MoneyAmountRepository +} + +/** + * Provides layer to manipulate product tags. + * @extends BaseService + */ +class PriceListService extends BaseService { + private manager_: EntityManager + private customerGroupService_: CustomerGroupService + private priceListRepo_: typeof PriceListRepository + private moneyAmountRepo_: typeof MoneyAmountRepository + + constructor({ + manager, + customerGroupService, + priceListRepository, + moneyAmountRepository, + }: PriceListConstructorProps) { + super() + this.manager_ = manager + this.customerGroupService_ = customerGroupService + this.priceListRepo_ = priceListRepository + this.moneyAmountRepo_ = moneyAmountRepository + } + + withTransaction(transactionManager: EntityManager): PriceListService { + if (!transactionManager) { + return this + } + + const cloned = new PriceListService({ + manager: transactionManager, + customerGroupService: this.customerGroupService_, + priceListRepository: this.priceListRepo_, + moneyAmountRepository: this.moneyAmountRepo_, + }) + + cloned.transactionManager_ = transactionManager + + return cloned + } + + /** + * Retrieves a product tag by id. + * @param {string} priceListId - the id of the product tag to retrieve + * @param {Object} config - the config to retrieve the tag by + * @return {Promise} the collection. + */ + async retrieve( + priceListId: string, + config: FindConfig = {} + ): Promise { + const priceListRepo = this.manager_.getCustomRepository(this.priceListRepo_) + + const query = this.buildQuery_({ id: priceListId }, config) + const priceList = await priceListRepo.findOne(query) + + if (!priceList) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Price list with id: ${priceListId} was not found` + ) + } + + return priceList + } + + /** + * Creates a Price List + * @param {CreatePriceListInput} priceListObject - the Price List to create + * @return {Promise} created Price List + */ + async create(priceListObject: CreatePriceListInput): Promise { + return await this.atomicPhase_(async (manager: EntityManager) => { + const priceListRepo = manager.getCustomRepository(this.priceListRepo_) + const moneyAmountRepo = manager.getCustomRepository(this.moneyAmountRepo_) + + const { prices, customer_groups, ...rest } = priceListObject + + try { + const entity = priceListRepo.create(rest) + + const priceList = await priceListRepo.save(entity) + + if (prices) { + await moneyAmountRepo.addPriceListPrices(priceList.id, prices) + } + + if (customer_groups) { + await this.upsertCustomerGroups_(priceList.id, customer_groups) + } + + const result = await this.retrieve(priceList.id, { + relations: ["prices", "customer_groups"], + }) + + return result + } catch (error) { + throw formatException(error) + } + }) + } + + /** + * Updates a Price List + * @param {string} id - the id of the Product List to update + * @param {UpdatePriceListInput} update - the update to apply + * @returns {Promise} updated Price List + */ + async update(id: string, update: UpdatePriceListInput): Promise { + return await this.atomicPhase_(async (manager: EntityManager) => { + const priceListRepo = manager.getCustomRepository(this.priceListRepo_) + const moneyAmountRepo = manager.getCustomRepository(this.moneyAmountRepo_) + + const priceList = await this.retrieve(id, { select: ["id"] }) + + const { prices, customer_groups, ...rest } = update + + for (const [key, value] of Object.entries(rest)) { + priceList[key] = value + } + + await priceListRepo.save(priceList) + + if (prices) { + await moneyAmountRepo.updatePriceListPrices(id, prices) + } + + if (customer_groups) { + await this.upsertCustomerGroups_(id, customer_groups) + } + + const result = await this.retrieve(id, { + relations: ["prices", "customer_groups"], + }) + + return result + }) + } + + /** + * Adds prices to a price list in bulk, optionally replacing all existing prices + * @param id - id of the price list + * @param prices - prices to add + * @param replace - whether to replace existing prices + * @returns {Promise} updated Price List + */ + async addPrices( + id: string, + prices: PriceListPriceCreateInput[], + replace = false + ): Promise { + return await this.atomicPhase_(async (manager: EntityManager) => { + const moneyAmountRepo = manager.getCustomRepository(this.moneyAmountRepo_) + + const priceList = await this.retrieve(id, { select: ["id"] }) + + await moneyAmountRepo.addPriceListPrices(priceList.id, prices, replace) + + const result = await this.retrieve(priceList.id, { + relations: ["prices"], + }) + + return result + }) + } + + /** + * Removes prices from a price list and deletes the removed prices in bulk + * @param id - id of the price list + * @param priceIds - ids of the prices to delete + * @returns {Promise} updated Price List + */ + async deletePrices(id: string, priceIds: string[]): Promise { + return await this.atomicPhase_(async (manager: EntityManager) => { + const moneyAmountRepo = manager.getCustomRepository(this.moneyAmountRepo_) + + const priceList = await this.retrieve(id, { select: ["id"] }) + + await moneyAmountRepo.deletePriceListPrices(priceList.id, priceIds) + + return Promise.resolve() + }) + } + + /** + * Deletes a Price List + * Will never fail due to delete being idempotent. + * @param id - id of the price list + * @returns {Promise} empty promise + */ + async delete(id: string): Promise { + return await this.atomicPhase_(async (manager: EntityManager) => { + const priceListRepo = manager.getCustomRepository(this.priceListRepo_) + + const priceList = await priceListRepo.findOne({ where: { id: id } }) + if (!priceList) { + return Promise.resolve() + } + + await priceListRepo.delete(priceList) + + return Promise.resolve() + }) + } + + /** + * Lists Price Lists + * @param {Object} selector - the query object for find + * @param {Object} config - the config to be used for find + * @return {Promise} the result of the find operation + */ + async list( + selector: FilterablePriceListProps = {}, + config: FindConfig = { skip: 0, take: 20 } + ): Promise { + const priceListRepo = this.manager_.getCustomRepository(this.priceListRepo_) + + const query = this.buildQuery_(selector, config) + return await priceListRepo.find(query) + } + + /** + * Lists Price Lists and adds count + * @param {Object} selector - the query object for find + * @param {Object} config - the config to be used for find + * @return {Promise} the result of the find operation + */ + async listAndCount( + selector: FilterablePriceListProps = {}, + config: FindConfig = { skip: 0, take: 20 } + ): Promise<[PriceList[], number]> { + const priceListRepo = this.manager_.getCustomRepository(this.priceListRepo_) + + const query = this.buildQuery_(selector, config) + return await priceListRepo.findAndCount(query) + } + + async upsertCustomerGroups_( + priceListId: string, + customerGroups: { id: string }[] + ): Promise { + const priceListRepo = this.manager_.getCustomRepository(this.priceListRepo_) + const priceList = await this.retrieve(priceListId, { select: ["id"] }) + + const groups: CustomerGroup[] = [] + + for (const cg of customerGroups) { + const customerGroup = await this.customerGroupService_.retrieve(cg.id) + groups.push(customerGroup) + } + + priceList.customer_groups = groups + + await priceListRepo.save(priceList) + } +} + +export default PriceListService diff --git a/packages/medusa/src/services/product-variant.ts b/packages/medusa/src/services/product-variant.ts index e0169b2f24..734fbc450c 100644 --- a/packages/medusa/src/services/product-variant.ts +++ b/packages/medusa/src/services/product-variant.ts @@ -1,7 +1,7 @@ import { MedusaError } from "medusa-core-utils" import { BaseService } from "medusa-interfaces" -import { Brackets, EntityManager, ILike, In, SelectQueryBuilder } from "typeorm" -import { MoneyAmount } from "../models/money-amount" +import { Brackets, EntityManager, ILike, SelectQueryBuilder } from "typeorm" +import { MoneyAmount } from ".." import { Product } from "../models/product" import { ProductOptionValue } from "../models/product-option-value" import { ProductVariant } from "../models/product-variant" @@ -230,7 +230,6 @@ class ProductVariantService extends BaseService { await this.setRegionPrice(result.id, { amount: price.amount, region_id: price.region_id, - sale_amount: price.sale_amount, }) } else { await this.setCurrencyPrice(result.id, price) @@ -317,6 +316,13 @@ class ProductVariantService extends BaseService { }) } + /** + * Updates a variant's prices. + * Deletes any prices that are not in the update object, and is not associated with a price list. + * @param variantId - the id of variant variant + * @param prices - the update prices + * @returns {Promise} empty promise + */ async updateVariantPrices( variantId: string, prices: ProductVariantPrice[] @@ -337,7 +343,6 @@ class ProductVariantService extends BaseService { await this.setRegionPrice(variantId, { region_id: price.region_id, amount: price.amount, - sale_amount: price.sale_amount || undefined, }) } else { await this.setCurrencyPrice(variantId, price) @@ -348,25 +353,6 @@ class ProductVariantService extends BaseService { }) } - /** - * Sets the default price for the given currency. - * @param {string} variantId - the id of the variant to set prices for - * @param {ProductVariantPrice} price - the price for the variant - * @return {Promise} the result of the update operation - */ - async setCurrencyPrice( - variantId: string, - price: ProductVariantPrice - ): Promise { - return this.atomicPhase_(async (manager: EntityManager) => { - const moneyAmountRepo = manager.getCustomRepository( - this.moneyAmountRepository_ - ) - - return await moneyAmountRepo.upsertCurrencyPrice(variantId, price) - }) - } - /** * Gets the price specific to a region. If no region specific money amount * exists the function will try to use a currency price. If no default @@ -406,17 +392,12 @@ class ProductVariantService extends BaseService { ) } - // Always return sale price, if present - if (moneyAmount.sale_amount) { - return moneyAmount.sale_amount - } - return moneyAmount.amount }) } /** - * Sets the price of a specific region + * Sets the default price of a specific region * @param {string} variantId - the id of the variant to update * @param {string} price - the price for the variant. * @return {Promise} the result of the update operation @@ -434,6 +415,7 @@ class ProductVariantService extends BaseService { where: { variant_id: variantId, region_id: price.region_id, + price_list_id: null, }, }) @@ -444,7 +426,6 @@ class ProductVariantService extends BaseService { }) } else { moneyAmount.amount = price.amount - moneyAmount.sale_amount = price.sale_amount } const result = await moneyAmountRepo.save(moneyAmount) @@ -452,6 +433,25 @@ class ProductVariantService extends BaseService { }) } + /** + * Sets the default price for the given currency. + * @param {string} variantId - the id of the variant to set prices for + * @param {ProductVariantPrice} price - the price for the variant + * @return {Promise} the result of the update operation + */ + async setCurrencyPrice( + variantId: string, + price: ProductVariantPrice + ): Promise { + return this.atomicPhase_(async (manager: EntityManager) => { + const moneyAmountRepo = manager.getCustomRepository( + this.moneyAmountRepository_ + ) + + return await moneyAmountRepo.upsertVariantCurrencyPrice(variantId, price) + }) + } + /** * Updates variant's option value. * Option value must be of type string or number. @@ -636,7 +636,10 @@ class ProductVariantService extends BaseService { this.productVariantRepository_ ) - const variant = await variantRepo.findOne({ where: { id: variantId } }) + const variant = await variantRepo.findOne({ + where: { id: variantId }, + relations: ["prices"], + }) if (!variant) { return Promise.resolve() @@ -662,7 +665,10 @@ class ProductVariantService extends BaseService { * @param {Object} metadata - the metadata to set * @return {Object} updated metadata object */ - setMetadata_(variant: ProductVariant, metadata: object): Record { + setMetadata_( + variant: ProductVariant, + metadata: object + ): Record { const existing = variant.metadata || {} const newData = {} for (const [key, value] of Object.entries(metadata)) { diff --git a/packages/medusa/src/types/price-list.ts b/packages/medusa/src/types/price-list.ts new file mode 100644 index 0000000000..42aae61aff --- /dev/null +++ b/packages/medusa/src/types/price-list.ts @@ -0,0 +1,151 @@ +import { Type } from "class-transformer" +import { + IsEnum, + IsInt, + IsOptional, + IsString, + Validate, + ValidateIf, + ValidateNested, +} from "class-validator" +import { PriceList } from "../models/price-list" +import { DateComparisonOperator } from "./common" +import { XorConstraint } from "./validators/xor" + +export enum PriceListType { + SALE = "sale", + OVERRIDE = "override", +} + +export enum PriceListStatus { + ACTIVE = "active", + DRAFT = "draft", +} + +export class FilterablePriceListProps { + @IsString() + @IsOptional() + id?: string + + @IsString() + @IsOptional() + q?: string + + @IsOptional() + @IsEnum(PriceListStatus, { each: true }) + status?: PriceListStatus[] + + @IsString() + @IsOptional() + name?: string + + @IsString() + @IsOptional() + description?: string + + @IsOptional() + @IsEnum(PriceListType, { each: true }) + type?: PriceListType[] + + @IsOptional() + @ValidateNested() + @Type(() => DateComparisonOperator) + created_at?: DateComparisonOperator + + @IsOptional() + @ValidateNested() + @Type(() => DateComparisonOperator) + updated_at?: DateComparisonOperator + + @ValidateNested() + @IsOptional() + @Type(() => DateComparisonOperator) + deleted_at?: DateComparisonOperator +} + +export class AdminPriceListPricesUpdateReq { + @IsString() + @IsOptional() + id?: string + + @ValidateIf((o) => !o.id) + @Validate(XorConstraint, ["currency_code"]) + region_id?: string + + @ValidateIf((o) => !o.id) + @Validate(XorConstraint, ["region_id"]) + currency_code?: string + + @IsString() + variant_id: string + + @IsInt() + amount: number + + @IsOptional() + @IsInt() + min_quantity?: number + + @IsOptional() + @IsInt() + max_quantity?: number +} + +export class AdminPriceListPricesCreateReq { + @Validate(XorConstraint, ["currency_code"]) + region_id?: string + + @Validate(XorConstraint, ["region_id"]) + currency_code?: string + + @IsInt() + amount: number + + @IsString() + variant_id: string + + @IsOptional() + @IsInt() + min_quantity?: number + + @IsOptional() + @IsInt() + max_quantity?: number +} + +export type CreatePriceListInput = { + name: string + description: string + type: PriceListType + status?: PriceListStatus + prices: AdminPriceListPricesCreateReq[] + customer_groups?: { id: string }[] +} + +export type UpdatePriceListInput = Partial< + Pick< + PriceList, + "name" | "description" | "starts_at" | "ends_at" | "status" | "type" + > +> & { + prices?: AdminPriceListPricesUpdateReq[] + customer_groups?: { id: string }[] +} + +export type PriceListPriceUpdateInput = { + id?: string + variant_id?: string + region_id?: string + currency_code?: string + amount?: number + min_quantity?: number + max_quantity?: number +} + +export type PriceListPriceCreateInput = { + region_id?: string + currency_code?: string + amount: number + min_quantity?: number + max_quantity?: number +} diff --git a/packages/medusa/src/types/product-variant.ts b/packages/medusa/src/types/product-variant.ts index 244d02471d..9578dd48c1 100644 --- a/packages/medusa/src/types/product-variant.ts +++ b/packages/medusa/src/types/product-variant.ts @@ -1,16 +1,28 @@ -import { IsBoolean, IsNumber, IsString, ValidateNested } from "class-validator" +import { + IsBoolean, + IsInt, + IsNumber, + IsOptional, + IsString, + Validate, + ValidateIf, + ValidateNested, +} from "class-validator" import { IsType } from "../utils/validators/is-type" import { DateComparisonOperator, NumericalComparisonOperator, StringComparisonOperator, } from "./common" +import { XorConstraint } from "./validators/xor" export type ProductVariantPrice = { + id?: string currency_code?: string region_id?: string amount: number - sale_amount?: number | undefined + min_quantity?: number + max_quantity?: number } export type ProductVariantOption = { @@ -130,3 +142,47 @@ export class FilterableProductVariantProps { @IsType([DateComparisonOperator]) updated_at?: DateComparisonOperator } + +export class ProductVariantPricesUpdateReq { + @IsString() + @IsOptional() + id?: string + + @ValidateIf((o) => !o.id) + @Validate(XorConstraint, ["currency_code"]) + region_id?: string + + @ValidateIf((o) => !o.id) + @Validate(XorConstraint, ["region_id"]) + currency_code?: string + + @IsInt() + amount: number + + @IsOptional() + @IsInt() + min_quantity?: number + + @IsOptional() + @IsInt() + max_quantity?: number +} + +export class ProductVariantPricesCreateReq { + @Validate(XorConstraint, ["currency_code"]) + region_id?: string + + @Validate(XorConstraint, ["region_id"]) + currency_code?: string + + @IsInt() + amount: number + + @IsOptional() + @IsInt() + min_quantity?: number + + @IsOptional() + @IsInt() + max_quantity?: number +}