Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: fill shippingInfo on setShippingMethod #223

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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"dependencies": {
"basic-auth": "^2.0.1",
"body-parser": "^1.20.2",
"decimal.js": "10.4.3",
"deep-equal": "^2.2.3",
"express": "^4.19.2",
"light-my-request": "^5.11.1",
Expand Down
3,537 changes: 1,550 additions & 1,987 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/product-projection-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export class ProductProjectionSearch {
.all(projectKey, "product")
.map((r) => this.transform(r, params.staged ?? false))
.filter((p) => {
if (!params.staged ?? false) {
if (!(params.staged ?? false)) {
return p.published;
}
return true;
Expand Down
2 changes: 1 addition & 1 deletion src/product-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export class ProductSearch {
this.transform(r, params.productProjectionParameters?.staged ?? false),
)
.filter((p) => {
if (!params.productProjectionParameters?.staged ?? false) {
if (!(params.productProjectionParameters?.staged ?? false)) {
return p.published;
}
return true;
Expand Down
161 changes: 157 additions & 4 deletions src/repositories/cart/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import {
CartSetAnonymousIdAction,
CartSetCustomerIdAction,
CartUpdateAction,
CentPrecisionMoney,
InvalidOperationError,
MissingTaxRateForCountryError,
ShippingMethodDoesNotMatchCartError,
type Address,
type AddressDraft,
type Cart,
Expand Down Expand Up @@ -32,9 +36,15 @@ import {
type ProductPagedQueryResponse,
type ProductVariant,
} from "@commercetools/platform-sdk";
import { DirectDiscount } from "@commercetools/platform-sdk/dist/declarations/src/generated/models/cart";
import {
DirectDiscount,
TaxPortion,
TaxedItemPrice,
} from "@commercetools/platform-sdk/dist/declarations/src/generated/models/cart";
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 {
AbstractUpdateHandler,
Expand All @@ -46,6 +56,7 @@ import {
createCentPrecisionMoney,
createCustomFields,
createTypedMoney,
roundDecimal,
} from "../helpers";
import {
calculateCartTotalPrice,
Expand Down Expand Up @@ -575,20 +586,162 @@ export class CartUpdateHandler
{ shippingMethod }: CartSetShippingMethodAction,
) {
if (shippingMethod) {
const method = this._storage.getByResourceIdentifier<"shipping-method">(
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,
);

// Based on the address we should select a shipping zone and
// use that to define the price.
// 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) =>
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,
});
}

// TODO: are zones on a single shipping method mutually exclusive in terms of countries in the set? If not, need to determine what to do in case of multiple matches.
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");
}

// TODO: how to pick which shipping rate in array to use? All in array are
// matching, but could there be multiple matching rates for a single zone?
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");
}

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
21 changes: 21 additions & 0 deletions src/repositories/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
BusinessUnitKeyReference,
BusinessUnitReference,
BusinessUnitResourceIdentifier,
RoundingMode,
type Address,
type Associate,
type AssociateDraft,
Expand All @@ -29,6 +30,7 @@ import {
type Type,
type _Money,
} from "@commercetools/platform-sdk";
import { Decimal } from "decimal.js/decimal";
import type { Request } from "express";
import { v4 as uuidv4 } from "uuid";
import { CommercetoolsError } from "~src/exceptions";
Expand Down Expand Up @@ -84,6 +86,25 @@ export const createPrice = (draft: PriceDraft): Price => ({
value: createTypedMoney(draft.value),
});

/**
* Rounds a decimal to the nearest whole number using the specified
* (Commercetools) rounding mode.
*
* @see https://docs.commercetools.com/api/projects/carts#roundingmode
*/
export const roundDecimal = (decimal: Decimal, roundingMode: RoundingMode) => {
switch (roundingMode) {
case "HalfEven":
return decimal.toDecimalPlaces(0, Decimal.ROUND_HALF_EVEN);
case "HalfUp":
return decimal.toDecimalPlaces(0, Decimal.ROUND_HALF_UP);
case "HalfDown":
return decimal.toDecimalPlaces(0, Decimal.ROUND_HALF_DOWN);
default:
throw new Error(`Unknown rounding mode: ${roundingMode}`);
}
};

export const createCentPrecisionMoney = (value: _Money): CentPrecisionMoney => {
// Taken from https://docs.adyen.com/development-resources/currency-codes
let fractionDigits = 2;
Expand Down
2 changes: 1 addition & 1 deletion src/repositories/product-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export class ProductProjectionRepository extends AbstractResourceRepository<"pro
.all(context.projectKey, "product")
.map((r) => this._searchService.transform(r, params.staged ?? false))
.filter((p) => {
if (!params.staged ?? false) {
if (!(params.staged ?? false)) {
return p.published;
}
return true;
Expand Down
56 changes: 2 additions & 54 deletions src/repositories/shipping-method/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import {
InvalidOperationError,
type ShippingMethod,
type ShippingMethodDraft,
type ZoneRate,
type ZoneRateDraft,
type ZoneReference,
} from "@commercetools/platform-sdk";
import { CommercetoolsError } from "~src/exceptions";
import { getBaseResourceProperties } from "../../helpers";
import { markMatchingShippingRate } from "../../shippingCalculator";
import { getShippingMethodsMatchingCart } from "../../shipping";
import { AbstractStorage } from "../../storage/abstract";
import {
AbstractResourceRepository,
Expand Down Expand Up @@ -69,57 +67,7 @@ export class ShippingMethodRepository extends AbstractResourceRepository<"shippi
return undefined;
}

if (!cart.shippingAddress?.country) {
throw new CommercetoolsError<InvalidOperationError>({
code: "InvalidOperation",
message: `The cart with ID '${cart.id}' does not have a shipping address set.`,
});
}

// Get all shipping methods that have a zone that matches the shipping address
const zones = this._storage.query<"zone">(context.projectKey, "zone", {
where: [`locations(country="${cart.shippingAddress.country}"))`],
limit: 100,
});
const zoneIds = zones.results.map((zone) => zone.id);
const shippingMethods = this.query(context, {
"where": [
`zoneRates(zone(id in (:zoneIds)))`,
`zoneRates(shippingRates(price(currencyCode="${cart.totalPrice.currencyCode}")))`,
],
"var.zoneIds": zoneIds,
"expand": params.expand,
});

// Make sure that each shipping method has exactly one shipping rate and
// that the shipping rate is marked as matching
const results = shippingMethods.results
.map((shippingMethod) => {
// Iterate through the zoneRates, process the shipping rates and filter
// out all zoneRates which have no matching shipping rates left
const rates = shippingMethod.zoneRates
.map((zoneRate) => ({
zone: zoneRate.zone,

// Iterate through the shippingRates and mark the matching ones
// then we filter out the non-matching ones
shippingRates: zoneRate.shippingRates
.map((rate) => markMatchingShippingRate(cart, rate))
.filter((rate) => rate.isMatching),
}))
.filter((zoneRate) => zoneRate.shippingRates.length > 0);

return {
...shippingMethod,
zoneRates: rates,
};
})
.filter((shippingMethod) => shippingMethod.zoneRates.length > 0);

return {
...shippingMethods,
results: results,
};
return getShippingMethodsMatchingCart(context, this._storage, cart, params);
}

private _transformZoneRateDraft(
Expand Down
Loading