Skip to content

Commit f370e38

Browse files
guguclaude
andcommitted
feat: add cents support to money widget
Adds opt-in `cents: true` widget_param so the money widget can render and edit values stored as Stripe-style integer minor units. Zero-decimal currencies (JPY, KRW, VND, ...) bypass division per Stripe convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 59902aa commit f370e38

8 files changed

Lines changed: 366 additions & 68 deletions

File tree

frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,15 @@ export class DbTableWidgetsComponent implements OnInit {
154154
}`,
155155
Markdown: `// No settings required`,
156156
Money: `// Configure money widget settings
157+
// cents: when true, stored values are integer minor units (e.g. 1099 = $10.99),
158+
// matching Stripe. For zero-decimal currencies (JPY, KRW, VND, ...) values are
159+
// shown as-is. Do NOT enable on a column that already stores decimal amounts.
157160
// example:
158161
{
159162
"default_currency": "USD",
160163
"decimal_places": 2,
161-
"allow_negative": true
164+
"allow_negative": true,
165+
"cents": false
162166
}
163167
`,
164168
Number: `// Configure number display with unit conversion and threshold validation

frontend/src/app/components/ui-components/record-edit-fields/money/money.component.spec.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,4 +218,105 @@ describe('MoneyEditComponent', () => {
218218

219219
expect(component.onFieldChange.emit).toHaveBeenCalledWith('');
220220
});
221+
222+
it('should load numeric value as major units when cents=true (USD)', () => {
223+
fixture.componentRef.setInput('widgetStructure', {
224+
field_name: 'price',
225+
widget_type: 'Money',
226+
name: 'Price',
227+
description: '',
228+
widget_params: { default_currency: 'USD', cents: true },
229+
});
230+
fixture.componentRef.setInput('value', 1099);
231+
component.ngOnInit();
232+
expect(component.amount).toBe(10.99);
233+
expect(component.displayAmount).toBe('10.99');
234+
expect(component.decimalPlaces).toBe(2);
235+
});
236+
237+
it('should load JPY cents value without division and use 0 decimals', () => {
238+
fixture.componentRef.setInput('widgetStructure', {
239+
field_name: 'price',
240+
widget_type: 'Money',
241+
name: 'Price',
242+
description: '',
243+
widget_params: { default_currency: 'JPY', cents: true, show_currency_selector: true },
244+
});
245+
fixture.componentRef.setInput('value', { amount: 1099, currency: 'JPY' });
246+
component.ngOnInit();
247+
expect(component.selectedCurrency).toBe('JPY');
248+
expect(component.decimalPlaces).toBe(0);
249+
expect(component.amount).toBe(1099);
250+
expect(component.displayAmount).toBe('1099');
251+
});
252+
253+
it('should emit cents integer on save when cents=true', () => {
254+
fixture.componentRef.setInput('widgetStructure', {
255+
field_name: 'price',
256+
widget_type: 'Money',
257+
name: 'Price',
258+
description: '',
259+
widget_params: { default_currency: 'USD', cents: true },
260+
});
261+
component.ngOnInit();
262+
component.displayAmount = '10.99';
263+
vi.spyOn(component.onFieldChange, 'emit');
264+
265+
component.onAmountChange();
266+
267+
expect(component.onFieldChange.emit).toHaveBeenCalledWith(1099);
268+
});
269+
270+
it('should round float-precision drift on save', () => {
271+
fixture.componentRef.setInput('widgetStructure', {
272+
field_name: 'price',
273+
widget_type: 'Money',
274+
name: 'Price',
275+
description: '',
276+
widget_params: { default_currency: 'USD', cents: true },
277+
});
278+
component.ngOnInit();
279+
component.displayAmount = '20.99';
280+
vi.spyOn(component.onFieldChange, 'emit');
281+
282+
component.onAmountChange();
283+
284+
expect(component.onFieldChange.emit).toHaveBeenCalledWith(2099);
285+
});
286+
287+
it('should reformat displayAmount and round on currency switch when cents=true', () => {
288+
fixture.componentRef.setInput('widgetStructure', {
289+
field_name: 'price',
290+
widget_type: 'Money',
291+
name: 'Price',
292+
description: '',
293+
widget_params: { default_currency: 'USD', cents: true, show_currency_selector: true },
294+
});
295+
fixture.componentRef.setInput('value', { amount: 1099, currency: 'USD' });
296+
component.ngOnInit();
297+
expect(component.displayAmount).toBe('10.99');
298+
299+
component.selectedCurrency = 'JPY';
300+
vi.spyOn(component.onFieldChange, 'emit');
301+
302+
component.onCurrencyChange();
303+
304+
expect(component.decimalPlaces).toBe(0);
305+
expect(component.displayAmount).toBe('11');
306+
expect(component.onFieldChange.emit).toHaveBeenCalledWith({ amount: 11, currency: 'JPY' });
307+
});
308+
309+
it('should preserve legacy behavior when cents is false or omitted', () => {
310+
fixture.componentRef.setInput('widgetStructure', {
311+
field_name: 'price',
312+
widget_type: 'Money',
313+
name: 'Price',
314+
description: '',
315+
widget_params: { default_currency: 'USD' },
316+
});
317+
fixture.componentRef.setInput('value', 10.99);
318+
component.ngOnInit();
319+
expect(component.amount).toBe(10.99);
320+
expect(component.displayAmount).toBe('10.99');
321+
});
221322
});

frontend/src/app/components/ui-components/record-edit-fields/money/money.component.ts

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import { FormsModule } from '@angular/forms';
44
import { MatFormFieldModule } from '@angular/material/form-field';
55
import { MatInputModule } from '@angular/material/input';
66
import { MatSelectModule } from '@angular/material/select';
7-
import { CURRENCIES, Money, MoneyValue } from 'src/app/consts/currencies';
7+
import {
8+
CURRENCIES,
9+
getCurrencyDecimalPlaces,
10+
getCurrencyMinorUnitFactor,
11+
Money,
12+
MoneyValue,
13+
} from 'src/app/consts/currencies';
814
import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component';
915

1016
@Component({
@@ -22,6 +28,7 @@ export class MoneyEditComponent extends BaseEditFieldComponent implements OnInit
2228
showCurrencySelector: boolean = false;
2329
decimalPlaces: number = 2;
2430
allowNegative: boolean = true;
31+
cents: boolean = false;
2532

2633
selectedCurrency: string = 'USD';
2734
amount: number | string = '';
@@ -55,26 +62,39 @@ export class MoneyEditComponent extends BaseEditFieldComponent implements OnInit
5562
if (typeof params.allow_negative === 'boolean') {
5663
this.allowNegative = params.allow_negative;
5764
}
65+
66+
if (params.cents === true) {
67+
this.cents = true;
68+
}
5869
}
70+
71+
this._applyCurrencyDecimalPlaces();
5972
}
6073

6174
private initializeMoneyValue(): void {
6275
const currentValue = this.value();
63-
if (currentValue) {
76+
if (currentValue !== '' && currentValue !== null && currentValue !== undefined) {
6477
if (typeof currentValue === 'string') {
6578
this.parseStringValue(currentValue);
66-
} else if (typeof currentValue === 'object' && (currentValue as MoneyValue).amount !== undefined && (currentValue as MoneyValue).currency) {
67-
this.amount = (currentValue as MoneyValue).amount;
79+
} else if (
80+
typeof currentValue === 'object' &&
81+
(currentValue as MoneyValue).amount !== undefined &&
82+
(currentValue as MoneyValue).currency
83+
) {
6884
this.selectedCurrency = (currentValue as MoneyValue).currency;
85+
this._applyCurrencyDecimalPlaces();
86+
this.amount = this._fromMinorUnits((currentValue as MoneyValue).amount);
6987
this.displayAmount = this.formatAmount(this.amount);
7088
} else if (typeof currentValue === 'number') {
7189
// Handle numeric values when currency selector is disabled
72-
this.amount = currentValue;
7390
this.selectedCurrency = this.defaultCurrency;
91+
this._applyCurrencyDecimalPlaces();
92+
this.amount = this._fromMinorUnits(currentValue);
7493
this.displayAmount = this.formatAmount(this.amount);
7594
}
7695
} else {
7796
this.selectedCurrency = this.defaultCurrency;
97+
this._applyCurrencyDecimalPlaces();
7898
this.amount = '';
7999
this.displayAmount = '';
80100
}
@@ -97,9 +117,12 @@ export class MoneyEditComponent extends BaseEditFieldComponent implements OnInit
97117
}
98118
}
99119

120+
this._applyCurrencyDecimalPlaces();
121+
100122
if (numberMatch) {
101123
const cleanNumber = numberMatch[1].replace(/,/g, '');
102-
this.amount = parseFloat(cleanNumber) || '';
124+
const parsed = parseFloat(cleanNumber);
125+
this.amount = Number.isNaN(parsed) ? '' : this._fromMinorUnits(parsed);
103126
this.displayAmount = this.formatAmount(this.amount);
104127
} else {
105128
this.amount = '';
@@ -108,6 +131,17 @@ export class MoneyEditComponent extends BaseEditFieldComponent implements OnInit
108131
}
109132

110133
onCurrencyChange(): void {
134+
if (this.cents) {
135+
this._applyCurrencyDecimalPlaces();
136+
if (this.amount !== '' && this.amount !== null && this.amount !== undefined) {
137+
const numericAmount = typeof this.amount === 'string' ? parseFloat(this.amount) : this.amount;
138+
if (!Number.isNaN(numericAmount)) {
139+
const rounded = parseFloat(numericAmount.toFixed(this.decimalPlaces));
140+
this.amount = rounded;
141+
this.displayAmount = this.formatAmount(rounded);
142+
}
143+
}
144+
}
111145
this.updateValue();
112146
}
113147

@@ -165,15 +199,16 @@ export class MoneyEditComponent extends BaseEditFieldComponent implements OnInit
165199
if (this.amount === '' || this.amount === null || this.amount === undefined) {
166200
this.value.set('');
167201
} else {
202+
const storedAmount = this._toMinorUnits();
168203
if (this.showCurrencySelector) {
169204
// Store as object with amount and currency when selector is enabled
170205
this.value.set({
171-
amount: this.amount,
206+
amount: storedAmount,
172207
currency: this.selectedCurrency,
173208
});
174209
} else {
175210
// Store only the numeric amount when currency selector is disabled
176-
this.value.set(typeof this.amount === 'string' ? parseFloat(this.amount) || 0 : this.amount);
211+
this.value.set(storedAmount);
177212
}
178213
}
179214

@@ -203,4 +238,38 @@ export class MoneyEditComponent extends BaseEditFieldComponent implements OnInit
203238
displayCurrencyFn(currency: Money): string {
204239
return currency ? `${currency.flag || ''} ${currency.code} - ${currency.name}` : '';
205240
}
241+
242+
private _applyCurrencyDecimalPlaces(): void {
243+
if (this.cents) {
244+
this.decimalPlaces = getCurrencyDecimalPlaces(this.selectedCurrency);
245+
}
246+
}
247+
248+
private _fromMinorUnits(stored: number | string): number | string {
249+
if (!this.cents) {
250+
return stored;
251+
}
252+
const numeric = typeof stored === 'string' ? parseFloat(stored) : stored;
253+
if (Number.isNaN(numeric)) {
254+
return '';
255+
}
256+
return numeric / getCurrencyMinorUnitFactor(this.selectedCurrency);
257+
}
258+
259+
private _toMinorUnits(): number {
260+
const sourceText =
261+
this.displayAmount !== '' && this.displayAmount !== null && this.displayAmount !== undefined
262+
? String(this.displayAmount).replace(/[^\d.-]/g, '')
263+
: typeof this.amount === 'string'
264+
? this.amount
265+
: String(this.amount);
266+
const numeric = parseFloat(sourceText);
267+
if (Number.isNaN(numeric)) {
268+
return 0;
269+
}
270+
if (!this.cents) {
271+
return numeric;
272+
}
273+
return Math.round(numeric * getCurrencyMinorUnitFactor(this.selectedCurrency));
274+
}
206275
}

frontend/src/app/components/ui-components/record-view-fields/money/money.component.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,40 @@ describe('MoneyRecordViewComponent', () => {
4747
component.ngOnInit();
4848
expect(component.formattedValue).toContain('100.00');
4949
});
50+
51+
it('should divide cents to major units when cents=true (USD)', () => {
52+
fixture.componentRef.setInput('widgetStructure', {
53+
widget_params: { default_currency: 'USD', cents: true },
54+
});
55+
fixture.componentRef.setInput('value', 2599);
56+
component.ngOnInit();
57+
expect(component.formattedValue).toBe('$25.99');
58+
});
59+
60+
it('should not divide for zero-decimal currencies when cents=true (JPY)', () => {
61+
fixture.componentRef.setInput('widgetStructure', {
62+
widget_params: { default_currency: 'JPY', cents: true },
63+
});
64+
fixture.componentRef.setInput('value', 1099);
65+
component.ngOnInit();
66+
expect(component.formattedValue).toBe('¥1099');
67+
});
68+
69+
it('should render zero amount as $0.00 with cents=true', () => {
70+
fixture.componentRef.setInput('widgetStructure', {
71+
widget_params: { default_currency: 'USD', cents: true },
72+
});
73+
fixture.componentRef.setInput('value', 0);
74+
component.ngOnInit();
75+
expect(component.formattedValue).toBe('$0.00');
76+
});
77+
78+
it('should treat object amount as cents when cents=true', () => {
79+
fixture.componentRef.setInput('widgetStructure', {
80+
widget_params: { default_currency: 'USD', cents: true },
81+
});
82+
fixture.componentRef.setInput('value', { amount: 1099, currency: 'EUR' });
83+
component.ngOnInit();
84+
expect(component.formattedValue).toContain('10.99');
85+
});
5086
});

frontend/src/app/components/ui-components/record-view-fields/money/money.component.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Component, OnInit } from '@angular/core';
2-
import { getCurrencyByCode } from 'src/app/consts/currencies';
2+
import { getCurrencyByCode, getCurrencyDecimalPlaces, getCurrencyMinorUnitFactor } from 'src/app/consts/currencies';
33
import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component';
44

55
@Component({
@@ -23,22 +23,23 @@ export class MoneyRecordViewComponent extends BaseRecordViewFieldComponent imple
2323
}
2424

2525
get formattedValue(): string {
26-
if (!this.value()) {
26+
const raw = this.value();
27+
if (raw == null || raw === '') {
2728
return '';
2829
}
2930

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

33-
if (typeof this.value() === 'object' && this.value().amount !== undefined) {
34-
amount = this.value().amount;
35-
if (this.value().currency) {
36-
currency = this.value().currency;
34+
if (typeof raw === 'object' && raw.amount !== undefined) {
35+
amount = raw.amount;
36+
if (raw.currency) {
37+
currency = raw.currency;
3738
const currencyObj = getCurrencyByCode(currency);
3839
this.currencySymbol = currencyObj ? currencyObj.symbol : '';
3940
}
4041
} else {
41-
amount = this.value();
42+
amount = raw;
4243
}
4344

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

52-
const decimalPlaces = this.widgetStructure()?.widget_params?.decimal_places ?? 2;
53+
const cents = this.widgetStructure()?.widget_params?.cents === true;
54+
let decimalPlaces: number;
55+
if (cents) {
56+
amount = (amount as number) / getCurrencyMinorUnitFactor(currency);
57+
decimalPlaces = getCurrencyDecimalPlaces(currency);
58+
} else {
59+
decimalPlaces = this.widgetStructure()?.widget_params?.decimal_places ?? 2;
60+
}
61+
5362
return `${this.currencySymbol}${(amount as number).toFixed(decimalPlaces)}`;
5463
}
5564
}

0 commit comments

Comments
 (0)