Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rare-towns-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@labdigital/commercetools-mock": minor
---

Feat: set shipping method from cart draft as shipping method
177 changes: 9 additions & 168 deletions src/repositories/cart/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@ import type {
CartSetAnonymousIdAction,
CartSetCustomerIdAction,
CartUpdateAction,
CentPrecisionMoney,
InvalidOperationError,
MissingTaxRateForCountryError,
ShippingMethodDoesNotMatchCartError,
} from "@commercetools/platform-sdk";
import type {
Address,
Expand Down Expand Up @@ -48,29 +44,23 @@ 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 {
createAddress,
createCentPrecisionMoney,
createCustomFields,
createTypedMoney,
getReferenceFromResourceIdentifier,
roundDecimal,
} from "../helpers";
import {
calculateCartTotalPrice,
calculateLineItemTotalPrice,
calculateTaxedPrice,
createCustomLineItemFromDraft,
selectPrice,
} from "./helpers";
Expand All @@ -79,6 +69,12 @@ export class CartUpdateHandler
extends AbstractUpdateHandler
implements Partial<UpdateHandlerInterface<Cart, CartUpdateAction>>
{
private repository: CartRepository;

constructor(storage: any, repository: CartRepository) {
super(storage);
this.repository = repository;
}
addItemShippingAddress(
context: RepositoryContext,
resource: Writable<Cart>,
Expand Down Expand Up @@ -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<InvalidOperationError>({
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<ShippingMethodDoesNotMatchCartError>({
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<MissingTaxRateForCountryError>({
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;
}
Expand Down
Loading
Loading