import type { BusinessUnit, Cart, CartDraft, DiscountCodeInfo, ExternalTaxRateDraft, GeneralError, InvalidOperationError, LineItem, LineItemDraft, Product, ShippingMethodDoesNotMatchCartError, } from "@commercetools/platform-sdk"; import { v4 as uuidv4 } from "uuid"; import type { Config } from "#src/config.ts"; import { CommercetoolsError } from "#src/exceptions.ts"; import { getBaseResourceProperties } from "#src/helpers.ts"; import { calculateTaxedPriceFromRate, calculateTaxTotals, taxRateFromExternalDraft, } from "#src/lib/tax.ts"; import { CartDraftSchema } from "#src/schemas/generated/cart.ts"; import { createShippingInfoFromMethod, getShippingMethodsMatchingCart, } from "#src/shipping.ts"; import type { Writable } from "#src/types.ts"; import { AbstractResourceRepository, type RepositoryContext, } from "../abstract.ts"; import { calculateMoneyTotalCentAmount, createAddress, createCentPrecisionMoney, createCustomFields, } from "../helpers.ts"; import { CartUpdateHandler } from "./actions.ts"; import { calculateCartTotalPrice, createCustomLineItemFromDraft, createDiscountCodeInfoFromCode, selectPrice, } from "./helpers.ts"; export class CartRepository extends AbstractResourceRepository<"cart"> { constructor(config: Config) { super("cart", config); this.actions = new CartUpdateHandler(this._storage, this); this.draftSchema = CartDraftSchema; } async create(context: RepositoryContext, draft: CartDraft): Promise { if (draft.anonymousId && draft.customerId) { throw new CommercetoolsError({ code: "InvalidOperation", message: "Can set only one of customer OR anonymousId", }); } // Validate that the customer exists if (draft.customerId) { await this._storage.getByResourceIdentifier(context.projectKey, { typeId: "customer", id: draft.customerId, }); } let storedBusinessUnit: BusinessUnit | undefined; if (draft.businessUnit?.id || draft.businessUnit?.key) { storedBusinessUnit = await this._storage.getByResourceIdentifier<"business-unit">( context.projectKey, { typeId: "business-unit", id: draft.businessUnit.id, key: draft.businessUnit.key, }, ); } const taxMode = draft.taxMode ?? "Platform"; const taxRoundingMode = draft.taxRoundingMode ?? "HalfEven"; const lineItems = draft.lineItems ? await Promise.all( draft.lineItems.map((draftLineItem) => this.draftLineItemtoLineItem( context.projectKey, draftLineItem, draft.currency, draft.country, taxMode, taxRoundingMode, ), ), ) : []; const customLineItems = draft.customLineItems ? await Promise.all( draft.customLineItems.map((draftCustomLineItem) => createCustomLineItemFromDraft( context.projectKey, draftCustomLineItem, this._storage, draft.shippingAddress?.country ?? draft.country, taxMode, taxRoundingMode, ), ), ) : []; // Validate that discount codes exist and deduplicate const discountCodeInfo: DiscountCodeInfo[] = []; if (draft.discountCodes?.length) { const seen = new Set(); for (const code of draft.discountCodes) { const info = await createDiscountCodeInfoFromCode( context.projectKey, this._storage, code, ); if (!seen.has(info.discountCode.id)) { seen.add(info.discountCode.id); discountCodeInfo.push(info); } } } const resource: Writable = { ...getBaseResourceProperties(context.clientId), anonymousId: draft.anonymousId, businessUnit: storedBusinessUnit && draft.businessUnit ? { typeId: draft.businessUnit.typeId, key: storedBusinessUnit.key, } : undefined, billingAddress: draft.billingAddress ? createAddress(draft.billingAddress, context.projectKey, this._storage) : undefined, cartState: "Active", country: draft.country, customerId: draft.customerId, customerEmail: draft.customerEmail, customLineItems, directDiscounts: [], discountCodes: discountCodeInfo, inventoryMode: "None", itemShippingAddresses: [], lineItems, locale: draft.locale, priceRoundingMode: draft.priceRoundingMode ?? "HalfEven", taxCalculationMode: draft.taxCalculationMode ?? "LineItemLevel", taxMode, taxRoundingMode, totalPrice: { type: "centPrecision", centAmount: 0, currencyCode: draft.currency, fractionDigits: 0, }, shippingMode: "Single", shippingAddress: draft.shippingAddress ? createAddress( draft.shippingAddress, context.projectKey, this._storage, ) : undefined, shipping: [], shippingInfo: undefined, origin: draft.origin ?? "Customer", purchaseOrderNumber: draft.purchaseOrderNumber, refusedGifts: [], custom: await createCustomFields( draft.custom, context.projectKey, this._storage, ), }; resource.totalPrice.centAmount = calculateCartTotalPrice(resource); resource.store = context.storeKey ? { typeId: "store", key: context.storeKey } : draft.store?.key ? { typeId: "store", key: draft.store.key } : undefined; // Set shipping info after resource is created if (draft.shippingMethod) { resource.shippingInfo = await this.createShippingInfo( context, resource, draft.shippingMethod, draft.externalTaxRateForShippingMethod ?? undefined, ); } const { taxedPrice, taxedShippingPrice } = calculateTaxTotals(resource); resource.taxedPrice = taxedPrice; resource.taxedShippingPrice = taxedShippingPrice; return await this.saveNew(context, resource); } async getActiveCart(projectKey: string): Promise { // Get first active cart const results = await this._storage.query(projectKey, this.getTypeId(), { where: [`cartState="Active"`], }); if (results.count > 0) { return results.results[0] as Cart; } return; } draftLineItemtoLineItem = async ( projectKey: string, draftLineItem: LineItemDraft, currency: string, country: string | undefined, taxMode: Cart["taxMode"] = "Platform", roundingMode: Cart["taxRoundingMode"] = "HalfEven", ): Promise => { const { productId, quantity, variantId, sku } = draftLineItem; let product: Product | null = null; if (productId && variantId) { // Fetch product and variant by ID product = await this._storage.get(projectKey, "product", productId, {}); } else if (sku) { // Fetch product and variant by SKU const items = await this._storage.query(projectKey, "product", { where: [ `masterData(current(masterVariant(sku="${sku}"))) or masterData(current(variants(sku="${sku}")))`, ], }); if (items.count === 1) { product = items.results[0]; } } if (!product) { // Check if product is found throw new CommercetoolsError({ code: "General", message: sku ? `A product containing a variant with SKU '${sku}' not found.` : `A product with ID '${productId}' not found.`, }); } // Find matching variant const variant = [ product.masterData.current.masterVariant, ...product.masterData.current.variants, ].find((x) => { if (sku) return x.sku === sku; if (variantId) return x.id === variantId; return false; }); if (!variant) { // Check if variant is found throw new CommercetoolsError({ code: "InvalidOperation", message: sku ? `A variant with SKU '${sku}' for product '${product.id}' not found.` : `A variant with ID '${variantId}' for product '${product.id}' not found.`, }); } const quant = quantity ?? 1; const price = selectPrice({ prices: variant.prices, currency, country }); if (!price) { throw new CommercetoolsError({ code: "InvalidOperation", message: `No valid price found for ${productId} for country ${country} and currency ${currency}`, }); } const totalPrice = createCentPrecisionMoney({ currencyCode: price.value.currencyCode, centAmount: calculateMoneyTotalCentAmount(price.value, quant), }); const taxRate = taxMode === "External" && draftLineItem.externalTaxRate ? taxRateFromExternalDraft(draftLineItem.externalTaxRate) : undefined; const taxedPrice = taxRate ? calculateTaxedPriceFromRate( totalPrice.centAmount, totalPrice.currencyCode, taxRate, roundingMode, ) : undefined; return { id: uuidv4(), productId: product.id, productKey: product.key, productSlug: product.masterData.current.slug, productType: product.productType, name: product.masterData.current.name, variant, price: price, totalPrice, taxRate, taxedPrice, taxedPricePortions: [], perMethodTaxRate: [], quantity: quant, discountedPricePerQuantity: [], lineItemMode: "Standard", priceMode: "Platform", state: [], custom: await createCustomFields( draftLineItem.custom, projectKey, this._storage, ), }; }; async createShippingInfo( context: RepositoryContext, resource: Writable, shippingMethodRef: NonNullable, externalTaxRate?: ExternalTaxRateDraft, ): Promise> { if (resource.taxMode === "External" && !externalTaxRate) { throw new CommercetoolsError({ code: "InvalidOperation", message: "An external tax rate is required for a cart with External tax mode.", }); } // Bit of a hack: calling this checks that the resource identifier is // valid (i.e. id xor key) and that the shipping method exists. await this._storage.getByResourceIdentifier<"shipping-method">( context.projectKey, shippingMethodRef, ); // getShippingMethodsMatchingCart does the work of determining whether the // shipping method is allowed for the cart, and which shipping rate to use const shippingMethods = await getShippingMethodsMatchingCart( context, this._storage, resource, { expand: ["zoneRates[*].zone"], }, ); const method = shippingMethods.results.find( (candidate: { id: string; key?: string }) => shippingMethodRef.id ? candidate.id === shippingMethodRef.id : candidate.key === shippingMethodRef.key, ); // Not finding the method in the results means it's not allowed, since // getShippingMethodsMatchingCart only returns allowed methods and we // already checked that the method exists. if (!method) { throw new CommercetoolsError({ code: "ShippingMethodDoesNotMatchCart", message: `The shipping method with ${shippingMethodRef.id ? `ID '${shippingMethodRef.id}'` : `key '${shippingMethodRef.key}'`} is not allowed for the cart with ID '${resource.id}'.`, }); } // Use the shared shipping info creation logic return await createShippingInfoFromMethod( context, this._storage, resource, method, resource.taxMode === "External" ? externalTaxRate : undefined, ); } }