diff --git a/.changeset/rare-towns-hang.md b/.changeset/rare-towns-hang.md new file mode 100644 index 00000000..99a55889 --- /dev/null +++ b/.changeset/rare-towns-hang.md @@ -0,0 +1,5 @@ +--- +"@labdigital/commercetools-mock": minor +--- + +Feat: set shipping method from cart draft as shipping method diff --git a/src/repositories/cart/actions.ts b/src/repositories/cart/actions.ts index 08ff5366..2e0898c0 100644 --- a/src/repositories/cart/actions.ts +++ b/src/repositories/cart/actions.ts @@ -2,10 +2,6 @@ import type { CartSetAnonymousIdAction, CartSetCustomerIdAction, CartUpdateAction, - CentPrecisionMoney, - InvalidOperationError, - MissingTaxRateForCountryError, - ShippingMethodDoesNotMatchCartError, } from "@commercetools/platform-sdk"; import type { Address, @@ -48,15 +44,12 @@ import type { import type { CustomLineItem, DirectDiscount, - TaxPortion, - TaxedItemPrice, } from "@commercetools/platform-sdk/dist/declarations/src/generated/models/cart"; import type { ShippingMethodResourceIdentifier } from "@commercetools/platform-sdk/dist/declarations/src/generated/models/shipping-method"; -import { Decimal } from "decimal.js/decimal"; import { v4 as uuidv4 } from "uuid"; import { CommercetoolsError } from "~src/exceptions"; -import { getShippingMethodsMatchingCart } from "~src/shipping"; import type { Writable } from "~src/types"; +import type { CartRepository } from "."; import type { UpdateHandlerInterface } from "../abstract"; import { AbstractUpdateHandler, type RepositoryContext } from "../abstract"; import { @@ -64,13 +57,10 @@ import { createCentPrecisionMoney, createCustomFields, createTypedMoney, - getReferenceFromResourceIdentifier, - roundDecimal, } from "../helpers"; import { calculateCartTotalPrice, calculateLineItemTotalPrice, - calculateTaxedPrice, createCustomLineItemFromDraft, selectPrice, } from "./helpers"; @@ -79,6 +69,12 @@ export class CartUpdateHandler extends AbstractUpdateHandler implements Partial> { + private repository: CartRepository; + + constructor(storage: any, repository: CartRepository) { + super(storage); + this.repository = repository; + } addItemShippingAddress( context: RepositoryContext, resource: Writable, @@ -769,166 +765,11 @@ export class CartUpdateHandler { shippingMethod }: CartSetShippingMethodAction, ) { if (shippingMethod) { - if (resource.taxMode === "External") { - throw new Error("External tax rate is not supported"); - } - - const country = resource.shippingAddress?.country; - - if (!country) { - throw new CommercetoolsError({ - code: "InvalidOperation", - message: `The cart with ID '${resource.id}' does not have a shipping address set.`, - }); - } - - // Bit of a hack: calling this checks that the resource identifier is - // valid (i.e. id xor key) and that the shipping method exists. - this._storage.getByResourceIdentifier<"shipping-method">( - context.projectKey, - shippingMethod, - ); - - // getShippingMethodsMatchingCart does the work of determining whether the - // shipping method is allowed for the cart, and which shipping rate to use - const shippingMethods = getShippingMethodsMatchingCart( + resource.shippingInfo = this.repository.createShippingInfo( context, - this._storage, resource, - { - expand: ["zoneRates[*].zone"], - }, - ); - - const method = shippingMethods.results.find((candidate) => - shippingMethod.id - ? candidate.id === shippingMethod.id - : candidate.key === shippingMethod.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 ${shippingMethod.id ? `ID '${shippingMethod.id}'` : `key '${shippingMethod.key}'`} is not allowed for the cart with ID '${resource.id}'.`, - }); - } - - const taxCategory = this._storage.getByResourceIdentifier<"tax-category">( - context.projectKey, - method.taxCategory, - ); - - // TODO: match state in addition to country - const taxRate = taxCategory.rates.find( - (rate) => rate.country === country, - ); - - if (!taxRate) { - throw new CommercetoolsError({ - code: "MissingTaxRateForCountry", - message: `Tax category '${taxCategory.id}' is missing a tax rate for country '${country}'.`, - taxCategoryId: taxCategory.id, - }); - } - - // There should only be one zone rate matching the address, since - // Locations cannot be assigned to more than one zone. - // See https://docs.commercetools.com/api/projects/zones#location - const zoneRate = method.zoneRates.find((rate) => - rate.zone.obj?.locations.some((loc) => loc.country === country), - ); - - if (!zoneRate) { - // This shouldn't happen because getShippingMethodsMatchingCart already - // filtered out shipping methods without any zones matching the address - throw new Error("Zone rate not found"); - } - - // Shipping rates are defined by currency, and getShippingMethodsMatchingCart - // also matches on currency, so there should only be one in the array. - // See https://docs.commercetools.com/api/projects/shippingMethods#zonerate - const shippingRate = zoneRate.shippingRates[0]; - if (!shippingRate) { - // This shouldn't happen because getShippingMethodsMatchingCart already - // filtered out shipping methods without any matching rates - throw new Error("Shipping rate not found"); - } - - const shippingRateTier = shippingRate.tiers.find( - (tier) => tier.isMatching, + shippingMethod, ); - if (shippingRateTier && shippingRateTier.type !== "CartValue") { - throw new Error("Non-CartValue shipping rate tier is not supported"); - } - - const shippingPrice = shippingRateTier - ? createCentPrecisionMoney(shippingRateTier.price) - : shippingRate.price; - - // TODO: handle freeAbove - - const totalGross: CentPrecisionMoney = taxRate.includedInPrice - ? shippingPrice - : { - ...shippingPrice, - centAmount: roundDecimal( - new Decimal(shippingPrice.centAmount).mul(1 + taxRate.amount), - resource.taxRoundingMode, - ).toNumber(), - }; - - const totalNet: CentPrecisionMoney = taxRate.includedInPrice - ? { - ...shippingPrice, - centAmount: roundDecimal( - new Decimal(shippingPrice.centAmount).div(1 + taxRate.amount), - resource.taxRoundingMode, - ).toNumber(), - } - : shippingPrice; - - const taxPortions: TaxPortion[] = [ - { - name: taxRate.name, - rate: taxRate.amount, - amount: { - ...shippingPrice, - centAmount: totalGross.centAmount - totalNet.centAmount, - }, - }, - ]; - - const totalTax: CentPrecisionMoney = { - ...shippingPrice, - centAmount: taxPortions.reduce( - (acc, portion) => acc + portion.amount.centAmount, - 0, - ), - }; - - const taxedPrice: TaxedItemPrice = { - totalNet, - totalGross, - taxPortions, - totalTax, - }; - - // @ts-ignore - resource.shippingInfo = { - shippingMethod: { - typeId: "shipping-method", - id: method.id, - }, - shippingMethodName: method.name, - price: shippingPrice, - shippingRate, - taxedPrice, - taxRate, - taxCategory: method.taxCategory, - }; } else { resource.shippingInfo = undefined; } diff --git a/src/repositories/cart/index.test.ts b/src/repositories/cart/index.test.ts index ae343fc7..aa5569b2 100644 --- a/src/repositories/cart/index.test.ts +++ b/src/repositories/cart/index.test.ts @@ -1,9 +1,10 @@ import type { + Cart, CartDraft, CustomLineItemDraft, LineItem, } from "@commercetools/platform-sdk"; -import { describe, expect, test } from "vitest"; +import { beforeEach, describe, expect, test } from "vitest"; import type { Config } from "~src/config"; import { getBaseResourceProperties } from "~src/helpers"; import { InMemoryStorage } from "~src/storage"; @@ -92,7 +93,70 @@ describe("Cart repository", () => { id: "tax-category-id", key: "standard-tax", name: "Standard Tax", - rates: [], + rates: [ + { + id: "nl-rate", + name: "Standard VAT", + amount: 0.21, + includedInPrice: false, + country: "NL", + }, + ], + }); + + storage.add("dummy", "zone", { + ...getBaseResourceProperties(), + id: "nl-zone-id", + key: "nl-zone", + name: "Netherlands Zone", + locations: [ + { + country: "NL", + }, + ], + }); + + storage.add("dummy", "shipping-method", { + ...getBaseResourceProperties(), + id: "shipping-method-id", + key: "standard-shipping", + name: "Standard Shipping", + taxCategory: { + typeId: "tax-category", + id: "tax-category-id", + }, + zoneRates: [ + { + zone: { + typeId: "zone", + id: "nl-zone-id", + obj: { + ...getBaseResourceProperties(), + id: "nl-zone-id", + key: "nl-zone", + name: "Netherlands Zone", + locations: [ + { + country: "NL", + }, + ], + }, + }, + shippingRates: [ + { + price: { + currencyCode: "EUR", + centAmount: 500, + type: "centPrecision", + fractionDigits: 2, + }, + tiers: [], + }, + ], + }, + ], + active: true, + isDefault: false, }); const cart: CartDraft = { @@ -109,6 +173,10 @@ describe("Cart repository", () => { country: "NL", currency: "EUR", customerEmail: "john.doe@example.com", + shippingMethod: { + typeId: "shipping-method", + id: "shipping-method-id", + }, customLineItems: [ { name: { "nl-NL": "Douane kosten" }, @@ -204,6 +272,18 @@ describe("Cart repository", () => { cart.customLineItems?.[0].name, ); expect(result.totalPrice.centAmount).toBe(3500); + + expect(result.shippingInfo).toBeDefined(); + expect(result.shippingInfo!.shippingMethod!.id).toBe("shipping-method-id"); + expect(result.shippingInfo!.shippingMethodName).toBe("Standard Shipping"); + expect(result.shippingInfo?.price).toBeDefined(); + expect(result.shippingInfo?.price.centAmount).toBe(500); + expect(result.shippingInfo?.price.currencyCode).toBe("EUR"); + expect(result.shippingInfo?.taxedPrice).toBeDefined(); + expect(result.shippingInfo?.taxedPrice?.totalGross.centAmount).toBe(605); + expect(result.shippingInfo?.taxedPrice?.totalNet.centAmount).toBe(500); + expect(result.shippingInfo?.taxRate?.amount).toBe(0.21); + expect(result.shippingInfo?.taxRate?.name).toBe("Standard VAT"); }); test("create cart with business unit", async () => { @@ -300,3 +380,287 @@ describe("Cart repository", () => { expect(customLineItem.taxRate?.country).toBe("NL"); }); }); + +describe("createShippingInfo", () => { + const storage = new InMemoryStorage(); + const config: Config = { storage, strict: false }; + const repository = new CartRepository(config); + + beforeEach(() => { + storage.add("dummy", "tax-category", { + ...getBaseResourceProperties(), + id: "shipping-tax-category-id", + key: "shipping-tax", + name: "Shipping Tax", + rates: [ + { + id: "nl-shipping-rate", + name: "Standard VAT", + amount: 0.21, + includedInPrice: false, + country: "NL", + }, + ], + }); + + storage.add("dummy", "zone", { + ...getBaseResourceProperties(), + id: "test-zone-id", + name: "Test Zone", + locations: [ + { + country: "NL", + }, + ], + }); + }); + + test("should calculate shipping info", () => { + storage.add("dummy", "shipping-method", { + ...getBaseResourceProperties(), + id: "basic-shipping-id", + name: "Standard Shipping", + taxCategory: { + typeId: "tax-category", + id: "shipping-tax-category-id", + }, + zoneRates: [ + { + zone: { + typeId: "zone", + id: "test-zone-id", + obj: { + ...getBaseResourceProperties(), + id: "test-zone-id", + name: "Test Zone", + locations: [ + { + country: "NL", + }, + ], + }, + }, + shippingRates: [ + { + price: { + currencyCode: "EUR", + centAmount: 595, + type: "centPrecision", + fractionDigits: 2, + }, + tiers: [], + }, + ], + }, + ], + active: true, + isDefault: false, + }); + + const cart: any = { + ...getBaseResourceProperties(), + id: "basic-cart-id", + version: 1, + cartState: "Active", + totalPrice: { + currencyCode: "EUR", + centAmount: 3000, + type: "centPrecision", + fractionDigits: 2, + }, + shippingAddress: { + country: "NL", + }, + taxRoundingMode: "HalfEven", + }; + + const context = { projectKey: "dummy", storeKey: "testStore" }; + const shippingMethodRef = { + typeId: "shipping-method" as const, + id: "basic-shipping-id", + }; + + const result = repository.createShippingInfo( + context, + cart, + shippingMethodRef, + ); + + expect(result.price.centAmount).toBe(595); + expect(result.shippingMethodName).toBe("Standard Shipping"); + expect(result.shippingMethod!.id).toBe("basic-shipping-id"); + expect(result.taxRate?.amount).toBe(0.21); + expect(result.taxedPrice!.totalNet.centAmount).toBe(595); + expect(result.taxedPrice!.totalGross.centAmount).toBe(720); + }); + + test("should apply free shipping when cart total is above freeAbove threshold", () => { + storage.add("dummy", "shipping-method", { + ...getBaseResourceProperties(), + id: "free-above-shipping-id", + key: "free-above-shipping", + name: "Free Above €50", + taxCategory: { + typeId: "tax-category", + id: "shipping-tax-category-id", + }, + zoneRates: [ + { + zone: { + typeId: "zone", + id: "test-zone-id", + obj: { + ...getBaseResourceProperties(), + id: "test-zone-id", + key: "test-zone", + name: "Test Zone", + locations: [ + { + country: "NL", + }, + ], + }, + }, + shippingRates: [ + { + price: { + currencyCode: "EUR", + centAmount: 995, + type: "centPrecision", + fractionDigits: 2, + }, + freeAbove: { + currencyCode: "EUR", + centAmount: 5000, + type: "centPrecision", + fractionDigits: 2, + }, + tiers: [], + }, + ], + }, + ], + active: true, + isDefault: false, + }); + + const cart: any = { + ...getBaseResourceProperties(), + id: "test-cart-id", + version: 1, + cartState: "Active", + totalPrice: { + currencyCode: "EUR", + centAmount: 6000, + type: "centPrecision", + fractionDigits: 2, + }, + shippingAddress: { + country: "NL", + }, + taxRoundingMode: "HalfEven", + }; + + const context = { projectKey: "dummy", storeKey: "testStore" }; + const shippingMethodRef = { + typeId: "shipping-method" as const, + id: "free-above-shipping-id", + }; + + const result = repository.createShippingInfo( + context, + cart, + shippingMethodRef, + ); + + expect(result.price.centAmount).toBe(0); + expect(result.shippingMethodName).toBe("Free Above €50"); + expect(result.taxedPrice!.totalGross.centAmount).toBe(0); + expect(result.taxedPrice!.totalNet.centAmount).toBe(0); + }); + + test("should charge normal shipping when cart total is below freeAbove threshold", () => { + storage.add("dummy", "shipping-method", { + ...getBaseResourceProperties(), + id: "free-above-shipping-id-2", + key: "free-above-shipping-2", + name: "Free Above €50", + taxCategory: { + typeId: "tax-category", + id: "shipping-tax-category-id", + }, + zoneRates: [ + { + zone: { + typeId: "zone", + id: "test-zone-id", + obj: { + ...getBaseResourceProperties(), + id: "test-zone-id", + key: "test-zone", + name: "Test Zone", + locations: [ + { + country: "NL", + }, + ], + }, + }, + shippingRates: [ + { + price: { + currencyCode: "EUR", + centAmount: 995, + type: "centPrecision", + fractionDigits: 2, + }, + freeAbove: { + currencyCode: "EUR", + centAmount: 5000, + type: "centPrecision", + fractionDigits: 2, + }, + tiers: [], + }, + ], + }, + ], + active: true, + isDefault: false, + }); + + const cart: any = { + ...getBaseResourceProperties(), + id: "test-cart-id-2", + version: 1, + cartState: "Active", + totalPrice: { + currencyCode: "EUR", + centAmount: 2000, + type: "centPrecision", + fractionDigits: 2, + }, + shippingAddress: { + country: "NL", + }, + taxRoundingMode: "HalfEven", + }; + + const context = { projectKey: "dummy", storeKey: "testStore" }; + const shippingMethodRef = { + typeId: "shipping-method" as const, + id: "free-above-shipping-id-2", + }; + + const result = repository.createShippingInfo( + context, + cart, + shippingMethodRef, + ); + + expect(result.price.centAmount).toBe(995); + expect(result.shippingMethodName).toBe("Free Above €50"); + expect(result.taxedPrice!.totalGross.centAmount).toBe(1204); + expect(result.taxedPrice!.totalNet.centAmount).toBe(995); + }); +}); diff --git a/src/repositories/cart/index.ts b/src/repositories/cart/index.ts index 132fdb76..ec03d4d1 100644 --- a/src/repositories/cart/index.ts +++ b/src/repositories/cart/index.ts @@ -1,6 +1,9 @@ import type { BusinessUnit, + CentPrecisionMoney, InvalidOperationError, + MissingTaxRateForCountryError, + ShippingMethodDoesNotMatchCartError, } from "@commercetools/platform-sdk"; import type { Cart, @@ -12,17 +15,27 @@ import type { LineItemDraft, Product, ProductPagedQueryResponse, + TaxPortion, + TaxedItemPrice, } from "@commercetools/platform-sdk"; +import { Decimal } from "decimal.js/decimal"; import { v4 as uuidv4 } from "uuid"; import type { Config } from "~src/config"; import { CommercetoolsError } from "~src/exceptions"; import { getBaseResourceProperties } from "~src/helpers"; +import { getShippingMethodsMatchingCart } from "~src/shipping"; import type { Writable } from "~src/types"; import { AbstractResourceRepository, type RepositoryContext, } from "../abstract"; -import { createAddress, createCustomFields } from "../helpers"; +import { + createAddress, + createCentPrecisionMoney, + createCustomFields, + createTypedMoney, + roundDecimal, +} from "../helpers"; import { CartUpdateHandler } from "./actions"; import { calculateCartTotalPrice, @@ -33,7 +46,7 @@ import { export class CartRepository extends AbstractResourceRepository<"cart"> { constructor(config: Config) { super("cart", config); - this.actions = new CartUpdateHandler(this._storage); + this.actions = new CartUpdateHandler(this._storage, this); } create(context: RepositoryContext, draft: CartDraft): Cart { @@ -128,6 +141,7 @@ export class CartRepository extends AbstractResourceRepository<"cart"> { ) : undefined, shipping: [], + shippingInfo: undefined, origin: draft.origin ?? "Customer", refusedGifts: [], custom: createCustomFields( @@ -141,6 +155,15 @@ export class CartRepository extends AbstractResourceRepository<"cart"> { ? { typeId: "store", key: context.storeKey } : undefined; + // Set shipping info after resource is created + if (draft.shippingMethod) { + resource.shippingInfo = this.createShippingInfo( + context, + resource, + draft.shippingMethod, + ); + } + return this.saveNew(context, resource); } @@ -249,4 +272,179 @@ export class CartRepository extends AbstractResourceRepository<"cart"> { ), }; }; + + createShippingInfo( + context: RepositoryContext, + resource: Writable, + shippingMethodRef: NonNullable, + ): NonNullable { + if (resource.taxMode === "External") { + throw new Error("External tax rate is not supported"); + } + + const country = resource.shippingAddress?.country; + + if (!country) { + throw new CommercetoolsError({ + code: "InvalidOperation", + message: `The cart with ID '${resource.id}' does not have a shipping address set.`, + }); + } + + // Bit of a hack: calling this checks that the resource identifier is + // valid (i.e. id xor key) and that the shipping method exists. + 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 = getShippingMethodsMatchingCart( + context, + this._storage, + resource, + { + expand: ["zoneRates[*].zone"], + }, + ); + + const method = shippingMethods.results.find((candidate) => + 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}'.`, + }); + } + + const taxCategory = this._storage.getByResourceIdentifier<"tax-category">( + context.projectKey, + method.taxCategory, + ); + + // TODO: match state in addition to country + const taxRate = taxCategory.rates.find((rate) => rate.country === country); + + if (!taxRate) { + throw new CommercetoolsError({ + code: "MissingTaxRateForCountry", + message: `Tax category '${taxCategory.id}' is missing a tax rate for country '${country}'.`, + taxCategoryId: taxCategory.id, + }); + } + + // There should only be one zone rate matching the address, since + // Locations cannot be assigned to more than one zone. + // See https://docs.commercetools.com/api/projects/zones#location + const zoneRate = method.zoneRates.find((rate) => + rate.zone.obj?.locations.some((loc) => loc.country === country), + ); + + if (!zoneRate) { + // This shouldn't happen because getShippingMethodsMatchingCart already + // filtered out shipping methods without any zones matching the address + throw new Error("Zone rate not found"); + } + + // Shipping rates are defined by currency, and getShippingMethodsMatchingCart + // also matches on currency, so there should only be one in the array. + // See https://docs.commercetools.com/api/projects/shippingMethods#zonerate + const shippingRate = zoneRate.shippingRates[0]; + if (!shippingRate) { + // This shouldn't happen because getShippingMethodsMatchingCart already + // filtered out shipping methods without any matching rates + throw new Error("Shipping rate not found"); + } + + const shippingRateTier = shippingRate.tiers.find((tier) => tier.isMatching); + if (shippingRateTier && shippingRateTier.type !== "CartValue") { + throw new Error("Non-CartValue shipping rate tier is not supported"); + } + + let shippingPrice = shippingRateTier + ? createCentPrecisionMoney(shippingRateTier.price) + : shippingRate.price; + + // Handle freeAbove: if cart total is above the freeAbove threshold, shipping is free + if ( + shippingRate.freeAbove && + shippingRate.freeAbove.currencyCode === + resource.totalPrice.currencyCode && + resource.totalPrice.centAmount >= shippingRate.freeAbove.centAmount + ) { + shippingPrice = { + ...shippingPrice, + centAmount: 0, + }; + } + + // Calculate tax amounts + const totalGross: CentPrecisionMoney = taxRate.includedInPrice + ? shippingPrice + : { + ...shippingPrice, + centAmount: roundDecimal( + new Decimal(shippingPrice.centAmount).mul(1 + taxRate.amount), + resource.taxRoundingMode, + ).toNumber(), + }; + + const totalNet: CentPrecisionMoney = taxRate.includedInPrice + ? { + ...shippingPrice, + centAmount: roundDecimal( + new Decimal(shippingPrice.centAmount).div(1 + taxRate.amount), + resource.taxRoundingMode, + ).toNumber(), + } + : shippingPrice; + + const taxPortions: TaxPortion[] = [ + { + name: taxRate.name, + rate: taxRate.amount, + amount: { + ...shippingPrice, + centAmount: totalGross.centAmount - totalNet.centAmount, + }, + }, + ]; + + const totalTax: CentPrecisionMoney = { + ...shippingPrice, + centAmount: taxPortions.reduce( + (acc, portion) => acc + portion.amount.centAmount, + 0, + ), + }; + + const taxedPrice: TaxedItemPrice = { + totalNet, + totalGross, + taxPortions, + totalTax, + }; + + return { + shippingMethod: { + typeId: "shipping-method" as const, + id: method.id, + }, + shippingMethodName: method.name, + price: shippingPrice, + shippingRate, + taxedPrice, + taxRate, + taxCategory: method.taxCategory, + shippingMethodState: "MatchesCart", + }; + } }