diff --git a/packages/medusa/src/models/draft-order.ts b/packages/medusa/src/models/draft-order.ts new file mode 100644 index 0000000000..0228289b90 --- /dev/null +++ b/packages/medusa/src/models/draft-order.ts @@ -0,0 +1,155 @@ +import { + Entity, + Generated, + BeforeInsert, + Index, + Column, + CreateDateColumn, + UpdateDateColumn, + PrimaryColumn, + OneToOne, + JoinColumn, + ManyToOne, + JoinTable, + ManyToMany, + OneToMany, +} from "typeorm" +import { ulid } from "ulid" +import { Address } from "./address" + +import { Cart } from "./cart" +import { Customer } from "./customer" +import { Discount } from "./discount" +import { LineItem } from "./line-item" +import { Order } from "./order" +import { Payment } from "./payment" +import { Region } from "./region" +import { ShippingMethod } from "./shipping-method" + +enum DraftOrderStatus { + OPEN = "open", + AWAITING = "awaiting", + COMPLETED = "completed", +} + +@Entity() +export class DraftOrder { + @PrimaryColumn() + id: string + + @Column({ type: "enum", enum: DraftOrderStatus, default: "open" }) + status: DraftOrderStatus + + @Index() + @Column() + @Generated("increment") + display_id: number + + @Index() + @Column({ nullable: true }) + cart_id: string + + @OneToOne(() => Cart) + @JoinColumn({ name: "cart_id" }) + cart: Cart + + @Index() + @Column({ nullable: true }) + order_id: string + + @OneToOne(() => Order) + @JoinColumn({ name: "order_id" }) + order: Order + + @Index() + @Column() + customer_id: string + + @ManyToOne(() => Customer, { cascade: ["insert"] }) + @JoinColumn({ name: "customer_id" }) + customer: Customer + + @OneToMany( + () => LineItem, + lineItem => lineItem.draft_order, + { cascade: ["insert", "remove"] } + ) + items: LineItem[] + + @Column() + email: string + + @Index() + @Column({ nullable: true }) + billing_address_id: string + + @ManyToOne(() => Address, { cascade: ["insert"] }) + @JoinColumn({ name: "billing_address_id" }) + billing_address: Address + + @Index() + @Column({ nullable: true }) + shipping_address_id: string + + @ManyToOne(() => Address, { cascade: ["insert"] }) + @JoinColumn({ name: "shipping_address_id" }) + shipping_address: Address + + @Index() + @Column() + region_id: string + + @ManyToOne(() => Region) + @JoinColumn({ name: "region_id" }) + region: Region + + @ManyToMany(() => Discount, { cascade: ["insert"] }) + @JoinTable({ + name: "draft_order_discounts", + joinColumn: { + name: "draft_order_id", + referencedColumnName: "id", + }, + inverseJoinColumn: { + name: "discount_id", + referencedColumnName: "id", + }, + }) + discounts: Discount[] + + @OneToMany( + () => ShippingMethod, + m => m.draft_order, + { cascade: ["soft-remove", "remove"] } + ) + shipping_methods: ShippingMethod[] + + @OneToMany( + () => Payment, + p => p.draft_order, + { cascade: ["insert"] } + ) + payments: Payment[] + + @Column({ nullable: true, type: "timestamptz" }) + canceled_at: Date + + @CreateDateColumn({ type: "timestamptz" }) + created_at: Date + + @UpdateDateColumn({ type: "timestamptz" }) + updated_at: Date + + @Column({ type: "jsonb", nullable: true }) + metadata: any + + @Column({ nullable: true }) + idempotency_key: string + + @BeforeInsert() + private beforeInsert() { + if (this.id) return + const id = ulid() + this.id = `dorder_${id}` + } +} diff --git a/packages/medusa/src/models/line-item.ts b/packages/medusa/src/models/line-item.ts index 764fd14df8..f5d0099b0e 100644 --- a/packages/medusa/src/models/line-item.ts +++ b/packages/medusa/src/models/line-item.ts @@ -17,6 +17,7 @@ import { Cart } from "./cart" import { Order } from "./order" import { ClaimOrder } from "./claim-order" import { ProductVariant } from "./product-variant" +import { DraftOrder } from "./draft-order" @Check(`"fulfilled_quantity" <= "quantity"`) @Check(`"shipped_quantity" <= "fulfilled_quantity"`) @@ -48,6 +49,17 @@ export class LineItem { ) @JoinColumn({ name: "order_id" }) order: Order + + @Index() + @Column({ nullable: true }) + draft_order_id: string + + @ManyToOne( + () => DraftOrder, + dorder => dorder.items + ) + @JoinColumn({ name: "draft_order_id" }) + draft_order: DraftOrder @Index() @Column({ nullable: true }) diff --git a/packages/medusa/src/models/order.ts b/packages/medusa/src/models/order.ts index 213949f3bb..e9b06aa1e2 100644 --- a/packages/medusa/src/models/order.ts +++ b/packages/medusa/src/models/order.ts @@ -32,6 +32,7 @@ import { Refund } from "./refund" import { Swap } from "./swap" import { ClaimOrder } from "./claim-order" import { ShippingMethod } from "./shipping-method" +import { DraftOrder } from "./draft-order" export enum OrderStatus { PENDING = "pending", @@ -212,6 +213,13 @@ export class Order { ) swaps: Swap[] + @Column({ nullable: true }) + draft_order_id: string + + @OneToOne(() => DraftOrder) + @JoinColumn({ name: "draft_order_id" }) + draft_order: DraftOrder + @OneToMany( () => LineItem, lineItem => lineItem.order, diff --git a/packages/medusa/src/models/payment.ts b/packages/medusa/src/models/payment.ts index df0cc45c2e..0f8b8e9be0 100644 --- a/packages/medusa/src/models/payment.ts +++ b/packages/medusa/src/models/payment.ts @@ -16,6 +16,7 @@ import { Swap } from "./swap" import { Currency } from "./currency" import { Cart } from "./cart" import { Order } from "./order" +import { DraftOrder } from "./draft-order" @Entity() export class Payment { @@ -49,6 +50,14 @@ export class Payment { @JoinColumn({ name: "order_id" }) order: Order + @Index() + @Column({ nullable: true }) + draft_order_id: string + + @OneToOne(() => DraftOrder) + @JoinColumn({ name: "draft_order_id" }) + draft_order: DraftOrder + @Column({ type: "int" }) amount: number diff --git a/packages/medusa/src/models/shipping-method.ts b/packages/medusa/src/models/shipping-method.ts index 19af8cae18..d385585a8b 100644 --- a/packages/medusa/src/models/shipping-method.ts +++ b/packages/medusa/src/models/shipping-method.ts @@ -17,6 +17,7 @@ import { Cart } from "./cart" import { Swap } from "./swap" import { Return } from "./return" import { ShippingOption } from "./shipping-option" +import { DraftOrder } from "./draft-order" @Check( `"claim_order_id" IS NOT NULL OR "order_id" IS NOT NULL OR "cart_id" IS NOT NULL OR "swap_id" IS NOT NULL OR "return_id" IS NOT NULL` @@ -67,6 +68,14 @@ export class ShippingMethod { @Column({ nullable: true }) return_id: string + @Index() + @Column({ nullable: true }) + draft_order_id: string + + @ManyToOne(() => DraftOrder) + @JoinColumn({ name: "draft_order_id" }) + draft_order: DraftOrder + @OneToOne( () => Return, ret => ret.shipping_method diff --git a/packages/medusa/src/repositories/draft-order.ts b/packages/medusa/src/repositories/draft-order.ts new file mode 100644 index 0000000000..6ba65d1c8a --- /dev/null +++ b/packages/medusa/src/repositories/draft-order.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from "typeorm" +import { DraftOrder } from "../models/draft-order" + +@EntityRepository(DraftOrder) +export class DraftOrderRepository extends Repository {} diff --git a/packages/medusa/src/services/draft-order.js b/packages/medusa/src/services/draft-order.js new file mode 100644 index 0000000000..f12895cb75 --- /dev/null +++ b/packages/medusa/src/services/draft-order.js @@ -0,0 +1,361 @@ +import _ from "lodash" +import { BaseService } from "medusa-interfaces" +import { MedusaError } from "medusa-core-utils" + +/** + * Handles swaps + * @implements BaseService + */ +class DraftOrderService extends BaseService { + static Events = { + CREATED: "draft_order.created", + } + + constructor({ + manager, + draftOrderRepository, + eventBusService, + addressRepository, + cartService, + totalsService, + lineItemService, + productVariantService, + shippingOptionService, + regionService, + }) { + super() + + /** @private @const {EntityManager} */ + this.manager_ = manager + + /** @private @const {SwapModel} */ + this.draftOrderRepository_ = draftOrderRepository + + /** @private @const {TotalsService} */ + this.totalsService_ = totalsService + + /** @private @const {AddressRepository} */ + this.addressRepository_ = addressRepository + + /** @private @const {LineItemService} */ + this.lineItemService_ = lineItemService + + /** @private @const {ReturnService} */ + this.returnService_ = returnService + + /** @private @const {CartService} */ + this.cartService_ = cartService + + /** @private @const {RegionService} */ + this.regionService_ = regionService + + /** @private @const {ProductVariantService} */ + this.productVariantService_ = productVariantService + + /** @private @const {ShippingOptionService} */ + this.shippingOptionService_ = shippingOptionService + + /** @private @const {EventBusService} */ + this.eventBus_ = eventBusService + } + + withTransaction(transactionManager) { + if (!transactionManager) { + return this + } + + const cloned = new DraftOrderService({ + manager: transactionManager, + draftOrderRepository: this.draftOrderRepository_, + eventBusService: this.eventBus_, + cartService: this.cartService_, + totalsService: this.totalsService_, + productVariantService: this.productVariantService_, + lineItemService: this.lineItemService_, + }) + + cloned.transactionManager_ = transactionManager + + return cloned + } + + /** + * Retrieves a draft order with the given id. + * @param {string} id - id of the draft order to retrieve + * @return {Promise} the draft order + */ + async retrieve(id, config = {}) { + const draftOrderRepo = this.manager_.getCustomRepository( + this.draftOrderRepository_ + ) + + const validatedId = this.validateId_(id) + + const query = this.buildQuery_({ id: validatedId }, config) + + const draftOrder = await draftOrderRepo.findOne(query) + + if (!draftOrder) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Draft order with id: ${id} was not found` + ) + } + + return draftOrder + } + + /** + * Retrieves a draft order based on its associated cart id + * @param {string} cartId - cart id that the draft orders's cart has + * @return {Promise} the draft order + */ + async retrieveByCartId(cartId, relations = []) { + const draftOrderRepo = this.manager_.getCustomRepository( + this.draftOrderRepository_ + ) + + const draftOrder = await draftOrderRepo.findOne({ + where: { + cart_id: cartId, + }, + relations, + }) + + if (!draftOrder) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Draft order was not found` + ) + } + + return draftOrder + } + + /** + * Lists draft orders + * @param {Object} selector - query object for find + * @param {Object} config - configurable attributes for find + * @return {Promise>} list of draft orders + */ + async list( + selector, + config = { skip: 0, take: 50, order: { created_at: "DESC" } } + ) { + const draftOrderRepo = this.manager_.getCustomRepository( + this.draftOrderRepository_ + ) + + const query = this.buildQuery_(selector, config) + + return draftOrderRepo.find(query) + } + + async setAddress_(region, address) { + const addressRepo = this.manager_.getCustomRepository( + this.addressRepository_ + ) + + const regCountries = region.countries.map(({ iso_2 }) => iso_2) + + if (!address.country_code) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Address is missing country code` + ) + } + + if (!regCountries.includes(address.country_code)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Country ${address.country_code} is not in region ${region.name}` + ) + } + + const created = addressRepo.create(adress) + return created + } + + /** + * Confirms if the contents of a line item is covered by the inventory. + * To be covered a variant must either not have its inventory managed or it + * must allow backorders or it must have enough inventory to cover the request. + * If the content is made up of multiple variants it will return true if all + * variants can be covered. If the content consists of a single variant it will + * return true if the variant is covered. + * @param {(LineItemContent | LineItemContentArray)} - the content of the line + * item + * @param {number} - the quantity of the line item + * @return {boolean} true if the inventory covers the line item. + */ + async confirmInventory_(variantId, quantity) { + // If the line item is not stock tracked we don't have double check it + if (!variantId) { + return true + } + + return this.productVariantService_.canCoverQuantity(variantId, quantity) + } + + async addLineItem(doId, lineItem) { + return this.atomicPhase_(async manager => { + const draftOrder = await this.retrieve(doId, { + relations: [ + "shipping_methods", + "items", + "payment_sessions", + "items.variant", + "items.variant.product", + ], + }) + + if (draftOrder.status !== "open") { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "You are not allowed to add items to a draft order with status awaiting or completed" + ) + } + + let currentItem + if (lineItem.should_merge) { + currentItem = draftOrder.items.find(line => { + if (line.should_merge && line.variant_id === lineItem.variant_id) { + return _.isEqual(line.metadata, lineItem.metadata) + } + }) + } + + // If content matches one of the line items currently in the cart we can + // simply update the quantity of the existing line item + if (currentItem) { + const newQuantity = currentItem.quantity + lineItem.quantity + + // Confirm inventory + const hasInventory = await this.confirmInventory_( + lineItem.variant_id, + newQuantity + ) + + if (!hasInventory) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Inventory doesn't cover the desired quantity" + ) + } + + await this.lineItemService_ + .withTransaction(manager) + .update(currentItem.id, { + quantity: newQuantity, + }) + } else { + // Confirm inventory + const hasInventory = await this.confirmInventory_( + lineItem.variant_id, + lineItem.quantity + ) + + if (!hasInventory) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Inventory doesn't cover the desired quantity" + ) + } + + await this.lineItemService_.withTransaction(manager).create({ + ...lineItem, + has_shipping: false, + cart_id: cartId, + }) + } + }) + } + + /** + * Creates a draft order. + * @param {Object} data - data to create draft order from + * @return {Promise} the created draft order + */ + async create(data) { + return this.atomicPhase_(async manager => { + const draftOrderRepo = manager.getCustomRepository( + this.draftOrderRepository_ + ) + + if (!data.region_id) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `region_id is required to create a draft order` + ) + } + + const region = await this.regionService_ + .withTransaction(manager) + .retrieve(data.region_id, { + relations: ["countries"], + }) + + if (!data.shipping_address && !data.shipping_address_id) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Shipping addresss is required to create a draft order` + ) + } + + if (data.shipping_address && !data.shipping_address_id) { + data.shipping_address = await this.setAddress_( + region, + data.shipping_address + ) + } + + if (data.billing_address && !data.billing_address_id) { + data.billing_address = await this.setAddress_( + region, + data.billing_address + ) + } + + const { items, shipping_methods, ...rest } = data + + const draftOrder = draftOrderRepo.create(rest) + const result = await draftOrderRepo.save(draftOrder) + + await this.eventBus_ + .withTransaction(manager) + .emit(DraftOrderService.Events.CREATED, { + id: result.id, + }) + + let shippingMethods = [] + for (const method of shipping_methods) { + const m = await this.shippingOptionService_ + .withTransaction(manager) + .createShippingMethod(method.option_id, method.data, { + draft_order_id: draftOrder.id, + }) + + shippingMethods.push(m) + } + + for (const item of items) { + const line = await this.lineItemService_ + .withTransaction(manager) + .generate( + item.variant_id, + cart.region_id, + item.quantity, + item.metadata + ) + + await this.lineItemService_ + .withTransaction(manager) + .create({ draft_order_id: draftOrder.id, ...line }) + } + + return result + }) + } +} + +export default DraftOrderService diff --git a/packages/medusa/src/services/shipping-option.js b/packages/medusa/src/services/shipping-option.js index 3b4ccca804..6a2797f9e4 100644 --- a/packages/medusa/src/services/shipping-option.js +++ b/packages/medusa/src/services/shipping-option.js @@ -254,6 +254,10 @@ class ShippingOptionService extends BaseService { toCreate.claim_order_id = config.claim_order_id } + if (config.draft_order_id) { + toCreate.draft_order_id = config.draft_order_id + } + const method = await methodRepo.create(toCreate) const created = await methodRepo.save(method)