diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts index 36eadbbe9..9511bf2f1 100644 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts +++ b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts @@ -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 diff --git a/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.spec.ts index ef047acc4..854e0f5d4 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.spec.ts @@ -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'); + }); }); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.ts index 78dc21504..801d0bccf 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.ts @@ -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({ @@ -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 = ''; @@ -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 = ''; } @@ -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 = ''; @@ -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); + } + } + } this.updateValue(); } @@ -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); } } @@ -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)); + } } diff --git a/frontend/src/app/components/ui-components/record-view-fields/money/money.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/money/money.component.spec.ts index 536575c3d..f08d06baf 100644 --- a/frontend/src/app/components/ui-components/record-view-fields/money/money.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-view-fields/money/money.component.spec.ts @@ -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'); + }); }); diff --git a/frontend/src/app/components/ui-components/record-view-fields/money/money.component.ts b/frontend/src/app/components/ui-components/record-view-fields/money/money.component.ts index fcf71c41d..2296b8b27 100644 --- a/frontend/src/app/components/ui-components/record-view-fields/money/money.component.ts +++ b/frontend/src/app/components/ui-components/record-view-fields/money/money.component.ts @@ -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({ @@ -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') { @@ -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)}`; } } diff --git a/frontend/src/app/components/ui-components/table-display-fields/money/money.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/money/money.component.spec.ts index 0a713b83d..8317798c6 100644 --- a/frontend/src/app/components/ui-components/table-display-fields/money/money.component.spec.ts +++ b/frontend/src/app/components/ui-components/table-display-fields/money/money.component.spec.ts @@ -35,4 +35,44 @@ describe('MoneyDisplayComponent', () => { fixture.componentRef.setInput('value', null); expect(component.formattedValue).toBe(''); }); + + it('should divide cents to major units when cents=true (USD)', () => { + fixture.componentRef.setInput('value', 1099); + fixture.componentRef.setInput('widgetStructure', { + widget_params: { default_currency: 'USD', cents: true }, + }); + component.ngOnInit(); + fixture.detectChanges(); + expect(component.formattedValue).toBe('$10.99'); + }); + + it('should not divide for zero-decimal currencies when cents=true (JPY)', () => { + fixture.componentRef.setInput('value', 1099); + fixture.componentRef.setInput('widgetStructure', { + widget_params: { default_currency: 'JPY', cents: true }, + }); + component.ngOnInit(); + fixture.detectChanges(); + expect(component.formattedValue).toBe('¥1099'); + }); + + it('should render zero amount instead of empty string', () => { + fixture.componentRef.setInput('value', 0); + fixture.componentRef.setInput('widgetStructure', { + widget_params: { default_currency: 'USD', cents: true }, + }); + component.ngOnInit(); + fixture.detectChanges(); + expect(component.formattedValue).toBe('$0.00'); + }); + + it('should ignore widget_params.decimal_places when cents=true and currency is zero-decimal', () => { + fixture.componentRef.setInput('value', 500); + fixture.componentRef.setInput('widgetStructure', { + widget_params: { default_currency: 'JPY', cents: true, decimal_places: 4 }, + }); + component.ngOnInit(); + fixture.detectChanges(); + expect(component.formattedValue).toBe('¥500'); + }); }); diff --git a/frontend/src/app/components/ui-components/table-display-fields/money/money.component.ts b/frontend/src/app/components/ui-components/table-display-fields/money/money.component.ts index c1ec43f2d..942914171 100644 --- a/frontend/src/app/components/ui-components/table-display-fields/money/money.component.ts +++ b/frontend/src/app/components/ui-components/table-display-fields/money/money.component.ts @@ -1,60 +1,68 @@ -import { Component, OnInit } from '@angular/core'; - -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; import { ClipboardModule } from '@angular/cdk/clipboard'; +import { Component, OnInit } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { getCurrencyByCode } from 'src/app/consts/currencies'; +import { getCurrencyByCode, getCurrencyDecimalPlaces, getCurrencyMinorUnitFactor } from 'src/app/consts/currencies'; +import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; @Component({ - selector: 'app-money-display', - templateUrl: './money.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './money.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] + selector: 'app-money-display', + templateUrl: './money.component.html', + styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './money.component.css'], + imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule], }) export class MoneyDisplayComponent extends BaseTableDisplayFieldComponent implements OnInit { - public displayCurrency: string = ''; - public currencySymbol: string = ''; - - ngOnInit(): void { - // Get currency from widget params - this.displayCurrency = ''; - if (this.widgetStructure()?.widget_params?.default_currency) { - this.displayCurrency = this.widgetStructure().widget_params.default_currency; - const currency = getCurrencyByCode(this.displayCurrency); - this.currencySymbol = currency ? currency.symbol : ''; - } - } - - get formattedValue(): string { - if (!this.value()) { - 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; - const currencyObj = getCurrencyByCode(currency); - this.currencySymbol = currencyObj ? currencyObj.symbol : ''; - } - } else { - amount = this.value(); - } - - if (typeof amount === 'string') { - amount = parseFloat(amount); - } - - if (Number.isNaN(amount as number)) { - return ''; - } - - const decimalPlaces = this.widgetStructure()?.widget_params?.decimal_places ?? 2; - return `${this.currencySymbol}${(amount as number).toFixed(decimalPlaces)}`; - } + public displayCurrency: string = ''; + public currencySymbol: string = ''; + + ngOnInit(): void { + // Get currency from widget params + this.displayCurrency = ''; + if (this.widgetStructure()?.widget_params?.default_currency) { + this.displayCurrency = this.widgetStructure().widget_params.default_currency; + const currency = getCurrencyByCode(this.displayCurrency); + this.currencySymbol = currency ? currency.symbol : ''; + } + } + + get formattedValue(): string { + const raw = this.value(); + if (raw == null || raw === '') { + return ''; + } + + let amount: number | string; + let currency: string = this.displayCurrency; + + 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 = raw; + } + + if (typeof amount === 'string') { + amount = parseFloat(amount); + } + + if (Number.isNaN(amount as number)) { + return ''; + } + + 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)}`; + } } diff --git a/frontend/src/app/consts/currencies.ts b/frontend/src/app/consts/currencies.ts index 233dcf2bb..174c5bfef 100644 --- a/frontend/src/app/consts/currencies.ts +++ b/frontend/src/app/consts/currencies.ts @@ -54,3 +54,34 @@ export function getCurrencySymbol(code: string): string { const currency = getCurrencyByCode(code); return currency ? currency.symbol : ''; } + +export const ZERO_DECIMAL_CURRENCIES = new Set([ + 'BIF', + 'CLP', + 'DJF', + 'GNF', + 'JPY', + 'KMF', + 'KRW', + 'MGA', + 'PYG', + 'RWF', + 'UGX', + 'VND', + 'VUV', + 'XAF', + 'XOF', + 'XPF', +]); + +export function isZeroDecimalCurrency(code: string): boolean { + return ZERO_DECIMAL_CURRENCIES.has(code); +} + +export function getCurrencyDecimalPlaces(code: string): number { + return isZeroDecimalCurrency(code) ? 0 : 2; +} + +export function getCurrencyMinorUnitFactor(code: string): number { + return isZeroDecimalCurrency(code) ? 1 : 100; +}