From b26e5dce9e6f33c7f8f5bf7f6ec3322c86b015c0 Mon Sep 17 00:00:00 2001 From: Surafel Date: Mon, 29 Jan 2024 03:13:45 +0300 Subject: [PATCH 1/5] feat: implemented distances based shipping and created the vendure-plugin-shipping-extensions --- .eslintrc.json | 3 +- packages/test/src/shop-utils.ts | 17 +- .../CHANGELOG.md | 3 + .../README.md | 64 +++ .../package.json | 28 ++ .../src/constants.ts | 2 + .../src/distance-based-shipping-calculator.ts | 71 +++ .../src/get-distance-between-points.ts | 23 + .../src/index.ts | 3 + .../src/shipping-extensions.plugin.ts | 93 ++++ .../order-address-to-geolocation-strategy.ts | 13 + .../uk-postalcode-to-geolocation-strategy.ts | 34 ++ .../src/weight-and-country-checker.ts | 128 ++++++ .../test/dev-server.ts | 66 +++ .../test/shipping-extensions.spec.ts | 411 ++++++++++++++++++ .../tsconfig.json | 9 + .../vitest.config.js | 22 + 17 files changed, 983 insertions(+), 7 deletions(-) create mode 100644 packages/vendure-plugin-shipping-extensions/CHANGELOG.md create mode 100644 packages/vendure-plugin-shipping-extensions/README.md create mode 100644 packages/vendure-plugin-shipping-extensions/package.json create mode 100644 packages/vendure-plugin-shipping-extensions/src/constants.ts create mode 100644 packages/vendure-plugin-shipping-extensions/src/distance-based-shipping-calculator.ts create mode 100644 packages/vendure-plugin-shipping-extensions/src/get-distance-between-points.ts create mode 100644 packages/vendure-plugin-shipping-extensions/src/index.ts create mode 100644 packages/vendure-plugin-shipping-extensions/src/shipping-extensions.plugin.ts create mode 100644 packages/vendure-plugin-shipping-extensions/src/strategies/order-address-to-geolocation-strategy.ts create mode 100644 packages/vendure-plugin-shipping-extensions/src/strategies/uk-postalcode-to-geolocation-strategy.ts create mode 100644 packages/vendure-plugin-shipping-extensions/src/weight-and-country-checker.ts create mode 100644 packages/vendure-plugin-shipping-extensions/test/dev-server.ts create mode 100644 packages/vendure-plugin-shipping-extensions/test/shipping-extensions.spec.ts create mode 100644 packages/vendure-plugin-shipping-extensions/tsconfig.json create mode 100644 packages/vendure-plugin-shipping-extensions/vitest.config.js diff --git a/.eslintrc.json b/.eslintrc.json index 11e5a73b..833aa3a9 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -60,6 +60,7 @@ "packages/vendure-plugin-stock-monitoring/", "packages/vendure-plugin-stripe-subscription/", "packages/vendure-plugin-variant-bulk-update/", - "packages/vendure-plugin-webhook/" + "packages/vendure-plugin-webhook/", + "packages/vendure-plugin-shipping-extensions" ] } diff --git a/packages/test/src/shop-utils.ts b/packages/test/src/shop-utils.ts index e8a47e11..663c4176 100644 --- a/packages/test/src/shop-utils.ts +++ b/packages/test/src/shop-utils.ts @@ -118,7 +118,8 @@ export async function createSettledOrder( { id: 'T_1', quantity: 1 }, { id: 'T_2', quantity: 2 }, ], - billingAddress?: SetBillingAddressMutationVariables + billingAddress?: SetBillingAddressMutationVariables, + shippingAddress?: SetShippingAddressMutationVariables ): Promise { if (authorizeFirst) { await shopClient.asUserWithCredentials( @@ -129,10 +130,9 @@ export async function createSettledOrder( for (const v of variants) { await addItem(shopClient, v.id, v.quantity); } - const res = await proceedToArrangingPayment( - shopClient, - shippingMethodId, - { + let orderShippingAddress = shippingAddress; + if (!orderShippingAddress) { + orderShippingAddress = { input: { fullName: 'Martinho Pinelabio', streetLine1: 'Verzetsstraat', @@ -141,7 +141,12 @@ export async function createSettledOrder( postalCode: '8923CP', countryCode: 'NL', }, - }, + }; + } + const res = await proceedToArrangingPayment( + shopClient, + shippingMethodId, + orderShippingAddress, billingAddress ); if ((res as ErrorResult)?.errorCode) { diff --git a/packages/vendure-plugin-shipping-extensions/CHANGELOG.md b/packages/vendure-plugin-shipping-extensions/CHANGELOG.md new file mode 100644 index 00000000..d24fefaa --- /dev/null +++ b/packages/vendure-plugin-shipping-extensions/CHANGELOG.md @@ -0,0 +1,3 @@ +# 1.0.0 (2024-01-29) + +- Initialized the plugin diff --git a/packages/vendure-plugin-shipping-extensions/README.md b/packages/vendure-plugin-shipping-extensions/README.md new file mode 100644 index 00000000..364afc21 --- /dev/null +++ b/packages/vendure-plugin-shipping-extensions/README.md @@ -0,0 +1,64 @@ +# Shipping Extenions Vendure Plugin + +### [Official documentation here](https://pinelab-plugins.com/plugin/vendure-plugin-shipping-extensions) + +This plugin does two things in general + +- adds a shipping eligibility checker to Vendure that checks the total weight and the shipping country of an + order, to verify if a shipping method is eligible for a given order. +- introduces a distance based (`ShippingCalculator`)[https://docs.vendure.io/reference/typescript-api/shipping/shipping-calculator/], based on a configurable `OrderAddressToGeolocationConversionStrategy` used to convert the `shippingAddress` of an `Order` to geographic latitudinal and longitudinal values. + +The weight of a product can be configured on the customfield `Product.weight`. You can configure the units to be in KG, +grams or whatever unit you like. + +A Custom `OrderAddressToGeolocationConversionStrategy` can be configured by as follows: + +```ts +import {OrderAddressToGeolocationConversionStrategy} from '@pinelab/vendure-plugin-shipping-extensions' +export class USStreetLineToGeolocationConversionStrategy implements OrderAddressToGeolocationConversionStrategy{ + async getGeoLocationForAddress(orderAddress: OrderAddress): Promise { + const location=//...result of a possible API call or any other lookup method + return {latitude: location.latitude, longitude: location.longitude} + } +} +``` + +Some examples: + +- Create a shippingmethod for orders placed in Australia, with a total order weight between 10kg and 40kg +- Create a shippingmethod for all orders except the ones placed in Canada and Norway, with a total order weight below + 1100 grams + +## Getting started + +1. Add the following to the plugins in `vendure-config.ts`: + +```ts +plugins: [ + ... + ShippingByWeightAndCountryPlugin.init({ + /** + * Weight unit used in the eligibility checker + * and product customfield. + * Only used for displaying purposes + */ + weightUnit: "kg", + /** + * The name of the tab the customfield should be added to + * This can be an existing tab + */ + customFieldsTab: "Physical properties", + orderAddressToGeolocationStrategy: new USStreetLineToGeolocationConversionStrategy() + }) + ... +] +``` + +2. Start your server +3. Login to the admin UI and go to `Shipping methods` +4. Create a new shippingmethod +5. Under `Shipping eligibility checker` you should see `Check by weight and country` +6. Under `Shipping calculator` you should see `Distance Based Shipping Calculator` + +This checker can be used to have a shippingmethod eligible for an order based on the total weight and shipping country +of an order. diff --git a/packages/vendure-plugin-shipping-extensions/package.json b/packages/vendure-plugin-shipping-extensions/package.json new file mode 100644 index 00000000..68623970 --- /dev/null +++ b/packages/vendure-plugin-shipping-extensions/package.json @@ -0,0 +1,28 @@ +{ + "name": "@pinelab/vendure-plugin-shipping-extensions", + "version": "1.0.0", + "description": "Vendure plugin for configurable shipping calculators and eligibility checkers.", + "icon": "truck", + "author": "Martijn van de Brug ", + "homepage": "https://pinelab-plugins.com/", + "repository": "https://github.com/Pinelab-studio/pinelab-vendure-plugins", + "license": "MIT", + "private": false, + "publishConfig": { + "access": "public" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "README.md", + "CHANGELOG.md" + ], + "scripts": { + "serve": "nodemon --watch \"src/**\" --ext \"ts,json\" --exec \"ts-node test/dev-server.ts\"", + "start": "ts-node test/dev-server.ts", + "build": "rimraf dist && tsc && copyfiles -u 1 'src/ui/**/*' dist/", + "test": "vitest run" + }, + "gitHead": "476f36da3aafea41fbf21c70774a30306f1d238f" +} diff --git a/packages/vendure-plugin-shipping-extensions/src/constants.ts b/packages/vendure-plugin-shipping-extensions/src/constants.ts new file mode 100644 index 00000000..f46c8099 --- /dev/null +++ b/packages/vendure-plugin-shipping-extensions/src/constants.ts @@ -0,0 +1,2 @@ +export const loggerCtx = 'ShippingExtensionsPlugin'; +export const PLUGIN_OPTIONS = Symbol('ShippingExtensionsOptions'); diff --git a/packages/vendure-plugin-shipping-extensions/src/distance-based-shipping-calculator.ts b/packages/vendure-plugin-shipping-extensions/src/distance-based-shipping-calculator.ts new file mode 100644 index 00000000..94e94a87 --- /dev/null +++ b/packages/vendure-plugin-shipping-extensions/src/distance-based-shipping-calculator.ts @@ -0,0 +1,71 @@ +import { + LanguageCode, + ShippingCalculator, + Injector, + InternalServerError, +} from '@vendure/core'; +import { ShippingExtensionsOptions } from './shipping-extensions.plugin'; +import { PLUGIN_OPTIONS } from './constants'; +import { getDistanceBetweenPointsInKMs } from './get-distance-between-points'; + +let pluginOptions: ShippingExtensionsOptions; +export const distanceBasedShippingCalculator = new ShippingCalculator({ + code: 'distance-based-shipping-calculator', + description: [ + { + languageCode: LanguageCode.en, + value: 'Distance Based Shipping Calculator', + }, + ], + args: { + storeLatitude: { + type: 'float', + ui: { component: 'number-form-input', min: -90, max: 90 }, + label: [{ languageCode: LanguageCode.en, value: 'Store Latitude' }], + }, + storeLongitude: { + type: 'float', + ui: { component: 'number-form-input', min: -180, max: 180 }, + label: [{ languageCode: LanguageCode.en, value: 'Store Longitude' }], + }, + pricePerKm: { + type: 'float', + ui: { component: 'number-form-input', suffix: '/km' }, + label: [{ languageCode: LanguageCode.en, value: 'Price per KM' }], + }, + taxRate: { + type: 'float', + ui: { component: 'number-form-input', suffix: '%', min: 0, max: 100 }, + label: [{ languageCode: LanguageCode.en, value: 'Tax rate' }], + }, + }, + init(injector: Injector) { + pluginOptions = injector.get(PLUGIN_OPTIONS); + }, + calculate: async (ctx, order, args) => { + if (!pluginOptions?.orderAddressToGeolocationStrategy) { + throw new InternalServerError( + 'OrderAddress to geolocation conversion strategy not configured' + ); + } + const storeGeoLocation = { + latitude: args.storeLatitude, + longitude: args.storeLongitude, + }; + const shippingAddressGeoLocation = + await pluginOptions.orderAddressToGeolocationStrategy.getGeoLocationForAddress( + order.shippingAddress + ); + const distance = getDistanceBetweenPointsInKMs( + shippingAddressGeoLocation, + storeGeoLocation + ); + const rate = distance * args.pricePerKm * (1 + args.taxRate / 100); + return { + price: rate, + priceIncludesTax: ctx.channel.pricesIncludeTax, + taxRate: args.taxRate, + metadata: { shippingAddressGeoLocation, storeGeoLocation }, + }; + }, +}); diff --git a/packages/vendure-plugin-shipping-extensions/src/get-distance-between-points.ts b/packages/vendure-plugin-shipping-extensions/src/get-distance-between-points.ts new file mode 100644 index 00000000..a7642ddd --- /dev/null +++ b/packages/vendure-plugin-shipping-extensions/src/get-distance-between-points.ts @@ -0,0 +1,23 @@ +import { GeoLocation } from './strategies/order-address-to-geolocation-strategy'; + +export function getDistanceBetweenPointsInKMs( + pointA: GeoLocation, + pointB: GeoLocation +): number { + const EARTH_RADIUS = 6371; // Radius of the earth in km + const dLat = deg2rad(pointA.latitude - pointB.latitude); + const dLon = deg2rad(pointA.longitude - pointB.longitude); + const a = + Math.pow(Math.sin(dLat / 2), 2) + + Math.cos(deg2rad(pointB.latitude)) * + Math.cos(deg2rad(pointB.longitude)) * + Math.pow(Math.sin(dLon / 2), 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + const d = EARTH_RADIUS * c; // Distance in km + + return d; +} + +function deg2rad(deg: number) { + return deg * (Math.PI / 180); +} diff --git a/packages/vendure-plugin-shipping-extensions/src/index.ts b/packages/vendure-plugin-shipping-extensions/src/index.ts new file mode 100644 index 00000000..9c78a0bf --- /dev/null +++ b/packages/vendure-plugin-shipping-extensions/src/index.ts @@ -0,0 +1,3 @@ +export * from './shipping-extensions.plugin'; +export * from './strategies/uk-postalcode-to-geolocation-strategy'; +export * from './strategies/order-address-to-geolocation-strategy'; diff --git a/packages/vendure-plugin-shipping-extensions/src/shipping-extensions.plugin.ts b/packages/vendure-plugin-shipping-extensions/src/shipping-extensions.plugin.ts new file mode 100644 index 00000000..8f67e77a --- /dev/null +++ b/packages/vendure-plugin-shipping-extensions/src/shipping-extensions.plugin.ts @@ -0,0 +1,93 @@ +import { LanguageCode, PluginCommonModule, VendurePlugin } from '@vendure/core'; +import { weightAndCountryChecker } from './weight-and-country-checker'; +import { OrderAddressToGeolocationConversionStrategy } from './strategies/order-address-to-geolocation-strategy'; +import { PLUGIN_OPTIONS } from './constants'; +import { distanceBasedShippingCalculator } from './distance-based-shipping-calculator'; + +export interface ShippingExtensionsOptions { + /** + * The unit of weight you would like to use as for the weight customfield + * and the unit used in the eligibility checker. + * Only used for display purposes + * Defaults is `grams` + */ + weightUnit?: string; + /** + * The name of the tab you want the customfield Product.weight to appear in. + * This can be an existing tab. For example: 'Physical properties' + */ + customFieldsTab?: string; + + /** + * The selected strategy to convert (OrderAddress)[https://docs.vendure.io/reference/graphql-api/shop/object-types/#orderaddress] values to lat/lon values + * to be used when calculating distance based shipping price + */ + orderAddressToGeolocationStrategy?: OrderAddressToGeolocationConversionStrategy; +} + +@VendurePlugin({ + imports: [PluginCommonModule], + providers: [ + { + provide: PLUGIN_OPTIONS, + useFactory: () => ShippingExtensionsPlugin.options, + }, + ], + configuration: (config) => { + config.shippingOptions.shippingEligibilityCheckers.push( + weightAndCountryChecker + ); + config.customFields.Product.push({ + name: 'weight', + label: [ + { + languageCode: LanguageCode.en, + value: `Weight in ${ShippingExtensionsPlugin.options?.weightUnit}`, + }, + ], + ui: { + component: 'number-form-input', + tab: ShippingExtensionsPlugin.options?.customFieldsTab, + options: { min: 0 }, + }, + public: true, + nullable: true, + type: 'int', + }); + config.customFields.ProductVariant.push({ + name: 'weight', + label: [ + { + languageCode: LanguageCode.en, + value: `Weight in ${ShippingExtensionsPlugin.options?.weightUnit}`, + }, + ], + ui: { + component: 'number-form-input', + tab: ShippingExtensionsPlugin.options?.customFieldsTab, + options: { min: 0 }, + }, + public: true, + nullable: true, + type: 'int', + }); + config.shippingOptions.shippingCalculators.push( + distanceBasedShippingCalculator + ); + return config; + }, + compatibility: '^2.0.0', +}) +export class ShippingExtensionsPlugin { + static options: ShippingExtensionsOptions; + + static init( + options: ShippingExtensionsOptions + ): typeof ShippingExtensionsPlugin { + if (!options.weightUnit) { + options.weightUnit = 'grams'; + } + this.options = options; + return ShippingExtensionsPlugin; + } +} diff --git a/packages/vendure-plugin-shipping-extensions/src/strategies/order-address-to-geolocation-strategy.ts b/packages/vendure-plugin-shipping-extensions/src/strategies/order-address-to-geolocation-strategy.ts new file mode 100644 index 00000000..55043470 --- /dev/null +++ b/packages/vendure-plugin-shipping-extensions/src/strategies/order-address-to-geolocation-strategy.ts @@ -0,0 +1,13 @@ +import { Address } from '@vendure/core'; +import { OrderAddress } from '@vendure/common/lib/generated-shop-types'; + +export interface OrderAddressToGeolocationConversionStrategy { + getGeoLocationForAddress: ( + orderAddress: OrderAddress + ) => Promise; +} + +export interface GeoLocation { + latitude: number; + longitude: number; +} diff --git a/packages/vendure-plugin-shipping-extensions/src/strategies/uk-postalcode-to-geolocation-strategy.ts b/packages/vendure-plugin-shipping-extensions/src/strategies/uk-postalcode-to-geolocation-strategy.ts new file mode 100644 index 00000000..936a26dc --- /dev/null +++ b/packages/vendure-plugin-shipping-extensions/src/strategies/uk-postalcode-to-geolocation-strategy.ts @@ -0,0 +1,34 @@ +import { UserInputError } from '@vendure/core'; +import { + OrderAddressToGeolocationConversionStrategy, + GeoLocation, +} from './order-address-to-geolocation-strategy'; +import { OrderAddress } from '@vendure/common/lib/generated-shop-types'; + +export const POSTCODES_URL = `https://postcodes.io/postcodes`; +export class UKPostalCodeToGelocationConversionStrategy + implements OrderAddressToGeolocationConversionStrategy +{ + async getGeoLocationForAddress( + orderAddress: OrderAddress + ): Promise { + if (!orderAddress?.postalCode) { + throw new UserInputError(`Order Shipping Address postal code not found`); + } + const url = `${POSTCODES_URL}/${encodeURIComponent( + orderAddress.postalCode + )}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + const responseBody: any = await response.json(); + if (responseBody?.status !== 200) { + throw new Error(responseBody?.error); + } + return { + latitude: responseBody.result.latitude, + longitude: responseBody.result.longitude, + }; + } +} diff --git a/packages/vendure-plugin-shipping-extensions/src/weight-and-country-checker.ts b/packages/vendure-plugin-shipping-extensions/src/weight-and-country-checker.ts new file mode 100644 index 00000000..13ad026f --- /dev/null +++ b/packages/vendure-plugin-shipping-extensions/src/weight-and-country-checker.ts @@ -0,0 +1,128 @@ +import { + CountryService, + LanguageCode, + Order, + Product, + RequestContext, + ShippingEligibilityChecker, + TransactionalConnection, +} from '@vendure/core'; +import { ShippingExtensionsPlugin } from './shipping-extensions.plugin'; + +export function calculateOrderWeight( + order: Order, + products: Product[] +): number { + return order.lines.reduce((acc, line) => { + const product = products.find( + (p) => p.id === line.productVariant.productId + ); + const weight = + (line.productVariant.customFields as any).weight ?? + (product?.customFields as any).weight ?? + 0; + const lineWeight = weight * line.quantity; + return acc + lineWeight; + }, 0); +} + +let connection: TransactionalConnection; +export const weightAndCountryChecker = new ShippingEligibilityChecker({ + code: 'shipping-by-weight-and-country', + description: [ + { + languageCode: LanguageCode.en, + value: 'Check by weight and country', + }, + ], + args: { + minWeight: { + type: 'int', + description: [{ languageCode: LanguageCode.en, value: `Minimum weight` }], + }, + maxWeight: { + type: 'int', + description: [{ languageCode: LanguageCode.en, value: `Maximum weight` }], + }, + countries: { + type: 'string', + list: true, + ui: { + component: 'select-form-input', + options: [ + { + value: 'nl', + label: [{ languageCode: LanguageCode.en, value: 'Nederland' }], + }, + ], + }, + }, + excludeCountries: { + type: 'boolean', + description: [ + { + languageCode: LanguageCode.en, + value: 'Eligible for all countries except the ones listed above', + }, + ], + ui: { + component: 'boolean-form-input', + }, + }, + }, + async init(injector) { + connection = injector.get(TransactionalConnection); + const ctx = RequestContext.empty(); + const countries = await injector.get(CountryService).findAll(ctx); + this.args.countries.ui.options = countries.items.map((c) => ({ + value: c.code, + label: [ + { + languageCode: LanguageCode.en, + value: c.name, + }, + ], + })); + this.args.minWeight.description = [ + { + languageCode: LanguageCode.en, + value: `Minimum weight in ${ShippingExtensionsPlugin.options?.weightUnit}`, + }, + ]; + this.args.maxWeight.description = [ + { + languageCode: LanguageCode.en, + value: `Maximum weight in ${ShippingExtensionsPlugin.options?.weightUnit}`, + }, + ]; + }, + async check( + ctx, + order, + { minWeight, maxWeight, countries, excludeCountries } + ) { + const shippingCountry = order.shippingAddress.countryCode; + const orderIsInSelectedCountry = shippingCountry + ? countries.includes(shippingCountry) + : false; + if (orderIsInSelectedCountry && excludeCountries) { + // Not eligible, because order.country is in our excluded-country-list + return false; + } + if (!orderIsInSelectedCountry && !excludeCountries) { + // Not eligible, because order.country is not in our list, but it should be + return false; + } + // Shipping country is allowed, continue checking order weight + const productIds = order.lines.map((line) => line.productVariant.productId); + const products = await connection.findByIdsInChannel( + ctx, + Product, + productIds, + ctx.channelId, + {} + ); + const totalOrderWeight = calculateOrderWeight(order, products); + return totalOrderWeight <= maxWeight && totalOrderWeight >= minWeight; + }, +}); diff --git a/packages/vendure-plugin-shipping-extensions/test/dev-server.ts b/packages/vendure-plugin-shipping-extensions/test/dev-server.ts new file mode 100644 index 00000000..22f658da --- /dev/null +++ b/packages/vendure-plugin-shipping-extensions/test/dev-server.ts @@ -0,0 +1,66 @@ +import { + createTestEnvironment, + registerInitializer, + SqljsInitializer, +} from '@vendure/testing'; +import { + DefaultLogger, + DefaultSearchPlugin, + LanguageCode, + LogLevel, + mergeConfig, +} from '@vendure/core'; +import { initialData } from '../../test/src/initial-data'; +import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; +import { ShippingExtensionsPlugin } from '../src/shipping-extensions.plugin'; +import { UKPostalCodeToGelocationConversionStrategy } from '../src/strategies/uk-postalcode-to-geolocation-strategy'; + +(async () => { + require('dotenv').config(); + const { testConfig } = require('@vendure/testing'); + registerInitializer('sqljs', new SqljsInitializer('__data__')); + const config = mergeConfig(testConfig, { + logger: new DefaultLogger({ level: LogLevel.Debug }), + dbConnectionOptions: { + synchronize: true, + }, + apiOptions: { + adminApiPlayground: {}, + shopApiPlayground: {}, + }, + customFields: { + Product: [ + { + name: 'width', + label: [{ value: 'Width', languageCode: LanguageCode.en }], + type: 'localeString', + ui: { component: 'text-form-input', tab: 'Physical properties' }, + }, + { + name: 'metaTitle', + label: [{ value: 'Meta title', languageCode: LanguageCode.en }], + type: 'localeString', + ui: { component: 'text-form-input', tab: 'SEO' }, + }, + ], + }, + plugins: [ + DefaultSearchPlugin, + AdminUiPlugin.init({ + port: 3002, + route: 'admin', + }), + ShippingExtensionsPlugin.init({ + weightUnit: 'kg', + customFieldsTab: 'Physical properties', + orderAddressToGeolocationStrategy: + new UKPostalCodeToGelocationConversionStrategy(), + }), + ], + }); + const { server, shopClient } = createTestEnvironment(config); + await server.init({ + initialData, + productsCsvPath: '../test/src/products-import.csv', + }); +})(); diff --git a/packages/vendure-plugin-shipping-extensions/test/shipping-extensions.spec.ts b/packages/vendure-plugin-shipping-extensions/test/shipping-extensions.spec.ts new file mode 100644 index 00000000..608bd61f --- /dev/null +++ b/packages/vendure-plugin-shipping-extensions/test/shipping-extensions.spec.ts @@ -0,0 +1,411 @@ +import { + createTestEnvironment, + registerInitializer, + SimpleGraphQLClient, + SqljsInitializer, + testConfig, +} from '@vendure/testing'; +import { initialData } from '../../test/src/initial-data'; +import { + DefaultLogger, + LogLevel, + mergeConfig, + ProductService, + ProductVariantService, + defaultShippingEligibilityChecker, + RequestContext, + ConfigService, +} from '@vendure/core'; +import { TestServer } from '@vendure/testing/lib/test-server'; +import { testPaymentMethod } from '../../test/src/test-payment-method'; +import { getSuperadminContext } from '@vendure/testing/lib/utils/get-superadmin-context'; +import { createSettledOrder } from '../../test/src/shop-utils'; +import { ShippingExtensionsPlugin } from '../src/shipping-extensions.plugin'; +import gql from 'graphql-tag'; +import { expect, describe, beforeAll, afterAll, it, vi, test } from 'vitest'; +import { distanceBasedShippingCalculator } from '../src/distance-based-shipping-calculator'; +import { + POSTCODES_URL, + UKPostalCodeToGelocationConversionStrategy, +} from '../src/strategies/uk-postalcode-to-geolocation-strategy'; +import { GeoLocation } from '../src/strategies/order-address-to-geolocation-strategy'; +import nock from 'nock'; +import { getDistanceBetweenPointsInKMs } from '../src/get-distance-between-points'; +import { CreateAddressInput } from '../../test/src/generated/shop-graphql'; + +describe('Order export plugin', function () { + let server: TestServer; + let adminClient: SimpleGraphQLClient; + let shopClient: SimpleGraphQLClient; + let serverStarted = false; + let ctx: RequestContext; + + beforeAll(async () => { + registerInitializer('sqljs', new SqljsInitializer('__data__')); + const config = mergeConfig(testConfig, { + logger: new DefaultLogger({ level: LogLevel.Debug }), + plugins: [ + ShippingExtensionsPlugin.init({ + orderAddressToGeolocationStrategy: + new UKPostalCodeToGelocationConversionStrategy(), + }), + ], + paymentOptions: { + paymentMethodHandlers: [testPaymentMethod], + }, + }); + + ({ server, adminClient, shopClient } = createTestEnvironment(config)); + await server.init({ + initialData: { + ...initialData, + shippingMethods: [], + paymentMethods: [ + { + name: testPaymentMethod.code, + handler: { code: testPaymentMethod.code, arguments: [] }, + }, + ], + }, + productsCsvPath: '../test/src/products-import.csv', + }); + serverStarted = true; + ctx = await getSuperadminContext(server.app); + }, 60000); + + it('Should start successfully', async () => { + await expect(serverStarted).toBe(true); + }); + + it('Creates shippingmethod 1 for NL and BE, with weight between 0 and 100 ', async () => { + await adminClient.asSuperAdmin(); + const res = await createShippingMethod(adminClient, { + minWeight: 0, + maxWeight: 100, + countries: ['NL', 'BE'], + rate: 111, + exclude: false, + }); + expect(res.code).toBeDefined(); + }); + + it('Creates shippingmethod 2 for everything except NL and BE, with weight between 0 and 100 ', async () => { + await adminClient.asSuperAdmin(); + const res = await createShippingMethod(adminClient, { + minWeight: 0, + maxWeight: 100, + countries: ['NL', 'BE'], + rate: 222, + exclude: true, + }); + expect(res.code).toBeDefined(); + }); + + it('Creates shippingmethod 3 for everything except BE, with weight between 150 and 200 ', async () => { + await adminClient.asSuperAdmin(); + const res = await createShippingMethod(adminClient, { + minWeight: 0, + maxWeight: 100, + countries: ['BE'], + rate: 333, + exclude: true, + }); + expect(res.code).toBeDefined(); + }); + + it('Is eligible for method 1 with country NL and weight 0', async () => { + const order = await createSettledOrder(shopClient, 1); + expect(order.state).toBe('PaymentSettled'); + expect(order.shippingWithTax).toBe(111); + }); + + it('Is eligible for method 3 with country NL and weight 200', async () => { + const order = await createSettledOrder(shopClient, 3); + expect(order.state).toBe('PaymentSettled'); + expect(order.shippingWithTax).toBe(333); + }); + + it('Is eligible for method 1 with country NL and product weight 100', async () => { + const product = await server.app + .get(ProductService) + .update(ctx, { id: 1, customFields: { weight: 25 } }); + expect((product.customFields as any).weight).toBe(25); + + const order = await createSettledOrder(shopClient, 1); + expect(order.state).toBe('PaymentSettled'); + expect(order.shippingWithTax).toBe(111); + }); + + it('Is eligible for method 1 with country NL and product weight 25 variant weight 50', async () => { + const product = await server.app + .get(ProductService) + .update(ctx, { id: 1, customFields: { weight: 25 } }); + expect((product.customFields as any).weight).toBe(25); + + const productVariants = await server.app + .get(ProductVariantService) + .update(ctx, [{ id: 1, customFields: { weight: 50 } }]); + expect(productVariants.length).toBe(1); + expect((productVariants[0].customFields as any).weight).toBe(50); + + const order = await createSettledOrder(shopClient, 1); + expect(order.state).toBe('PaymentSettled'); + expect(order.shippingWithTax).toBe(111); + }); + + it('Is eligible for method 1 with country NL and product weight 50 variant weight 0', async () => { + const product = await server.app + .get(ProductService) + .update(ctx, { id: 1, customFields: { weight: 50 } }); + expect((product.customFields as any).weight).toBe(50); + + const productVariants = await server.app + .get(ProductVariantService) + .update(ctx, [{ id: 1, customFields: { weight: 0 } }]); + expect(productVariants.length).toBe(1); + expect((productVariants[0].customFields as any).weight).toBe(0); + + const order = await createSettledOrder(shopClient, 1); + expect(order.state).toBe('PaymentSettled'); + expect(order.shippingWithTax).toBe(111); + }); + + it('Is NOT eligible for method 2 with country NL', async () => { + await adminClient.asSuperAdmin(); + await expect(createSettledOrder(shopClient, 2)).rejects.toThrow( + 'ORDER_STATE_TRANSITION_ERROR' + ); + }); + + it('Is NOT eligible for method 1 and 2 with weight 200', async () => { + const product = await server.app + .get(ProductService) + .update(ctx, { id: 1, customFields: { weight: 200 } }); + expect((product.customFields as any).weight).toBe(200); + await expect(createSettledOrder(shopClient, 1)).rejects.toThrow( + 'ORDER_STATE_TRANSITION_ERROR' + ); + await expect(createSettledOrder(shopClient, 2)).rejects.toThrow( + 'ORDER_STATE_TRANSITION_ERROR' + ); + }); + + it('Is NOT eligible for method 1 and 2 with variant weight 200', async () => { + const product = await server.app + .get(ProductService) + .update(ctx, { id: 1, customFields: { weight: 0 } }); + expect((product.customFields as any).weight).toBe(0); + + const productVariants = await server.app + .get(ProductVariantService) + .update(ctx, [{ id: 1, customFields: { weight: 200 } }]); + expect(productVariants.length).toBe(1); + expect((productVariants[0].customFields as any).weight).toBe(200); + + await expect(createSettledOrder(shopClient, 1)).rejects.toThrow( + 'ORDER_STATE_TRANSITION_ERROR' + ); + await expect(createSettledOrder(shopClient, 2)).rejects.toThrow( + 'ORDER_STATE_TRANSITION_ERROR' + ); + }); + + it('Should calculate distance based Shipping Price', async () => { + const shippingAddressPostalCode = 'SW1W 0NY'; + const shippingAddressGeoLocation: GeoLocation = { + longitude: -0.147421, + latitude: 51.495373, + }; + const storeGeoLocation: GeoLocation = { + latitude: 51.5072, + longitude: -0.118092, + }; + const shippingAdress: CreateAddressInput = { + countryCode: 'GB', + streetLine1: 'London Street', + postalCode: shippingAddressPostalCode, + }; + nock(POSTCODES_URL) + .get(`/${shippingAddressPostalCode}`) + .reply(200, { + data: { + status: 200, + result: { + postcode: shippingAddressPostalCode, + longitude: shippingAddressGeoLocation.longitude, + latitude: shippingAddressGeoLocation.latitude, + }, + }, + }); + const priceBasedShippingMethodArgs: DistanceBasedShippingCalculatorOptions = + { + storeLatitude: storeGeoLocation.latitude, + storeLongitude: storeGeoLocation.longitude, + pricePerKm: 10, + taxRate: 0, + }; + const shippingDistance = getDistanceBetweenPointsInKMs( + storeGeoLocation, + shippingAddressGeoLocation + ); + const serverConfig = server.app.get(ConfigService); + const configuredMoneyStrategy = serverConfig.entityOptions.moneyStrategy; + const expectedPrice = configuredMoneyStrategy.round( + priceBasedShippingMethodArgs.pricePerKm * shippingDistance + ); + const distanceBasedShippingMethod = await createDistanceBasedShippingMethod( + adminClient, + priceBasedShippingMethodArgs + ); + expect(distanceBasedShippingMethod.id).toBeDefined(); + const order: any = await createSettledOrder( + shopClient, + distanceBasedShippingMethod.id, + true, + [ + { id: 'T_1', quantity: 1 }, + { id: 'T_2', quantity: 2 }, + ], + undefined, + { input: shippingAdress } + ); + expect(order.shippingWithTax).toBe(expectedPrice); + }); + + afterAll(async () => { + await server.destroy(); + }, 100000); +}); + +const CREATE_SHIPPING_METHOD = gql` + mutation CreateShippingMethod($input: CreateShippingMethodInput!) { + createShippingMethod(input: $input) { + ... on ShippingMethod { + id + code + } + __typename + } + } +`; + +interface Options { + minWeight: number; + maxWeight: number; + countries: string[]; + exclude: boolean; + rate: number; +} + +async function createShippingMethod( + adminClient: SimpleGraphQLClient, + options: Options +) { + const res = await adminClient.query(CREATE_SHIPPING_METHOD, { + input: { + code: 'shipping-by-weight-and-country', + checker: { + code: 'shipping-by-weight-and-country', + arguments: [ + { + name: 'minWeight', + value: String(options.minWeight), + }, + { + name: 'maxWeight', + value: String(options.maxWeight), + }, + { + name: 'countries', + value: JSON.stringify(options.countries), + }, + { + name: 'excludeCountries', + value: String(options.exclude), + }, + ], + }, + calculator: { + code: 'default-shipping-calculator', + arguments: [ + { + name: 'rate', + value: String(options.rate), + }, + { + name: 'includesTax', + value: 'exclude', + }, + { + name: 'taxRate', + value: '0', + }, + ], + }, + fulfillmentHandler: 'manual-fulfillment', + customFields: {}, + translations: [ + { + languageCode: 'en', + name: 'Shipping by weight and country', + description: '', + customFields: {}, + }, + ], + }, + }); + return res.createShippingMethod; +} + +interface DistanceBasedShippingCalculatorOptions { + storeLatitude: number; + storeLongitude: number; + pricePerKm: number; + taxRate: number; +} +async function createDistanceBasedShippingMethod( + adminClient: SimpleGraphQLClient, + options: DistanceBasedShippingCalculatorOptions +) { + const res = await adminClient.query(CREATE_SHIPPING_METHOD, { + input: { + code: 'shipping-by-distance', + checker: { + code: defaultShippingEligibilityChecker.code, + arguments: [{ name: 'orderMinimum', value: '0' }], + }, + calculator: { + code: distanceBasedShippingCalculator.code, + arguments: [ + { + name: 'storeLatitude', + value: String(options.storeLatitude), + }, + { + name: 'storeLongitude', + value: String(options.storeLongitude), + }, + { + name: 'taxRate', + value: String(options.taxRate), + }, + { + name: 'pricePerKm', + value: String(options.pricePerKm), + }, + ], + }, + fulfillmentHandler: 'manual-fulfillment', + customFields: {}, + translations: [ + { + languageCode: 'en', + name: 'Shipping by Distance', + description: 'Distance Based Shipping Method', + customFields: {}, + }, + ], + }, + }); + return res.createShippingMethod; +} diff --git a/packages/vendure-plugin-shipping-extensions/tsconfig.json b/packages/vendure-plugin-shipping-extensions/tsconfig.json new file mode 100644 index 00000000..306af4f7 --- /dev/null +++ b/packages/vendure-plugin-shipping-extensions/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/"], + "exclude": ["src/ui"] +} diff --git a/packages/vendure-plugin-shipping-extensions/vitest.config.js b/packages/vendure-plugin-shipping-extensions/vitest.config.js new file mode 100644 index 00000000..189d3591 --- /dev/null +++ b/packages/vendure-plugin-shipping-extensions/vitest.config.js @@ -0,0 +1,22 @@ +import path from 'path'; +import swc from 'unplugin-swc'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: './test/shipping-extensions.spec.ts', + }, + plugins: [ + // SWC required to support decorators used in test plugins + // See https://github.com/vitest-dev/vitest/issues/708#issuecomment-1118628479 + // Vite plugin + swc.vite({ + jsc: { + transform: { + // See https://github.com/vendure-ecommerce/vendure/issues/2099 + useDefineForClassFields: false, + }, + }, + }), + ], +}); From e922accd936122eb797fbf46f68707831b2f3cef Mon Sep 17 00:00:00 2001 From: Surafel Date: Mon, 29 Jan 2024 11:41:47 +0300 Subject: [PATCH 2/5] feat: currency-form-input --- .../src/distance-based-shipping-calculator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vendure-plugin-shipping-extensions/src/distance-based-shipping-calculator.ts b/packages/vendure-plugin-shipping-extensions/src/distance-based-shipping-calculator.ts index 94e94a87..7cf9d372 100644 --- a/packages/vendure-plugin-shipping-extensions/src/distance-based-shipping-calculator.ts +++ b/packages/vendure-plugin-shipping-extensions/src/distance-based-shipping-calculator.ts @@ -30,7 +30,7 @@ export const distanceBasedShippingCalculator = new ShippingCalculator({ }, pricePerKm: { type: 'float', - ui: { component: 'number-form-input', suffix: '/km' }, + ui: { component: 'currency-form-input' }, label: [{ languageCode: LanguageCode.en, value: 'Price per KM' }], }, taxRate: { From 53656f8033beed646bdec47533cc62278aac330f Mon Sep 17 00:00:00 2001 From: Surafel Date: Mon, 29 Jan 2024 11:48:50 +0300 Subject: [PATCH 3/5] feat: don't include tax when calculating --- .../src/distance-based-shipping-calculator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vendure-plugin-shipping-extensions/src/distance-based-shipping-calculator.ts b/packages/vendure-plugin-shipping-extensions/src/distance-based-shipping-calculator.ts index 7cf9d372..86189a4d 100644 --- a/packages/vendure-plugin-shipping-extensions/src/distance-based-shipping-calculator.ts +++ b/packages/vendure-plugin-shipping-extensions/src/distance-based-shipping-calculator.ts @@ -60,7 +60,7 @@ export const distanceBasedShippingCalculator = new ShippingCalculator({ shippingAddressGeoLocation, storeGeoLocation ); - const rate = distance * args.pricePerKm * (1 + args.taxRate / 100); + const rate = distance * args.pricePerKm; return { price: rate, priceIncludesTax: ctx.channel.pricesIncludeTax, From 844cd80c6dd97246401002c79b0990f7d76d53eb Mon Sep 17 00:00:00 2001 From: Surafel Date: Mon, 29 Jan 2024 13:27:07 +0300 Subject: [PATCH 4/5] feat: use roundMoney and compare shipping field --- packages/test/src/generated/shop-graphql.ts | 13 +++++++------ packages/test/src/shop.graphql | 1 + .../test/shipping-extensions.spec.ts | 8 +++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/test/src/generated/shop-graphql.ts b/packages/test/src/generated/shop-graphql.ts index dbcc8330..a3a2082d 100644 --- a/packages/test/src/generated/shop-graphql.ts +++ b/packages/test/src/generated/shop-graphql.ts @@ -3325,7 +3325,7 @@ export type Zone = Node & { updatedAt: Scalars['DateTime']; }; -export type OrderFieldsFragment = { __typename?: 'Order', id: string, code: string, state: string, active: boolean, total: any, totalWithTax: any, shippingWithTax: any, couponCodes: Array, shippingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, billingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, customer?: { __typename?: 'Customer', id: string, firstName: string, lastName: string, emailAddress: string } | null, lines: Array<{ __typename?: 'OrderLine', id: string, quantity: number, productVariant: { __typename?: 'ProductVariant', id: string }, discounts: Array<{ __typename?: 'Discount', adjustmentSource: string, amount: any, amountWithTax: any, description: string, type: AdjustmentType }> }> }; +export type OrderFieldsFragment = { __typename?: 'Order', id: string, code: string, state: string, active: boolean, total: any, shipping: any, totalWithTax: any, shippingWithTax: any, couponCodes: Array, shippingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, billingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, customer?: { __typename?: 'Customer', id: string, firstName: string, lastName: string, emailAddress: string } | null, lines: Array<{ __typename?: 'OrderLine', id: string, quantity: number, productVariant: { __typename?: 'ProductVariant', id: string }, discounts: Array<{ __typename?: 'Discount', adjustmentSource: string, amount: any, amountWithTax: any, description: string, type: AdjustmentType }> }> }; export type AddItemToOrderMutationVariables = Exact<{ productVariantId: Scalars['ID']; @@ -3333,28 +3333,28 @@ export type AddItemToOrderMutationVariables = Exact<{ }>; -export type AddItemToOrderMutation = { __typename?: 'Mutation', addItemToOrder: { __typename?: 'InsufficientStockError', errorCode: ErrorCode, message: string } | { __typename?: 'NegativeQuantityError', errorCode: ErrorCode, message: string } | { __typename?: 'Order', id: string, code: string, state: string, active: boolean, total: any, totalWithTax: any, shippingWithTax: any, couponCodes: Array, shippingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, billingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, customer?: { __typename?: 'Customer', id: string, firstName: string, lastName: string, emailAddress: string } | null, lines: Array<{ __typename?: 'OrderLine', id: string, quantity: number, productVariant: { __typename?: 'ProductVariant', id: string }, discounts: Array<{ __typename?: 'Discount', adjustmentSource: string, amount: any, amountWithTax: any, description: string, type: AdjustmentType }> }> } | { __typename?: 'OrderLimitError', errorCode: ErrorCode, message: string } | { __typename?: 'OrderModificationError', errorCode: ErrorCode, message: string } }; +export type AddItemToOrderMutation = { __typename?: 'Mutation', addItemToOrder: { __typename?: 'InsufficientStockError', errorCode: ErrorCode, message: string } | { __typename?: 'NegativeQuantityError', errorCode: ErrorCode, message: string } | { __typename?: 'Order', id: string, code: string, state: string, active: boolean, total: any, shipping: any, totalWithTax: any, shippingWithTax: any, couponCodes: Array, shippingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, billingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, customer?: { __typename?: 'Customer', id: string, firstName: string, lastName: string, emailAddress: string } | null, lines: Array<{ __typename?: 'OrderLine', id: string, quantity: number, productVariant: { __typename?: 'ProductVariant', id: string }, discounts: Array<{ __typename?: 'Discount', adjustmentSource: string, amount: any, amountWithTax: any, description: string, type: AdjustmentType }> }> } | { __typename?: 'OrderLimitError', errorCode: ErrorCode, message: string } | { __typename?: 'OrderModificationError', errorCode: ErrorCode, message: string } }; export type ApplyCouponCodeMutationVariables = Exact<{ couponCode: Scalars['String']; }>; -export type ApplyCouponCodeMutation = { __typename?: 'Mutation', applyCouponCode: { __typename?: 'CouponCodeExpiredError', errorCode: ErrorCode, message: string } | { __typename?: 'CouponCodeInvalidError', errorCode: ErrorCode, message: string } | { __typename?: 'CouponCodeLimitError', errorCode: ErrorCode, message: string } | { __typename?: 'Order', id: string, code: string, state: string, active: boolean, total: any, totalWithTax: any, shippingWithTax: any, couponCodes: Array, shippingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, billingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, customer?: { __typename?: 'Customer', id: string, firstName: string, lastName: string, emailAddress: string } | null, lines: Array<{ __typename?: 'OrderLine', id: string, quantity: number, productVariant: { __typename?: 'ProductVariant', id: string }, discounts: Array<{ __typename?: 'Discount', adjustmentSource: string, amount: any, amountWithTax: any, description: string, type: AdjustmentType }> }> } }; +export type ApplyCouponCodeMutation = { __typename?: 'Mutation', applyCouponCode: { __typename?: 'CouponCodeExpiredError', errorCode: ErrorCode, message: string } | { __typename?: 'CouponCodeInvalidError', errorCode: ErrorCode, message: string } | { __typename?: 'CouponCodeLimitError', errorCode: ErrorCode, message: string } | { __typename?: 'Order', id: string, code: string, state: string, active: boolean, total: any, shipping: any, totalWithTax: any, shippingWithTax: any, couponCodes: Array, shippingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, billingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, customer?: { __typename?: 'Customer', id: string, firstName: string, lastName: string, emailAddress: string } | null, lines: Array<{ __typename?: 'OrderLine', id: string, quantity: number, productVariant: { __typename?: 'ProductVariant', id: string }, discounts: Array<{ __typename?: 'Discount', adjustmentSource: string, amount: any, amountWithTax: any, description: string, type: AdjustmentType }> }> } }; export type SetShippingAddressMutationVariables = Exact<{ input: CreateAddressInput; }>; -export type SetShippingAddressMutation = { __typename?: 'Mutation', setOrderShippingAddress: { __typename?: 'NoActiveOrderError' } | { __typename?: 'Order', id: string, code: string, state: string, active: boolean, total: any, totalWithTax: any, shippingWithTax: any, couponCodes: Array, shippingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, billingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, customer?: { __typename?: 'Customer', id: string, firstName: string, lastName: string, emailAddress: string } | null, lines: Array<{ __typename?: 'OrderLine', id: string, quantity: number, productVariant: { __typename?: 'ProductVariant', id: string }, discounts: Array<{ __typename?: 'Discount', adjustmentSource: string, amount: any, amountWithTax: any, description: string, type: AdjustmentType }> }> } }; +export type SetShippingAddressMutation = { __typename?: 'Mutation', setOrderShippingAddress: { __typename?: 'NoActiveOrderError' } | { __typename?: 'Order', id: string, code: string, state: string, active: boolean, total: any, shipping: any, totalWithTax: any, shippingWithTax: any, couponCodes: Array, shippingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, billingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, customer?: { __typename?: 'Customer', id: string, firstName: string, lastName: string, emailAddress: string } | null, lines: Array<{ __typename?: 'OrderLine', id: string, quantity: number, productVariant: { __typename?: 'ProductVariant', id: string }, discounts: Array<{ __typename?: 'Discount', adjustmentSource: string, amount: any, amountWithTax: any, description: string, type: AdjustmentType }> }> } }; export type SetBillingAddressMutationVariables = Exact<{ input: CreateAddressInput; }>; -export type SetBillingAddressMutation = { __typename?: 'Mutation', setOrderBillingAddress: { __typename?: 'NoActiveOrderError' } | { __typename?: 'Order', id: string, code: string, state: string, active: boolean, total: any, totalWithTax: any, shippingWithTax: any, couponCodes: Array, shippingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, billingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, customer?: { __typename?: 'Customer', id: string, firstName: string, lastName: string, emailAddress: string } | null, lines: Array<{ __typename?: 'OrderLine', id: string, quantity: number, productVariant: { __typename?: 'ProductVariant', id: string }, discounts: Array<{ __typename?: 'Discount', adjustmentSource: string, amount: any, amountWithTax: any, description: string, type: AdjustmentType }> }> } }; +export type SetBillingAddressMutation = { __typename?: 'Mutation', setOrderBillingAddress: { __typename?: 'NoActiveOrderError' } | { __typename?: 'Order', id: string, code: string, state: string, active: boolean, total: any, shipping: any, totalWithTax: any, shippingWithTax: any, couponCodes: Array, shippingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, billingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, customer?: { __typename?: 'Customer', id: string, firstName: string, lastName: string, emailAddress: string } | null, lines: Array<{ __typename?: 'OrderLine', id: string, quantity: number, productVariant: { __typename?: 'ProductVariant', id: string }, discounts: Array<{ __typename?: 'Discount', adjustmentSource: string, amount: any, amountWithTax: any, description: string, type: AdjustmentType }> }> } }; export type SetShippingMethodMutationVariables = Exact<{ ids: Array | Scalars['ID']; @@ -3375,7 +3375,7 @@ export type AddPaymentToOrderMutationVariables = Exact<{ }>; -export type AddPaymentToOrderMutation = { __typename?: 'Mutation', addPaymentToOrder: { __typename?: 'IneligiblePaymentMethodError', errorCode: ErrorCode, message: string } | { __typename?: 'NoActiveOrderError', errorCode: ErrorCode, message: string } | { __typename?: 'Order', id: string, code: string, state: string, active: boolean, total: any, totalWithTax: any, shippingWithTax: any, couponCodes: Array, shippingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, billingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, customer?: { __typename?: 'Customer', id: string, firstName: string, lastName: string, emailAddress: string } | null, lines: Array<{ __typename?: 'OrderLine', id: string, quantity: number, productVariant: { __typename?: 'ProductVariant', id: string }, discounts: Array<{ __typename?: 'Discount', adjustmentSource: string, amount: any, amountWithTax: any, description: string, type: AdjustmentType }> }> } | { __typename?: 'OrderPaymentStateError', errorCode: ErrorCode, message: string } | { __typename?: 'OrderStateTransitionError', errorCode: ErrorCode, message: string } | { __typename?: 'PaymentDeclinedError', errorCode: ErrorCode, message: string } | { __typename?: 'PaymentFailedError', errorCode: ErrorCode, message: string } }; +export type AddPaymentToOrderMutation = { __typename?: 'Mutation', addPaymentToOrder: { __typename?: 'IneligiblePaymentMethodError', errorCode: ErrorCode, message: string } | { __typename?: 'NoActiveOrderError', errorCode: ErrorCode, message: string } | { __typename?: 'Order', id: string, code: string, state: string, active: boolean, total: any, shipping: any, totalWithTax: any, shippingWithTax: any, couponCodes: Array, shippingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, billingAddress?: { __typename?: 'OrderAddress', fullName?: string | null, company?: string | null, streetLine1?: string | null, streetLine2?: string | null, city?: string | null, postalCode?: string | null, country?: string | null } | null, customer?: { __typename?: 'Customer', id: string, firstName: string, lastName: string, emailAddress: string } | null, lines: Array<{ __typename?: 'OrderLine', id: string, quantity: number, productVariant: { __typename?: 'ProductVariant', id: string }, discounts: Array<{ __typename?: 'Discount', adjustmentSource: string, amount: any, amountWithTax: any, description: string, type: AdjustmentType }> }> } | { __typename?: 'OrderPaymentStateError', errorCode: ErrorCode, message: string } | { __typename?: 'OrderStateTransitionError', errorCode: ErrorCode, message: string } | { __typename?: 'PaymentDeclinedError', errorCode: ErrorCode, message: string } | { __typename?: 'PaymentFailedError', errorCode: ErrorCode, message: string } }; export const OrderFields = gql` fragment OrderFields on Order { @@ -3384,6 +3384,7 @@ export const OrderFields = gql` state active total + shipping totalWithTax shippingWithTax shippingAddress { diff --git a/packages/test/src/shop.graphql b/packages/test/src/shop.graphql index cc25750f..777af604 100644 --- a/packages/test/src/shop.graphql +++ b/packages/test/src/shop.graphql @@ -4,6 +4,7 @@ fragment OrderFields on Order { state active total + shipping totalWithTax shippingWithTax shippingAddress { diff --git a/packages/vendure-plugin-shipping-extensions/test/shipping-extensions.spec.ts b/packages/vendure-plugin-shipping-extensions/test/shipping-extensions.spec.ts index 608bd61f..a412106e 100644 --- a/packages/vendure-plugin-shipping-extensions/test/shipping-extensions.spec.ts +++ b/packages/vendure-plugin-shipping-extensions/test/shipping-extensions.spec.ts @@ -14,7 +14,7 @@ import { ProductVariantService, defaultShippingEligibilityChecker, RequestContext, - ConfigService, + roundMoney, } from '@vendure/core'; import { TestServer } from '@vendure/testing/lib/test-server'; import { testPaymentMethod } from '../../test/src/test-payment-method'; @@ -248,9 +248,7 @@ describe('Order export plugin', function () { storeGeoLocation, shippingAddressGeoLocation ); - const serverConfig = server.app.get(ConfigService); - const configuredMoneyStrategy = serverConfig.entityOptions.moneyStrategy; - const expectedPrice = configuredMoneyStrategy.round( + const expectedPrice = roundMoney( priceBasedShippingMethodArgs.pricePerKm * shippingDistance ); const distanceBasedShippingMethod = await createDistanceBasedShippingMethod( @@ -269,7 +267,7 @@ describe('Order export plugin', function () { undefined, { input: shippingAdress } ); - expect(order.shippingWithTax).toBe(expectedPrice); + expect(order.shipping).toBe(expectedPrice); }); afterAll(async () => { From 532ac0f0cf604acd8c559b967eceabd0919c856e Mon Sep 17 00:00:00 2001 From: Surafel Date: Tue, 30 Jan 2024 14:55:17 +0300 Subject: [PATCH 5/5] feat: work on requested changes --- .../CHANGELOG.md | 9 +- .../README.md | 4 +- .../package.json | 2 +- .../src/distance-based-shipping-calculator.ts | 10 ++- .../uk-postalcode-to-geolocation-strategy.ts | 2 +- .../test/shipping-extensions.spec.ts | 90 ++++++++++--------- 6 files changed, 66 insertions(+), 51 deletions(-) diff --git a/packages/vendure-plugin-shipping-extensions/CHANGELOG.md b/packages/vendure-plugin-shipping-extensions/CHANGELOG.md index d24fefaa..6461a700 100644 --- a/packages/vendure-plugin-shipping-extensions/CHANGELOG.md +++ b/packages/vendure-plugin-shipping-extensions/CHANGELOG.md @@ -1,3 +1,8 @@ -# 1.0.0 (2024-01-29) +# 2.0.0 (2024-01-29) -- Initialized the plugin +- Added distance based `ShippingCalculator` +- Introduced `OrderAddressToGeolocationConversionStrategy` + +# 1.1.0 (2023-10-24) + +- Updated vendure to 2.1.1 diff --git a/packages/vendure-plugin-shipping-extensions/README.md b/packages/vendure-plugin-shipping-extensions/README.md index 364afc21..5509900d 100644 --- a/packages/vendure-plugin-shipping-extensions/README.md +++ b/packages/vendure-plugin-shipping-extensions/README.md @@ -6,7 +6,7 @@ This plugin does two things in general - adds a shipping eligibility checker to Vendure that checks the total weight and the shipping country of an order, to verify if a shipping method is eligible for a given order. -- introduces a distance based (`ShippingCalculator`)[https://docs.vendure.io/reference/typescript-api/shipping/shipping-calculator/], based on a configurable `OrderAddressToGeolocationConversionStrategy` used to convert the `shippingAddress` of an `Order` to geographic latitudinal and longitudinal values. +- introduces a distance based (`ShippingCalculator`)[https://docs.vendure.io/reference/typescript-api/shipping/shipping-calculator/], based on a configurable `OrderAddressToGeolocationConversionStrategy` used to convert the `shippingAddress` of an `Order` to a geographic latitudinal and longitudinal values to be used when calculating the shipping distance. The weight of a product can be configured on the customfield `Product.weight`. You can configure the units to be in KG, grams or whatever unit you like. @@ -36,7 +36,7 @@ Some examples: ```ts plugins: [ ... - ShippingByWeightAndCountryPlugin.init({ + ShippingExtensionsPlugin.init({ /** * Weight unit used in the eligibility checker * and product customfield. diff --git a/packages/vendure-plugin-shipping-extensions/package.json b/packages/vendure-plugin-shipping-extensions/package.json index 68623970..b9d9ec6f 100644 --- a/packages/vendure-plugin-shipping-extensions/package.json +++ b/packages/vendure-plugin-shipping-extensions/package.json @@ -1,6 +1,6 @@ { "name": "@pinelab/vendure-plugin-shipping-extensions", - "version": "1.0.0", + "version": "2.0.0", "description": "Vendure plugin for configurable shipping calculators and eligibility checkers.", "icon": "truck", "author": "Martijn van de Brug ", diff --git a/packages/vendure-plugin-shipping-extensions/src/distance-based-shipping-calculator.ts b/packages/vendure-plugin-shipping-extensions/src/distance-based-shipping-calculator.ts index 86189a4d..cd0cfdf1 100644 --- a/packages/vendure-plugin-shipping-extensions/src/distance-based-shipping-calculator.ts +++ b/packages/vendure-plugin-shipping-extensions/src/distance-based-shipping-calculator.ts @@ -29,7 +29,7 @@ export const distanceBasedShippingCalculator = new ShippingCalculator({ label: [{ languageCode: LanguageCode.en, value: 'Store Longitude' }], }, pricePerKm: { - type: 'float', + type: 'int', ui: { component: 'currency-form-input' }, label: [{ languageCode: LanguageCode.en, value: 'Price per KM' }], }, @@ -52,6 +52,14 @@ export const distanceBasedShippingCalculator = new ShippingCalculator({ latitude: args.storeLatitude, longitude: args.storeLongitude, }; + if (!order?.shippingAddress) { + return { + price: 0, + priceIncludesTax: ctx.channel.pricesIncludeTax, + taxRate: args.taxRate, + metadata: { storeGeoLocation }, + }; + } const shippingAddressGeoLocation = await pluginOptions.orderAddressToGeolocationStrategy.getGeoLocationForAddress( order.shippingAddress diff --git a/packages/vendure-plugin-shipping-extensions/src/strategies/uk-postalcode-to-geolocation-strategy.ts b/packages/vendure-plugin-shipping-extensions/src/strategies/uk-postalcode-to-geolocation-strategy.ts index 936a26dc..dd5056c1 100644 --- a/packages/vendure-plugin-shipping-extensions/src/strategies/uk-postalcode-to-geolocation-strategy.ts +++ b/packages/vendure-plugin-shipping-extensions/src/strategies/uk-postalcode-to-geolocation-strategy.ts @@ -13,7 +13,7 @@ export class UKPostalCodeToGelocationConversionStrategy orderAddress: OrderAddress ): Promise { if (!orderAddress?.postalCode) { - throw new UserInputError(`Order Shipping Address postal code not found`); + throw new Error(`Order Shipping Address postal code not found`); } const url = `${POSTCODES_URL}/${encodeURIComponent( orderAddress.postalCode diff --git a/packages/vendure-plugin-shipping-extensions/test/shipping-extensions.spec.ts b/packages/vendure-plugin-shipping-extensions/test/shipping-extensions.spec.ts index a412106e..5e2dda65 100644 --- a/packages/vendure-plugin-shipping-extensions/test/shipping-extensions.spec.ts +++ b/packages/vendure-plugin-shipping-extensions/test/shipping-extensions.spec.ts @@ -33,50 +33,50 @@ import nock from 'nock'; import { getDistanceBetweenPointsInKMs } from '../src/get-distance-between-points'; import { CreateAddressInput } from '../../test/src/generated/shop-graphql'; -describe('Order export plugin', function () { - let server: TestServer; - let adminClient: SimpleGraphQLClient; - let shopClient: SimpleGraphQLClient; - let serverStarted = false; - let ctx: RequestContext; +let server: TestServer; +let adminClient: SimpleGraphQLClient; +let shopClient: SimpleGraphQLClient; +let serverStarted = false; +let ctx: RequestContext; - beforeAll(async () => { - registerInitializer('sqljs', new SqljsInitializer('__data__')); - const config = mergeConfig(testConfig, { - logger: new DefaultLogger({ level: LogLevel.Debug }), - plugins: [ - ShippingExtensionsPlugin.init({ - orderAddressToGeolocationStrategy: - new UKPostalCodeToGelocationConversionStrategy(), - }), - ], - paymentOptions: { - paymentMethodHandlers: [testPaymentMethod], - }, - }); - - ({ server, adminClient, shopClient } = createTestEnvironment(config)); - await server.init({ - initialData: { - ...initialData, - shippingMethods: [], - paymentMethods: [ - { - name: testPaymentMethod.code, - handler: { code: testPaymentMethod.code, arguments: [] }, - }, - ], - }, - productsCsvPath: '../test/src/products-import.csv', - }); - serverStarted = true; - ctx = await getSuperadminContext(server.app); - }, 60000); +beforeAll(async () => { + registerInitializer('sqljs', new SqljsInitializer('__data__')); + const config = mergeConfig(testConfig, { + logger: new DefaultLogger({ level: LogLevel.Debug }), + plugins: [ + ShippingExtensionsPlugin.init({ + orderAddressToGeolocationStrategy: + new UKPostalCodeToGelocationConversionStrategy(), + }), + ], + paymentOptions: { + paymentMethodHandlers: [testPaymentMethod], + }, + }); - it('Should start successfully', async () => { - await expect(serverStarted).toBe(true); + ({ server, adminClient, shopClient } = createTestEnvironment(config)); + await server.init({ + initialData: { + ...initialData, + shippingMethods: [], + paymentMethods: [ + { + name: testPaymentMethod.code, + handler: { code: testPaymentMethod.code, arguments: [] }, + }, + ], + }, + productsCsvPath: '../test/src/products-import.csv', }); + serverStarted = true; + ctx = await getSuperadminContext(server.app); +}, 60000); +it('Should start successfully', async () => { + await expect(serverStarted).toBe(true); +}); + +describe('Shipping by weight and country', function () { it('Creates shippingmethod 1 for NL and BE, with weight between 0 and 100 ', async () => { await adminClient.asSuperAdmin(); const res = await createShippingMethod(adminClient, { @@ -209,7 +209,9 @@ describe('Order export plugin', function () { 'ORDER_STATE_TRANSITION_ERROR' ); }); +}); +describe('Distance based shipping calculator', function () { it('Should calculate distance based Shipping Price', async () => { const shippingAddressPostalCode = 'SW1W 0NY'; const shippingAddressGeoLocation: GeoLocation = { @@ -269,12 +271,12 @@ describe('Order export plugin', function () { ); expect(order.shipping).toBe(expectedPrice); }); - - afterAll(async () => { - await server.destroy(); - }, 100000); }); +afterAll(async () => { + await server.destroy(); +}, 100000); + const CREATE_SHIPPING_METHOD = gql` mutation CreateShippingMethod($input: CreateShippingMethodInput!) { createShippingMethod(input: $input) {