diff --git a/packages/order/integration-tests/__tests__/create-order.ts b/packages/order/integration-tests/__tests__/create-order.ts index 69ffc68b6b..ebd7ea9f61 100644 --- a/packages/order/integration-tests/__tests__/create-order.ts +++ b/packages/order/integration-tests/__tests__/create-order.ts @@ -107,6 +107,14 @@ moduleIntegrationTestRunner({ ], }, ], + transactions: [ + { + amount: 58, + currency_code: "USD", + reference: "payment", + reference_id: "pay_123", + }, + ], currency_code: "usd", customer_id: "joe", } as CreateOrderDTO @@ -114,6 +122,9 @@ moduleIntegrationTestRunner({ const expectation = expect.objectContaining({ id: expect.stringContaining("order_"), version: 1, + summary: expect.objectContaining({ + total: expect.any(Number), + }), shipping_address: expect.objectContaining({ id: expect.stringContaining("ordaddr_"), }), @@ -193,6 +204,7 @@ moduleIntegrationTestRunner({ "id", "version", "items.id", + "summary", "items.quantity", "items.detail.id", "items.detail.version", @@ -223,20 +235,93 @@ moduleIntegrationTestRunner({ expect(getOrder).toEqual(expectation) }) - it.skip("should transform where clause to match the db schema and return the order", async function () { + it("should return order transactions", async function () { const createdOrder = await service.create(input) const getOrder = await service.retrieve(createdOrder.id, { select: [ "id", - "version", - "items.id", - "items.detail.version", - "items.quantity", + "transactions.amount", + "transactions.reference", + "transactions.reference_id", ], - relations: ["items"], + relations: ["transactions"], }) - expect(getOrder).toEqual(expectation) + expect(getOrder).toEqual( + expect.objectContaining({ + id: createdOrder.id, + transactions: [ + expect.objectContaining({ + amount: 58, + reference: "payment", + reference_id: "pay_123", + }), + ], + }) + ) + }) + + it("should transform where clause to match the db schema and return the order", async function () { + await service.create(input) + const orders = await service.list( + { + items: { + quantity: 2, + }, + }, + { + select: ["id"], + relations: ["items"], + take: null, + } + ) + expect(orders.length).toEqual(1) + + const orders2 = await service.list( + { + items: { + quantity: 5, + }, + }, + { + select: ["items.quantity"], + relations: ["items"], + take: null, + } + ) + expect(orders2.length).toEqual(0) + + const orders3 = await service.list( + { + items: { + detail: { + shipped_quantity: 0, + }, + }, + }, + { + select: ["id"], + relations: ["items.detail"], + take: null, + } + ) + expect(orders3.length).toEqual(1) + + const orders4 = await service.list( + { + items: { + detail: { + shipped_quantity: 1, + }, + }, + }, + { + select: ["id"], + relations: ["items.detail"], + take: null, + } + ) + expect(orders4.length).toEqual(0) }) }) }, diff --git a/packages/order/integration-tests/__tests__/order-edit.ts b/packages/order/integration-tests/__tests__/order-edit.ts new file mode 100644 index 0000000000..44a1dab9b8 --- /dev/null +++ b/packages/order/integration-tests/__tests__/order-edit.ts @@ -0,0 +1,557 @@ +import { Modules } from "@medusajs/modules-sdk" +import { CreateOrderDTO, IOrderModuleService } from "@medusajs/types" +import { SuiteOptions, moduleIntegrationTestRunner } from "medusa-test-utils" +import { ChangeActionType } from "../../src/utils" + +jest.setTimeout(100000) + +moduleIntegrationTestRunner({ + debug: 0, + moduleName: Modules.ORDER, + testSuite: ({ service }: SuiteOptions) => { + describe("Order Module Service - Order Edits", () => { + const input = { + email: "foo@bar.com", + items: [ + { + title: "Item 1", + subtitle: "Subtitle 1", + thumbnail: "thumbnail1.jpg", + quantity: 1, + product_id: "product1", + product_title: "Product 1", + product_description: "Description 1", + product_subtitle: "Product Subtitle 1", + product_type: "Type 1", + product_collection: "Collection 1", + product_handle: "handle1", + variant_id: "variant1", + variant_sku: "SKU1", + variant_barcode: "Barcode1", + variant_title: "Variant 1", + variant_option_values: { + color: "Red", + size: "Large", + }, + requires_shipping: true, + is_discountable: true, + is_tax_inclusive: true, + compare_at_unit_price: 10, + unit_price: 8, + tax_lines: [ + { + description: "Tax 1", + tax_rate_id: "tax_usa", + code: "code", + rate: 0.1, + provider_id: "taxify_master", + }, + ], + adjustments: [ + { + code: "VIP_10", + amount: 10, + description: "VIP discount", + promotion_id: "prom_123", + provider_id: "coupon_kings", + }, + ], + }, + { + title: "Item 2", + quantity: 2, + unit_price: 5, + }, + { + title: "Item 3", + quantity: 1, + unit_price: 30, + }, + ], + sales_channel_id: "test", + shipping_address: { + first_name: "Test", + last_name: "Test", + address_1: "Test", + city: "Test", + country_code: "US", + postal_code: "12345", + phone: "12345", + }, + billing_address: { + first_name: "Test", + last_name: "Test", + address_1: "Test", + city: "Test", + country_code: "US", + postal_code: "12345", + }, + shipping_methods: [ + { + name: "Test shipping method", + amount: 10, + data: {}, + tax_lines: [ + { + description: "shipping Tax 1", + tax_rate_id: "tax_usa_shipping", + code: "code", + rate: 10, + }, + ], + adjustments: [ + { + code: "VIP_10", + amount: 1, + description: "VIP discount", + promotion_id: "prom_123", + }, + ], + }, + ], + transactions: [ + { + amount: 58, + currency_code: "USD", + reference: "payment", + reference_id: "pay_123", + }, + ], + currency_code: "usd", + customer_id: "joe", + } as CreateOrderDTO + + it("should change an order by adding actions to it", async function () { + const createdOrder = await service.create(input) + + await service.addOrderAction([ + { + action: ChangeActionType.ITEM_ADD, + order_id: createdOrder.id, + version: createdOrder.version, + internal_note: "adding an item", + reference: "order_line_item", + reference_id: createdOrder.items[0].id, + amount: + createdOrder.items[0].unit_price * createdOrder.items[0].quantity, + details: { + quantity: 1, + }, + }, + { + action: ChangeActionType.ITEM_ADD, + order_id: createdOrder.id, + version: createdOrder.version, + reference: "order_line_item", + reference_id: createdOrder.items[1].id, + amount: + createdOrder.items[1].unit_price * createdOrder.items[1].quantity, + details: { + quantity: 3, + }, + }, + { + action: ChangeActionType.FULFILL_ITEM, + order_id: createdOrder.id, + version: createdOrder.version, + reference: "fullfilment", + reference_id: "fulfill_123", + details: { + reference_id: createdOrder.items[2].id, + quantity: 1, + }, + }, + { + action: ChangeActionType.SHIP_ITEM, + order_id: createdOrder.id, + version: createdOrder.version, + reference: "fullfilment", + reference_id: "shipping_123", + details: { + reference_id: createdOrder.items[2].id, + quantity: 1, + }, + }, + { + action: ChangeActionType.RETURN_ITEM, + order_id: createdOrder.id, + version: createdOrder.version, + internal_note: "client has called and wants to return an item", + reference: "return", + reference_id: "return_123", + details: { + reference_id: createdOrder.items[2].id, + quantity: 1, + }, + }, + { + action: ChangeActionType.RECEIVE_DAMAGED_RETURN_ITEM, + order_id: createdOrder.id, + version: createdOrder.version, + internal_note: "Item broken", + reference: "return", + reference_id: "return_123", + details: { + reference_id: createdOrder.items[2].id, + quantity: 1, + }, + }, + ]) + + await service.applyPendingOrderActions(createdOrder.id) + + const finalOrder = await service.retrieve(createdOrder.id, { + select: [ + "id", + "version", + "items.detail", + "summary", + "shipping_methods", + "transactions", + ], + relations: ["items", "shipping_methods", "transactions"], + }) + + expect(createdOrder.items).toEqual([ + expect.objectContaining({ + title: "Item 1", + unit_price: 8, + quantity: 1, + detail: expect.objectContaining({ + version: 1, + quantity: 1, + fulfilled_quantity: 0, + shipped_quantity: 0, + return_requested_quantity: 0, + return_received_quantity: 0, + return_dismissed_quantity: 0, + written_off_quantity: 0, + }), + }), + expect.objectContaining({ + title: "Item 2", + compare_at_unit_price: null, + unit_price: 5, + quantity: 2, + }), + expect.objectContaining({ + title: "Item 3", + unit_price: 30, + quantity: 1, + detail: expect.objectContaining({ + version: 1, + quantity: 1, + fulfilled_quantity: 0, + shipped_quantity: 0, + return_requested_quantity: 0, + return_received_quantity: 0, + return_dismissed_quantity: 0, + written_off_quantity: 0, + }), + }), + ]) + + expect(finalOrder).toEqual( + expect.objectContaining({ + version: 1, + }) + ) + expect(finalOrder.items).toEqual([ + expect.objectContaining({ + title: "Item 1", + subtitle: "Subtitle 1", + thumbnail: "thumbnail1.jpg", + variant_id: "variant1", + product_id: "product1", + product_title: "Product 1", + product_description: "Description 1", + product_subtitle: "Product Subtitle 1", + product_type: "Type 1", + product_collection: "Collection 1", + product_handle: "handle1", + variant_sku: "SKU1", + variant_barcode: "Barcode1", + variant_title: "Variant 1", + variant_option_values: { size: "Large", color: "Red" }, + requires_shipping: true, + is_discountable: true, + is_tax_inclusive: true, + compare_at_unit_price: 10, + unit_price: 8, + quantity: 2, + detail: expect.objectContaining({ + version: 1, + quantity: 2, + fulfilled_quantity: 0, + shipped_quantity: 0, + return_requested_quantity: 0, + return_received_quantity: 0, + return_dismissed_quantity: 0, + written_off_quantity: 0, + }), + }), + expect.objectContaining({ + title: "Item 2", + compare_at_unit_price: null, + unit_price: 5, + quantity: 5, + detail: expect.objectContaining({ + version: 1, + quantity: 5, + fulfilled_quantity: 0, + shipped_quantity: 0, + return_requested_quantity: 0, + return_received_quantity: 0, + return_dismissed_quantity: 0, + written_off_quantity: 0, + }), + }), + expect.objectContaining({ + title: "Item 3", + unit_price: 30, + quantity: 1, + detail: expect.objectContaining({ + version: 1, + quantity: 1, + fulfilled_quantity: 1, + shipped_quantity: 1, + return_requested_quantity: 0, + return_received_quantity: 0, + return_dismissed_quantity: 1, + written_off_quantity: 0, + }), + }), + ]) + }) + + it("should create an order change, add actions to it and confirm the changes.", async function () { + const createdOrder = await service.create(input) + + const orderChange = await service.createOrderChange({ + order_id: createdOrder.id, + description: "changing the order", + internal_note: "changing the order to version 2", + created_by: "user_123", + actions: [ + { + action: ChangeActionType.ITEM_ADD, + reference: "order_line_item", + reference_id: createdOrder.items[0].id, + amount: + createdOrder.items[0].unit_price * + createdOrder.items[0].quantity, + details: { + quantity: 1, + }, + }, + { + action: ChangeActionType.ITEM_ADD, + reference: "order_line_item", + reference_id: createdOrder.items[1].id, + amount: + createdOrder.items[1].unit_price * + createdOrder.items[1].quantity, + details: { + quantity: 3, + }, + }, + { + action: ChangeActionType.FULFILL_ITEM, + reference: "fullfilment", + reference_id: "fulfill_123", + details: { + reference_id: createdOrder.items[2].id, + quantity: 1, + }, + }, + { + action: ChangeActionType.SHIP_ITEM, + reference: "fullfilment", + reference_id: "shipping_123", + details: { + reference_id: createdOrder.items[2].id, + quantity: 1, + }, + }, + { + action: ChangeActionType.RETURN_ITEM, + reference: "return", + reference_id: "return_123", + details: { + reference_id: createdOrder.items[2].id, + quantity: 1, + }, + }, + { + action: ChangeActionType.RECEIVE_DAMAGED_RETURN_ITEM, + internal_note: "Item broken", + reference: "return", + reference_id: "return_123", + details: { + reference_id: createdOrder.items[2].id, + quantity: 1, + }, + }, + ], + }) + + await service.confirmOrderChange(orderChange.id, { + confirmed_by: "cx_agent_123", + }) + + expect(service.confirmOrderChange(orderChange.id)).rejects.toThrowError( + `Order Change cannot be modified: ${orderChange.id}` + ) + + const modified = await service.retrieve(createdOrder.id, { + select: [ + "id", + "version", + "items.detail", + "summary", + "shipping_methods", + "transactions", + ], + relations: ["items", "shipping_methods", "transactions"], + }) + + expect(modified).toEqual( + expect.objectContaining({ + version: 2, + }) + ) + + expect(modified.items).toEqual([ + expect.objectContaining({ + quantity: 2, + detail: expect.objectContaining({ + version: 2, + quantity: 2, + }), + }), + expect.objectContaining({ + title: "Item 2", + unit_price: 5, + quantity: 5, + detail: expect.objectContaining({ + version: 2, + quantity: 5, + fulfilled_quantity: 0, + shipped_quantity: 0, + return_requested_quantity: 0, + return_received_quantity: 0, + return_dismissed_quantity: 0, + written_off_quantity: 0, + }), + }), + expect.objectContaining({ + title: "Item 3", + unit_price: 30, + quantity: 1, + detail: expect.objectContaining({ + version: 2, + quantity: 1, + fulfilled_quantity: 1, + shipped_quantity: 1, + return_requested_quantity: 0, + return_received_quantity: 0, + return_dismissed_quantity: 1, + written_off_quantity: 0, + }), + }), + ]) + }) + + it("should create order changes, cancel and reject them.", async function () { + const createdOrder = await service.create(input) + + const orderChange = await service.createOrderChange({ + order_id: createdOrder.id, + description: "changing the order", + internal_note: "changing the order to version 2", + created_by: "user_123", + }) + + const orderChange2 = await service.createOrderChange({ + order_id: createdOrder.id, + description: "changing the order again", + internal_note: "trying again...", + created_by: "user_123", + actions: [ + { + action: ChangeActionType.ITEM_ADD, + reference: "order_line_item", + reference_id: createdOrder.items[0].id, + amount: + createdOrder.items[0].unit_price * + createdOrder.items[0].quantity, + details: { + quantity: 1, + }, + }, + ], + }) + + await service.cancelOrderChange({ + id: orderChange.id, + canceled_by: "cx_agent_123", + }) + + expect(service.cancelOrderChange(orderChange.id)).rejects.toThrowError( + "Order Change cannot be modified" + ) + + await service.declineOrderChange({ + id: orderChange2.id, + declined_by: "user_123", + declined_reason: "changed my mind", + }) + + expect( + service.declineOrderChange(orderChange2.id) + ).rejects.toThrowError("Order Change cannot be modified") + + const [change1, change2] = await service.listOrderChanges( + { + id: [orderChange.id, orderChange2.id], + }, + { + select: [ + "id", + "status", + "canceled_by", + "canceled_at", + "declined_by", + "declined_at", + "declined_reason", + ], + } + ) + + expect(change1).toEqual( + expect.objectContaining({ + id: expect.any(String), + status: "canceled", + declined_by: null, + declined_reason: null, + declined_at: null, + canceled_by: "cx_agent_123", + canceled_at: expect.any(Date), + }) + ) + + expect(change2).toEqual( + expect.objectContaining({ + id: expect.any(String), + status: "declined", + declined_by: "user_123", + declined_reason: "changed my mind", + declined_at: expect.any(Date), + canceled_by: null, + canceled_at: null, + }) + ) + }) + }) + }, +}) diff --git a/packages/order/src/migrations/Migration20240219102530.ts b/packages/order/src/migrations/Migration20240219102530.ts index 56aa1b3f79..308d670acb 100644 --- a/packages/order/src/migrations/Migration20240219102530.ts +++ b/packages/order/src/migrations/Migration20240219102530.ts @@ -31,7 +31,6 @@ export class Migration20240219102530 extends Migration { "id" TEXT NOT NULL, "region_id" TEXT NULL, "customer_id" TEXT NULL, - "original_order_id" TEXT NULL, "version" INTEGER NOT NULL DEFAULT 1, "sales_channel_id" TEXT NULL, "status" text check ( @@ -49,7 +48,6 @@ export class Migration20240219102530 extends Migration { "shipping_address_id" text NULL, "billing_address_id" text NULL, "no_notification" boolean NULL, - "summary" jsonb NOT NULL, "metadata" jsonb NULL, "created_at" timestamptz NOT NULL DEFAULT now(), "updated_at" timestamptz NOT NULL DEFAULT now(), @@ -61,9 +59,6 @@ export class Migration20240219102530 extends Migration { ALTER TABLE "order" ADD COLUMN if NOT exists "deleted_at" timestamptz NULL; - ALTER TABLE "order" - ADD COLUMN if NOT exists "original_order_id" text NULL; - ALTER TABLE "order" DROP constraint if EXISTS "FK_6ff7e874f01b478c115fdd462eb" CASCADE; ALTER TABLE "order" DROP constraint if EXISTS "FK_19b0c6293443d1b464f604c3316" CASCADE; @@ -112,11 +107,6 @@ export class Migration20240219102530 extends Migration { ) WHERE deleted_at IS NOT NULL; - CREATE INDEX IF NOT EXISTS "IDX_order_original_order_id" ON "order" ( - original_order_id - ) - WHERE deleted_at IS NOT NULL; - CREATE INDEX IF NOT EXISTS "IDX_order_customer_id" ON "order" ( customer_id ) @@ -142,6 +132,22 @@ export class Migration20240219102530 extends Migration { ) WHERE deleted_at IS NOT NULL; + + CREATE TABLE IF NOT EXISTS "order_summary" ( + "id" TEXT NOT NULL, + "order_id" TEXT NOT NULL, + "version" INTEGER NOT NULL DEFAULT 1, + "totals" JSONB NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT Now(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT Now(), + CONSTRAINT "order_summary_pkey" PRIMARY KEY ("id") + ); + + CREATE INDEX IF NOT EXISTS "IDX_order_summary_order_id_version" ON "order_summary" ( + order_id, + version + ); + CREATE TABLE IF NOT EXISTS "order_change" ( "id" TEXT NOT NULL, "order_id" TEXT NOT NULL, @@ -186,12 +192,18 @@ export class Migration20240219102530 extends Migration { CREATE TABLE IF NOT EXISTS "order_change_action" ( "id" TEXT NOT NULL, - "order_change_id" TEXT NOT NULL, - "reference" TEXT NOT NULL, - "reference_id" TEXT NOT NULL, - "action" JSONB NOT NULL, - "metadata" JSONB NULL, + "order_id" TEXT NULL, + "version" INTEGER NULL, + "ordering" BIGSERIAL NOT NULL, + "order_change_id" TEXT NULL, + "reference" TEXT NULL, + "reference_id" TEXT NULL, + "action" TEXT NOT NULL, + "details" JSONB NULL, + "amount" NUMERIC NULL, + "raw_amount" JSONB NULL, "internal_note" TEXT NULL, + "applied" BOOLEAN NOT NULL DEFAULT false, "created_at" TIMESTAMPTZ NOT NULL DEFAULT Now(), "updated_at" TIMESTAMPTZ NOT NULL DEFAULT Now(), CONSTRAINT "order_change_action_pkey" PRIMARY KEY ("id") @@ -201,9 +213,12 @@ export class Migration20240219102530 extends Migration { order_change_id ); - CREATE INDEX IF NOT EXISTS "IDX_order_change_action_reference_reference_id" ON "order_change_action" ( - reference, - reference_id + CREATE INDEX IF NOT EXISTS "IDX_order_change_action_order_id" ON "order_change_action" ( + order_id + ); + + CREATE INDEX IF NOT EXISTS "IDX_order_change_action_ordering" ON "order_change_action" ( + ordering ); CREATE TABLE IF NOT EXISTS "order_item" ( diff --git a/packages/order/src/models/index.ts b/packages/order/src/models/index.ts index 6eb9915790..56db40a996 100644 --- a/packages/order/src/models/index.ts +++ b/packages/order/src/models/index.ts @@ -6,6 +6,7 @@ export { default as Order } from "./order" export { default as OrderChange } from "./order-change" export { default as OrderChangeAction } from "./order-change-action" export { default as OrderItem } from "./order-item" +export { default as OrderSummary } from "./order-summary" export { default as ShippingMethod } from "./shipping-method" export { default as ShippingMethodAdjustment } from "./shipping-method-adjustment" export { default as ShippingMethodTaxLine } from "./shipping-method-tax-line" diff --git a/packages/order/src/models/order-change-action.ts b/packages/order/src/models/order-change-action.ts index 5b3c683075..1542e2beed 100644 --- a/packages/order/src/models/order-change-action.ts +++ b/packages/order/src/models/order-change-action.ts @@ -1,5 +1,7 @@ -import { DAL } from "@medusajs/types" +import { BigNumberRawValue, DAL } from "@medusajs/types" import { + BigNumber, + MikroOrmBigNumberProperty, createPsqlIndexStatementHelper, generateEntityId, } from "@medusajs/utils" @@ -13,6 +15,7 @@ import { PrimaryKey, Property, } from "@mikro-orm/core" +import Order from "./order" import OrderChange from "./order-change" type OptionalLineItemProps = DAL.EntityDateColumns @@ -22,45 +25,87 @@ const OrderChangeIdIndex = createPsqlIndexStatementHelper({ columns: "order_change_id", }) -const ReferenceIndex = createPsqlIndexStatementHelper({ +const OrderIdIndex = createPsqlIndexStatementHelper({ tableName: "order_change_action", - columns: ["reference", "reference_id"], + columns: "order_id", +}) + +const ActionOrderingIndex = createPsqlIndexStatementHelper({ + tableName: "order_change_action", + columns: "ordering", }) @Entity({ tableName: "order_change_action" }) -@ReferenceIndex.MikroORMIndex() export default class OrderChangeAction { [OptionalProps]?: OptionalLineItemProps @PrimaryKey({ columnType: "text" }) id: string + @Property({ columnType: "integer", autoincrement: true }) + @ActionOrderingIndex.MikroORMIndex() + ordering: number + + @ManyToOne({ + entity: () => Order, + columnType: "text", + fieldName: "order_id", + cascade: [Cascade.REMOVE], + mapToPk: true, + nullable: true, + }) + @OrderIdIndex.MikroORMIndex() + order_id: string | null = null + + @ManyToOne(() => Order, { + persist: false, + nullable: true, + }) + order: Order | null = null + + @Property({ columnType: "integer", nullable: true }) + version: number | null = null + @ManyToOne({ entity: () => OrderChange, columnType: "text", fieldName: "order_change_id", cascade: [Cascade.REMOVE], mapToPk: true, + nullable: true, }) @OrderChangeIdIndex.MikroORMIndex() - order_change_id: string + order_change_id: string | null @ManyToOne(() => OrderChange, { persist: false, + nullable: true, }) - order_change: OrderChange + order_change: OrderChange | null = null + + @Property({ + columnType: "text", + nullable: true, + }) + reference: string | null = null + + @Property({ + columnType: "text", + nullable: true, + }) + reference_id: string | null = null @Property({ columnType: "text" }) - reference: string - - @Property({ columnType: "text" }) - reference_id: string + action: string @Property({ columnType: "jsonb" }) - action: Record = {} + details: Record = {} + + @MikroOrmBigNumberProperty({ nullable: true }) + amount: BigNumber | number | null = null @Property({ columnType: "jsonb", nullable: true }) - metadata: Record | null = null + raw_amount: BigNumberRawValue | null = null @Property({ columnType: "text", @@ -68,6 +113,12 @@ export default class OrderChangeAction { }) internal_note: string | null = null + @Property({ + columnType: "boolean", + defaultRaw: "false", + }) + applied: boolean = false + @Property({ onCreate: () => new Date(), columnType: "timestamptz", @@ -86,12 +137,14 @@ export default class OrderChangeAction { @BeforeCreate() onCreate() { this.id = generateEntityId(this.id, "ordchact") - this.order_change_id ??= this.order_change?.id + this.order_id ??= this.order?.id ?? null + this.order_change_id ??= this.order_change?.id ?? null } @OnInit() onInit() { this.id = generateEntityId(this.id, "ordchact") - this.order_change_id ??= this.order_change?.id + this.order_id ??= this.order?.id ?? null + this.order_change_id ??= this.order_change?.id ?? null } } diff --git a/packages/order/src/models/order-change.ts b/packages/order/src/models/order-change.ts index a93f3584a5..7b1ffbf40b 100644 --- a/packages/order/src/models/order-change.ts +++ b/packages/order/src/models/order-change.ts @@ -12,11 +12,11 @@ import { Enum, ManyToOne, OnInit, + OneToMany, OptionalProps, PrimaryKey, Property, } from "@mikro-orm/core" -import { OneToMany } from "typeorm" import Order from "./order" import OrderChangeAction from "./order-change-action" @@ -64,8 +64,8 @@ export default class OrderChange { @VersionIndex.MikroORMIndex() version: number - @OneToMany(() => OrderChangeAction, (action) => action.order_change_id, { - cascade: [Cascade.REMOVE], + @OneToMany(() => OrderChangeAction, (action) => action.order_change, { + cascade: [Cascade.PERSIST], }) actions = new Collection(this) @@ -77,7 +77,7 @@ export default class OrderChange { @Enum({ items: () => OrderChangeStatus, default: OrderChangeStatus.PENDING }) @OrderChangeStatusIndex.MikroORMIndex() - status: OrderChangeStatus + status: OrderChangeStatus = OrderChangeStatus.PENDING @Property({ columnType: "text", nullable: true }) internal_note: string | null = null @@ -92,7 +92,7 @@ export default class OrderChange { columnType: "timestamptz", nullable: true, }) - requested_at?: Date + requested_at: Date | null = null @Property({ columnType: "text", nullable: true }) confirmed_by: string | null = null // customer or user ID @@ -101,7 +101,7 @@ export default class OrderChange { columnType: "timestamptz", nullable: true, }) - confirmed_at?: Date + confirmed_at: Date | null = null @Property({ columnType: "text", nullable: true }) declined_by: string | null = null // customer or user ID diff --git a/packages/order/src/models/order-summary.ts b/packages/order/src/models/order-summary.ts new file mode 100644 index 0000000000..a77b7f553d --- /dev/null +++ b/packages/order/src/models/order-summary.ts @@ -0,0 +1,105 @@ +import { + BigNumber, + createPsqlIndexStatementHelper, + generateEntityId, +} from "@medusajs/utils" +import { + BeforeCreate, + Cascade, + Entity, + ManyToOne, + OnInit, + PrimaryKey, + Property, +} from "@mikro-orm/core" +import { Order } from "@models" + +type OrderSummaryTotals = { + total: BigNumber + subtotal: BigNumber + total_tax: BigNumber + + ordered_total: BigNumber + fulfilled_total: BigNumber + returned_total: BigNumber + return_request_total: BigNumber + write_off_total: BigNumber + projected_total: BigNumber + + net_total: BigNumber + net_subtotal: BigNumber + net_total_tax: BigNumber + + future_total: BigNumber + future_subtotal: BigNumber + future_total_tax: BigNumber + future_projected_total: BigNumber + + balance: BigNumber + future_balance: BigNumber +} + +const OrderIdVersionIndex = createPsqlIndexStatementHelper({ + tableName: "order_summary", + columns: ["order_id", "version"], +}) + +@Entity({ tableName: "order_summary" }) +@OrderIdVersionIndex.MikroORMIndex() +export default class OrderSummary { + @PrimaryKey({ columnType: "text" }) + id: string + + @ManyToOne({ + entity: () => Order, + columnType: "text", + fieldName: "order_id", + mapToPk: true, + cascade: [Cascade.REMOVE], + }) + order_id: string + + @ManyToOne({ + entity: () => Order, + fieldName: "order_id", + cascade: [Cascade.REMOVE], + persist: false, + }) + order: Order + + @Property({ + columnType: "integer", + defaultRaw: "1", + }) + version: number = 1 + + @Property({ columnType: "jsonb" }) + totals: OrderSummaryTotals | null = {} as OrderSummaryTotals + + @Property({ + onCreate: () => new Date(), + columnType: "timestamptz", + defaultRaw: "now()", + }) + created_at: Date + + @Property({ + onCreate: () => new Date(), + onUpdate: () => new Date(), + columnType: "timestamptz", + defaultRaw: "now()", + }) + updated_at: Date + + @BeforeCreate() + onCreate() { + this.id = generateEntityId(this.id, "ordsum") + this.order_id ??= this.order?.id + } + + @OnInit() + onInit() { + this.id = generateEntityId(this.id, "ordsum") + this.order_id ??= this.order?.id + } +} diff --git a/packages/order/src/models/order.ts b/packages/order/src/models/order.ts index 2bdb24d894..86c2d02ced 100644 --- a/packages/order/src/models/order.ts +++ b/packages/order/src/models/order.ts @@ -17,9 +17,9 @@ import { PrimaryKey, Property, } from "@mikro-orm/core" -import { OrderSummary } from "../types/common" import Address from "./address" import OrderItem from "./order-item" +import OrderSummary from "./order-summary" import ShippingMethod from "./shipping-method" import Transaction from "./transaction" @@ -52,12 +52,6 @@ const OrderDeletedAtIndex = createPsqlIndexStatementHelper({ where: "deleted_at IS NOT NULL", }) -const OriginalOrderIdIndex = createPsqlIndexStatementHelper({ - tableName: "order", - columns: "original_order_id", - where: "deleted_at IS NOT NULL", -}) - const CurrencyCodeIndex = createPsqlIndexStatementHelper({ tableName: "order", columns: "currency_code", @@ -97,13 +91,6 @@ export default class Order { @CustomerIdIndex.MikroORMIndex() customer_id: string | null = null - @Property({ - columnType: "text", - nullable: true, - }) - @OriginalOrderIdIndex.MikroORMIndex() - original_order_id: string | null = null - @Property({ columnType: "integer", defaultRaw: "1", @@ -154,8 +141,10 @@ export default class Order { @Property({ columnType: "boolean", nullable: true }) no_notification: boolean | null = null - @Property({ columnType: "jsonb" }) - summary: OrderSummary | null = {} as OrderSummary + @OneToMany(() => OrderSummary, (summary) => summary.order, { + cascade: [Cascade.PERSIST], + }) + summary = new Collection(this) @Property({ columnType: "jsonb", nullable: true }) metadata: Record | null = null diff --git a/packages/order/src/repositories/order.ts b/packages/order/src/repositories/order.ts index dd7065fe2c..0c9a51e3a6 100644 --- a/packages/order/src/repositories/order.ts +++ b/packages/order/src/repositories/order.ts @@ -33,31 +33,35 @@ export class OrderRepository extends DALUtils.mikroOrmBaseRepositoryFactory - p.includes("items") - )?.length + let defaultVersion = knex.raw(`"o0"."version"`) + const strategy = config.options.strategy ?? LoadStrategy.JOINED + if (strategy === LoadStrategy.SELECT_IN) { + const sql = manager + .qb(Order, "_sub0") + .select("version") + .where({ id: knex.raw(`"o0"."order_id"`) }) + .getKnexQuery() + .toString() - // If no version is specified, we default to the latest version - if (expandDetails) { - let defaultVersion = knex.raw(`"o0"."version"`) - const strategy = config.options.strategy ?? LoadStrategy.JOINED - if (strategy === LoadStrategy.SELECT_IN) { - const sql = manager - .qb(Order, "_sub0") - .select("version") - .where({ id: knex.raw(`"o0"."order_id"`) }) - .getKnexQuery() - .toString() + defaultVersion = knex.raw(`(${sql})`) + } - defaultVersion = knex.raw(`(${sql})`) - } + const version = config.where.version ?? defaultVersion + delete config.where?.version - const version = config.where.version ?? defaultVersion - delete config.where?.version + config.options.populateWhere ??= {} - config.options.populateWhere ??= {} - config.options.populateWhere.items ??= {} - config.options.populateWhere.items.version = version + config.options.populateWhere.items ??= {} + config.options.populateWhere.items.version = version + + config.options.populateWhere.summary ??= {} + config.options.populateWhere.summary.version = version + + config.options.populateWhere.shipping_methods ??= {} + config.options.populateWhere.shipping_methods.version = version + + if (!config.options.orderBy) { + config.options.orderBy = { id: "ASC" } } return await manager.find(Order, config.where, config.options) @@ -82,31 +86,34 @@ export class OrderRepository extends DALUtils.mikroOrmBaseRepositoryFactory - p.includes("items") - )?.length + let defaultVersion = knex.raw(`"o0"."version"`) + const strategy = config.options.strategy ?? LoadStrategy.JOINED + if (strategy === LoadStrategy.SELECT_IN) { + const sql = manager + .qb(Order, "_sub0") + .select("version") + .where({ id: knex.raw(`"o0"."order_id"`) }) + .getKnexQuery() + .toString() - // If no version is specified, we default to the latest version - if (expandDetails) { - let defaultVersion = knex.raw(`"o0"."version"`) - const strategy = config.options.strategy ?? LoadStrategy.JOINED - if (strategy === LoadStrategy.SELECT_IN) { - const sql = manager - .qb(Order, "_sub0") - .select("version") - .where({ id: knex.raw(`"o0"."order_id"`) }) - .getKnexQuery() - .toString() + defaultVersion = knex.raw(`(${sql})`) + } - defaultVersion = knex.raw(`(${sql})`) - } + const version = config.where.version ?? defaultVersion + delete config.where.version - const version = config.where.version ?? defaultVersion - delete config.where.version + config.options.populateWhere ??= {} + config.options.populateWhere.items ??= {} + config.options.populateWhere.items.version = version - config.options.populateWhere ??= {} - config.options.populateWhere.items ??= {} - config.options.populateWhere.items.version = version + config.options.populateWhere.summary ??= {} + config.options.populateWhere.summary.version = version + + config.options.populateWhere.shipping_methods ??= {} + config.options.populateWhere.shipping_methods.version = version + + if (!config.options.orderBy) { + config.options.orderBy = { id: "ASC" } } return await manager.findAndCount(Order, config.where, config.options) diff --git a/packages/order/src/services/__tests__/util/actions/exchanges.ts b/packages/order/src/services/__tests__/util/actions/exchanges.ts index 103d24bf5a..de2ef8e134 100644 --- a/packages/order/src/services/__tests__/util/actions/exchanges.ts +++ b/packages/order/src/services/__tests__/util/actions/exchanges.ts @@ -9,33 +9,45 @@ describe("Order Exchange - Actions", function () { quantity: 1, unit_price: 10, - fulfilled_quantity: 1, - return_requested_quantity: 0, - return_received_quantity: 0, - return_dismissed_quantity: 0, - written_off_quantity: 0, + detail: { + quantity: 1, + shipped_quantity: 1, + fulfilled_quantity: 1, + return_requested_quantity: 0, + return_received_quantity: 0, + return_dismissed_quantity: 0, + written_off_quantity: 0, + }, }, { id: "2", quantity: 2, unit_price: 100, - fulfilled_quantity: 1, - return_requested_quantity: 0, - return_received_quantity: 0, - return_dismissed_quantity: 0, - written_off_quantity: 0, + detail: { + quantity: 2, + shipped_quantity: 1, + fulfilled_quantity: 1, + return_requested_quantity: 0, + return_received_quantity: 0, + return_dismissed_quantity: 0, + written_off_quantity: 0, + }, }, { id: "3", quantity: 3, unit_price: 20, - fulfilled_quantity: 3, - return_requested_quantity: 0, - return_received_quantity: 0, - return_dismissed_quantity: 0, - written_off_quantity: 0, + detail: { + quantity: 3, + shipped_quantity: 3, + fulfilled_quantity: 3, + return_requested_quantity: 0, + return_received_quantity: 0, + return_dismissed_quantity: 0, + written_off_quantity: 0, + }, }, ], shipping_methods: [ @@ -44,8 +56,9 @@ describe("Order Exchange - Actions", function () { price: 0, }, ], - total: 270, - shipping_total: 0, + summary: { + total: 270, + }, } it("should perform an item exchage", function () { @@ -99,31 +112,43 @@ describe("Order Exchange - Actions", function () { id: "1", quantity: 1, unit_price: 10, - fulfilled_quantity: 1, - return_requested_quantity: 0, - return_received_quantity: 0, - return_dismissed_quantity: 0, - written_off_quantity: 0, + detail: { + quantity: 1, + shipped_quantity: 1, + fulfilled_quantity: 1, + return_requested_quantity: 0, + return_received_quantity: 0, + return_dismissed_quantity: 0, + written_off_quantity: 0, + }, }, { id: "2", quantity: 2, unit_price: 100, - fulfilled_quantity: 1, - return_requested_quantity: 0, - return_received_quantity: 0, - return_dismissed_quantity: 0, - written_off_quantity: 0, + detail: { + quantity: 2, + shipped_quantity: 1, + fulfilled_quantity: 1, + return_requested_quantity: 0, + return_received_quantity: 0, + return_dismissed_quantity: 0, + written_off_quantity: 0, + }, }, { id: "3", quantity: 3, unit_price: 20, - fulfilled_quantity: 3, - return_requested_quantity: 1, - return_received_quantity: 0, - return_dismissed_quantity: 0, - written_off_quantity: 0, + detail: { + quantity: 3, + shipped_quantity: 3, + fulfilled_quantity: 3, + return_requested_quantity: 1, + return_received_quantity: 0, + return_dismissed_quantity: 0, + written_off_quantity: 0, + }, }, { id: "item_555", diff --git a/packages/order/src/services/__tests__/util/actions/returns.ts b/packages/order/src/services/__tests__/util/actions/returns.ts index 71ac1dce71..45356e4bde 100644 --- a/packages/order/src/services/__tests__/util/actions/returns.ts +++ b/packages/order/src/services/__tests__/util/actions/returns.ts @@ -9,33 +9,45 @@ describe("Order Return - Actions", function () { quantity: 1, unit_price: 10, - fulfilled_quantity: 1, - return_requested_quantity: 0, - return_received_quantity: 0, - return_dismissed_quantity: 0, - written_off_quantity: 0, + detail: { + quantity: 1, + shipped_quantity: 1, + fulfilled_quantity: 1, + return_requested_quantity: 0, + return_received_quantity: 0, + return_dismissed_quantity: 0, + written_off_quantity: 0, + }, }, { id: "2", quantity: 2, unit_price: 100, - fulfilled_quantity: 1, - return_requested_quantity: 0, - return_received_quantity: 0, - return_dismissed_quantity: 0, - written_off_quantity: 0, + detail: { + quantity: 2, + shipped_quantity: 1, + fulfilled_quantity: 1, + return_requested_quantity: 0, + return_received_quantity: 0, + return_dismissed_quantity: 0, + written_off_quantity: 0, + }, }, { id: "3", quantity: 3, unit_price: 20, - fulfilled_quantity: 3, - return_requested_quantity: 0, - return_received_quantity: 0, - return_dismissed_quantity: 0, - written_off_quantity: 0, + detail: { + quantity: 3, + shipped_quantity: 3, + fulfilled_quantity: 3, + return_requested_quantity: 0, + return_received_quantity: 0, + return_dismissed_quantity: 0, + written_off_quantity: 0, + }, }, ], shipping_methods: [ @@ -45,7 +57,6 @@ describe("Order Return - Actions", function () { }, ], total: 270, - shipping_total: 0, } it("should validate return requests", function () { @@ -66,7 +77,9 @@ describe("Order Return - Actions", function () { order: originalOrder, actions, }) - }).toThrow("Cannot request to return more items than what was fulfilled.") + }).toThrow( + "Cannot request to return more items than what was shipped for item 1." + ) expect(() => { actions[0].details!.reference_id = undefined @@ -87,6 +100,7 @@ describe("Order Return - Actions", function () { it("should validate return received", function () { const [] = [] + const actions = [ { action: ChangeActionType.RETURN_ITEM, @@ -116,31 +130,43 @@ describe("Order Return - Actions", function () { id: "1", quantity: 1, unit_price: 10, - fulfilled_quantity: 1, - return_requested_quantity: 0, - return_received_quantity: 0, - return_dismissed_quantity: 0, - written_off_quantity: 0, + detail: { + quantity: 1, + shipped_quantity: 1, + fulfilled_quantity: 1, + return_requested_quantity: 0, + return_received_quantity: 0, + return_dismissed_quantity: 0, + written_off_quantity: 0, + }, }, { id: "2", quantity: 2, unit_price: 100, - fulfilled_quantity: 1, - return_requested_quantity: 1, - return_received_quantity: 0, - return_dismissed_quantity: 0, - written_off_quantity: 0, + detail: { + quantity: 2, + shipped_quantity: 1, + fulfilled_quantity: 1, + return_requested_quantity: 1, + return_received_quantity: 0, + return_dismissed_quantity: 0, + written_off_quantity: 0, + }, }, { id: "3", quantity: 3, unit_price: 20, - fulfilled_quantity: 3, - return_requested_quantity: 2, - return_received_quantity: 0, - return_dismissed_quantity: 0, - written_off_quantity: 0, + detail: { + quantity: 3, + shipped_quantity: 3, + fulfilled_quantity: 3, + return_requested_quantity: 2, + return_received_quantity: 0, + return_dismissed_quantity: 0, + written_off_quantity: 0, + }, }, ]) @@ -162,7 +188,9 @@ describe("Order Return - Actions", function () { }, ], }) - }).toThrow("Cannot request to return more items than what was fulfilled.") + }).toThrow( + "Cannot request to return more items than what was shipped for item 3." + ) expect(() => { calculateOrderChange({ @@ -178,7 +206,7 @@ describe("Order Return - Actions", function () { ], }) }).toThrow( - "Cannot receive more items than what was requested to be returned." + "Cannot receive more items than what was requested to be returned for item 3." ) expect(() => { @@ -202,7 +230,7 @@ describe("Order Return - Actions", function () { ], }) }).toThrow( - "Cannot receive more items than what was requested to be returned." + "Cannot receive more items than what was requested to be returned for item 3." ) const receivedChanges = calculateOrderChange({ @@ -230,31 +258,43 @@ describe("Order Return - Actions", function () { id: "1", quantity: 1, unit_price: 10, - fulfilled_quantity: 1, - return_requested_quantity: 0, - return_received_quantity: 0, - return_dismissed_quantity: 0, - written_off_quantity: 0, + detail: { + quantity: 1, + shipped_quantity: 1, + fulfilled_quantity: 1, + return_requested_quantity: 0, + return_received_quantity: 0, + return_dismissed_quantity: 0, + written_off_quantity: 0, + }, }, { id: "2", quantity: 2, unit_price: 100, - fulfilled_quantity: 1, - return_requested_quantity: 1, - return_received_quantity: 0, - return_dismissed_quantity: 0, - written_off_quantity: 0, + detail: { + quantity: 2, + shipped_quantity: 1, + fulfilled_quantity: 1, + return_requested_quantity: 1, + return_received_quantity: 0, + return_dismissed_quantity: 0, + written_off_quantity: 0, + }, }, { id: "3", quantity: 3, unit_price: 20, - fulfilled_quantity: 3, - return_requested_quantity: 0, - return_received_quantity: 1, - return_dismissed_quantity: 1, - written_off_quantity: 0, + detail: { + quantity: 3, + shipped_quantity: 3, + fulfilled_quantity: 3, + return_requested_quantity: 0, + return_received_quantity: 1, + return_dismissed_quantity: 1, + written_off_quantity: 0, + }, }, ]) }) diff --git a/packages/order/src/services/index.ts b/packages/order/src/services/index.ts index 383ee9897a..cf72196234 100644 --- a/packages/order/src/services/index.ts +++ b/packages/order/src/services/index.ts @@ -1 +1,3 @@ +export { default as OrderChangeService } from "./order-change-service" export { default as OrderModuleService } from "./order-module-service" +export { default as OrderService } from "./order-service" diff --git a/packages/order/src/services/order-change-service.ts b/packages/order/src/services/order-change-service.ts new file mode 100644 index 0000000000..9b3d6a3eeb --- /dev/null +++ b/packages/order/src/services/order-change-service.ts @@ -0,0 +1,135 @@ +import { + Context, + DAL, + FindConfig, + OrderTypes, + RepositoryService, +} from "@medusajs/types" +import { + InjectManager, + InjectTransactionManager, + MedusaContext, + MedusaError, + ModulesSdkUtils, + OrderChangeStatus, + deduplicate, +} from "@medusajs/utils" +import { OrderChange } from "@models" + +type InjectedDependencies = { + orderChangeRepository: DAL.RepositoryService +} + +export default class OrderChangeService< + TEntity extends OrderChange = OrderChange +> extends ModulesSdkUtils.internalModuleServiceFactory( + OrderChange +) { + protected readonly orderChangeRepository_: RepositoryService + + constructor(container: InjectedDependencies) { + // @ts-ignore + super(...arguments) + this.orderChangeRepository_ = container.orderChangeRepository + } + + @InjectManager("orderChangeRepository_") + async listCurrentOrderChange( + orderId: string | string[], + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const allChanges = await super.list( + { order_id: orderId }, + config ?? { + select: ["order_id", "status", "version"], + order: { + order_id: "ASC", + version: "DESC", + }, + } + ) + if (!allChanges.length) { + return [] + } + + const lastChanges: string[] = [] + + const seen = new Set() + for (let i = 0; i < allChanges.length; i++) { + if (seen.has(allChanges[i].order_id)) { + continue + } + seen.add(allChanges[i].order_id) + + if (this.isActive(allChanges[i])) { + lastChanges.push(allChanges[i].id) + } + } + + let orderChange!: TEntity + if (allChanges?.length > 0) { + if (this.isActive(allChanges[0])) { + orderChange = allChanges[0] + } + } + + const relations = deduplicate([...(config.relations ?? []), "actions"]) + config.relations = relations + + const queryConfig = ModulesSdkUtils.buildQuery( + { + id: lastChanges, + order: { + items: { + version: orderChange.version, + }, + }, + }, + config + ) + + return await this.orderChangeRepository_.find(queryConfig, sharedContext) + } + + isActive(orderChange: OrderChange): boolean { + return ( + orderChange.status === OrderChangeStatus.PENDING || + orderChange.status === OrderChangeStatus.REQUESTED + ) + } + + async create( + data: Partial[], + sharedContext?: Context + ): Promise + + async create( + data: Partial, + sharedContext?: Context + ): Promise + + @InjectTransactionManager("orderChangeRepository_") + async create( + data: Partial[] | Partial, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const dataArr = Array.isArray(data) ? data : [data] + const activeOrderEdit = await this.listCurrentOrderChange( + dataArr.map((d) => d.order_id!), + {}, + sharedContext + ) + + if (activeOrderEdit.length > 0) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `An active order change already exists for the order(s) ${activeOrderEdit + .map((a) => a.order_id) + .join(",")}` + ) + } + + return await super.create(dataArr, sharedContext) + } +} diff --git a/packages/order/src/services/order-module-service.ts b/packages/order/src/services/order-module-service.ts index cbed513952..d06c381530 100644 --- a/packages/order/src/services/order-module-service.ts +++ b/packages/order/src/services/order-module-service.ts @@ -11,6 +11,7 @@ import { UpdateOrderItemWithSelectorDTO, } from "@medusajs/types" import { + deduplicate, InjectManager, InjectTransactionManager, isObject, @@ -18,6 +19,8 @@ import { MedusaContext, MedusaError, ModulesSdkUtils, + OrderChangeStatus, + promiseAll, } from "@medusajs/utils" import { Address, @@ -28,6 +31,7 @@ import { OrderChange, OrderChangeAction, OrderItem, + OrderSummary, ShippingMethod, ShippingMethodAdjustment, ShippingMethodTaxLine, @@ -45,11 +49,14 @@ import { UpdateOrderShippingMethodTaxLineDTO, } from "@types" import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config" +import { calculateOrderChange } from "../utils" import { formatOrder } from "../utils/transform-order" +import OrderChangeService from "./order-change-service" +import OrderService from "./order-service" type InjectedDependencies = { baseRepository: DAL.RepositoryService - orderService: ModulesSdkTypes.InternalModuleService + orderService: OrderService addressService: ModulesSdkTypes.InternalModuleService lineItemService: ModulesSdkTypes.InternalModuleService shippingMethodAdjustmentService: ModulesSdkTypes.InternalModuleService @@ -58,9 +65,10 @@ type InjectedDependencies = { lineItemTaxLineService: ModulesSdkTypes.InternalModuleService shippingMethodTaxLineService: ModulesSdkTypes.InternalModuleService transactionService: ModulesSdkTypes.InternalModuleService - orderChangeService: ModulesSdkTypes.InternalModuleService + orderChangeService: OrderChangeService orderChangeActionService: ModulesSdkTypes.InternalModuleService orderItemService: ModulesSdkTypes.InternalModuleService + orderSummaryService: ModulesSdkTypes.InternalModuleService } const generateMethodForModels = [ @@ -75,6 +83,7 @@ const generateMethodForModels = [ OrderChange, OrderChangeAction, OrderItem, + OrderSummary, ] export default class OrderModuleService< @@ -89,7 +98,8 @@ export default class OrderModuleService< TTransaction extends Transaction = Transaction, TOrderChange extends OrderChange = OrderChange, TOrderChangeAction extends OrderChangeAction = OrderChangeAction, - TOrderItem extends OrderItem = OrderItem + TOrderItem extends OrderItem = OrderItem, + TOrderSummary extends OrderSummary = OrderSummary > extends ModulesSdkUtils.abstractModuleServiceFactory< InjectedDependencies, @@ -105,15 +115,16 @@ export default class OrderModuleService< } ShippingMethodTaxLine: { dto: OrderTypes.OrderShippingMethodTaxLineDTO } Transaction: { dto: OrderTypes.OrderTransactionDTO } - Change: { dto: OrderTypes.OrderChangeDTO } - ChangeAction: { dto: OrderTypes.OrderChangeActionDTO } + OrderChange: { dto: OrderTypes.OrderChangeDTO } + OrderChangeAction: { dto: OrderTypes.OrderChangeActionDTO } OrderItem: { dto: OrderTypes.OrderItemDTO } + OrderSummary: { dto: OrderTypes.OrderSummaryDTO } } >(Order, generateMethodForModels, entityNameToLinkableKeysMap) implements IOrderModuleService { protected baseRepository_: DAL.RepositoryService - protected orderService_: ModulesSdkTypes.InternalModuleService + protected orderService_: OrderService protected addressService_: ModulesSdkTypes.InternalModuleService protected lineItemService_: ModulesSdkTypes.InternalModuleService protected shippingMethodAdjustmentService_: ModulesSdkTypes.InternalModuleService @@ -122,9 +133,10 @@ export default class OrderModuleService< protected lineItemTaxLineService_: ModulesSdkTypes.InternalModuleService protected shippingMethodTaxLineService_: ModulesSdkTypes.InternalModuleService protected transactionService_: ModulesSdkTypes.InternalModuleService - protected orderChangeService_: ModulesSdkTypes.InternalModuleService + protected orderChangeService_: OrderChangeService protected orderChangeActionService_: ModulesSdkTypes.InternalModuleService protected orderItemService_: ModulesSdkTypes.InternalModuleService + protected orderSummaryService_: ModulesSdkTypes.InternalModuleService constructor( { @@ -141,6 +153,7 @@ export default class OrderModuleService< orderChangeService, orderChangeActionService, orderItemService, + orderSummaryService, }: InjectedDependencies, protected readonly moduleDeclaration: InternalModuleDeclaration ) { @@ -160,6 +173,7 @@ export default class OrderModuleService< this.orderChangeService_ = orderChangeService this.orderChangeActionService_ = orderChangeActionService this.orderItemService_ = orderItemService + this.orderSummaryService_ = orderSummaryService } __joinerConfig(): ModuleJoinerConfig { @@ -217,6 +231,20 @@ export default class OrderModuleService< ): Promise { const input = Array.isArray(data) ? data : [data] + // TODO: calculate order total + for (const inp of input) { + const ordTotals = inp as any + ordTotals.summary = { + totals: { + total: + inp.items?.reduce((acc, item) => { + const it = item as any + return acc + it.unit_price * it.quantity + }, 0) ?? 0, + }, + } + } + const orders = await this.create_(input, sharedContext) const result = await this.list( @@ -227,12 +255,14 @@ export default class OrderModuleService< relations: [ "shipping_address", "billing_address", + "summary", "items", "items.tax_lines", "items.adjustments", "shipping_methods", "shipping_methods.tax_lines", "shipping_methods.adjustments", + "transactions", ], }, sharedContext @@ -428,15 +458,19 @@ export default class OrderModuleService< const item = lineItems[i] const toCreate = data[i] - orderItemToCreate.push({ - order_id: toCreate.order_id, - version: toCreate.version ?? 1, - item_id: item.id, - quantity: toCreate.quantity, - }) + if (toCreate.order_id) { + orderItemToCreate.push({ + order_id: toCreate.order_id, + version: toCreate.version ?? 1, + item_id: item.id, + quantity: toCreate.quantity, + }) + } } - await this.orderItemService_.create(orderItemToCreate, sharedContext) + if (orderItemToCreate.length) { + await this.orderItemService_.create(orderItemToCreate, sharedContext) + } return lineItems } @@ -808,7 +842,7 @@ export default class OrderModuleService< @InjectTransactionManager("baseRepository_") protected async addShippingMethods_( orderId: string, - data: OrderTypes.CreateOrderShippingMethodDTO[], + data: CreateOrderShippingMethodDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { const order = await this.retrieve( @@ -821,7 +855,7 @@ export default class OrderModuleService< return { ...method, order_id: order.id, - version: order.version ?? 1, + version: method.version ?? order.version ?? 1, } }) @@ -1501,4 +1535,496 @@ export default class OrderModuleService< await this.shippingMethodTaxLineService_.delete(ids, sharedContext) } + + async createOrderChange( + data: OrderTypes.CreateOrderChangeDTO, + sharedContext?: Context + ): Promise + + async createOrderChange( + data: OrderTypes.CreateOrderChangeDTO[], + sharedContext?: Context + ): Promise + + @InjectManager("baseRepository_") + async createOrderChange( + data: OrderTypes.CreateOrderChangeDTO | OrderTypes.CreateOrderChangeDTO[], + sharedContext?: Context + ): Promise { + const changes = await this.createOrderChange_(data, sharedContext) + + return await this.baseRepository_.serialize( + Array.isArray(data) ? changes : changes[0], + { + populate: true, + } + ) + } + + @InjectTransactionManager("baseRepository_") + protected async createOrderChange_( + data: OrderTypes.CreateOrderChangeDTO | OrderTypes.CreateOrderChangeDTO[], + sharedContext?: Context + ): Promise { + const dataArr = Array.isArray(data) ? data : [data] + + const orderIds: string[] = [] + const dataMap: Record = {} + for (const change of dataArr) { + orderIds.push(change.order_id) + dataMap[change.order_id] = change + } + + const orders = await this.list( + { + id: orderIds, + }, + { + select: ["id", "version"], + }, + sharedContext + ) + + if (orders.length !== orderIds.length) { + const foundOrders = orders.map((o) => o.id) + const missing = orderIds.filter((id) => !foundOrders.includes(id)) + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Order could not be found: ${missing.join(", ")}` + ) + } + + const input = orders.map((order) => { + return { + ...dataMap[order.id], + version: order.version + 1, + } as any + }) + + return await this.orderChangeService_.create(input, sharedContext) + } + + async cancelOrderChange( + orderId: string, + sharedContext?: Context + ): Promise + + async cancelOrderChange( + orderId: string[], + sharedContext?: Context + ): Promise + + async cancelOrderChange( + data: OrderTypes.CancelOrderChangeDTO, + sharedContext?: Context + ): Promise + + async cancelOrderChange( + data: OrderTypes.CancelOrderChangeDTO[], + sharedContext?: Context + ): Promise + + @InjectTransactionManager("baseRepository_") + async cancelOrderChange( + orderChangeIdOrData: + | string + | string[] + | OrderTypes.CancelOrderChangeDTO + | OrderTypes.CancelOrderChangeDTO[], + sharedContext?: Context + ): Promise { + const data = Array.isArray(orderChangeIdOrData) + ? orderChangeIdOrData + : [orderChangeIdOrData] + + const orderChangeIds = isString(data[0]) + ? data + : (data as any).map((dt) => dt.id) + + await this.getAndValidateOrderChange_(orderChangeIds, false, sharedContext) + + const updates = data.map((dt) => { + return { + ...(isString(dt) ? { id: dt } : dt), + canceled_at: new Date(), + status: OrderChangeStatus.CANCELED, + } + }) + + await this.orderChangeService_.update(updates as any, sharedContext) + } + + async confirmOrderChange( + orderId: string, + sharedContext?: Context + ): Promise + + async confirmOrderChange( + orderId: string[], + sharedContext?: Context + ): Promise + + async confirmOrderChange( + data: OrderTypes.ConfirmOrderChangeDTO, + sharedContext?: Context + ): Promise + + async confirmOrderChange( + data: OrderTypes.ConfirmOrderChangeDTO[], + sharedContext?: Context + ): Promise + + @InjectTransactionManager("baseRepository_") + async confirmOrderChange( + orderChangeIdOrData: + | string + | string[] + | OrderTypes.ConfirmOrderChangeDTO + | OrderTypes.ConfirmOrderChangeDTO[], + sharedContext?: Context + ): Promise { + const data = Array.isArray(orderChangeIdOrData) + ? orderChangeIdOrData + : [orderChangeIdOrData] + + const orderChangeIds = isString(data[0]) + ? data + : (data as any).map((dt) => dt.id) + + const orderChange = await this.getAndValidateOrderChange_( + orderChangeIds, + true, + sharedContext + ) + + const updates = data.map((dt) => { + return { + ...(isString(dt) ? { id: dt } : dt), + confirmed_at: new Date(), + status: OrderChangeStatus.CONFIRMED, + } + }) + + await this.orderChangeService_.update(updates as any, sharedContext) + + const orderChanges = orderChange.map((change) => { + change.actions = change.actions.map((action) => { + return { + ...action, + version: change.version, + order_id: change.order_id, + } + }) + return change.actions + }) + + await this.applyOrderChanges_(orderChanges.flat(), sharedContext) + } + + async declineOrderChange( + orderId: string, + sharedContext?: Context + ): Promise + + async declineOrderChange( + orderId: string[], + sharedContext?: Context + ): Promise + + async declineOrderChange( + data: OrderTypes.DeclineOrderChangeDTO, + sharedContext?: Context + ): Promise + + async declineOrderChange( + data: OrderTypes.DeclineOrderChangeDTO[], + sharedContext?: Context + ): Promise + + @InjectTransactionManager("baseRepository_") + async declineOrderChange( + orderChangeIdOrData: + | string + | string[] + | OrderTypes.DeclineOrderChangeDTO + | OrderTypes.DeclineOrderChangeDTO[], + sharedContext?: Context + ): Promise { + const data = Array.isArray(orderChangeIdOrData) + ? orderChangeIdOrData + : [orderChangeIdOrData] + + const orderChangeIds = isString(data[0]) + ? data + : (data as any).map((dt) => dt.id) + + await this.getAndValidateOrderChange_(orderChangeIds, false, sharedContext) + + const updates = data.map((dt) => { + return { + ...(isString(dt) ? { id: dt } : dt), + declined_at: new Date(), + status: OrderChangeStatus.DECLINED, + } + }) + + await this.orderChangeService_.update(updates as any, sharedContext) + } + + @InjectManager("baseRepository_") + async applyPendingOrderActions( + orderId: string | string[], + sharedContext?: Context + ): Promise { + const orderIds = Array.isArray(orderId) ? orderId : [orderId] + + const orders = await this.list( + { id: orderIds }, + { + select: ["id", "version"], + }, + sharedContext + ) + + const changes = await this.orderChangeActionService_.list( + { + order_id: orders.map((order) => order.id), + version: orders[0].version, + applied: false, + }, + { + select: [ + "id", + "order_id", + "ordering", + "version", + "applied", + "reference", + "reference_id", + "action", + "details", + "amount", + "raw_amount", + "internal_note", + ], + order: { + ordering: "ASC", + }, + }, + sharedContext + ) + + await this.applyOrderChanges_(changes, sharedContext) + } + + private async getAndValidateOrderChange_( + orderChangeIds: string[], + includeActions: boolean, + sharedContext?: Context + ): Promise { + const options = { + select: ["id", "order_id", "version", "status"], + relations: [] as string[], + order: {}, + } + + if (includeActions) { + options.select.push("actions") + options.relations.push("actions") + options.order = { + actions: { + ordering: "ASC", + }, + } + } + + const orderChanges = await this.listOrderChanges( + { + id: orderChangeIds, + }, + options, + sharedContext + ) + + if (orderChanges.length !== orderChangeIds.length) { + const foundOrders = orderChanges.map((o) => o.id) + const missing = orderChangeIds.filter((id) => !foundOrders.includes(id)) + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Order Change could not be found: ${missing.join(", ")}` + ) + } + + for (const orderChange of orderChanges) { + const notAllowed: string[] = [] + if ( + !( + orderChange.status === OrderChangeStatus.PENDING || + orderChange.status === OrderChangeStatus.REQUESTED + ) + ) { + notAllowed.push(orderChange.id) + } + + if (notAllowed.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Order Change cannot be modified: ${notAllowed.join(", ")}.` + ) + } + } + + return orderChanges + } + + @InjectTransactionManager("baseRepository_") + async addOrderAction(data: any, sharedContext?: Context): Promise { + let dataArr = Array.isArray(data) ? data : [data] + + const orderChangeMap = {} + const orderChangeIds = dataArr + .map((data, idx) => { + if (data.order_change_id) { + orderChangeMap[data.order_change_id] ??= [] + orderChangeMap[data.order_change_id].push(dataArr[idx]) + } + return data.order_change_id + }) + .filter(Boolean) + + if (orderChangeIds.length) { + const ordChanges = await this.getAndValidateOrderChange_( + orderChangeIds, + false, + sharedContext + ) + for (const ordChange of ordChanges) { + orderChangeMap[ordChange.id].forEach((data) => { + if (data) { + data.order_id = ordChange.order_id + data.version = ordChange.version + } + }) + } + } + + return await this.orderChangeActionService_.create(dataArr, sharedContext) + } + + private async applyOrderChanges_( + changeActions: any[], + sharedContext?: Context + ): Promise { + type ApplyOrderChangeDTO = { + id: string + order_id: string + version: number + actions: OrderChangeAction[] + applied: boolean + } + + const actionsMap: Record = {} + const ordersIds: string[] = [] + const usedActions: any[] = [] + + for (const action of changeActions as ApplyOrderChangeDTO[]) { + if (action.applied) { + continue + } + + ordersIds.push(action.order_id) + + actionsMap[action.order_id] ??= [] + actionsMap[action.order_id].push(action) + + usedActions.push({ + selector: { + id: action.id, + }, + data: { + applied: true, + }, + }) + } + + if (!ordersIds.length) { + return + } + + const orders = await this.list( + { id: deduplicate(ordersIds) }, + { + select: ["id", "version", "items.detail", "transactions", "summary"], + relations: ["transactions", "items", "items.detail"], + }, + sharedContext + ) + + const itemsToUpsert: OrderItem[] = [] + const summariesToUpdate: any[] = [] + const orderToUpdate: any[] = [] + + for (const order of orders) { + const calculated = calculateOrderChange({ + order: order as any, + actions: actionsMap[order.id], + transactions: order.transactions, + }) + + const version = actionsMap[order.id][0].version! + + for (const item of calculated.order.items) { + itemsToUpsert.push({ + id: item.detail.id, + item_id: item.id, + order_id: order.id, + version, + quantity: item.detail.quantity, + fulfilled_quantity: item.detail.fulfilled_quantity, + shipped_quantity: item.detail.shipped_quantity, + return_requested_quantity: item.detail.return_requested_quantity, + return_received_quantity: item.detail.return_received_quantity, + return_dismissed_quantity: item.detail.return_dismissed_quantity, + written_off_quantity: item.detail.written_off_quantity, + metadata: item.detail.metadata, + } as any) + } + + if (version > order.version) { + orderToUpdate.push({ + selector: { + id: order.id, + }, + data: { + version, + }, + }) + } + + summariesToUpdate.push({ + selector: { + order_id: order.id, + }, + data: { + version, + totals: calculated.summary, + }, + }) + } + + await promiseAll([ + orderToUpdate.length + ? this.orderService_.update(orderToUpdate, sharedContext) + : null, + usedActions.length + ? this.orderChangeActionService_.update(usedActions, sharedContext) + : null, + itemsToUpsert.length + ? this.orderItemService_.upsert(itemsToUpsert, sharedContext) + : null, + summariesToUpdate.length + ? this.orderSummaryService_.update(summariesToUpdate, sharedContext) + : null, + ]) + } } diff --git a/packages/order/src/types/common.ts b/packages/order/src/types/common.ts deleted file mode 100644 index 3eb338e720..0000000000 --- a/packages/order/src/types/common.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { BigNumber } from "@medusajs/utils" - -export type OrderSummary = { - total: BigNumber - subtotal: BigNumber - total_tax: BigNumber - - ordered_total: BigNumber - fulfilled_total: BigNumber - returned_total: BigNumber - return_request_total: BigNumber - write_off_total: BigNumber - projected_total: BigNumber - - net_total: BigNumber - net_subtotal: BigNumber - net_total_tax: BigNumber - - future_total: BigNumber - future_subtotal: BigNumber - future_total_tax: BigNumber - future_projected_total: BigNumber - - balance: BigNumber - future_balance: BigNumber -} - -export type ItemSummary = { - returnable_quantity: BigNumber - ordered_quantity: BigNumber - fulfilled_quantity: BigNumber - return_requested_quantity: BigNumber - return_received_quantity: BigNumber - return_dismissed_quantity: BigNumber - written_off_quantity: BigNumber -} diff --git a/packages/order/src/types/shipping-method.ts b/packages/order/src/types/shipping-method.ts index 88cf191c3f..3bc3570427 100644 --- a/packages/order/src/types/shipping-method.ts +++ b/packages/order/src/types/shipping-method.ts @@ -1,10 +1,10 @@ import { BigNumberInput } from "@medusajs/types" export interface CreateOrderShippingMethodDTO { - version?: number name: string shipping_method_id: string order_id: string + version?: number amount: BigNumberInput data?: Record } diff --git a/packages/order/src/types/utils/index.ts b/packages/order/src/types/utils/index.ts index ddfcc43312..5ccaf57451 100644 --- a/packages/order/src/types/utils/index.ts +++ b/packages/order/src/types/utils/index.ts @@ -4,11 +4,17 @@ export type VirtualOrder = { unit_price: number quantity: number - fulfilled_quantity: number - return_requested_quantity: number - return_received_quantity: number - return_dismissed_quantity: number - written_off_quantity: number + detail: { + id?: string + quantity: number + shipped_quantity: number + fulfilled_quantity: number + return_requested_quantity: number + return_received_quantity: number + return_dismissed_quantity: number + written_off_quantity: number + metadata?: Record + } }[] shipping_methods: { @@ -16,8 +22,11 @@ export type VirtualOrder = { price: number }[] - total: number - shipping_total: number + summary: { + total: number + } + + metadata?: Record } export enum EVENT_STATUS { @@ -26,7 +35,7 @@ export enum EVENT_STATUS { DONE = "done", } -export interface OrderSummary { +export interface OrderSummaryCalculated { currentOrderTotal: number originalOrderTotal: number transactionTotal: number @@ -70,7 +79,7 @@ export type OrderReferences = { action: InternalOrderChangeEvent previousEvents?: InternalOrderChangeEvent[] currentOrder: VirtualOrder - summary: OrderSummary + summary: OrderSummaryCalculated transactions: OrderTransaction[] type: ActionTypeDefinition actions: InternalOrderChangeEvent[] diff --git a/packages/order/src/utils/action-key.ts b/packages/order/src/utils/action-key.ts index 84ad507c49..f2f7b03f94 100644 --- a/packages/order/src/utils/action-key.ts +++ b/packages/order/src/utils/action-key.ts @@ -8,5 +8,6 @@ export enum ChangeActionType { RECEIVE_RETURN_ITEM = "RECEIVE_RETURN_ITEM", RETURN_ITEM = "RETURN_ITEM", SHIPPING_ADD = "SHIPPING_ADD", + SHIP_ITEM = "SHIP_ITEM", WRITE_OFF_ITEM = "WRITE_OFF_ITEM", } diff --git a/packages/order/src/utils/actions/cancel-return.ts b/packages/order/src/utils/actions/cancel-return.ts index 83f41dae59..e39e69d4c2 100644 --- a/packages/order/src/utils/actions/cancel-return.ts +++ b/packages/order/src/utils/actions/cancel-return.ts @@ -8,7 +8,9 @@ OrderChangeProcessing.registerActionType(ChangeActionType.CANCEL_RETURN, { (item) => item.id === action.details.reference_id )! - existing.return_requested_quantity -= action.details.quantity + existing.detail.return_requested_quantity ??= 0 + + existing.detail.return_requested_quantity -= action.details.quantity return action.details.unit_price * action.details.quantity }, @@ -17,7 +19,7 @@ OrderChangeProcessing.registerActionType(ChangeActionType.CANCEL_RETURN, { (item) => item.id === action.details.reference_id )! - existing.return_requested_quantity += action.details.quantity + existing.detail.return_requested_quantity += action.details.quantity }, validate({ action, currentOrder }) { const refId = action.details?.reference_id @@ -28,6 +30,13 @@ OrderChangeProcessing.registerActionType(ChangeActionType.CANCEL_RETURN, { ) } + if (!isDefined(action.amount) && !isDefined(action.details?.unit_price)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Unit price of item ${action.reference_id} is required if no action.amount is provided.` + ) + } + const existing = currentOrder.items.find((item) => item.id === refId) if (!existing) { @@ -37,13 +46,17 @@ OrderChangeProcessing.registerActionType(ChangeActionType.CANCEL_RETURN, { ) } - const notFulfilled = - (existing.quantity as number) - (existing.fulfilled_quantity as number) - - if (action.details.quantity > notFulfilled) { + if (!action.details?.quantity) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - "Cannot fulfill more items than what was ordered." + `Quantity to cancel return of item ${refId} is required.` + ) + } + + if (action.details?.quantity > existing.detail?.return_requested_quantity) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Cannot cancel more items than what was requested to return for item ${refId}.` ) } }, diff --git a/packages/order/src/utils/actions/fulfill-item.ts b/packages/order/src/utils/actions/fulfill-item.ts index c4d8a62aa3..17649dac3b 100644 --- a/packages/order/src/utils/actions/fulfill-item.ts +++ b/packages/order/src/utils/actions/fulfill-item.ts @@ -8,17 +8,19 @@ OrderChangeProcessing.registerActionType(ChangeActionType.FULFILL_ITEM, { (item) => item.id === action.details.reference_id )! - existing.fulfilled_quantity += action.details.quantity + existing.detail.fulfilled_quantity ??= 0 + + existing.detail.fulfilled_quantity += action.details.quantity }, revert({ action, currentOrder }) { const existing = currentOrder.items.find( (item) => item.id === action.reference_id )! - existing.fulfilled_quantity -= action.details.quantity + existing.detail.fulfilled_quantity -= action.details.quantity }, validate({ action, currentOrder }) { - const refId = action.details.reference_id + const refId = action.details?.reference_id if (!isDefined(refId)) { throw new MedusaError( MedusaError.Types.INVALID_DATA, @@ -34,10 +36,28 @@ OrderChangeProcessing.registerActionType(ChangeActionType.FULFILL_ITEM, { ) } - if (action.details.quantity < 1) { + if (!action.details?.quantity) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - "Quantity must be greater than 0." + `Quantity to fulfill of item ${refId} is required.` + ) + } + + if (action.details?.quantity < 1) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Quantity of item ${refId} must be greater than 0.` + ) + } + + const notFulfilled = + (existing.quantity as number) - + (existing.detail?.fulfilled_quantity as number) + + if (action.details?.quantity > notFulfilled) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Cannot fulfill more items than what was ordered for item ${refId}.` ) } }, diff --git a/packages/order/src/utils/actions/index.ts b/packages/order/src/utils/actions/index.ts index 74d67f0ba1..ff28c4a921 100644 --- a/packages/order/src/utils/actions/index.ts +++ b/packages/order/src/utils/actions/index.ts @@ -6,4 +6,5 @@ export * from "./item-remove" export * from "./receive-damaged-return-item" export * from "./receive-return-item" export * from "./return-item" +export * from "./ship-item" export * from "./shipping-add" diff --git a/packages/order/src/utils/actions/item-add.ts b/packages/order/src/utils/actions/item-add.ts index 2a3bda16b2..6f112d748c 100644 --- a/packages/order/src/utils/actions/item-add.ts +++ b/packages/order/src/utils/actions/item-add.ts @@ -10,12 +10,16 @@ OrderChangeProcessing.registerActionType(ChangeActionType.ITEM_ADD, { ) if (existing) { + existing.detail.quantity ??= 0 + existing.quantity += action.details.quantity + existing.detail.quantity += action.details.quantity } else { currentOrder.items.push({ id: action.reference_id!, unit_price: action.details.unit_price, quantity: action.details.quantity, + // detail: {} } as VirtualOrder["items"][0]) } @@ -29,6 +33,7 @@ OrderChangeProcessing.registerActionType(ChangeActionType.ITEM_ADD, { if (existingIndex > -1) { const existing = currentOrder.items[existingIndex] existing.quantity -= action.details.quantity + existing.detail.quantity -= action.details.quantity if (existing.quantity <= 0) { currentOrder.items.splice(existingIndex, 1) @@ -36,6 +41,7 @@ OrderChangeProcessing.registerActionType(ChangeActionType.ITEM_ADD, { } }, validate({ action }) { + const refId = action.reference_id if (!isDefined(action.reference_id)) { throw new MedusaError( MedusaError.Types.INVALID_DATA, @@ -43,17 +49,24 @@ OrderChangeProcessing.registerActionType(ChangeActionType.ITEM_ADD, { ) } - if (!isDefined(action.details.unit_price)) { + if (!isDefined(action.amount) && !isDefined(action.details?.unit_price)) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - "Unit price is required." + `Unit price of item ${refId} is required if no action.amount is provided.` ) } - if (action.details.quantity < 1) { + if (!action.details?.quantity) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - "Quantity must be greater than 0." + `Quantity of item ${refId} is required.` + ) + } + + if (action.details?.quantity < 1) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Quantity of item ${refId} must be greater than 0.` ) } }, diff --git a/packages/order/src/utils/actions/item-remove.ts b/packages/order/src/utils/actions/item-remove.ts index c325985fa5..eed706cc48 100644 --- a/packages/order/src/utils/actions/item-remove.ts +++ b/packages/order/src/utils/actions/item-remove.ts @@ -11,7 +11,11 @@ OrderChangeProcessing.registerActionType(ChangeActionType.ITEM_REMOVE, { ) const existing = currentOrder.items[existingIndex] + + existing.detail.quantity ??= 0 + existing.quantity -= action.details.quantity + existing.detail.quantity -= action.details.quantity if (existing.quantity <= 0) { currentOrder.items.splice(existingIndex, 1) @@ -26,6 +30,7 @@ OrderChangeProcessing.registerActionType(ChangeActionType.ITEM_REMOVE, { if (existing) { existing.quantity += action.details.quantity + existing.detail.quantity += action.details.quantity } else { currentOrder.items.push({ id: action.reference_id!, @@ -51,27 +56,35 @@ OrderChangeProcessing.registerActionType(ChangeActionType.ITEM_REMOVE, { ) } - if (!isDefined(action.details.unit_price)) { + if (!isDefined(action.amount) && !isDefined(action.details?.unit_price)) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - "Unit price is required." + `Unit price of item ${refId} is required if no action.amount is provided.` ) } - if (action.details.quantity < 1) { + if (!action.details?.quantity) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - "Quantity must be greater than 0." + `Quantity of item ${refId} is required.` + ) + } + + if (action.details?.quantity < 1) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Quantity of item ${refId} must be greater than 0.` ) } const notFulfilled = - (existing.quantity as number) - (existing.fulfilled_quantity as number) + (existing.quantity as number) - + (existing.detail?.fulfilled_quantity as number) - if (action.details.quantity > notFulfilled) { + if (action.details?.quantity > notFulfilled) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - "Cannot remove fulfilled items." + `Cannot remove fulfilled item: Item ${refId}.` ) } }, diff --git a/packages/order/src/utils/actions/receive-damaged-return-item.ts b/packages/order/src/utils/actions/receive-damaged-return-item.ts index 087661c753..1ddcad5b69 100644 --- a/packages/order/src/utils/actions/receive-damaged-return-item.ts +++ b/packages/order/src/utils/actions/receive-damaged-return-item.ts @@ -15,9 +15,11 @@ OrderChangeProcessing.registerActionType( let toReturn = action.details.quantity - existing.return_dismissed_quantity ??= 0 - existing.return_dismissed_quantity += toReturn - existing.return_requested_quantity -= toReturn + existing.detail.return_dismissed_quantity ??= 0 + existing.detail.return_requested_quantity ??= 0 + + existing.detail.return_dismissed_quantity += toReturn + existing.detail.return_requested_quantity -= toReturn if (previousEvents) { for (const previousEvent of previousEvents) { @@ -40,8 +42,8 @@ OrderChangeProcessing.registerActionType( (item) => item.id === action.details.reference_id )! - existing.return_dismissed_quantity -= action.details.quantity - existing.return_requested_quantity += action.details.quantity + existing.detail.return_dismissed_quantity -= action.details.quantity + existing.detail.return_requested_quantity += action.details.quantity if (previousEvents) { for (const previousEvent of previousEvents) { @@ -76,11 +78,18 @@ OrderChangeProcessing.registerActionType( ) } - const quantityRequested = existing?.return_requested_quantity || 0 - if (action.details.quantity > quantityRequested) { + if (!action.details?.quantity) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - "Cannot receive more items than what was requested to be returned." + `Quantity to return of item ${refId} is required.` + ) + } + + const quantityRequested = existing?.detail.return_requested_quantity || 0 + if (action.details?.quantity > quantityRequested) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Cannot receive more items than what was requested to be returned for item ${refId}.` ) } }, diff --git a/packages/order/src/utils/actions/receive-return-item.ts b/packages/order/src/utils/actions/receive-return-item.ts index b1317999e7..6ce6105aad 100644 --- a/packages/order/src/utils/actions/receive-return-item.ts +++ b/packages/order/src/utils/actions/receive-return-item.ts @@ -13,9 +13,11 @@ OrderChangeProcessing.registerActionType(ChangeActionType.RECEIVE_RETURN_ITEM, { let toReturn = action.details.quantity - existing.return_received_quantity ??= 0 - existing.return_received_quantity += toReturn - existing.return_requested_quantity -= toReturn + existing.detail.return_received_quantity ??= 0 + existing.detail.return_requested_quantity ??= 0 + + existing.detail.return_received_quantity += toReturn + existing.detail.return_requested_quantity -= toReturn if (previousEvents) { for (const previousEvent of previousEvents) { @@ -38,8 +40,8 @@ OrderChangeProcessing.registerActionType(ChangeActionType.RECEIVE_RETURN_ITEM, { (item) => item.id === action.details.reference_id )! - existing.return_received_quantity -= action.details.quantity - existing.return_requested_quantity += action.details.quantity + existing.detail.return_received_quantity -= action.details.quantity + existing.detail.return_requested_quantity += action.details.quantity if (previousEvents) { for (const previousEvent of previousEvents) { @@ -74,11 +76,18 @@ OrderChangeProcessing.registerActionType(ChangeActionType.RECEIVE_RETURN_ITEM, { ) } - const quantityRequested = existing?.return_requested_quantity || 0 - if (action.details.quantity > quantityRequested) { + if (!action.details?.quantity) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - "Cannot receive more items than what was requested to be returned." + `Quantity to receive return of item ${refId} is required.` + ) + } + + const quantityRequested = existing?.detail?.return_requested_quantity || 0 + if (action.details?.quantity > quantityRequested) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Cannot receive more items than what was requested to be returned for item ${refId}.` ) } }, diff --git a/packages/order/src/utils/actions/return-item.ts b/packages/order/src/utils/actions/return-item.ts index 18ed5a025e..ca606e1e82 100644 --- a/packages/order/src/utils/actions/return-item.ts +++ b/packages/order/src/utils/actions/return-item.ts @@ -10,8 +10,8 @@ OrderChangeProcessing.registerActionType(ChangeActionType.RETURN_ITEM, { (item) => item.id === action.details.reference_id )! - existing.return_requested_quantity ??= 0 - existing.return_requested_quantity += action.details.quantity + existing.detail.return_requested_quantity ??= 0 + existing.detail.return_requested_quantity += action.details.quantity return existing.unit_price * action.details.quantity }, @@ -20,7 +20,7 @@ OrderChangeProcessing.registerActionType(ChangeActionType.RETURN_ITEM, { (item) => item.id === action.details.reference_id )! - existing.return_requested_quantity -= action.details.quantity + existing.detail.return_requested_quantity -= action.details.quantity }, validate({ action, currentOrder }) { const refId = action.details?.reference_id @@ -40,14 +40,21 @@ OrderChangeProcessing.registerActionType(ChangeActionType.RETURN_ITEM, { ) } - const quantityAvailable = - (existing!.fulfilled_quantity ?? 0) - - (existing!.return_requested_quantity ?? 0) - - if (action.details.quantity > quantityAvailable) { + if (!action.details?.quantity) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - "Cannot request to return more items than what was fulfilled." + `Quantity to return of item ${refId} is required.` + ) + } + + const quantityAvailable = + (existing!.detail?.shipped_quantity ?? 0) - + (existing!.detail?.return_requested_quantity ?? 0) + + if (action.details?.quantity > quantityAvailable) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Cannot request to return more items than what was shipped for item ${refId}.` ) } }, diff --git a/packages/order/src/utils/actions/ship-item.ts b/packages/order/src/utils/actions/ship-item.ts new file mode 100644 index 0000000000..2558f96b2f --- /dev/null +++ b/packages/order/src/utils/actions/ship-item.ts @@ -0,0 +1,64 @@ +import { MedusaError, isDefined } from "@medusajs/utils" +import { ChangeActionType } from "../action-key" +import { OrderChangeProcessing } from "../calculate-order-change" + +OrderChangeProcessing.registerActionType(ChangeActionType.SHIP_ITEM, { + operation({ action, currentOrder }) { + const existing = currentOrder.items.find( + (item) => item.id === action.details.reference_id + )! + + existing.detail.shipped_quantity ??= 0 + + existing.detail.shipped_quantity += action.details.quantity + }, + revert({ action, currentOrder }) { + const existing = currentOrder.items.find( + (item) => item.id === action.reference_id + )! + + existing.detail.shipped_quantity -= action.details.quantity + }, + validate({ action, currentOrder }) { + const refId = action.details?.reference_id + if (!isDefined(refId)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Reference ID is required." + ) + } + + const existing = currentOrder.items.find((item) => item.id === refId) + if (!existing) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Reference ID "${refId}" not found.` + ) + } + + if (!action.details?.quantity) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Quantity to ship of item ${refId} is required.` + ) + } + + if (action.details?.quantity < 1) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Quantity of item ${refId} must be greater than 0.` + ) + } + + const notShipped = + (existing.detail?.fulfilled_quantity as number) - + (existing.detail?.shipped_quantity as number) + + if (action.details?.quantity > notShipped) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Cannot ship more items than what was fulfilled for item ${refId}.` + ) + } + }, +}) diff --git a/packages/order/src/utils/actions/shipping-add.ts b/packages/order/src/utils/actions/shipping-add.ts index f009343ce1..c3f61fba04 100644 --- a/packages/order/src/utils/actions/shipping-add.ts +++ b/packages/order/src/utils/actions/shipping-add.ts @@ -14,7 +14,6 @@ OrderChangeProcessing.registerActionType(ChangeActionType.SHIPPING_ADD, { }) currentOrder.shipping_methods = shipping - return action.amount }, revert({ action, currentOrder }) { const shipping = Array.isArray(currentOrder.shipping_methods) diff --git a/packages/order/src/utils/actions/write-off-item.ts b/packages/order/src/utils/actions/write-off-item.ts index cb3615c35b..3a5237601e 100644 --- a/packages/order/src/utils/actions/write-off-item.ts +++ b/packages/order/src/utils/actions/write-off-item.ts @@ -8,15 +8,15 @@ OrderChangeProcessing.registerActionType(ChangeActionType.WRITE_OFF_ITEM, { (item) => item.id === action.details.reference_id )! - existing.written_off_quantity ??= 0 - existing.written_off_quantity += action.details.quantity + existing.detail.written_off_quantity ??= 0 + existing.detail.written_off_quantity += action.details.quantity }, revert({ action, currentOrder }) { const existing = currentOrder.items.find( (item) => item.id === action.details.reference_id )! - existing.written_off_quantity -= action.details.quantity + existing.detail.written_off_quantity -= action.details.quantity }, validate({ action, currentOrder }) { const refId = action.details?.reference_id @@ -36,8 +36,15 @@ OrderChangeProcessing.registerActionType(ChangeActionType.WRITE_OFF_ITEM, { ) } + if (!action.details?.quantity) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Quantity to write-off item ${refId} is required.` + ) + } + const quantityAvailable = existing!.quantity ?? 0 - if (action.details.quantity > quantityAvailable) { + if (action.details?.quantity > quantityAvailable) { throw new MedusaError( MedusaError.Types.INVALID_DATA, "Cannot claim more items than what was ordered." diff --git a/packages/order/src/utils/calculate-order-change.ts b/packages/order/src/utils/calculate-order-change.ts index 2e63dd5dbc..3132e3ce88 100644 --- a/packages/order/src/utils/calculate-order-change.ts +++ b/packages/order/src/utils/calculate-order-change.ts @@ -1,14 +1,16 @@ +import { OrderSummaryDTO } from "@medusajs/types" +import { isDefined } from "@medusajs/utils" import { ActionTypeDefinition, EVENT_STATUS, InternalOrderChangeEvent, OrderChangeEvent, - OrderSummary, + OrderSummaryCalculated, OrderTransaction, VirtualOrder, } from "@types" -type InternalOrderSummary = OrderSummary & { +type InternalOrderSummary = OrderSummaryCalculated & { futureTemporarySum: number } @@ -55,8 +57,8 @@ export class OrderChangeProcessing { pendingDifference: 0, futureTemporarySum: 0, differenceSum: 0, - currentOrderTotal: order.total as number, - originalOrderTotal: order.total as number, + currentOrderTotal: order?.summary?.total ?? 0, + originalOrderTotal: order?.summary?.total ?? 0, transactionTotal, } } @@ -181,7 +183,10 @@ export class OrderChangeProcessing { if (typeof type.operation === "function") { calculatedAmount = type.operation(params) as number - action.amount = calculatedAmount ?? 0 + // the action.amount has priority over the calculated amount + if (!isDefined(action.amount)) { + action.amount = calculatedAmount ?? 0 + } } // If an action commits previous ones, replay them with updated values @@ -313,7 +318,7 @@ export class OrderChangeProcessing { }) } - public getSummary(): OrderSummary { + public getSummary(): OrderSummaryDTO { const summary = this.summary const orderSummary = { transactionTotal: summary.transactionTotal, @@ -324,7 +329,35 @@ export class OrderChangeProcessing { futureTemporaryDifference: summary.futureTemporaryDifference, pendingDifference: summary.pendingDifference, differenceSum: summary.differenceSum, + } as unknown as OrderSummaryDTO + + /* + { + total: summary.currentOrderTotal + + subtotal: number + total_tax: number + + ordered_total: summary.originalOrderTotal + fulfilled_total: number + returned_total: number + return_request_total: number + write_off_total: number + projected_total: number + + net_total: number + net_subtotal: number + net_total_tax: number + + future_total: number + future_subtotal: number + future_total_tax: number + future_projected_total: number + + balance: summary.pendingDifference + future_balance: number } + */ return orderSummary } diff --git a/packages/order/src/utils/transform-order.ts b/packages/order/src/utils/transform-order.ts index 7b1d1cc166..6d1d11af94 100644 --- a/packages/order/src/utils/transform-order.ts +++ b/packages/order/src/utils/transform-order.ts @@ -1,11 +1,11 @@ import { OrderTypes } from "@medusajs/types" -import { isDefined } from "@medusajs/utils" +import { deduplicate, isDefined } from "@medusajs/utils" export function formatOrder( order ): OrderTypes.OrderDTO | OrderTypes.OrderDTO[] { const isArray = Array.isArray(order) - const orders = isArray ? order : [order] + const orders = [...(isArray ? order : [order])] orders.map((order) => { order.items = order.items?.map((orderItem) => { @@ -21,6 +21,8 @@ export function formatOrder( } }) + order.summary = order.summary?.[0]?.totals + return order }) @@ -35,25 +37,27 @@ export function mapRepositoryToOrderModel(config) { return } - return [ - ...new Set( - obj[type].sort().map((rel) => { - if (rel == "items.quantity") { - if (type === "fields") { - obj.populate.push("items.item") - } - return "items.item.quantity" - } else if (rel.includes("items.detail")) { - return rel.replace("items.detail", "items") - } else if (rel == "items") { - return "items.item" - } else if (rel.includes("items.") && !rel.includes("items.item")) { - return rel.replace("items.", "items.item.") + return deduplicate( + obj[type].sort().map((rel) => { + if (rel == "items.quantity") { + if (type === "fields") { + obj.populate.push("items.item") } - return rel - }) - ), - ] + return "items.item.quantity" + } + if (rel == "summary" && type === "fields") { + obj.populate.push("summary") + return "summary.totals" + } else if (rel.includes("items.detail")) { + return rel.replace("items.detail", "items") + } else if (rel == "items") { + return "items.item" + } else if (rel.includes("items.") && !rel.includes("items.item")) { + return rel.replace("items.", "items.item.") + } + return rel + }) + ) } conf.options.fields = replace(config.options, "fields") diff --git a/packages/types/src/order/common.ts b/packages/types/src/order/common.ts index 08587afbd7..c35bcec029 100644 --- a/packages/types/src/order/common.ts +++ b/packages/types/src/order/common.ts @@ -2,7 +2,7 @@ import { BaseFilterable } from "../dal" import { OperatorMap } from "../dal/utils" import { BigNumberRawValue } from "../totals" -type OrderSummary = { +export type OrderSummaryDTO = { total: number subtotal: number total_tax: number @@ -600,10 +600,17 @@ export interface OrderDTO { * @expandable */ shipping_methods?: OrderShippingMethodDTO[] + + /** + * The tramsactions associated with the order + * + * @expandable + */ + transactions?: OrderTransactionDTO[] /** * The summary of the order totals. */ - summary?: OrderSummary + summary?: OrderSummaryDTO /** * Holds custom data in key-value pairs. */ @@ -633,6 +640,13 @@ export interface OrderChangeDTO { * @expandable */ order: OrderDTO + + /** + * The actions of the order change + * + * @expandable + */ + actions: OrderChangeActionDTO[] /** * The status of the order change */ @@ -695,13 +709,24 @@ export interface OrderChangeActionDTO { /** * The ID of the associated order change */ - order_change_id: string + order_change_id: string | null /** * The associated order change * * @expandable */ - order_change: OrderChangeDTO + order_change: OrderChangeDTO | null + + /** + * The ID of the associated order + */ + order_id: string | null + /** + * The associated order + * + * @expandable + */ + order: OrderDTO | null /** * The reference of the order change action */ diff --git a/packages/types/src/order/mutations.ts b/packages/types/src/order/mutations.ts index 4852259273..6b29fdb786 100644 --- a/packages/types/src/order/mutations.ts +++ b/packages/types/src/order/mutations.ts @@ -40,11 +40,13 @@ export interface CreateOrderDTO { no_notification?: boolean items?: CreateOrderLineItemDTO[] shipping_methods?: CreateOrderShippingMethodDTO[] + transactions?: CreateOrderTransactionDTO[] metadata?: Record } export interface UpdateOrderDTO { - id: string + id?: string + version?: number region_id?: string customer_id?: string sales_channel_id?: string @@ -234,17 +236,10 @@ export interface UpdateOrderShippingMethodAdjustmentDTO { export interface CreateOrderChangeDTO { order_id: string - status: string description?: string internal_note?: string requested_by?: string requested_at?: Date - confirmed_by?: string - confirmed_at?: Date - declined_by?: string - declined_reason?: string - declined_at?: Date - canceled_by?: string metadata?: Record } @@ -264,6 +259,24 @@ export interface UpdateOrderChangeDTO { metadata?: Record } +export interface CancelOrderChangeDTO { + id: string + canceled_by?: string + metadata?: Record +} + +export interface DeclineOrderChangeDTO { + id: string + declined_by?: string + metadata?: Record +} + +export interface ConfirmOrderChangeDTO { + id: string + confirmed_by?: string + metadata?: Record +} + /** ORDER CHANGE END */ /** ORDER CHANGE ACTION START */ diff --git a/packages/types/src/order/service.ts b/packages/types/src/order/service.ts index 455c680243..a88d649b6e 100644 --- a/packages/types/src/order/service.ts +++ b/packages/types/src/order/service.ts @@ -11,6 +11,7 @@ import { FilterableOrderShippingMethodProps, FilterableOrderShippingMethodTaxLineProps, OrderAddressDTO, + OrderChangeDTO, OrderDTO, OrderItemDTO, OrderLineItemAdjustmentDTO, @@ -21,8 +22,11 @@ import { OrderShippingMethodTaxLineDTO, } from "./common" import { + CancelOrderChangeDTO, + ConfirmOrderChangeDTO, CreateOrderAddressDTO, CreateOrderAdjustmentDTO, + CreateOrderChangeDTO, CreateOrderDTO, CreateOrderLineItemDTO, CreateOrderLineItemForOrderDTO, @@ -30,6 +34,7 @@ import { CreateOrderShippingMethodAdjustmentDTO, CreateOrderShippingMethodDTO, CreateOrderShippingMethodTaxLineDTO, + DeclineOrderChangeDTO, UpdateOrderAddressDTO, UpdateOrderDTO, UpdateOrderItemDTO, @@ -356,4 +361,55 @@ export interface IOrderModuleService extends IModuleService { selector: FilterableOrderShippingMethodTaxLineProps, sharedContext?: Context ): Promise + + // Order Change + createOrderChange( + data: CreateOrderChangeDTO, + sharedContext?: Context + ): Promise + createOrderChange( + data: CreateOrderChangeDTO[], + sharedContext?: Context + ): Promise + createOrderChange( + data: CreateOrderChangeDTO | CreateOrderChangeDTO[], + sharedContext?: Context + ): Promise + + cancelOrderChange(orderId: string, sharedContext?: Context): Promise + cancelOrderChange(orderId: string[], sharedContext?: Context): Promise + cancelOrderChange( + data: CancelOrderChangeDTO, + sharedContext?: Context + ): Promise + cancelOrderChange( + data: CancelOrderChangeDTO[], + sharedContext?: Context + ): Promise + + confirmOrderChange(orderId: string, sharedContext?: Context): Promise + confirmOrderChange(orderId: string[], sharedContext?: Context): Promise + confirmOrderChange( + data: ConfirmOrderChangeDTO, + sharedContext?: Context + ): Promise + confirmOrderChange( + data: ConfirmOrderChangeDTO[], + sharedContext?: Context + ): Promise + + declineOrderChange(orderId: string, sharedContext?: Context): Promise + declineOrderChange(orderId: string[], sharedContext?: Context): Promise + declineOrderChange( + data: DeclineOrderChangeDTO, + sharedContext?: Context + ): Promise + declineOrderChange( + data: DeclineOrderChangeDTO[], + sharedContext?: Context + ): Promise + + applyPendingOrderActions(orderId: string | string[], sharedContext?: Context) + + addOrderAction(data: any, sharedContext?: Context) }