diff --git a/integration-tests/api/__tests__/store/__snapshots__/swaps.js.snap b/integration-tests/api/__tests__/store/__snapshots__/swaps.js.snap index edc921b0ba..930fb88d72 100644 --- a/integration-tests/api/__tests__/store/__snapshots__/swaps.js.snap +++ b/integration-tests/api/__tests__/store/__snapshots__/swaps.js.snap @@ -95,7 +95,7 @@ Object { "payment_authorized_at": null, "payment_id": null, "region_id": "test-region", - "shipping_address_id": "test-shipping-address", + "shipping_address_id": StringMatching /\\^addr_\\*/, "type": "swap", "updated_at": Any, }, @@ -266,7 +266,7 @@ Object { "payment_authorized_at": null, "payment_id": null, "region_id": "test-region", - "shipping_address_id": "test-shipping-address", + "shipping_address_id": StringMatching /\\^addr_\\*/, "type": "swap", "updated_at": Any, }, diff --git a/integration-tests/api/__tests__/store/swaps.js b/integration-tests/api/__tests__/store/swaps.js index dddf5a3b1e..243d5e1e96 100644 --- a/integration-tests/api/__tests__/store/swaps.js +++ b/integration-tests/api/__tests__/store/swaps.js @@ -137,6 +137,7 @@ describe("/store/carts", () => { type: "swap", created_at: expect.any(String), updated_at: expect.any(String), + shipping_address_id: expect.stringMatching(/^addr_*/), metadata: { swap_id: expect.stringMatching(/^swap_*/), }, @@ -220,6 +221,7 @@ describe("/store/carts", () => { cart: { id: expect.stringMatching(/^cart_*/), billing_address_id: "test-billing-address", + shipping_address_id: expect.stringMatching(/^addr_*/), type: "swap", created_at: expect.any(String), updated_at: expect.any(String), diff --git a/packages/medusa-core-utils/src/errors.ts b/packages/medusa-core-utils/src/errors.ts index a063104e3b..fefe1536cd 100644 --- a/packages/medusa-core-utils/src/errors.ts +++ b/packages/medusa-core-utils/src/errors.ts @@ -10,6 +10,7 @@ export const MedusaErrorTypes = { INVALID_DATA: "invalid_data", NOT_FOUND: "not_found", NOT_ALLOWED: "not_allowed", + UNEXPECTED_STATE: "unexpected_state", } export const MedusaErrorCodes = { diff --git a/packages/medusa/src/api/routes/admin/draft-orders/index.ts b/packages/medusa/src/api/routes/admin/draft-orders/index.ts index fa720667c2..146bff1127 100644 --- a/packages/medusa/src/api/routes/admin/draft-orders/index.ts +++ b/packages/medusa/src/api/routes/admin/draft-orders/index.ts @@ -1,5 +1,5 @@ import { Router } from "express" -import { DraftOrder, Order } from "../../../.." +import { DraftOrder, Order, Cart } from "../../../.." import middlewares from "../../../middlewares" import { DeleteResponse, PaginatedResponse } from "../../../../types/common" @@ -62,7 +62,7 @@ export const defaultAdminDraftOrdersCartRelations = [ "discounts.rule", ] -export const defaultAdminDraftOrdersCartFields = [ +export const defaultAdminDraftOrdersCartFields: (keyof Cart)[] = [ "subtotal", "tax_total", "shipping_total", diff --git a/packages/medusa/src/api/routes/admin/draft-orders/update-line-item.ts b/packages/medusa/src/api/routes/admin/draft-orders/update-line-item.ts index e73be0d441..5ff8f9a29e 100644 --- a/packages/medusa/src/api/routes/admin/draft-orders/update-line-item.ts +++ b/packages/medusa/src/api/routes/admin/draft-orders/update-line-item.ts @@ -7,6 +7,7 @@ import { defaultAdminDraftOrdersFields, } from "." import { DraftOrder } from "../../../.." +import { LineItemUpdate } from "../../../../types/cart" import { CartService, DraftOrderService } from "../../../../services" import { validator } from "../../../../utils/validator" /** @@ -112,15 +113,6 @@ export default async (req, res) => { }) } -class LineItemUpdate { - title?: string - unit_price?: number - quantity?: number - metadata?: object = {} - region_id?: string - variant_id?: string -} - export class AdminPostDraftOrdersDraftOrderLineItemsItemReq { @IsString() @IsOptional() diff --git a/packages/medusa/src/api/routes/store/carts/complete-cart.ts b/packages/medusa/src/api/routes/store/carts/complete-cart.ts index c7f871f103..f7f5bf2c2b 100644 --- a/packages/medusa/src/api/routes/store/carts/complete-cart.ts +++ b/packages/medusa/src/api/routes/store/carts/complete-cart.ts @@ -6,6 +6,8 @@ import { SwapService, } from "../../../../services" +import { Order } from "../../../../models/order" + /** * @oas [post] /carts/{id}/complete * summary: "Complete a Cart" @@ -144,7 +146,7 @@ export default async (req, res) => { relations: ["payment", "payment_sessions"], }) - let order + let order: Order // If cart is part of swap, we register swap as complete switch (cart.type) { @@ -183,6 +185,15 @@ export default async (req, res) => { } // case "payment_link": default: { + if (typeof cart.total === "undefined") { + return { + response_code: 500, + response_body: { + message: "Unexpected state", + }, + } + } + if (!cart.payment && cart.total > 0) { throw new MedusaError( MedusaError.Types.INVALID_DATA, diff --git a/packages/medusa/src/api/routes/store/carts/create-cart.ts b/packages/medusa/src/api/routes/store/carts/create-cart.ts index cb0c614365..f940ad3b5e 100644 --- a/packages/medusa/src/api/routes/store/carts/create-cart.ts +++ b/packages/medusa/src/api/routes/store/carts/create-cart.ts @@ -10,9 +10,11 @@ import { import { MedusaError } from "medusa-core-utils" import reqIp from "request-ip" import { EntityManager } from "typeorm" + import { defaultStoreCartFields, defaultStoreCartRelations } from "." import { CartService, LineItemService } from "../../../../services" import { validator } from "../../../../utils/validator" +import { AddressPayload } from "../../../../types/common" /** * @oas [post] /carts @@ -74,8 +76,11 @@ export default async (req, res) => { await entityManager.transaction(async (manager) => { // Add a default region if no region has been specified - let regionId = validated.region_id - if (!validated.region_id) { + let regionId: string + + if (typeof validated.region_id !== "undefined") { + regionId = validated.region_id + } else { const regionService = req.scope.resolve("regionService") const regions = await regionService.withTransaction(manager).list({}) @@ -90,11 +95,11 @@ export default async (req, res) => { } const toCreate: { - region_id: string | undefined + region_id: string context: object customer_id?: string email?: string - shipping_address?: object + shipping_address?: Partial } = { region_id: regionId, context: { @@ -117,11 +122,11 @@ export default async (req, res) => { country_code: validated.country_code.toLowerCase(), } } - + let cart = await cartService.withTransaction(manager).create(toCreate) if (validated.items) { await Promise.all( - validated.items.map(async i => { + validated.items.map(async (i) => { const lineItem = await lineItemService .withTransaction(manager) .generate(i.variant_id, regionId, i.quantity) diff --git a/packages/medusa/src/api/routes/store/carts/index.ts b/packages/medusa/src/api/routes/store/carts/index.ts index 583adbda71..5f85ae4ba2 100644 --- a/packages/medusa/src/api/routes/store/carts/index.ts +++ b/packages/medusa/src/api/routes/store/carts/index.ts @@ -97,7 +97,7 @@ export default (app, container) => { return app } -export const defaultStoreCartFields = [ +export const defaultStoreCartFields: (keyof Cart)[] = [ "subtotal", "tax_total", "shipping_total", diff --git a/packages/medusa/src/api/routes/store/carts/update-cart.ts b/packages/medusa/src/api/routes/store/carts/update-cart.ts index f7fd76955e..405d5bf1cf 100644 --- a/packages/medusa/src/api/routes/store/carts/update-cart.ts +++ b/packages/medusa/src/api/routes/store/carts/update-cart.ts @@ -9,6 +9,7 @@ import { import { defaultStoreCartFields, defaultStoreCartRelations } from "." import { CartService } from "../../../../services" import { AddressPayload } from "../../../../types/common" +import { CartUpdateProps } from "../../../../types/cart" import { IsType } from "../../../../utils/validators/is-type" import { validator } from "../../../../utils/validator" @@ -87,7 +88,25 @@ export default async (req, res) => { const cartService: CartService = req.scope.resolve("cartService") // Update the cart - await cartService.update(id, validated) + const { shipping_address, billing_address, ...rest } = validated + + const toUpdate: CartUpdateProps = { + ...rest, + } + + if (typeof shipping_address === "string") { + toUpdate.shipping_address_id = shipping_address + } else { + toUpdate.shipping_address = shipping_address + } + + if (typeof billing_address === "string") { + toUpdate.billing_address_id = billing_address + } else { + toUpdate.billing_address = billing_address + } + + await cartService.update(id, toUpdate) // If the cart has payment sessions update these const updated = await cartService.retrieve(id, { diff --git a/packages/medusa/src/models/address.ts b/packages/medusa/src/models/address.ts index f6e3ad3155..d2fad17885 100644 --- a/packages/medusa/src/models/address.ts +++ b/packages/medusa/src/models/address.ts @@ -50,46 +50,46 @@ export class Address { id: string @Index() - @Column({ nullable: true }) - customer_id: string + @Column({ type: "text", nullable: true }) + customer_id: string | null @ManyToOne(() => Customer) @JoinColumn({ name: "customer_id" }) - customer: Customer + customer: Customer | null - @Column({ nullable: true }) - company: string + @Column({ type: "text", nullable: true }) + company: string | null - @Column({ nullable: true }) - first_name: string + @Column({ type: "text", nullable: true }) + first_name: string | null - @Column({ nullable: true }) - last_name: string + @Column({ type: "text", nullable: true }) + last_name: string | null - @Column({ nullable: true }) - address_1: string + @Column({ type: "text", nullable: true }) + address_1: string | null - @Column({ nullable: true }) - address_2: string + @Column({ type: "text", nullable: true }) + address_2: string | null - @Column({ nullable: true }) - city: string + @Column({ type: "text", nullable: true }) + city: string | null - @Column({ nullable: true }) - country_code: string + @Column({ type: "text", nullable: true }) + country_code: string | null @ManyToOne(() => Country) @JoinColumn({ name: "country_code", referencedColumnName: "iso_2" }) - country: Country + country: Country | null - @Column({ nullable: true }) - province: string + @Column({ type: "text", nullable: true }) + province: string | null - @Column({ nullable: true }) - postal_code: string + @Column({ type: "text", nullable: true }) + postal_code: string | null - @Column({ nullable: true }) - phone: string + @Column({ type: "text", nullable: true }) + phone: string | null @CreateDateColumn({ type: resolveDbType("timestamptz") }) created_at: Date @@ -98,7 +98,7 @@ export class Address { updated_at: Date @DeleteDateColumn({ type: resolveDbType("timestamptz") }) - deleted_at: Date + deleted_at: Date | null @DbAwareColumn({ type: "jsonb", nullable: true }) metadata: any diff --git a/packages/medusa/src/models/cart.ts b/packages/medusa/src/models/cart.ts index 2358d9f5e8..e72b0730dd 100644 --- a/packages/medusa/src/models/cart.ts +++ b/packages/medusa/src/models/cart.ts @@ -145,7 +145,7 @@ export class Cart { cascade: ["insert", "remove", "soft-remove"], }) @JoinColumn({ name: "shipping_address_id" }) - shipping_address: Address + shipping_address: Address | null @OneToMany(() => LineItem, (lineItem) => lineItem.cart, { cascade: ["insert", "remove"], @@ -172,7 +172,7 @@ export class Cart { referencedColumnName: "id", }, }) - discounts: Discount + discounts: Discount[] @ManyToMany(() => GiftCard) @JoinTable({ @@ -186,7 +186,7 @@ export class Cart { referencedColumnName: "id", }, }) - gift_cards: GiftCard + gift_cards: GiftCard[] @Index() @Column({ nullable: true }) @@ -196,7 +196,7 @@ export class Cart { @JoinColumn({ name: "customer_id" }) customer: Customer - payment_session: PaymentSession + payment_session: PaymentSession | null @OneToMany(() => PaymentSession, (paymentSession) => paymentSession.cart, { cascade: true, @@ -217,7 +217,7 @@ export class Cart { shipping_methods: ShippingMethod[] @DbAwareColumn({ type: "enum", enum: CartType, default: "default" }) - type: boolean + type: CartType @Column({ type: resolveDbType("timestamptz"), nullable: true }) completed_at: Date @@ -243,15 +243,14 @@ export class Cart { @DbAwareColumn({ type: "jsonb", nullable: true }) context: any - // Total fields - shipping_total: number - discount_total: number - tax_total: number - refunded_total: number - total: number - subtotal: number - refundable_amount: number - gift_card_total: number + shipping_total?: number + discount_total?: number + tax_total?: number + refunded_total?: number + total?: number + subtotal?: number + refundable_amount?: number + gift_card_total?: number @BeforeInsert() private beforeInsert(): undefined | void { diff --git a/packages/medusa/src/models/payment-session.ts b/packages/medusa/src/models/payment-session.ts index 179a830e48..58b0f60d36 100644 --- a/packages/medusa/src/models/payment-session.ts +++ b/packages/medusa/src/models/payment-session.ts @@ -32,10 +32,7 @@ export class PaymentSession { @Column() cart_id: string - @ManyToOne( - () => Cart, - cart => cart.payment_sessions - ) + @ManyToOne(() => Cart, (cart) => cart.payment_sessions) @JoinColumn({ name: "cart_id" }) cart: Cart @@ -43,8 +40,8 @@ export class PaymentSession { @Column() provider_id: string - @Column({ nullable: true }) - is_selected: boolean + @Column({ type: "boolean", nullable: true }) + is_selected: boolean | null @DbAwareColumn({ type: "enum", enum: PaymentSessionStatus }) status: string diff --git a/packages/medusa/src/services/cart.js b/packages/medusa/src/services/cart.ts similarity index 78% rename from packages/medusa/src/services/cart.js rename to packages/medusa/src/services/cart.ts index f7347692c7..42d4c9eb2b 100644 --- a/packages/medusa/src/services/cart.js +++ b/packages/medusa/src/services/cart.ts @@ -1,7 +1,64 @@ import _ from "lodash" +import { EntityManager, DeepPartial } from "typeorm" import { MedusaError, Validator } from "medusa-core-utils" import { BaseService } from "medusa-interfaces" +import { ShippingMethodRepository } from "../repositories/shipping-method" +import { CartRepository } from "../repositories/cart" +import { AddressRepository } from "../repositories/address" +import { PaymentSessionRepository } from "../repositories/payment-session" + +import { Address } from "../models/address" +import { Discount } from "../models/discount" +import { Cart } from "../models/cart" +import { Customer } from "../models/customer" +import { LineItem } from "../models/line-item" +import { ShippingMethod } from "../models/shipping-method" +import { CustomShippingOption } from "../models/custom-shipping-option" + +import { TotalField, FindConfig } from "../types/common" +import { + FilterableCartProps, + LineItemUpdate, + CartUpdateProps, + CartCreateProps, +} from "../types/cart" + +import EventBusService from "./event-bus" +import ProductVariantService from "./product-variant" +import ProductService from "./product" +import RegionService from "./region" +import LineItemService from "./line-item" +import PaymentProviderService from "./payment-provider" +import ShippingOptionService from "./shipping-option" +import CustomerService from "./customer" +import DiscountService from "./discount" +import GiftCardService from "./gift-card" +import TotalsService from "./totals" +import InventoryService from "./inventory" +import CustomShippingOptionService from "./custom-shipping-option" + +type CartConstructorProps = { + manager: EntityManager + cartRepository: typeof CartRepository + shippingMethodRepository: typeof ShippingMethodRepository + addressRepository: typeof AddressRepository + paymentSessionRepository: typeof PaymentSessionRepository + eventBusService: EventBusService + paymentProviderService: PaymentProviderService + productService: ProductService + productVariantService: ProductVariantService + regionService: RegionService + lineItemService: LineItemService + shippingOptionService: ShippingOptionService + customerService: CustomerService + discountService: DiscountService + giftCardService: GiftCardService + totalsService: TotalsService + inventoryService: InventoryService + customShippingOptionService: CustomShippingOptionService +} + /* Provides layer to manipulate carts. * @implements BaseService */ @@ -12,6 +69,25 @@ class CartService extends BaseService { UPDATED: "cart.updated", } + private manager_: EntityManager + private shippingMethodRepository_: typeof ShippingMethodRepository + private cartRepository_: typeof CartRepository + private eventBus_: EventBusService + private productVariantService_: ProductVariantService + private productService_: ProductService + private regionService_: RegionService + private lineItemService_: LineItemService + private paymentProviderService_: PaymentProviderService + private customerService_: CustomerService + private shippingOptionService_: ShippingOptionService + private discountService_: DiscountService + private giftCardService_: GiftCardService + private totalsService_: TotalsService + private addressRepository_: typeof AddressRepository + private paymentSessionRepository_: typeof PaymentSessionRepository + private inventoryService_: InventoryService + private customShippingOptionService_: CustomShippingOptionService + constructor({ manager, cartRepository, @@ -31,7 +107,7 @@ class CartService extends BaseService { paymentSessionRepository, inventoryService, customShippingOptionService, - }) { + }: CartConstructorProps) { super() /** @private @const {EntityManager} */ @@ -89,7 +165,7 @@ class CartService extends BaseService { this.customShippingOptionService_ = customShippingOptionService } - withTransaction(transactionManager) { + withTransaction(transactionManager: EntityManager): CartService { if (!transactionManager) { return this } @@ -139,7 +215,9 @@ class CartService extends BaseService { * @typedef {LineItemContent[]} LineItemContentArray */ - transformQueryForTotals_(config) { + transformQueryForTotals_( + config: FindConfig + ): FindConfig & { totalsToSelect: TotalField[] } { let { select, relations } = config if (!select) { @@ -159,7 +237,9 @@ class CartService extends BaseService { "total", ] - const totalsToSelect = select.filter((v) => totalFields.includes(v)) + const totalsToSelect = select.filter((v) => + totalFields.includes(v) + ) as TotalField[] if (totalsToSelect.length > 0) { const relationSet = new Set(relations) relationSet.add("items") @@ -184,26 +264,40 @@ class CartService extends BaseService { } } - async decorateTotals_(cart, totalsFields = []) { - if (totalsFields.includes("shipping_total")) { - cart.shipping_total = await this.totalsService_.getShippingTotal(cart) + async decorateTotals_( + cart: Cart, + totalsToSelect: TotalField[] + ): Promise { + const totals: { [K in TotalField]?: number } = {} + + for (const key of totalsToSelect) { + switch (key) { + case "total": { + totals.total = await this.totalsService_.getTotal(cart) + break + } + case "shipping_total": { + totals.shipping_total = this.totalsService_.getShippingTotal(cart) + break + } + case "discount_total": + totals.discount_total = this.totalsService_.getDiscountTotal(cart) + break + case "tax_total": + totals.tax_total = await this.totalsService_.getTaxTotal(cart) + break + case "gift_card_total": + totals.gift_card_total = this.totalsService_.getGiftCardTotal(cart) + break + case "subtotal": + totals.subtotal = this.totalsService_.getSubtotal(cart) + break + default: + break + } } - if (totalsFields.includes("discount_total")) { - cart.discount_total = await this.totalsService_.getDiscountTotal(cart) - } - if (totalsFields.includes("tax_total")) { - cart.tax_total = await this.totalsService_.getTaxTotal(cart) - } - if (totalsFields.includes("gift_card_total")) { - cart.gift_card_total = await this.totalsService_.getGiftCardTotal(cart) - } - if (totalsFields.includes("subtotal")) { - cart.subtotal = await this.totalsService_.getSubtotal(cart) - } - if (totalsFields.includes("total")) { - cart.total = await this.totalsService_.getTotal(cart) - } - return cart + + return Object.assign(cart, totals) } /** @@ -211,22 +305,14 @@ class CartService extends BaseService { * @param {Object} config - config object * @return {Promise} the result of the find operation */ - list(selector, config = {}) { + async list( + selector: FilterableCartProps, + config: FindConfig = {} + ): Promise { const cartRepo = this.manager_.getCustomRepository(this.cartRepository_) - const query = { - where: selector, - } - - if (config.select) { - query.select = config.select - } - - if (config.relations) { - query.relations = config.relations - } - - return cartRepo.find(query) + const query = this.buildQuery_(selector, config) + return await cartRepo.find(query) } /** @@ -235,16 +321,20 @@ class CartService extends BaseService { * @param {Object} options - the options to get a cart * @return {Promise} the cart document. */ - async retrieve(cartId, options = {}) { + async retrieve( + cartId: string, + options: FindConfig = {} + ): Promise { const cartRepo = this.manager_.getCustomRepository(this.cartRepository_) const validatedId = this.validateId_(cartId) const { select, relations, totalsToSelect } = this.transformQueryForTotals_(options) - const query = { - where: { id: validatedId }, - } + const query = this.buildQuery_( + { id: validatedId }, + { ...options, select, relations } + ) if (relations && relations.length > 0) { query.relations = relations @@ -252,10 +342,13 @@ class CartService extends BaseService { if (select && select.length > 0) { query.select = select + } else { + delete query.select } const rels = query.relations delete query.relations + const raw = await cartRepo.findOneWithRelations(rels, query) if (!raw) { @@ -265,8 +358,7 @@ class CartService extends BaseService { ) } - const cart = await this.decorateTotals_(raw, totalsToSelect) - return cart + return await this.decorateTotals_(raw, totalsToSelect) } /** @@ -274,10 +366,11 @@ class CartService extends BaseService { * @param {Object} data - the data to create the cart with * @return {Promise} the result of the create operation */ - async create(data) { - return this.atomicPhase_(async (manager) => { + async create(data: CartCreateProps): Promise { + return this.atomicPhase_(async (manager: EntityManager) => { const cartRepo = manager.getCustomRepository(this.cartRepository_) const addressRepo = manager.getCustomRepository(this.addressRepository_) + const { region_id } = data if (!region_id) { throw new MedusaError( @@ -289,26 +382,35 @@ class CartService extends BaseService { const region = await this.regionService_.retrieve(region_id, { relations: ["countries"], }) - const regCountries = region.countries.map(({ iso_2 }) => iso_2) - if (data.email) { + const toCreate: DeepPartial = {} + toCreate.region_id = region.id + + if (typeof data.email !== "undefined") { const customer = await this.createOrFetchUserFromEmail_(data.email) - data.customer = customer - data.customer_id = customer.id - data.email = customer.email + toCreate.customer = customer + toCreate.customer_id = customer.id + toCreate.email = customer.email } - if (data.shipping_address_id) { + if (typeof data.shipping_address_id !== "undefined") { const addr = await addressRepo.findOne(data.shipping_address_id) - data.shipping_address = addr + if (addr && !regCountries.includes(addr.country_code)) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Shipping country not in region" + ) + } + + toCreate.shipping_address = addr } if (!data.shipping_address) { if (region.countries.length === 1) { // Preselect the country if the region only has 1 // and create address entity - data.shipping_address = addressRepo.create({ + toCreate.shipping_address = addressRepo.create({ country_code: regCountries[0], }) } @@ -319,14 +421,26 @@ class CartService extends BaseService { "Shipping country not in region" ) } + + toCreate.shipping_address = data.shipping_address } - const toCreate = { - ...data, - region_id: region.id, + const remainingFields: (keyof Cart)[] = [ + "billing_address_id", + "context", + "type", + "metadata", + "discounts", + "gift_cards", + ] + + for (const k of remainingFields) { + if (typeof data[k] !== "undefined") { + toCreate[k] = data[k] + } } - const inProgress = await cartRepo.create(toCreate) + const inProgress = cartRepo.create(toCreate) const result = await cartRepo.save(inProgress) await this.eventBus_ .withTransaction(manager) @@ -343,8 +457,8 @@ class CartService extends BaseService { * @param {LineItem} lineItemId - the line item to remove. * @return {Promise} the result of the update operation */ - async removeLineItem(cartId, lineItemId) { - return this.atomicPhase_(async (manager) => { + async removeLineItem(cartId: string, lineItemId: string): Promise { + return this.atomicPhase_(async (manager: EntityManager) => { const cart = await this.retrieve(cartId, { relations: [ "items", @@ -395,7 +509,10 @@ class CartService extends BaseService { * @param {LineItem} lineItem - the line item * @return {boolean} */ - validateLineItemShipping_(shippingMethods, lineItem) { + validateLineItemShipping_( + shippingMethods: ShippingMethod[], + lineItem: LineItem + ): boolean { if (!lineItem.variant_id) { return true } @@ -422,8 +539,8 @@ class CartService extends BaseService { * @param {LineItem} lineItem - the line item to add. * @return {Promise} the result of the update operation */ - async addLineItem(cartId, lineItem) { - return this.atomicPhase_(async (manager) => { + async addLineItem(cartId: string, lineItem: LineItem): Promise { + return this.atomicPhase_(async (manager: EntityManager) => { const cart = await this.retrieve(cartId, { relations: [ "shipping_methods", @@ -434,12 +551,13 @@ class CartService extends BaseService { ], }) - let currentItem + let currentItem: LineItem | undefined if (lineItem.should_merge) { currentItem = cart.items.find((line) => { if (line.should_merge && line.variant_id === lineItem.variant_id) { return _.isEqual(line.metadata, lineItem.metadata) } + return false }) } @@ -501,8 +619,12 @@ class CartService extends BaseService { * include an id field. * @return {Promise} the result of the update operation */ - async updateLineItem(cartId, lineItemId, lineItemUpdate) { - return this.atomicPhase_(async (manager) => { + async updateLineItem( + cartId: string, + lineItemId: string, + lineItemUpdate: LineItemUpdate + ): Promise { + return this.atomicPhase_(async (manager: EntityManager) => { const cart = await this.retrieve(cartId, { relations: ["items", "payment_sessions"], }) @@ -550,7 +672,7 @@ class CartService extends BaseService { * @param {Cart} cart - the the cart to adjust free shipping for * @param {boolean} shouldAdd - flag to indicate, if we should add or remove */ - async adjustFreeShipping_(cart, shouldAdd) { + async adjustFreeShipping_(cart: Cart, shouldAdd: boolean): Promise { if (cart.shipping_methods?.length) { // if any free shipping discounts, we ensure to update shipping method amount if (shouldAdd) { @@ -585,8 +707,8 @@ class CartService extends BaseService { } } - async update(cartId, update) { - return this.atomicPhase_(async (manager) => { + async update(cartId: string, update: CartUpdateProps): Promise { + return this.atomicPhase_(async (manager: EntityManager) => { const cartRepo = manager.getCustomRepository(this.cartRepository_) const cart = await this.retrieve(cartId, { select: [ @@ -613,16 +735,16 @@ class CartService extends BaseService { ], }) - if ("region_id" in update) { + if (typeof update.region_id !== "undefined") { const countryCode = - update.country_code || update.shipping_address?.country_code + (update.country_code || update.shipping_address?.country_code) ?? null await this.setRegion_(cart, update.region_id, countryCode) } - if ("customer_id" in update) { + if (typeof update.customer_id !== "undefined") { await this.updateCustomerId_(cart, update.customer_id) } else { - if ("email" in update) { + if (typeof update.email !== "undefined") { const customer = await this.createOrFetchUserFromEmail_(update.email) cart.customer = customer cart.customer_id = customer.id @@ -632,16 +754,32 @@ class CartService extends BaseService { const addrRepo = manager.getCustomRepository(this.addressRepository_) if ("shipping_address_id" in update || "shipping_address" in update) { - const address = update.shipping_address_id || update.shipping_address - await this.updateShippingAddress_(cart, address, addrRepo) + let address: string | Partial
| undefined + if (typeof update.shipping_address_id !== "undefined") { + address = update.shipping_address_id + } else if (typeof update.shipping_address !== "undefined") { + address = update.shipping_address + } + + if (typeof address !== "undefined") { + await this.updateShippingAddress_(cart, address, addrRepo) + } } if ("billing_address_id" in update || "billing_address" in update) { - const address = update.billing_address_id || update.billing_address - await this.updateBillingAddress_(cart, address, addrRepo) + let address: string | Partial
| undefined + if (typeof update.billing_address_id !== "undefined") { + address = update.billing_address_id + } else if (typeof update.billing_address !== "undefined") { + address = update.billing_address + } + + if (typeof address !== "undefined") { + await this.updateBillingAddress_(cart, address, addrRepo) + } } - if ("discounts" in update) { + if (typeof update.discounts !== "undefined") { const previousDiscounts = cart.discounts cart.discounts = [] @@ -670,7 +808,7 @@ class CartService extends BaseService { if ("gift_cards" in update) { cart.gift_cards = [] - for (const { code } of update.gift_cards) { + for (const { code } of update.gift_cards!) { await this.applyGiftCard_(cart, code) } } @@ -688,11 +826,11 @@ class CartService extends BaseService { } if ("completed_at" in update) { - cart.completed_at = update.completed_at + cart.completed_at = update.completed_at! } if ("payment_authorized_at" in update) { - cart.payment_authorized_at = update.payment_authorized_at + cart.payment_authorized_at = update.payment_authorized_at! } const result = await cartRepo.save(cart) @@ -717,7 +855,7 @@ class CartService extends BaseService { * @param {string} customerId - the customer to add to cart * @return {Promise} the result of the update operation */ - async updateCustomerId_(cart, customerId) { + async updateCustomerId_(cart: Cart, customerId: string): Promise { const customer = await this.customerService_ .withTransaction(this.transactionManager_) .retrieve(customerId) @@ -732,7 +870,7 @@ class CartService extends BaseService { * @param {string} email - the email to use * @return {Promise} the resultign customer object */ - async createOrFetchUserFromEmail_(email) { + async createOrFetchUserFromEmail_(email: string): Promise { const schema = Validator.string().email().required() const { value, error } = schema.validate(email.toLowerCase()) if (error) { @@ -765,17 +903,24 @@ class CartService extends BaseService { * updates * @return {Promise} the result of the update operation */ - async updateBillingAddress_(cart, addressOrId, addrRepo) { + async updateBillingAddress_( + cart: Cart, + addressOrId: Partial
| string, + addrRepo: AddressRepository + ): Promise { + let address: Address if (typeof addressOrId === `string`) { - addressOrId = await addrRepo.findOne({ + address = (await addrRepo.findOne({ where: { id: addressOrId }, - }) + })) as Address + } else { + address = addressOrId as Address } - addressOrId.country_code = addressOrId.country_code.toLowerCase() + address.country_code = address.country_code?.toLowerCase() ?? null - if (addressOrId.id) { - const updated = await addrRepo.save(addressOrId) + if (address.id) { + const updated = await addrRepo.save(address) cart.billing_address = updated } else { if (cart.billing_address_id) { @@ -783,10 +928,10 @@ class CartService extends BaseService { where: { id: cart.billing_address_id }, }) - await addrRepo.save({ ...addr, ...addressOrId }) + await addrRepo.save({ ...addr, ...address }) } else { const created = addrRepo.create({ - ...addressOrId, + ...address, }) cart.billing_address = created @@ -803,25 +948,31 @@ class CartService extends BaseService { * updates * @return {Promise} the result of the update operation */ - async updateShippingAddress_(cart, addressOrId, addrRepo) { + async updateShippingAddress_( + cart: Cart, + addressOrId: Partial
| string, + addrRepo: AddressRepository + ): Promise { + let address: Address + if (addressOrId === null) { cart.shipping_address = null return } if (typeof addressOrId === `string`) { - addressOrId = await addrRepo.findOne({ + address = (await addrRepo.findOne({ where: { id: addressOrId }, - }) + })) as Address + } else { + address = addressOrId as Address } - addressOrId.country_code = addressOrId.country_code?.toLowerCase() ?? null + address.country_code = address.country_code?.toLowerCase() ?? null if ( - addressOrId.country_code && - !cart.region.countries.find( - ({ iso_2 }) => addressOrId.country_code === iso_2 - ) + address.country_code && + !cart.region.countries.find(({ iso_2 }) => address.country_code === iso_2) ) { throw new MedusaError( MedusaError.Types.INVALID_DATA, @@ -829,8 +980,8 @@ class CartService extends BaseService { ) } - if (addressOrId.id) { - const updated = await addrRepo.save(addressOrId) + if (address.id) { + const updated = await addrRepo.save(address) cart.shipping_address = updated } else { if (cart.shipping_address_id) { @@ -838,10 +989,10 @@ class CartService extends BaseService { where: { id: cart.shipping_address_id }, }) - await addrRepo.save({ ...addr, ...addressOrId }) + await addrRepo.save({ ...addr, ...address }) } else { const created = addrRepo.create({ - ...addressOrId, + ...address, }) cart.shipping_address = created @@ -849,7 +1000,7 @@ class CartService extends BaseService { } } - async applyGiftCard_(cart, code) { + async applyGiftCard_(cart: Cart, code: string): Promise { const giftCard = await this.giftCardService_.retrieveByCode(code) if (giftCard.is_disabled) { @@ -883,7 +1034,7 @@ class CartService extends BaseService { * @param {string} discountCode - the discount code * @return {Promise} the result of the update operation */ - async applyDiscount(cart, discountCode) { + async applyDiscount(cart: Cart, discountCode: string): Promise { const discount = await this.discountService_.retrieveByCode(discountCode, [ "rule", "rule.valid_for", @@ -982,8 +1133,8 @@ class CartService extends BaseService { * @param {string} discountCode - the discount code to remove * @return {Promise} the resulting cart */ - async removeDiscount(cartId, discountCode) { - return this.atomicPhase_(async (manager) => { + async removeDiscount(cartId: string, discountCode: string): Promise { + return this.atomicPhase_(async (manager: EntityManager) => { const cart = await this.retrieve(cartId, { relations: [ "discounts", @@ -1030,8 +1181,8 @@ class CartService extends BaseService { * @param {string} cartId - the id of the cart to update the payment session for * @param {object} update - the data to update the payment session with */ - async updatePaymentSession(cartId, update) { - return this.atomicPhase_(async (manager) => { + async updatePaymentSession(cartId: string, update: object): Promise { + return this.atomicPhase_(async (manager: EntityManager) => { const cart = await this.retrieve(cartId, { relations: ["payment_sessions"], }) @@ -1064,8 +1215,8 @@ class CartService extends BaseService { * this could be IP address or similar for fraud handling. * @return {Promise} the resulting cart */ - async authorizePayment(cartId, context = {}) { - return this.atomicPhase_(async (manager) => { + async authorizePayment(cartId: string, context: object = {}): Promise { + return this.atomicPhase_(async (manager: EntityManager) => { const cartRepository = manager.getCustomRepository(this.cartRepository_) const cart = await this.retrieve(cartId, { @@ -1073,6 +1224,13 @@ class CartService extends BaseService { relations: ["region", "payment_sessions"], }) + if (typeof cart.total === "undefined") { + throw new MedusaError( + MedusaError.Types.UNEXPECTED_STATE, + "cart.total should be defined" + ) + } + // If cart total is 0, we don't perform anything payment related if (cart.total <= 0) { cart.payment_authorized_at = new Date() @@ -1111,8 +1269,8 @@ class CartService extends BaseService { * @param {string} providerId - the id of the provider to be set to the cart * @return {Promise} result of update operation */ - async setPaymentSession(cartId, providerId) { - return this.atomicPhase_(async (manager) => { + async setPaymentSession(cartId: string, providerId: string): Promise { + return this.atomicPhase_(async (manager: EntityManager) => { const psRepo = manager.getCustomRepository(this.paymentSessionRepository_) const cart = await this.retrieve(cartId, { @@ -1141,8 +1299,8 @@ class CartService extends BaseService { } await Promise.all( - cart.payment_sessions.map((ps) => { - return psRepo.save({ ...ps, is_selected: null }) + cart.payment_sessions.map(async (ps) => { + return await psRepo.save({ ...ps, is_selected: null }) }) ) @@ -1150,6 +1308,13 @@ class CartService extends BaseService { (ps) => ps.provider_id === providerId ) + if (!sess) { + throw new MedusaError( + MedusaError.Types.UNEXPECTED_STATE, + "Could not find payment session" + ) + } + sess.is_selected = true await psRepo.save(sess) @@ -1173,12 +1338,13 @@ class CartService extends BaseService { * session for * @return {Promise} the result of the update operation. */ - async setPaymentSessions(cartOrCartId) { - return this.atomicPhase_(async (manager) => { + async setPaymentSessions(cartOrCartId: Cart | string): Promise { + return this.atomicPhase_(async (manager: EntityManager) => { const psRepo = manager.getCustomRepository(this.paymentSessionRepository_) const cartId = typeof cartOrCartId === `string` ? cartOrCartId : cartOrCartId.id + const cart = await this.retrieve(cartId, { select: [ "gift_card_total", @@ -1205,8 +1371,15 @@ class CartService extends BaseService { const region = cart.region + if (typeof cart.total === "undefined") { + throw new MedusaError( + MedusaError.Types.UNEXPECTED_STATE, + "cart.total should be defined" + ) + } + // If there are existing payment sessions ensure that these are up to date - const seen = [] + const seen: string[] = [] if (cart.payment_sessions && cart.payment_sessions.length) { for (const session of cart.payment_sessions) { if ( @@ -1258,8 +1431,11 @@ class CartService extends BaseService { * should be removed. * @return {Promise} the resulting cart. */ - async deletePaymentSession(cartId, providerId) { - return this.atomicPhase_(async (manager) => { + async deletePaymentSession( + cartId: string, + providerId: string + ): Promise { + return this.atomicPhase_(async (manager: EntityManager) => { const cart = await this.retrieve(cartId, { relations: ["payment_sessions"], }) @@ -1299,8 +1475,11 @@ class CartService extends BaseService { * should be removed. * @return {Promise} the resulting cart. */ - async refreshPaymentSession(cartId, providerId) { - return this.atomicPhase_(async (manager) => { + async refreshPaymentSession( + cartId: string, + providerId: string + ): Promise { + return this.atomicPhase_(async (manager: EntityManager) => { const cart = await this.retrieve(cartId, { relations: ["payment_sessions"], }) @@ -1338,8 +1517,12 @@ class CartService extends BaseService { * @param {Object} data - the fulmillment data for the method * @return {Promise} the result of the update operation */ - async addShippingMethod(cartId, optionId, data) { - return this.atomicPhase_(async (manager) => { + async addShippingMethod( + cartId: string, + optionId: string, + data: object = {} + ): Promise { + return this.atomicPhase_(async (manager: EntityManager) => { const cart = await this.retrieve(cartId, { select: ["subtotal"], relations: [ @@ -1431,7 +1614,10 @@ class CartService extends BaseService { * @param {string} optionId - id of the normal or custom shipping option to find in the cartCustomShippingOptions * @return {CustomShippingOption | undefined} */ - findCustomShippingOption(cartCustomShippingOptions, optionId) { + findCustomShippingOption( + cartCustomShippingOptions: CustomShippingOption[], + optionId: string + ): CustomShippingOption | undefined { const customOption = cartCustomShippingOptions?.find( (cso) => cso.shipping_option_id === optionId ) @@ -1454,7 +1640,11 @@ class CartService extends BaseService { * @param {string} countryCode - the country code to set the country to * @return {Promise} the result of the update operation */ - async setRegion_(cart, regionId, countryCode) { + async setRegion_( + cart: Cart, + regionId: string, + countryCode: string | null + ): Promise { if (cart.completed_at || cart.payment_authorized_at) { throw new MedusaError( MedusaError.Types.NOT_ALLOWED, @@ -1506,18 +1696,18 @@ class CartService extends BaseService { * First check if there is an existing shipping address on the cart if so * fetch the entire thing so we can modify the shipping country */ - let shippingAddress = {} + let shippingAddress: Partial
= {} if (cart.shipping_address_id) { - shippingAddress = await addrRepo.findOne({ + shippingAddress = (await addrRepo.findOne({ where: { id: cart.shipping_address_id }, - }) + })) as Address } /* * If the client has specified which country code we are updating to check * that that country is in fact in the country and perform the update. */ - if (countryCode !== undefined) { + if (countryCode !== null) { if ( !region.countries.find( ({ iso_2 }) => iso_2 === countryCode.toLowerCase() @@ -1580,9 +1770,10 @@ class CartService extends BaseService { if (d.regions.find(({ id }) => id === regionId)) { return d } + return null }) - cart.discounts = newDiscounts.filter((d) => !!d) + cart.discounts = newDiscounts.filter(Boolean) as Discount[] } cart.gift_cards = [] @@ -1603,11 +1794,11 @@ class CartService extends BaseService { /** * Deletes a cart from the database. Completed carts cannot be deleted. * @param {string} cartId - the id of the cart to delete - * @return {Promise} the deleted cart or undefined if the cart was + * @return {Promise} the deleted cart or undefined if the cart was * not found. */ - async delete(cartId) { - return this.atomicPhase_(async (manager) => { + async delete(cartId: string): Promise { + return await this.atomicPhase_(async (manager: EntityManager) => { const cart = await this.retrieve(cartId, { relations: [ "items", @@ -1633,7 +1824,7 @@ class CartService extends BaseService { } const cartRepo = manager.getCustomRepository(this.cartRepository_) - return cartRepo.remove(cartId) + return cartRepo.remove(cart) }) } @@ -1646,8 +1837,12 @@ class CartService extends BaseService { * @param {string} value - value for metadata field. * @return {Promise} resolves to the updated result. */ - async setMetadata(cartId, key, value) { - return this.atomicPhase_(async (manager) => { + async setMetadata( + cartId: string, + key: string, + value: string | number + ): Promise { + return await this.atomicPhase_(async (manager: EntityManager) => { const cartRepo = manager.getCustomRepository(this.cartRepository_) const validatedId = this.validateId_(cartId) @@ -1658,7 +1853,7 @@ class CartService extends BaseService { ) } - const cart = await cartRepo.findOne(validatedId) + const cart = (await cartRepo.findOne(validatedId)) as Cart const existing = cart.metadata || {} cart.metadata = { @@ -1680,8 +1875,8 @@ class CartService extends BaseService { * @param {string} key - key for metadata field * @return {Promise} resolves to the updated result. */ - async deleteMetadata(cartId, key) { - return this.atomicPhase_(async (manager) => { + async deleteMetadata(cartId: string, key: string): Promise { + return this.atomicPhase_(async (manager: EntityManager) => { const cartRepo = manager.getCustomRepository(this.cartRepository_) const validatedId = this.validateId_(cartId) diff --git a/packages/medusa/src/services/inventory.js b/packages/medusa/src/services/inventory.js index 3883506b30..6ce4dd4f2c 100644 --- a/packages/medusa/src/services/inventory.js +++ b/packages/medusa/src/services/inventory.js @@ -57,7 +57,7 @@ class InventoryService extends BaseService { * allows backorders or if the inventory quantity is greater than `quantity`. * @param {string} variantId - the id of the variant to check * @param {number} quantity - the number of units to check availability for - * @return {boolean} true if the inventory covers the quantity + * @return {Promise} true if the inventory covers the quantity */ async confirmInventory(variantId, quantity) { // if variantId is undefined then confirm inventory as it diff --git a/packages/medusa/src/services/shipping-option.js b/packages/medusa/src/services/shipping-option.js index 75cfd1fc4d..89232d08f8 100644 --- a/packages/medusa/src/services/shipping-option.js +++ b/packages/medusa/src/services/shipping-option.js @@ -201,7 +201,7 @@ class ShippingOptionService extends BaseService { /** * Removes a given shipping method - * @param {string} sm - the shipping method to remove + * @param {ShippingMethod} sm - the shipping method to remove */ async deleteShippingMethod(sm) { return this.atomicPhase_(async (manager) => { diff --git a/packages/medusa/src/types/cart.ts b/packages/medusa/src/types/cart.ts new file mode 100644 index 0000000000..8486e34d58 --- /dev/null +++ b/packages/medusa/src/types/cart.ts @@ -0,0 +1,70 @@ +import { ValidateNested } from "class-validator" +import { IsType } from "../utils/validators/is-type" +import { CartType } from "../models/cart" +import { + AddressPayload, + DateComparisonOperator, + StringComparisonOperator, +} from "./common" + +export class FilterableCartProps { + @ValidateNested() + @IsType([String, [String], StringComparisonOperator]) + id?: string | string[] | StringComparisonOperator + + @IsType([DateComparisonOperator]) + created_at?: DateComparisonOperator + + @IsType([DateComparisonOperator]) + updated_at?: DateComparisonOperator +} + +// TODO: Probably worth moving to `./line-item` instead +export type LineItemUpdate = { + title?: string + unit_price?: number + quantity?: number + metadata?: object + region_id?: string + variant_id?: string +} + +class GiftCard { + code: string +} + +class Discount { + code: string +} + +export type CartCreateProps = { + region_id: string + email?: string + billing_address_id?: string + billing_address?: Partial + shipping_address_id?: string + shipping_address?: Partial + gift_cards?: GiftCard[] + discounts?: Discount[] + customer_id?: string + type?: CartType + context?: object + metadata?: object +} + +export type CartUpdateProps = { + region_id?: string + country_code?: string + email?: string + shipping_address_id?: string + billing_address_id?: string + billing_address?: AddressPayload + shipping_address?: AddressPayload + completed_at?: Date + payment_authorized_at?: Date + gift_cards?: GiftCard[] + discounts?: Discount[] + customer_id?: string + context?: object + metadata?: object +} diff --git a/packages/medusa/src/types/common.ts b/packages/medusa/src/types/common.ts index 1adddd5a12..abbd8a7ce7 100644 --- a/packages/medusa/src/types/common.ts +++ b/packages/medusa/src/types/common.ts @@ -8,6 +8,16 @@ export type PartialPick = { [P in K]?: T[P] } +export type TotalField = + | "shipping_total" + | "discount_total" + | "tax_total" + | "refunded_total" + | "total" + | "subtotal" + | "refundable_amount" + | "gift_card_total" + export interface FindConfig { select?: (keyof Entity)[] skip?: number