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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,15 @@ export class DbTableWidgetsComponent implements OnInit {
}`,
Markdown: `// No settings required`,
Money: `// Configure money widget settings
// cents: when true, stored values are integer minor units (e.g. 1099 = $10.99),
// matching Stripe. For zero-decimal currencies (JPY, KRW, VND, ...) values are
// shown as-is. Do NOT enable on a column that already stores decimal amounts.
// example:
{
"default_currency": "USD",
"decimal_places": 2,
"allow_negative": true
"allow_negative": true,
"cents": false
}
`,
Number: `// Configure number display with unit conversion and threshold validation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,4 +218,105 @@ describe('MoneyEditComponent', () => {

expect(component.onFieldChange.emit).toHaveBeenCalledWith('');
});

it('should load numeric value as major units when cents=true (USD)', () => {
fixture.componentRef.setInput('widgetStructure', {
field_name: 'price',
widget_type: 'Money',
name: 'Price',
description: '',
widget_params: { default_currency: 'USD', cents: true },
});
fixture.componentRef.setInput('value', 1099);
component.ngOnInit();
expect(component.amount).toBe(10.99);
expect(component.displayAmount).toBe('10.99');
expect(component.decimalPlaces).toBe(2);
});

it('should load JPY cents value without division and use 0 decimals', () => {
fixture.componentRef.setInput('widgetStructure', {
field_name: 'price',
widget_type: 'Money',
name: 'Price',
description: '',
widget_params: { default_currency: 'JPY', cents: true, show_currency_selector: true },
});
fixture.componentRef.setInput('value', { amount: 1099, currency: 'JPY' });
component.ngOnInit();
expect(component.selectedCurrency).toBe('JPY');
expect(component.decimalPlaces).toBe(0);
expect(component.amount).toBe(1099);
expect(component.displayAmount).toBe('1099');
});

it('should emit cents integer on save when cents=true', () => {
fixture.componentRef.setInput('widgetStructure', {
field_name: 'price',
widget_type: 'Money',
name: 'Price',
description: '',
widget_params: { default_currency: 'USD', cents: true },
});
component.ngOnInit();
component.displayAmount = '10.99';
vi.spyOn(component.onFieldChange, 'emit');

component.onAmountChange();

expect(component.onFieldChange.emit).toHaveBeenCalledWith(1099);
});

it('should round float-precision drift on save', () => {
fixture.componentRef.setInput('widgetStructure', {
field_name: 'price',
widget_type: 'Money',
name: 'Price',
description: '',
widget_params: { default_currency: 'USD', cents: true },
});
component.ngOnInit();
component.displayAmount = '20.99';
vi.spyOn(component.onFieldChange, 'emit');

component.onAmountChange();

expect(component.onFieldChange.emit).toHaveBeenCalledWith(2099);
});

it('should reformat displayAmount and round on currency switch when cents=true', () => {
fixture.componentRef.setInput('widgetStructure', {
field_name: 'price',
widget_type: 'Money',
name: 'Price',
description: '',
widget_params: { default_currency: 'USD', cents: true, show_currency_selector: true },
});
fixture.componentRef.setInput('value', { amount: 1099, currency: 'USD' });
component.ngOnInit();
expect(component.displayAmount).toBe('10.99');

component.selectedCurrency = 'JPY';
vi.spyOn(component.onFieldChange, 'emit');

component.onCurrencyChange();

expect(component.decimalPlaces).toBe(0);
expect(component.displayAmount).toBe('11');
expect(component.onFieldChange.emit).toHaveBeenCalledWith({ amount: 11, currency: 'JPY' });
});

it('should preserve legacy behavior when cents is false or omitted', () => {
fixture.componentRef.setInput('widgetStructure', {
field_name: 'price',
widget_type: 'Money',
name: 'Price',
description: '',
widget_params: { default_currency: 'USD' },
});
fixture.componentRef.setInput('value', 10.99);
component.ngOnInit();
expect(component.amount).toBe(10.99);
expect(component.displayAmount).toBe('10.99');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import { FormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { CURRENCIES, Money, MoneyValue } from 'src/app/consts/currencies';
import {
CURRENCIES,
getCurrencyDecimalPlaces,
getCurrencyMinorUnitFactor,
Money,
MoneyValue,
} from 'src/app/consts/currencies';
import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component';

@Component({
Expand All @@ -22,6 +28,7 @@ export class MoneyEditComponent extends BaseEditFieldComponent implements OnInit
showCurrencySelector: boolean = false;
decimalPlaces: number = 2;
allowNegative: boolean = true;
cents: boolean = false;

selectedCurrency: string = 'USD';
amount: number | string = '';
Expand Down Expand Up @@ -55,26 +62,39 @@ export class MoneyEditComponent extends BaseEditFieldComponent implements OnInit
if (typeof params.allow_negative === 'boolean') {
this.allowNegative = params.allow_negative;
}

if (params.cents === true) {
this.cents = true;
}
}

this._applyCurrencyDecimalPlaces();
}

private initializeMoneyValue(): void {
const currentValue = this.value();
if (currentValue) {
if (currentValue !== '' && currentValue !== null && currentValue !== undefined) {
if (typeof currentValue === 'string') {
this.parseStringValue(currentValue);
} else if (typeof currentValue === 'object' && (currentValue as MoneyValue).amount !== undefined && (currentValue as MoneyValue).currency) {
this.amount = (currentValue as MoneyValue).amount;
} else if (
typeof currentValue === 'object' &&
(currentValue as MoneyValue).amount !== undefined &&
(currentValue as MoneyValue).currency
) {
this.selectedCurrency = (currentValue as MoneyValue).currency;
this._applyCurrencyDecimalPlaces();
this.amount = this._fromMinorUnits((currentValue as MoneyValue).amount);
this.displayAmount = this.formatAmount(this.amount);
} else if (typeof currentValue === 'number') {
// Handle numeric values when currency selector is disabled
this.amount = currentValue;
this.selectedCurrency = this.defaultCurrency;
this._applyCurrencyDecimalPlaces();
this.amount = this._fromMinorUnits(currentValue);
this.displayAmount = this.formatAmount(this.amount);
}
} else {
this.selectedCurrency = this.defaultCurrency;
this._applyCurrencyDecimalPlaces();
this.amount = '';
this.displayAmount = '';
}
Expand All @@ -97,9 +117,12 @@ export class MoneyEditComponent extends BaseEditFieldComponent implements OnInit
}
}

this._applyCurrencyDecimalPlaces();

if (numberMatch) {
const cleanNumber = numberMatch[1].replace(/,/g, '');
this.amount = parseFloat(cleanNumber) || '';
const parsed = parseFloat(cleanNumber);
this.amount = Number.isNaN(parsed) ? '' : this._fromMinorUnits(parsed);
this.displayAmount = this.formatAmount(this.amount);
} else {
this.amount = '';
Expand All @@ -108,6 +131,17 @@ export class MoneyEditComponent extends BaseEditFieldComponent implements OnInit
}

onCurrencyChange(): void {
if (this.cents) {
this._applyCurrencyDecimalPlaces();
if (this.amount !== '' && this.amount !== null && this.amount !== undefined) {
const numericAmount = typeof this.amount === 'string' ? parseFloat(this.amount) : this.amount;
if (!Number.isNaN(numericAmount)) {
const rounded = parseFloat(numericAmount.toFixed(this.decimalPlaces));
this.amount = rounded;
this.displayAmount = this.formatAmount(rounded);
}
}
Comment on lines +136 to +143
}
this.updateValue();
}

Expand Down Expand Up @@ -165,15 +199,16 @@ export class MoneyEditComponent extends BaseEditFieldComponent implements OnInit
if (this.amount === '' || this.amount === null || this.amount === undefined) {
this.value.set('');
} else {
const storedAmount = this._toMinorUnits();
if (this.showCurrencySelector) {
// Store as object with amount and currency when selector is enabled
this.value.set({
amount: this.amount,
amount: storedAmount,
currency: this.selectedCurrency,
});
} else {
// Store only the numeric amount when currency selector is disabled
this.value.set(typeof this.amount === 'string' ? parseFloat(this.amount) || 0 : this.amount);
this.value.set(storedAmount);
}
}

Expand Down Expand Up @@ -203,4 +238,38 @@ export class MoneyEditComponent extends BaseEditFieldComponent implements OnInit
displayCurrencyFn(currency: Money): string {
return currency ? `${currency.flag || ''} ${currency.code} - ${currency.name}` : '';
}

private _applyCurrencyDecimalPlaces(): void {
if (this.cents) {
this.decimalPlaces = getCurrencyDecimalPlaces(this.selectedCurrency);
}
}

private _fromMinorUnits(stored: number | string): number | string {
if (!this.cents) {
return stored;
}
const numeric = typeof stored === 'string' ? parseFloat(stored) : stored;
if (Number.isNaN(numeric)) {
return '';
}
return numeric / getCurrencyMinorUnitFactor(this.selectedCurrency);
}

private _toMinorUnits(): number {
const sourceText =
this.displayAmount !== '' && this.displayAmount !== null && this.displayAmount !== undefined
? String(this.displayAmount).replace(/[^\d.-]/g, '')
: typeof this.amount === 'string'
? this.amount
: String(this.amount);
const numeric = parseFloat(sourceText);
if (Number.isNaN(numeric)) {
return 0;
}
if (!this.cents) {
return numeric;
}
return Math.round(numeric * getCurrencyMinorUnitFactor(this.selectedCurrency));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,40 @@ describe('MoneyRecordViewComponent', () => {
component.ngOnInit();
expect(component.formattedValue).toContain('100.00');
});

it('should divide cents to major units when cents=true (USD)', () => {
fixture.componentRef.setInput('widgetStructure', {
widget_params: { default_currency: 'USD', cents: true },
});
fixture.componentRef.setInput('value', 2599);
component.ngOnInit();
expect(component.formattedValue).toBe('$25.99');
});

it('should not divide for zero-decimal currencies when cents=true (JPY)', () => {
fixture.componentRef.setInput('widgetStructure', {
widget_params: { default_currency: 'JPY', cents: true },
});
fixture.componentRef.setInput('value', 1099);
component.ngOnInit();
expect(component.formattedValue).toBe('¥1099');
});

it('should render zero amount as $0.00 with cents=true', () => {
fixture.componentRef.setInput('widgetStructure', {
widget_params: { default_currency: 'USD', cents: true },
});
fixture.componentRef.setInput('value', 0);
component.ngOnInit();
expect(component.formattedValue).toBe('$0.00');
});

it('should treat object amount as cents when cents=true', () => {
fixture.componentRef.setInput('widgetStructure', {
widget_params: { default_currency: 'USD', cents: true },
});
fixture.componentRef.setInput('value', { amount: 1099, currency: 'EUR' });
component.ngOnInit();
expect(component.formattedValue).toContain('10.99');
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { getCurrencyByCode } from 'src/app/consts/currencies';
import { getCurrencyByCode, getCurrencyDecimalPlaces, getCurrencyMinorUnitFactor } from 'src/app/consts/currencies';
import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component';

@Component({
Expand All @@ -23,22 +23,23 @@ export class MoneyRecordViewComponent extends BaseRecordViewFieldComponent imple
}

get formattedValue(): string {
if (!this.value()) {
const raw = this.value();
if (raw == null || raw === '') {
return '';
}

let amount: number | string;
let currency: string = this.displayCurrency;

if (typeof this.value() === 'object' && this.value().amount !== undefined) {
amount = this.value().amount;
if (this.value().currency) {
currency = this.value().currency;
if (typeof raw === 'object' && raw.amount !== undefined) {
amount = raw.amount;
if (raw.currency) {
currency = raw.currency;
const currencyObj = getCurrencyByCode(currency);
this.currencySymbol = currencyObj ? currencyObj.symbol : '';
}
} else {
amount = this.value();
amount = raw;
}

if (typeof amount === 'string') {
Expand All @@ -49,7 +50,15 @@ export class MoneyRecordViewComponent extends BaseRecordViewFieldComponent imple
return '';
}

const decimalPlaces = this.widgetStructure()?.widget_params?.decimal_places ?? 2;
const cents = this.widgetStructure()?.widget_params?.cents === true;
let decimalPlaces: number;
if (cents) {
amount = (amount as number) / getCurrencyMinorUnitFactor(currency);
decimalPlaces = getCurrencyDecimalPlaces(currency);
} else {
decimalPlaces = this.widgetStructure()?.widget_params?.decimal_places ?? 2;
}

return `${this.currencySymbol}${(amount as number).toFixed(decimalPlaces)}`;
}
}
Loading
Loading