Skip to content

Commit

Permalink
feat(core): Add precision property to MoneyStrategy
Browse files Browse the repository at this point in the history
This allows us to easily support use-cases where more than 2 decimal places on the
major currency unit are needed (e.g. some B2B stores).
  • Loading branch information
michaelbromley committed Dec 15, 2023
1 parent f6f2975 commit c33ba63
Show file tree
Hide file tree
Showing 25 changed files with 166 additions and 48 deletions.
47 changes: 47 additions & 0 deletions docs/docs/guides/core-concepts/money/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ The `MoneyStrategy` allows you to define:

- How the value is stored and retrieved from the database
- How rounding is applied internally
- The precision represented by the monetary value (since v2.2.0)

For example, in addition to the [`DefaultMoneyStrategy`](/reference/typescript-api/money/default-money-strategy), Vendure
also provides the [`BigIntMoneyStrategy`](/reference/typescript-api/money/big-int-money-strategy) which stores monetary values
Expand All @@ -188,3 +189,49 @@ export const config: VendureConfig = {
}
}
```

### Example: supporting three decimal places

Let's say you have a B2B store which sells products in bulk, and you want to support prices with three decimal places.
For example, you want to be able to sell a product for `$1.234` per unit. To do this, you would need to:

1. Configure the `MoneyStrategy` to use three decimal places

```ts
import { DefaultMoneyStrategy, VendureConfig } from '@vendure/core';

export class ThreeDecimalPlacesMoneyStrategy extends DefaultMoneyStrategy {
// highlight-next-line
readonly precision = 3;
}

export const config: VendureConfig = {
// ...
entityOptions: {
moneyStrategy: new ThreeDecimalPlacesMoneyStrategy(),
}
};
```

2. Set up your storefront to correctly convert the integer value to a decimal value with three decimal places. Using the
`formatCurrency` example above, we can modify it to divide by 1000 instead of 100:

```ts title="src/utils/format-currency.ts"
export function formatCurrency(value: number, currencyCode: string, locale?: string) {
// highlight-next-line
const majorUnits = value / 1000;
try {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
// highlight-start
minimumFractionDigits: 3,
maximumFractionDigits: 3,
// highlight-end
}).format(majorUnits);
} catch (e: any) {
// highlight-next-line
return majorUnits.toFixed(3);
}
}
```
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
import { DataService } from '@vendure/admin-ui/core';
import { BehaviorSubject, combineLatest, Observable, Subject } from 'rxjs';
import { map } from 'rxjs/operators';
import { map, tap } from 'rxjs/operators';

@Component({
selector: 'vdr-variant-price-detail',
Expand Down
5 changes: 3 additions & 2 deletions packages/admin-ui/src/lib/core/src/common/generated-types.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ConfigurableOperationDefinition } from '../generated-types';
export function interpolateDescription(
operation: ConfigurableOperationDefinition,
values: { [name: string]: any },
precisionFactor = 100,
): string {
if (!operation) {
return '';
Expand All @@ -20,7 +21,7 @@ export function interpolateDescription(
let formatted = value;
const argDef = operation.args.find(arg => arg.name === normalizedArgName);
if (argDef && argDef.type === 'int' && argDef.ui && argDef.ui.component === 'currency-form-input') {
formatted = value / 100;
formatted = value / precisionFactor;
}
if (argDef && argDef.type === 'datetime' && value instanceof Date) {
formatted = value.toLocaleDateString();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,7 @@ export const GET_SERVER_CONFIG = gql`
globalSettings {
id
serverConfig {
moneyStrategyPrecision
orderProcess {
name
to
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Injectable } from '@angular/core';
import { ServerConfigService } from '../../data/server-config';

@Injectable({
providedIn: 'root',
})
export class CurrencyService {
readonly precision: number;
readonly precisionFactor: number;
constructor(serverConfigService: ServerConfigService) {
this.precision = serverConfigService.serverConfig.moneyStrategyPrecision;
this.precisionFactor = Math.pow(10, this.precision);
}

toMajorUnits(value: number): number {
return value / this.precisionFactor;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ViewChild,
} from '@angular/core';
import { easings, LineChart, LineChartData, LineChartOptions } from 'chartist';
import { CurrencyService } from '../../../providers/currency/currency.service';
import { tooltipPlugin } from './tooltip-plugin';

export interface ChartFormatOptions {
Expand All @@ -36,6 +37,8 @@ export class ChartComponent implements OnInit, OnChanges, OnDestroy {
@ViewChild('chartDiv', { static: true }) private chartDivRef: ElementRef<HTMLDivElement>;
private chart: LineChart;

constructor(private currencyService: CurrencyService) {}

ngOnInit() {
this.chart = new LineChart(
this.chartDivRef.nativeElement,
Expand All @@ -55,7 +58,12 @@ export class ChartComponent implements OnInit, OnChanges, OnDestroy {
showLabel: false,
offset: 1,
},
plugins: [tooltipPlugin()],
plugins: [
tooltipPlugin({
currencyPrecision: this.currencyService.precision,
currencyPrecisionFactor: this.currencyService.precisionFactor,
}),
],
...this.options,
},
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
*/
/* global Chartist */

import { LineChart, PieChart, DrawEvent } from 'chartist';
import { DrawEvent, LineChart } from 'chartist';
import { ChartFormatOptions } from './chart.component';

const defaultOptions = {
currency: undefined,
currencyPrecision: 2,
currencyPrecisionFactor: 100,
currencyFormatCallback: undefined,
tooltipOffset: {
x: 0,
Expand All @@ -20,13 +22,12 @@ const defaultOptions = {
pointClass: 'ct-point',
};

export function tooltipPlugin(userOptions?: any) {
export function tooltipPlugin(userOptions?: Partial<typeof defaultOptions>) {
return function tooltip(chart: LineChart) {
const options = {
...defaultOptions,
...userOptions,
};
const tooltipSelector = options.pointClass;

const $chart = (chart as any).container as HTMLDivElement;
let $toolTip = $chart.querySelector('.chartist-tooltip') as HTMLDivElement;
Expand Down Expand Up @@ -97,8 +98,6 @@ export function tooltipPlugin(userOptions?: any) {
closestPoint.element.addClass('ct-tooltip-hover');

const $point = closestPoint.element.getNode() as HTMLElement;

const seriesName = 'ct:series-name';
const meta: {
label: string;
formatOptions: ChartFormatOptions;
Expand All @@ -111,8 +110,9 @@ export function tooltipPlugin(userOptions?: any) {
? new Intl.NumberFormat(meta.formatOptions.locale, {
style: 'currency',
currency: meta.formatOptions.currencyCode,
minimumFractionDigits: 2,
}).format(+(value ?? 0) / 100)
minimumFractionDigits: options.currencyPrecision,
maximumFractionDigits: options.currencyPrecision,
}).format(+(value ?? 0) / options.currencyPrecisionFactor)
: new Intl.NumberFormat(meta.formatOptions.locale).format(+(value ?? 0));

const tooltipText = `
Expand Down Expand Up @@ -172,16 +172,6 @@ function hasClass(element, className) {
return (' ' + element.getAttribute('class') + ' ').indexOf(' ' + className + ' ') > -1;
}

function next(element, className) {
do {
element = element.nextSibling;
} while (element && !hasClass(element, className));
return element;
}

function text(element) {
return element.innerText || element.textContent;
}
function calculateDistance(x1, x2) {
return Math.abs(x2 - x1);
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
} from '../../../common/generated-types';
import { getDefaultConfigArgValue } from '../../../common/utilities/configurable-operation-utils';
import { interpolateDescription } from '../../../common/utilities/interpolate-description';
import { CurrencyService } from '../../../providers/currency/currency.service';

/**
* A form input which renders a card with the internal form fields of the given ConfigurableOperation.
Expand Down Expand Up @@ -69,9 +70,15 @@ export class ConfigurableInputComponent
private positionChangeSubject = new BehaviorSubject<number>(0);
private subscription: Subscription;

constructor(private currencyService: CurrencyService) {}

interpolateDescription(): string {
if (this.operationDefinition) {
return interpolateDescription(this.operationDefinition, this.form.value);
return interpolateDescription(
this.operationDefinition,
this.form.value,
this.currencyService.precisionFactor,
);
} else {
return '';
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
>
<input
type="number"
[step]="hasFractionPart ? 0.01 : 1"
[step]="hasFractionPart ? 1 / precisionFactor : 1"
[value]="_inputValue"
[disabled]="disabled"
[readonly]="readonly"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {
ChangeDetectorRef,
Component,
EventEmitter,
Input,
Expand All @@ -14,6 +13,7 @@ import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
import { map } from 'rxjs/operators';

import { DataService } from '../../../data/providers/data.service';
import { CurrencyService } from '../../../providers/currency/currency.service';

/**
* @description
Expand Down Expand Up @@ -56,8 +56,13 @@ export class CurrencyInputComponent implements ControlValueAccessor, OnInit, OnC
_inputValue: string;
private currencyCode$ = new BehaviorSubject<string>('');
private subscription: Subscription;
readonly precision: number;
readonly precisionFactor: number;

constructor(private dataService: DataService, private changeDetectorRef: ChangeDetectorRef) {}
constructor(private dataService: DataService, private currencyService: CurrencyService) {
this.precision = currencyService.precision;
this.precisionFactor = currencyService.precisionFactor;
}

ngOnInit() {
const languageCode$ = this.dataService.client.uiState().mapStream(data => data.uiState.language);
Expand Down Expand Up @@ -133,7 +138,7 @@ export class CurrencyInputComponent implements ControlValueAccessor, OnInit, OnC
}

onInput(value: string) {
const integerValue = Math.round(+value * 100);
const integerValue = Math.round(+value * this.currencyService.precisionFactor);
if (typeof this.onChange === 'function') {
this.onChange(integerValue);
}
Expand All @@ -155,11 +160,11 @@ export class CurrencyInputComponent implements ControlValueAccessor, OnInit, OnC
writeValue(value: any): void {
const numericValue = +value;
if (!Number.isNaN(numericValue)) {
this._inputValue = this.toNumericString(Math.floor(value) / 100);
this._inputValue = this.toNumericString(this.currencyService.toMajorUnits(Math.floor(value)));
}
}

private toNumericString(value: number | string): string {
return this.hasFractionPart ? Number(value).toFixed(2) : Number(value).toFixed(0);
return this.hasFractionPart ? Number(value).toFixed(this.precision) : Number(value).toFixed(0);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ChangeDetectorRef, Optional, Pipe, PipeTransform } from '@angular/core';

import { DataService } from '../../data/providers/data.service';
import { CurrencyService } from '../../providers/currency/currency.service';

import { LocaleBasePipe } from './locale-base.pipe';

Expand All @@ -21,22 +22,29 @@ import { LocaleBasePipe } from './locale-base.pipe';
pure: false,
})
export class LocaleCurrencyPipe extends LocaleBasePipe implements PipeTransform {
constructor(@Optional() dataService?: DataService, @Optional() changeDetectorRef?: ChangeDetectorRef) {
readonly precisionFactor: number;
constructor(
private currencyService: CurrencyService,
@Optional() dataService?: DataService,
@Optional() changeDetectorRef?: ChangeDetectorRef,
) {
super(dataService, changeDetectorRef);
}

transform(value: unknown, ...args: unknown[]): string | unknown {
const [currencyCode, locale] = args;
if (typeof value === 'number') {
const activeLocale = this.getActiveLocale(locale);
const majorUnits = value / 100;
const majorUnits = this.currencyService.toMajorUnits(value);
try {
return new Intl.NumberFormat(activeLocale, {
style: 'currency',
currency: currencyCode as any,
minimumFractionDigits: this.currencyService.precision,
maximumFractionDigits: this.currencyService.precision,
}).format(majorUnits);
} catch (e: any) {
return majorUnits.toFixed(2);
return majorUnits.toFixed(this.currencyService.precision);
}
}
return value;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
</div>
<div class="stat">
<div class="stat-figure">
{{ totalOrderValue$ | async | currency: (currencyCode$ | async) || undefined }}
{{ totalOrderValue$ | async | localeCurrency: (currencyCode$ | async) || undefined }}
</div>
<div class="stat-label">{{ 'dashboard.total-order-value' | translate }}</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export class OrderSummaryWidgetComponent implements OnInit {
);
this.totalOrderCount$ = orderSummary$.pipe(map(res => res.totalItems));
this.totalOrderValue$ = orderSummary$.pipe(
map(res => res.items.reduce((total, order) => total + order.totalWithTax, 0) / 100),
map(res => res.items.reduce((total, order) => total + order.totalWithTax, 0)),
);
this.currencyCode$ = this.dataService.settings
.getActiveChannel()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5421,6 +5421,7 @@ export type SellerSortParameter = {

export type ServerConfig = {
customFieldConfig: CustomFields;
moneyStrategyPrecision: Scalars['Int']['output'];
orderProcess: Array<OrderProcessState>;
permissions: Array<PermissionDefinition>;
permittedAssetTypes: Array<Scalars['String']['output']>;
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/generated-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5568,6 +5568,7 @@ export type SellerSortParameter = {
export type ServerConfig = {
__typename?: 'ServerConfig';
customFieldConfig: CustomFields;
moneyStrategyPrecision: Scalars['Int']['output'];
orderProcess: Array<OrderProcessState>;
permissions: Array<PermissionDefinition>;
permittedAssetTypes: Array<Scalars['String']['output']>;
Expand Down
1 change: 1 addition & 0 deletions packages/core/e2e/graphql/generated-e2e-admin-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5421,6 +5421,7 @@ export type SellerSortParameter = {

export type ServerConfig = {
customFieldConfig: CustomFields;
moneyStrategyPrecision: Scalars['Int']['output'];
orderProcess: Array<OrderProcessState>;
permissions: Array<PermissionDefinition>;
permittedAssetTypes: Array<Scalars['String']['output']>;
Expand Down
Loading

0 comments on commit c33ba63

Please sign in to comment.