@stackend/api
Version:
JS bindings to api.stackend.com
502 lines (443 loc) • 12.7 kB
text/typescript
import { getJson, getJsonErrorText, Thunk, XcapJsonResult } from '../api';
import { ShopState } from './shopReducer';
import { Cart, Checkout, MoneyV2, Product, ProductVariant, SlimProduct } from './index';
import { forEachGraphQLList } from '../util/graphql';
import { getProductAndVariant, getProductAndVariant2, setCommunityVATS, setCustomerVatInfo } from './shopActions';
import { getCountryCode } from '../util/getCountryCode';
import { Community } from '../stackend';
export enum TradeRegion {
/** Domestic trade */
NATIONAL = 'national',
/** Trade within EU, etc */
REGIONAL = 'regional',
/** Trade with the rest, etc */
WORLDWIDE = 'worldwide'
}
export enum CustomerType {
CONSUMER = 'b2c',
BUSINESS = 'b2b'
}
/**
* Format 1.25 as 25%
* @param vatMultiplier
*/
export function formatVatPercentage(vatMultiplier: number): string {
return vatMultiplierToPercent(vatMultiplier) + '%';
}
/**
* Convert a vat multiplier to a percentage
* @param vatMultiplier
*/
export function vatMultiplierToPercent(vatMultiplier: number): number {
return Math.round(100 * (vatMultiplier - 1));
}
/**
* Convert a percentage (25%) to a vat multiplier (1.25)
* @param percent
*/
export function percentToVatMultiplier(percent: number): number {
return 1 + percent / 100;
}
export enum VatType {
STANDARD = 'standardRate',
REDUCED = 'reducedRate',
REDUCED_ALT = 'reducedRateAlt',
SUPER_REDUCED = 'superReducedRate',
PARKING = 'parkingRate'
}
/** National vat rates in percent or null if not applicable */
export interface NationalVatRates {
/** ISO country code */
countryCode: string;
country: string;
/** The local name of the VAT */
vatName: string;
vatAbbreviatedName: string;
standardRate: number | null;
reducedRate: number | null;
reducedRateAlt: number | null;
superReducedRate: number | null;
parkingRate: number | null;
}
export interface GetVatsResult extends XcapJsonResult {
shopCountryCode: string;
vats: NationalVatRates;
}
/**
* Get VATs for a country. Will try to use the stacks default country if not specified.
* @param shopCountryCode
* @returns {Thunk<XcapJsonResult>}
*/
export function getVats({ shopCountryCode }: { shopCountryCode?: string }): Thunk<Promise<GetVatsResult>> {
return getJson({
url: '/shop/vat/get-vats',
parameters: arguments
});
}
export interface ListVatsResult extends XcapJsonResult {
vats: Array<NationalVatRates>;
}
/**
* Get all supported VATs.
* @returns {Thunk<XcapJsonResult>}
*/
export function listVats(): Thunk<Promise<ListVatsResult>> {
return getJson({
url: '/shop/vat/list',
parameters: arguments
});
}
export interface VatCountry {
countryCode: string;
name: string;
}
export interface ListCountriesResult extends XcapJsonResult {
countries: Array<VatCountry>;
}
/**
* Get all countries.
* @returns {Thunk<ListCountriesResult>}
*/
export function listCountries(): Thunk<Promise<ListCountriesResult>> {
return getJson({
url: '/shop/vat/list-countries',
parameters: arguments
});
}
export interface GetTradeRegionResult extends XcapJsonResult {
tradeRegion: TradeRegion;
customerCountryCode: string;
shopCountryCode: string;
}
/**
* Get the trade region
* @returns {Thunk<XcapJsonResult>}
*/
export function getTradeRegion({
customerCountryCode,
shopCountryCode
}: {
customerCountryCode: string;
shopCountryCode?: string;
}): Thunk<Promise<GetTradeRegionResult>> {
return getJson({
url: '/shop/vat/get-trade-region',
parameters: arguments
});
}
/**
* Should VATs be used?
* @param shopState
* @param customerType
* @param tradeRegion
*/
export function useVATS({
shopState,
customerType,
tradeRegion
}: {
shopState: ShopState;
customerType?: CustomerType;
tradeRegion?: TradeRegion;
}): boolean {
if (!shopState.vats || !shopState.vats.showPricesUsingVAT) {
return false;
}
const typeOfCustomer = customerType || shopState.vats.customerType || CustomerType.CONSUMER;
const region = tradeRegion || shopState.vats.customerTradeRegion || TradeRegion.NATIONAL;
if (
/* No VAT charged to international customers */
region === TradeRegion.WORLDWIDE ||
/* No VAT charged to b2b customer within the region */
(region == TradeRegion.REGIONAL && typeOfCustomer == CustomerType.BUSINESS)
) {
return false;
}
return true;
}
/**
* Get the price including vat.
* Supply the price, a product variant or get the minVariantPrice of the product.
* @param shopState
* @param product
* @param productVariant
* @param customerType
* @param tradeRegion
* @param price Optional price. Overrides the product and product variant price if supplied.
* @param quantity Optional quantity, defaults to 1
*/
export function getPriceIncludingVAT({
shopState,
product,
productVariant,
customerType,
tradeRegion,
price,
quantity = 1
}: {
shopState: ShopState;
product: SlimProduct | Product;
productVariant?: ProductVariant | null;
customerType?: CustomerType;
tradeRegion?: TradeRegion;
price?: MoneyV2 | null;
quantity?: number;
}): MoneyV2 {
let p: MoneyV2 | null = null;
if (price) {
p = price;
} else {
if (productVariant) {
p = productVariant.price;
} else {
p = product.priceRange.minVariantPrice; //getLowestVariantPrice(product);
}
}
if (!useVATS({ shopState, customerType, tradeRegion })) {
return multiplyPrice(p, quantity);
}
// Check if there is a VAT exception for any of the collections the product belongs to
const vatType: VatType = getVATType(shopState, product);
return applyVat(shopState, vatType, p, quantity);
}
/**
* Apply VAT to the price
* @param shopState
* @param vatType
* @param price
* @param quantity
*/
export function applyVat(shopState: ShopState, vatType: VatType, price: MoneyV2, quantity = 1): MoneyV2 {
if (!shopState.vats) {
return multiplyPrice(price, quantity);
}
if (!shopState.vats.vatRates) {
console.warn('Stackend: VAT rates not available.');
return multiplyPrice(price, quantity);
}
let rate = shopState.vats.vatRates[vatType];
if (!rate) {
rate = shopState.vats.vatRates[VatType.STANDARD];
if (!rate) {
return multiplyPrice(price, quantity);
}
}
if (typeof rate !== 'number') {
return multiplyPrice(price, quantity);
}
return multiplyPrice(price, quantity * (1 + rate / 100));
}
export function multiplyPrice(price: MoneyV2, factor: number): MoneyV2 {
if (factor === 1) {
return price;
}
return {
amount: String(parseFloat(price.amount) * factor),
currencyCode: price.currencyCode
};
}
/**
* Get the vat type for a product
* @param shopState
* @param product
*/
export function getVATType(shopState: ShopState, product: SlimProduct): VatType {
let vatType = VatType.STANDARD;
const vats = shopState.vats;
if (vats && product && product.collections) {
forEachGraphQLList(product.collections, i => {
const v = vats.overrides[i.handle];
if (v) {
vatType = v;
}
});
}
return vatType;
}
/**
* Get the total price for a checkout
* @param shopState
* @param checkout
* @param customerType
* @param tradeRegion
*/
export function getTotalPriceIncludingVAT({
shopState,
checkout,
customerType,
tradeRegion
}: {
shopState: ShopState;
checkout: Checkout;
customerType?: CustomerType;
tradeRegion?: TradeRegion;
}): MoneyV2 {
let total = 0;
forEachGraphQLList(checkout.lineItems, i => {
const pv = getProductAndVariant(shopState, i);
if (pv) {
const p = getPriceIncludingVAT({
shopState,
product: pv.product,
productVariant: pv.variant,
quantity: i.quantity,
customerType,
tradeRegion
});
total += parseFloat(p.amount);
}
});
return {
amount: String(total),
currencyCode: checkout.currencyCode
};
}
/**
* Get the total price for a cart
* @param shopState
* @param cart
* @param customerType
* @param tradeRegion
*/
export function getCartTotalPriceIncludingVAT({
shopState,
cart,
customerType,
tradeRegion
}: {
shopState: ShopState;
cart: Cart;
customerType?: CustomerType;
tradeRegion?: TradeRegion;
}): MoneyV2 {
let total = 0;
forEachGraphQLList(cart.lines, i => {
const pv = getProductAndVariant2(shopState, i.merchandise.product.handle, i.merchandise.id);
if (pv) {
const p = getPriceIncludingVAT({
shopState,
product: pv.product,
productVariant: pv.variant,
quantity: i.quantity,
customerType,
tradeRegion
});
total += parseFloat(p.amount);
}
});
return {
amount: String(total),
currencyCode: cart.estimatedCost.totalAmount.currencyCode
};
}
/**
* Get the shops country code, fall back to the locale or 'EN' if not set.
*/
export function getShopCountryCode(): Thunk<Promise<string>> {
return async (dispatch: any, getState): Promise<string> => {
let { shop, communities } = getState();
// Load the vats, if available from the community
if (!shop.vats) {
if (!communities.community) {
throw 'No current community';
}
dispatch(setCommunityVATS(communities.community));
shop = getState();
if (!shop.vats) {
console.error("Stackend: Can't get shop country: No VAT data set up");
}
}
if (shop.vats && shop.vats.shopCountryCode) {
return shop.vats.shopCountryCode;
}
if (!communities.community) {
throw 'No current community';
}
if (communities.community.locale) {
const cc = getCountryCode(communities.community.locale);
if (cc) {
return cc;
}
}
console.error("Stackend: Can't get shop country: No VAT or community locale set up. Falling back to EN");
return 'EN';
};
}
/** Customer info stored in local storage */
export interface CustomerInfo {
customerCountryCode: string;
tradeRegion: TradeRegion;
customerType: CustomerType;
}
/**
* Set the customers country code and update trade region accordingly
* @param customerCountryCode
*/
export function setCustomerCountryCode(customerCountryCode: string): Thunk<Promise<void>> {
return async (dispatch: any, getState): Promise<void> => {
const { shop, communities } = getState();
const shopCountryCode = dispatch(getShopCountryCode());
customerCountryCode = customerCountryCode.toUpperCase();
let tradeRegion = TradeRegion.NATIONAL;
if (customerCountryCode === shopCountryCode) {
tradeRegion = TradeRegion.NATIONAL;
} else {
const r = await dispatch(getTradeRegion({ customerCountryCode }));
if (r.error) {
console.error('Stackend: failed to get trade region for ' + customerCountryCode + ': ' + getJsonErrorText(r));
return;
}
tradeRegion = r.tradeRegion.toLowerCase(); // To match js
}
const customerType = shop?.vats.customerType || CustomerType.CONSUMER;
if (localStorage) {
const ci: CustomerInfo = {
customerCountryCode,
tradeRegion,
customerType
};
localStorage.setItem(getLocalStorageCustomerInfoKey(communities.community), JSON.stringify(ci));
}
dispatch(setCustomerVatInfo({ customerCountryCode, customerTradeRegion: tradeRegion, customerType }));
};
}
/**
* Set the type of customer
* @param customerType
*/
export function setCustomerType(customerType: CustomerType): Thunk<void> {
return async (dispatch: any, getState): Promise<void> => {
const { communities } = getState();
const ci = dispatch(getCustomerInfo());
if (ci && localStorage) {
ci.customerType = customerType;
localStorage.setItem(getLocalStorageCustomerInfoKey(communities.community), JSON.stringify(ci));
}
dispatch(setCustomerVatInfo({ customerType }));
};
}
/**
* Get customer info from shop.vats, with fallback to local storage
*/
export function getCustomerInfo(): Thunk<CustomerInfo | null> {
return (dispatch: any, getState): CustomerInfo | null => {
const { shop, communities } = getState();
if (shop.vats && shop.vats.tradeRegion) {
return {
customerCountryCode: shop.vats.customerCountryCode,
tradeRegion: shop.vats.tradeRegion,
customerType: shop.vats.customerType || CustomerType.CONSUMER
};
}
if (localStorage) {
const cc = localStorage.getItem(getLocalStorageCustomerInfoKey(communities.community));
if (cc) {
return JSON.parse(cc);
}
}
return null;
};
}
export function getLocalStorageCustomerInfoKey(community: Community): string {
return community.permalink + '-customer';
}