add initital work on draft order

This commit is contained in:
olivermrbl
2021-02-12 08:51:29 +01:00
parent 81df78384c
commit 75bc1f67f4
8 changed files with 563 additions and 0 deletions

View File

@@ -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}`
}
}

View File

@@ -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 })

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,5 @@
import { EntityRepository, Repository } from "typeorm"
import { DraftOrder } from "../models/draft-order"
@EntityRepository(DraftOrder)
export class DraftOrderRepository extends Repository<DraftOrder> {}

View File

@@ -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<DraftOrder>} 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<DraftOrder>} 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<Array<DraftOrder>>} 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<DraftOrder>} 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

View File

@@ -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)