diff --git a/src/data/__tests__/exportService.test.ts b/src/data/__tests__/exportService.test.ts index 4ee0de7..c2ae63b 100644 --- a/src/data/__tests__/exportService.test.ts +++ b/src/data/__tests__/exportService.test.ts @@ -187,13 +187,23 @@ describe('generateReportHTML — currency-aware amounts', () => { expect(html).toContain(`${kwd.symbol}1.500`); }); - it('applies the user decimal separator (comma)', () => { + it('applies the user number-format profile (comma: dot groups, symbol suffixed)', () => { const html = generateReportHTML( 2026, 2, [makeTx({ amount: 200050, type: TransactionType.INCOME })], wallets, categories, usd, 'comma', ); - expect(html).toContain(`${usd.symbol}2.000,50`); + // The comma profile suffixes the symbol behind a U+00A0 gap. + expect(html).toContain(`2.000,50\u00A0${usd.symbol}`); + }); + + it('applies the user number-format profile (space: U+00A0 groups, symbol suffixed)', () => { + const html = generateReportHTML( + 2026, 2, + [makeTx({ amount: 200050, type: TransactionType.INCOME })], + wallets, categories, usd, 'space', + ); + expect(html).toContain(`2\u00A0000,50\u00A0${usd.symbol}`); }); // DoD-5: totals use the same exponent as the line items (no mixed scaling). diff --git a/src/data/__tests__/numberFormatDerivation.test.ts b/src/data/__tests__/numberFormatDerivation.test.ts new file mode 100644 index 0000000..021829e --- /dev/null +++ b/src/data/__tests__/numberFormatDerivation.test.ts @@ -0,0 +1,214 @@ +/** + * Number-format profile derivation tests (V-20 / F-14). + * + * Two things are under test and they are not the same thing: + * + * 1. numberFormatForLocale - a pure locale-tag -> profile mapping. + * 2. WHEN that mapping is allowed to run. It runs on first launch and never + * again, so a user's explicit choice can never be overwritten by their + * device locale. That guarantee is the point of the feature, and the second + * half of this file exists to hold it in place. + */ + +// ─── Mocks ──────────────────────────────────────────────────────────── + +jest.mock('@react-native-async-storage/async-storage', () => + require('@react-native-async-storage/async-storage/jest/async-storage-mock') +); + +// Mutable so a test can change the device locale between launches. The getter +// means every read of NativeModules picks up the current value rather than a +// value captured when the module was first imported. +const mockDeviceLocale = { value: 'en_US' }; + +jest.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + NativeModules: { + SettingsManager: { + get settings() { + return { + AppleLocale: mockDeviceLocale.value, + AppleLanguages: [mockDeviceLocale.value], + }; + }, + }, + }, +})); + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + getDefaultSettings, + getInitialSettings, + loadSettings, + updateSetting, +} from '../../data/services/settingsService'; +import { getDeviceLocale } from '../../domain/constants/languages'; +import { numberFormatForLocale } from '../../domain/constants/numberFormats'; + +const SETTINGS_KEY = '@valto:settings'; + +beforeEach(async () => { + await AsyncStorage.clear(); + mockDeviceLocale.value = 'en_US'; + jest.clearAllMocks(); +}); + +// ─── The pure mapping ───────────────────────────────────────────────── + +describe('numberFormatForLocale', () => { + it('maps French locales to the space profile', () => { + expect(numberFormatForLocale('fr')).toBe('space'); + expect(numberFormatForLocale('fr-FR')).toBe('space'); + expect(numberFormatForLocale('fr_CI')).toBe('space'); + expect(numberFormatForLocale('fr-SN')).toBe('space'); + }); + + it('maps English locales to the dot profile', () => { + expect(numberFormatForLocale('en')).toBe('dot'); + expect(numberFormatForLocale('en_US')).toBe('dot'); + expect(numberFormatForLocale('en-GB')).toBe('dot'); + }); + + it('maps continental European locales to the comma profile', () => { + expect(numberFormatForLocale('de')).toBe('comma'); + expect(numberFormatForLocale('es-ES')).toBe('comma'); + expect(numberFormatForLocale('pt-BR')).toBe('comma'); + expect(numberFormatForLocale('it')).toBe('comma'); + }); + + it('applies region overrides where the language default is wrong', () => { + expect(numberFormatForLocale('es-MX')).toBe('dot'); + expect(numberFormatForLocale('es_US')).toBe('dot'); + expect(numberFormatForLocale('pt-PT')).toBe('space'); + expect(numberFormatForLocale('de-CH')).toBe('dot'); + }); + + it('skips a script subtag when looking for the region', () => { + expect(numberFormatForLocale('zh-Hans-CN')).toBe('dot'); + expect(numberFormatForLocale('sr-Latn-RS')).toBe('comma'); + }); + + it('falls back to the dot profile for anything unrecognised', () => { + expect(numberFormatForLocale(null)).toBe('dot'); + expect(numberFormatForLocale(undefined)).toBe('dot'); + expect(numberFormatForLocale('')).toBe('dot'); + expect(numberFormatForLocale('xx-YY')).toBe('dot'); + }); +}); + +// ─── Reading the device locale ──────────────────────────────────────── + +describe('getDeviceLocale', () => { + it('returns the full tag, region included', () => { + mockDeviceLocale.value = 'fr_CI'; + expect(getDeviceLocale()).toBe('fr_CI'); + }); +}); + +// ─── First launch only ──────────────────────────────────────────────── + +describe('first-launch profile derivation', () => { + it('derives the space profile on a French device', async () => { + mockDeviceLocale.value = 'fr_FR'; + const settings = await loadSettings(); + expect(settings.decimalSeparator).toBe('space'); + }); + + it('derives the dot profile on an English device', async () => { + mockDeviceLocale.value = 'en_US'; + const settings = await loadSettings(); + expect(settings.decimalSeparator).toBe('dot'); + }); + + it('derives from the region, not only the language', async () => { + mockDeviceLocale.value = 'fr_CI'; + expect((await loadSettings()).decimalSeparator).toBe('space'); + + await AsyncStorage.clear(); + mockDeviceLocale.value = 'es_MX'; + expect((await loadSettings()).decimalSeparator).toBe('dot'); + }); + + it('getInitialSettings is the only entry point that reads the device', () => { + mockDeviceLocale.value = 'fr_FR'; + // getDefaultSettings stays static, so nothing that merges over defaults + // can accidentally pull the device locale in. + expect(getDefaultSettings().decimalSeparator).toBe('dot'); + expect(getInitialSettings().decimalSeparator).toBe('space'); + }); +}); + +// ─── Never again ────────────────────────────────────────────────────── + +describe('a stored choice always wins over the device locale', () => { + it('does not overwrite an explicit choice on the next launch', async () => { + // Launch 1 on a French device: derived. + mockDeviceLocale.value = 'fr_FR'; + expect((await loadSettings()).decimalSeparator).toBe('space'); + + // The user disagrees and picks dot. + await updateSetting('decimalSeparator', 'dot'); + + // Launch 2, same French device: the stored choice stands. + expect((await loadSettings()).decimalSeparator).toBe('dot'); + + // Launch 3, and a fourth for good measure - still no drift. + expect((await loadSettings()).decimalSeparator).toBe('dot'); + expect((await loadSettings()).decimalSeparator).toBe('dot'); + }); + + it('does not re-derive when the device locale changes under a stored choice', async () => { + mockDeviceLocale.value = 'en_US'; + await updateSetting('decimalSeparator', 'dot'); + + // User travels, changes phone language, restores a backup - whatever. + mockDeviceLocale.value = 'fr_FR'; + expect((await loadSettings()).decimalSeparator).toBe('dot'); + }); + + it('keeps a stored space choice on an English device', async () => { + mockDeviceLocale.value = 'fr_FR'; + await updateSetting('decimalSeparator', 'space'); + + mockDeviceLocale.value = 'en_US'; + expect((await loadSettings()).decimalSeparator).toBe('space'); + }); + + it('does not derive for an existing install whose stored settings predate the field', async () => { + // Storage exists but carries no decimalSeparator. This is NOT a first + // launch, so the static default applies rather than the device locale - + // an upgrade must not silently restyle every amount in the app. + mockDeviceLocale.value = 'fr_FR'; + await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify({ + theme: 'system', + currency: 'XOF', + currencyLocked: true, + notificationsEnabled: false, + language: 'fr', + dateFormat: 'DD/MM/YYYY', + firstDayOfWeek: 'monday', + onboardingCompleted: true, + })); + + expect((await loadSettings()).decimalSeparator).toBe('dot'); + }); + + it('sanitises a corrupted profile to the static default, not to the device locale', async () => { + mockDeviceLocale.value = 'fr_FR'; + await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify({ + ...getDefaultSettings(), + decimalSeparator: 'semicolon', + })); + + expect((await loadSettings()).decimalSeparator).toBe('dot'); + }); + + it('accepts a stored space profile through sanitisation', async () => { + await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify({ + ...getDefaultSettings(), + decimalSeparator: 'space', + })); + + expect((await loadSettings()).decimalSeparator).toBe('space'); + }); +}); diff --git a/src/data/__tests__/settingsFeatures.test.ts b/src/data/__tests__/settingsFeatures.test.ts index 60f40e4..d7ca8b2 100644 --- a/src/data/__tests__/settingsFeatures.test.ts +++ b/src/data/__tests__/settingsFeatures.test.ts @@ -366,7 +366,12 @@ describe('Formatting Utilities', () => { }); it('formatAmount with comma separator', () => { - expect(formatAmount(200050, '$', 'comma')).toBe('$2.000,50'); + // The comma profile suffixes the symbol behind a U+00A0 gap. + expect(formatAmount(200050, '$', 'comma')).toBe('2.000,50\u00A0$'); + }); + + it('formatAmount with space separator', () => { + expect(formatAmount(200050, '$', 'space')).toBe('2\u00A0000,50\u00A0$'); }); it('formatDate DD/MM/YYYY', () => { diff --git a/src/data/services/settingsService.ts b/src/data/services/settingsService.ts index 992a066..df65f2c 100644 --- a/src/data/services/settingsService.ts +++ b/src/data/services/settingsService.ts @@ -6,7 +6,8 @@ */ import { DEFAULT_CURRENCY_CODE } from '../../domain/constants/currencies'; -import { DEFAULT_LANGUAGE_CODE, getDeviceLanguage, isSupportedLanguage } from '../../domain/constants/languages'; +import { DEFAULT_LANGUAGE_CODE, getDeviceLanguage, getDeviceLocale, isSupportedLanguage } from '../../domain/constants/languages'; +import { DEFAULT_NUMBER_FORMAT, NUMBER_FORMAT_PROFILES, numberFormatForLocale } from '../../domain/constants/numberFormats'; import { asyncStorageAdapter, StorageKeys } from '../storage'; // ─── Types ──────────────────────────────────────────────────────────── @@ -34,16 +35,37 @@ export function getDefaultSettings(): AppSettings { language: getDeviceLanguage(), dateFormat: 'MM/DD/YYYY', firstDayOfWeek: 'monday', - decimalSeparator: 'dot', + decimalSeparator: DEFAULT_NUMBER_FORMAT, onboardingCompleted: false, }; } +/** + * Settings for an install that has never stored any. + * + * This is the ONLY place the device locale reaches the number-format profile, and + * it runs on exactly one condition: the settings storage key is absent, which by + * definition is first launch. Every later load takes the merge path in + * loadSettings, where a stored `decimalSeparator` overrides the default, so a + * user's explicit choice can never be overwritten by their device locale - not on + * a language change, not on a device migration, not on an app update. + * + * Deliberately NOT used by the catch path in loadSettings: a transient storage + * read failure is not a first launch, and re-deriving there could flip a chosen + * format on a bad read. + */ +export function getInitialSettings(): AppSettings { + return { + ...getDefaultSettings(), + decimalSeparator: numberFormatForLocale(getDeviceLocale()), + }; +} + // ─── Validation Helpers ─────────────────────────────────────────────── const VALID_DATE_FORMATS: DateFormatPreference[] = ['DD/MM/YYYY', 'MM/DD/YYYY', 'YYYY-MM-DD']; const VALID_FIRST_DAYS: FirstDayOfWeek[] = ['monday', 'sunday']; -const VALID_DECIMAL_SEPS: DecimalSeparator[] = ['dot', 'comma']; +const VALID_DECIMAL_SEPS: DecimalSeparator[] = NUMBER_FORMAT_PROFILES; // ─── Load ───────────────────────────────────────────────────────────── @@ -56,7 +78,9 @@ export async function loadSettings(): Promise { try { const stored = await asyncStorageAdapter.get>(StorageKeys.SETTINGS); if (!stored) { - return getDefaultSettings(); + // Nothing has ever been persisted: first launch, and the only moment + // the device locale is allowed to choose the number-format profile. + return getInitialSettings(); } const defaults = getDefaultSettings(); const merged = { ...defaults, ...stored }; @@ -87,7 +111,7 @@ export async function loadSettings(): Promise { merged.firstDayOfWeek = 'monday'; } if (!VALID_DECIMAL_SEPS.includes(merged.decimalSeparator)) { - merged.decimalSeparator = 'dot'; + merged.decimalSeparator = DEFAULT_NUMBER_FORMAT; } // Validate onboardingCompleted diff --git a/src/domain/__tests__/validators.test.ts b/src/domain/__tests__/validators.test.ts index 88cb7b0..7eb4470 100644 --- a/src/domain/__tests__/validators.test.ts +++ b/src/domain/__tests__/validators.test.ts @@ -209,7 +209,16 @@ describe('validateSettings', () => { }); it('throws for invalid decimalSeparator', () => { - expect(() => validateSettings({ ...validSettings, decimalSeparator: 'space' as any })) + expect(() => validateSettings({ ...validSettings, decimalSeparator: 'semicolon' as any })) .toThrow(ValidationError); }); + + it('accepts every number-format profile', () => { + // 'space' became a real profile (grouping U+00A0, comma decimal, suffixed + // symbol), so it must validate rather than throw. + for (const profile of ['dot', 'comma', 'space'] as const) { + expect(() => validateSettings({ ...validSettings, decimalSeparator: profile })) + .not.toThrow(); + } + }); }); diff --git a/src/domain/constants/currencies.ts b/src/domain/constants/currencies.ts index f1c0d4d..2073105 100644 --- a/src/domain/constants/currencies.ts +++ b/src/domain/constants/currencies.ts @@ -166,7 +166,7 @@ export const SUPPORTED_CURRENCIES: CurrencyDefinition[] = [ { code: 'WST', symbol: 'WS$', name: 'Samoan Tala', decimals: 2 }, { code: 'XAF', symbol: 'FCFA', name: 'Central African CFA Franc', decimals: 0 }, { code: 'XCD', symbol: 'EC$', name: 'East Caribbean Dollar', decimals: 2 }, - { code: 'XOF', symbol: 'CFA', name: 'West African CFA Franc', decimals: 0 }, + { code: 'XOF', symbol: 'FCFA', name: 'West African CFA Franc', decimals: 0 }, { code: 'XPF', symbol: '₣', name: 'CFP Franc', decimals: 0 }, { code: 'YER', symbol: '﷼', name: 'Yemeni Rial', decimals: 2 }, { code: 'ZAR', symbol: 'R', name: 'South African Rand', decimals: 2 }, diff --git a/src/domain/constants/languages.ts b/src/domain/constants/languages.ts index f2c6d5d..81fbdb9 100644 --- a/src/domain/constants/languages.ts +++ b/src/domain/constants/languages.ts @@ -34,28 +34,38 @@ export const DEFAULT_LANGUAGE_CODE = 'en'; const SUPPORTED_CODES = new Set(SUPPORTED_LANGUAGES.map(l => l.code)); /** - * Get the device's language, falling back to English if unsupported. + * Get the device's full locale tag ("en_US", "fr-CI", "zh-Hans_CN"), or null if + * the platform will not give one up. + * + * The region subtag is preserved here deliberately. getDeviceLanguage below drops + * it because it only wants an ISO 639-1 code, but the number-format profile is + * region-sensitive, so it needs the whole tag. */ -export function getDeviceLanguage(): string { +export function getDeviceLocale(): string | null { try { - let locale: string | undefined; - if (Platform.OS === 'ios') { - locale = NativeModules.SettingsManager?.settings?.AppleLocale - ?? NativeModules.SettingsManager?.settings?.AppleLanguages?.[0]; - } else { - locale = NativeModules.I18nManager?.localeIdentifier; - } - - if (locale) { - // Extract language code from "en_US", "zh-Hans_CN", etc. - const code = locale.split(/[_-]/)[0].toLowerCase(); - if (SUPPORTED_CODES.has(code)) { - return code; - } + return NativeModules.SettingsManager?.settings?.AppleLocale + ?? NativeModules.SettingsManager?.settings?.AppleLanguages?.[0] + ?? null; } + return NativeModules.I18nManager?.localeIdentifier ?? null; } catch { // Silently fall back + return null; + } +} + +/** + * Get the device's language, falling back to English if unsupported. + */ +export function getDeviceLanguage(): string { + const locale = getDeviceLocale(); + if (locale) { + // Extract language code from "en_US", "zh-Hans_CN", etc. + const code = locale.split(/[_-]/)[0].toLowerCase(); + if (SUPPORTED_CODES.has(code)) { + return code; + } } return DEFAULT_LANGUAGE_CODE; } diff --git a/src/domain/constants/numberFormats.ts b/src/domain/constants/numberFormats.ts new file mode 100644 index 0000000..2f3d377 --- /dev/null +++ b/src/domain/constants/numberFormats.ts @@ -0,0 +1,133 @@ +/** + * Number Format Profiles + * + * One profile per value of the persisted `decimalSeparator` setting. A profile + * carries EVERY typographic decision a monetary string makes: the grouping + * character, the decimal character, which side of the digits the currency symbol + * sits on, and whether a gap separates the two. + * + * This table is the single source of truth for those four rules. formatAmount, + * formatAmountCompact and formatAmountWhole read it and restate none of them; + * the parser derives its separator set from it so it can always re-read what the + * formatters write. + * + * Separator characters are non-ASCII by necessity. They are written as escapes so + * this file stays ASCII, and referred to by code point in comments: + * U+0020 plain space + * U+00A0 no-break space (what this app emits for space grouping) + * U+202F narrow no-break space (what French CLDR emits, so pasted text has it) + */ + +import type { NumberFormatProfile } from '../entities/Settings'; + +/** No-break space, U+00A0. */ +export const NO_BREAK_SPACE = '\u00A0'; + +/** Narrow no-break space, U+202F. */ +export const NARROW_NO_BREAK_SPACE = '\u202F'; + +export type SymbolPosition = 'prefix' | 'suffix'; + +export interface NumberFormatDefinition { + /** Character inserted between thousands groups. */ + group: string; + /** Character separating the integer part from the fraction. */ + decimal: string; + /** Which side of the digits the currency symbol sits on. */ + symbolPosition: SymbolPosition; + /** Characters between symbol and digits. Empty string means the two are flush. */ + symbolGap: string; +} + +/** + * The three conventions the app can render. + * + * `dot` is byte-identical to what the app rendered before profiles existed, so + * every existing install keeps the display it had. + */ +export const NUMBER_FORMATS: Record = { + /** 1,234.56 and $1,234.56 - English convention, symbol flush against the digits. */ + dot: { group: ',', decimal: '.', symbolPosition: 'prefix', symbolGap: '' }, + /** 1.234,56 and 1.234,56EUR - continental European convention. */ + comma: { group: '.', decimal: ',', symbolPosition: 'suffix', symbolGap: NO_BREAK_SPACE }, + /** 1234,56 and 2000FCFA - French convention. */ + space: { group: NO_BREAK_SPACE, decimal: ',', symbolPosition: 'suffix', symbolGap: NO_BREAK_SPACE }, +}; + +/** Profile used when nothing else resolves. Matches the pre-profile rendering. */ +export const DEFAULT_NUMBER_FORMAT: NumberFormatProfile = 'dot'; + +export const NUMBER_FORMAT_PROFILES = Object.keys(NUMBER_FORMATS) as NumberFormatProfile[]; + +/** + * RegExp character class matching any character a user may type or paste as a + * GROUPING separator: U+0020, U+00A0, U+202F. + * + * No writing convention uses a space as a DECIMAL separator, so the parser can + * treat all three as grouping in every profile without introducing ambiguity. + */ +export const GROUPING_SPACE_CLASS = '[\\u0020\\u00A0\\u202F]'; + +/** Global form of GROUPING_SPACE_CLASS, for stripping grouping out of a body. */ +export const GROUPING_SPACES_GLOBAL = /[\u0020\u00A0\u202F]/g; + +// --- Device locale derivation ----------------------------------------------- +// Pure: takes a locale tag, returns a profile. The impure device read lives in +// domain/constants/languages (getDeviceLocale), which owns the NativeModules +// access. Keeping this side effect free is what makes it directly testable. + +/** + * Languages whose convention groups thousands with a space and uses a comma for + * the decimal. The app normalises every one of them to U+00A0. + */ +const SPACE_GROUPING_LANGUAGES = new Set([ + 'be', 'bg', 'cs', 'et', 'fi', 'fr', 'hu', 'kk', 'lt', 'lv', + 'nb', 'nn', 'no', 'pl', 'ru', 'sk', 'sv', 'uk', +]); + +/** Languages that write 1.234,56 - dot groups, comma is the decimal. */ +const COMMA_DECIMAL_LANGUAGES = new Set([ + 'af', 'bs', 'ca', 'da', 'de', 'el', 'es', 'eu', 'gl', 'hr', 'id', 'is', + 'it', 'mk', 'nl', 'pt', 'ro', 'sl', 'sq', 'sr', 'tr', 'vi', +]); + +/** + * The few regions where the language default is wrong. Keyed by + * "language-REGION" with the region upper-cased. Deliberately small: a locale + * absent from here falls back to its language rule, which is right far more + * often than it is wrong. + */ +const REGION_OVERRIDES: Record = { + // Latin American Spanish follows the English convention, unlike Spain. + 'es-MX': 'dot', + 'es-US': 'dot', + // European Portuguese groups with a space; Brazilian Portuguese does not. + 'pt-PT': 'space', + // Swiss German groups with an apostrophe and uses a dot decimal; of the three + // profiles the app has, dot is the one that keeps the decimal point right. + 'de-CH': 'dot', +}; + +/** + * Map a BCP 47 or POSIX locale tag ("fr", "fr-CI", "es_MX", "zh-Hans-CN") to a + * number-format profile. Returns the default for anything unrecognised. + */ +export function numberFormatForLocale(locale: string | null | undefined): NumberFormatProfile { + if (!locale) return DEFAULT_NUMBER_FORMAT; + + const subtags = locale.replace(/_/g, '-').split('-'); + const language = (subtags[0] ?? '').toLowerCase(); + if (!language) return DEFAULT_NUMBER_FORMAT; + + // A region subtag is two letters or three digits. Looking for that shape + // rather than taking subtags[1] skips scripts, as in "zh-Hans-CN". + const region = subtags.slice(1).find(part => /^[A-Za-z]{2}$/.test(part) || /^\d{3}$/.test(part)); + if (region) { + const override = REGION_OVERRIDES[`${language}-${region.toUpperCase()}`]; + if (override) return override; + } + + if (SPACE_GROUPING_LANGUAGES.has(language)) return 'space'; + if (COMMA_DECIMAL_LANGUAGES.has(language)) return 'comma'; + return DEFAULT_NUMBER_FORMAT; +} diff --git a/src/domain/entities/Settings.ts b/src/domain/entities/Settings.ts index 73478e1..4d7819d 100644 --- a/src/domain/entities/Settings.ts +++ b/src/domain/entities/Settings.ts @@ -9,7 +9,24 @@ export type ThemePreference = 'light' | 'dark' | 'system'; export type DateFormatPreference = 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY-MM-DD'; export type FirstDayOfWeek = 'monday' | 'sunday'; -export type DecimalSeparator = 'dot' | 'comma'; + +/** + * Number-format profile: the whole typographic convention a monetary string + * follows, not just its decimal character. Each value selects a grouping + * character, a decimal character, a currency-symbol side and a symbol gap - see + * NUMBER_FORMATS in src/domain/constants/numberFormats. + * + * dot -> 1,234.56 $1,234.56 + * comma -> 1.234,56 1.234,56 EUR + * space -> 1 234,56 2 000 FCFA + */ +export type NumberFormatProfile = 'dot' | 'comma' | 'space'; + +/** + * Historical name for NumberFormatProfile, kept because the persisted settings + * key is still `decimalSeparator` and several modules import this type. + */ +export type DecimalSeparator = NumberFormatProfile; export interface AppSettings { /** User's theme preference */ @@ -26,8 +43,12 @@ export interface AppSettings { dateFormat: DateFormatPreference; /** First day of the week for calendars and reports */ firstDayOfWeek: FirstDayOfWeek; - /** Decimal separator for monetary display */ - decimalSeparator: DecimalSeparator; + /** + * Number-format profile for monetary display. The key keeps its original + * name so stored settings from every existing install still resolve; the + * value now selects grouping, decimal, symbol side and symbol gap together. + */ + decimalSeparator: NumberFormatProfile; /** Whether the user has completed the onboarding flow */ onboardingCompleted: boolean; } diff --git a/src/domain/validators/SettingsValidator.ts b/src/domain/validators/SettingsValidator.ts index 01e2db4..61a93b0 100644 --- a/src/domain/validators/SettingsValidator.ts +++ b/src/domain/validators/SettingsValidator.ts @@ -5,13 +5,14 @@ * All checks are pure and side-effect-free. */ +import { NUMBER_FORMAT_PROFILES } from '../constants/numberFormats'; import type { AppSettings } from '../entities/Settings'; import { ValidationError } from './ValidationError'; const VALID_THEMES = ['light', 'dark', 'system'] as const; const VALID_DATE_FORMATS = ['DD/MM/YYYY', 'MM/DD/YYYY', 'YYYY-MM-DD'] as const; const VALID_FIRST_DAYS = ['monday', 'sunday'] as const; -const VALID_DECIMAL_SEPS = ['dot', 'comma'] as const; +const VALID_DECIMAL_SEPS = NUMBER_FORMAT_PROFILES; /** * Validate an AppSettings object before persistence. diff --git a/src/hooks/useFormatting.ts b/src/hooks/useFormatting.ts index ce17f80..f87f2bb 100644 --- a/src/hooks/useFormatting.ts +++ b/src/hooks/useFormatting.ts @@ -9,6 +9,7 @@ import { useCallback, useEffect, useState } from 'react'; import { dataEvents } from '../core/events/dataEvents'; import { type AppSettings, loadSettings } from '../data/services/settingsService'; import { getCurrencyByCode } from '../domain/constants/currencies'; +import { DEFAULT_NUMBER_FORMAT } from '../domain/constants/numberFormats'; import { formatAmountCompact as formatAmountCompactUtil, formatAmount as formatAmountUtil, formatAmountWhole as formatAmountWholeUtil } from '../utils/formatAmount'; import { formatDate as formatDateUtil } from '../utils/formatDate'; import { centsToMajor as centsToMajorUtil, normalizeAmount as normalizeAmountUtil, parseAmountInput as parseAmountInputUtil, parseAndNormalizeAmount as parseAndNormalizeAmountUtil } from '../utils/normalizeAmount'; @@ -29,22 +30,24 @@ export function useFormatting() { const currencySymbol = currency?.symbol ?? '$'; const decimals = currency?.decimals ?? 2; - const separator = settings?.decimalSeparator ?? 'dot'; + // The persisted key is still `decimalSeparator`, but its value now selects a + // whole number-format profile: grouping, decimal, symbol side and symbol gap. + const numberFormat = settings?.decimalSeparator ?? DEFAULT_NUMBER_FORMAT; const dateFormat = settings?.dateFormat ?? 'MM/DD/YYYY'; const formatAmount = useCallback( - (amountMinor: number) => formatAmountUtil(amountMinor, currencySymbol, separator, decimals), - [currencySymbol, separator, decimals], + (amountMinor: number) => formatAmountUtil(amountMinor, currencySymbol, numberFormat, decimals), + [currencySymbol, numberFormat, decimals], ); const formatAmountCompact = useCallback( - (amountMinor: number) => formatAmountCompactUtil(amountMinor, currencySymbol, separator, decimals), - [currencySymbol, separator, decimals], + (amountMinor: number) => formatAmountCompactUtil(amountMinor, currencySymbol, numberFormat, decimals), + [currencySymbol, numberFormat, decimals], ); const formatAmountWhole = useCallback( - (amountMinor: number) => formatAmountWholeUtil(amountMinor, currencySymbol, separator, decimals), - [currencySymbol, separator, decimals], + (amountMinor: number) => formatAmountWholeUtil(amountMinor, currencySymbol, numberFormat, decimals), + [currencySymbol, numberFormat, decimals], ); const formatDate = useCallback( @@ -54,14 +57,14 @@ export function useFormatting() { /** Parse a typed amount to major units, or null if invalid. Caller applies its own zero/sign policy. */ const parseAmount = useCallback( - (input: string) => parseAmountInputUtil(input, separator, decimals), - [separator, decimals], + (input: string) => parseAmountInputUtil(input, numberFormat, decimals), + [numberFormat, decimals], ); /** Parse a typed amount straight to integer minor units, or null if invalid or not positive. */ const parseAmountToCents = useCallback( - (input: string) => parseAndNormalizeAmountUtil(input, separator, decimals), - [separator, decimals], + (input: string) => parseAndNormalizeAmountUtil(input, numberFormat, decimals), + [numberFormat, decimals], ); /** Convert a major-unit amount to integer minor units for the active currency. */ diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 36c0ffd..402bd15 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -27,6 +27,7 @@ import { } from '../data/services/settingsService'; import { type CurrencyDefinition, getCurrencyByCode } from '../domain/constants/currencies'; import { getLanguageByCode, type LanguageDefinition } from '../domain/constants/languages'; +import { DEFAULT_NUMBER_FORMAT, NUMBER_FORMAT_PROFILES } from '../domain/constants/numberFormats'; import { useTheme } from '../theme/theme'; // ─── Interface ──────────────────────────────────────────────────────── @@ -66,7 +67,7 @@ export function useSettings(): UseSettingsResult { language: 'en', dateFormat: 'MM/DD/YYYY', firstDayOfWeek: 'monday', - decimalSeparator: 'dot', + decimalSeparator: DEFAULT_NUMBER_FORMAT, onboardingCompleted: false, }); const { setThemePreference } = useTheme(); @@ -380,7 +381,7 @@ export function useSettings(): UseSettingsResult { // ── Decimal Separator ───────────────────────────────────────────── const changeDecimalSeparator = useCallback(() => { - const options: DecimalSeparator[] = ['dot', 'comma']; + const options: DecimalSeparator[] = NUMBER_FORMAT_PROFILES; Alert.alert( t('settings.decimalSeparator'), diff --git a/src/localization/locales/en.json b/src/localization/locales/en.json index f3e673f..6052c69 100644 --- a/src/localization/locales/en.json +++ b/src/localization/locales/en.json @@ -402,7 +402,8 @@ "monday": "Monday", "sunday": "Sunday", "dot": "Dot (1,000.00)", - "comma": "Comma (1.000,00)" + "comma": "Comma (1.000,00)", + "space": "Space (1\u00a0000,00)" }, "notifications": { "channelName": "Daily reminder", diff --git a/src/localization/locales/es.json b/src/localization/locales/es.json index 4acc0c9..f418f67 100644 --- a/src/localization/locales/es.json +++ b/src/localization/locales/es.json @@ -402,7 +402,8 @@ "monday": "Lunes", "sunday": "Domingo", "dot": "Punto (1,000.00)", - "comma": "Coma (1.000,00)" + "comma": "Coma (1.000,00)", + "space": "Espacio (1\u00a0000,00)" }, "notifications": { "channelName": "Recordatorio diario", diff --git a/src/localization/locales/fr.json b/src/localization/locales/fr.json index df1f51f..3dd8902 100644 --- a/src/localization/locales/fr.json +++ b/src/localization/locales/fr.json @@ -401,8 +401,9 @@ "decimalSeparator": "Séparateur décimal", "monday": "Lundi", "sunday": "Dimanche", - "dot": "Point (1 000.00)", - "comma": "Virgule (1.000,00)" + "dot": "Point (1,000.00)", + "comma": "Virgule (1.000,00)", + "space": "Espace (1\u00a0000,00)" }, "notifications": { "channelName": "Rappel quotidien", diff --git a/src/localization/locales/pt.json b/src/localization/locales/pt.json index 4d388c6..c7ae778 100644 --- a/src/localization/locales/pt.json +++ b/src/localization/locales/pt.json @@ -402,7 +402,8 @@ "monday": "Segunda-feira", "sunday": "Domingo", "dot": "Ponto (1,000.00)", - "comma": "Vírgula (1.000,00)" + "comma": "Vírgula (1.000,00)", + "space": "Espaço (1\u00a0000,00)" }, "notifications": { "channelName": "Lembrete diário", diff --git a/src/localization/locales/ru.json b/src/localization/locales/ru.json index 3cb9a46..d542d9d 100644 --- a/src/localization/locales/ru.json +++ b/src/localization/locales/ru.json @@ -402,7 +402,8 @@ "monday": "Понедельник", "sunday": "Воскресенье", "dot": "Точка (1,000.00)", - "comma": "Запятая (1.000,00)" + "comma": "Запятая (1.000,00)", + "space": "Пробел (1\u00a0000,00)" }, "notifications": { "channelName": "Ежедневное напоминание", diff --git a/src/localization/locales/zh.json b/src/localization/locales/zh.json index cc20c5c..d2a40f5 100644 --- a/src/localization/locales/zh.json +++ b/src/localization/locales/zh.json @@ -38,7 +38,8 @@ "monday": "星期一", "sunday": "星期日", "dot": "点 (1,000.00)", - "comma": "逗号 (1.000,00)" + "comma": "逗号 (1.000,00)", + "space": "空格 (1\u00a0000,00)" }, "alerts": { "cancel": "取消", diff --git a/src/utils/__tests__/formatAmount.test.ts b/src/utils/__tests__/formatAmount.test.ts index 48fa060..e047fbc 100644 --- a/src/utils/__tests__/formatAmount.test.ts +++ b/src/utils/__tests__/formatAmount.test.ts @@ -3,13 +3,23 @@ * * Tests for all three formatting functions: * formatAmount, formatAmountCompact, formatAmountWhole. - * Covers both decimal separators, negative values, zero, and edge cases. + * Covers all three number-format profiles, negative values, zero, and edge cases. + * + * A profile fixes four things at once - grouping character, decimal character, + * which side the currency symbol sits on, and the gap between symbol and digits - + * so the assertions below are deliberately full-string and deliberately spell the + * separator out by code point. A test that only checked the digits would not + * protect the thing these profiles exist for. */ import { formatAmount, formatAmountCompact, formatAmountWhole } from '../formatAmount'; +/** U+00A0, the grouping and symbol-gap character. Spelled out so the expected + * strings below cannot be satisfied by a plain U+0020. */ +const NBSP = '\u00A0'; + describe('formatAmount', () => { - // ─── Basic formatting with dot separator ─────────────────────────── + // ─── Basic formatting with the dot profile ───────────────────────── it('formats standard amount', () => { expect(formatAmount(200050)).toBe('$2,000.50'); @@ -45,22 +55,44 @@ describe('formatAmount', () => { expect(formatAmount(10000, 'R$')).toBe('R$100.00'); }); - // ─── Comma separator ─────────────────────────────────────────────── + // ─── Comma profile: dot groups, comma decimal, symbol suffixed ───── - it('formats with comma separator', () => { - expect(formatAmount(200050, '$', 'comma')).toBe('$2.000,50'); + it('formats with the comma profile', () => { + expect(formatAmount(200050, '$', 'comma')).toBe(`2.000,50${NBSP}$`); }); - it('formats large amount with comma separator', () => { - expect(formatAmount(1234567, '€', 'comma')).toBe('€12.345,67'); + it('formats large amount with the comma profile', () => { + expect(formatAmount(1234567, '€', 'comma')).toBe(`12.345,67${NBSP}€`); }); - it('formats sub-dollar with comma separator', () => { - expect(formatAmount(99, '€', 'comma')).toBe('€0,99'); + it('formats sub-dollar with the comma profile', () => { + expect(formatAmount(99, '€', 'comma')).toBe(`0,99${NBSP}€`); + }); + + it('formats zero with the comma profile', () => { + expect(formatAmount(0, '$', 'comma')).toBe(`0,00${NBSP}$`); + }); + + it('keeps the sign leading when the symbol is suffixed', () => { + expect(formatAmount(-5000, '€', 'comma')).toBe(`-50,00${NBSP}€`); + }); + + // ─── Space profile: U+00A0 groups, comma decimal, symbol suffixed ── + + it('formats with the space profile', () => { + expect(formatAmount(200050, '€', 'space')).toBe(`2${NBSP}000,50${NBSP}€`); }); - it('formats zero with comma separator', () => { - expect(formatAmount(0, '$', 'comma')).toBe('$0,00'); + it('formats large amount with the space profile', () => { + expect(formatAmount(123456789, '€', 'space')).toBe(`1${NBSP}234${NBSP}567,89${NBSP}€`); + }); + + it('formats an ungrouped amount with the space profile', () => { + expect(formatAmount(99, '€', 'space')).toBe(`0,99${NBSP}€`); + }); + + it('keeps the sign leading in the space profile', () => { + expect(formatAmount(-200050, '€', 'space')).toBe(`-2${NBSP}000,50${NBSP}€`); }); }); @@ -77,8 +109,12 @@ describe('formatAmountCompact', () => { expect(formatAmountCompact(10000000)).toBe('$100.0k'); }); - it('formats with comma separator', () => { - expect(formatAmountCompact(200000, '$', 'comma')).toBe('$2,0k'); + it('formats with the comma profile', () => { + expect(formatAmountCompact(200000, '$', 'comma')).toBe(`2,0k${NBSP}$`); + }); + + it('formats with the space profile', () => { + expect(formatAmountCompact(200000, '€', 'space')).toBe(`2,0k${NBSP}€`); }); it('uses custom currency symbol', () => { @@ -103,24 +139,41 @@ describe('formatAmountWhole', () => { expect(formatAmountWhole(0)).toBe('$0'); }); - it('formats with comma separator (swaps grouping)', () => { - expect(formatAmountWhole(1234500, '€', 'comma')).toBe('€12.345'); + it('formats with the comma profile (swaps grouping, suffixes the symbol)', () => { + expect(formatAmountWhole(1234500, '€', 'comma')).toBe(`12.345${NBSP}€`); + }); + + it('formats with the space profile', () => { + expect(formatAmountWhole(1234500, '€', 'space')).toBe(`12${NBSP}345${NBSP}€`); }); it('uses custom currency symbol', () => { expect(formatAmountWhole(100000, '¥')).toBe('¥1,000'); }); + + // A negative whole amount used to render as positive: the magnitude was taken + // with Math.abs and the sign was never put back. A wrong number on screen. + it('keeps the sign on a negative amount', () => { + expect(formatAmountWhole(-200050)).toBe('-$2,001'); + expect(formatAmountWhole(-99)).toBe('-$1'); + expect(formatAmountWhole(-1234500, '€', 'space')).toBe(`-12${NBSP}345${NBSP}€`); + }); + + it('leaves zero unsigned', () => { + expect(formatAmountWhole(0)).toBe('$0'); + expect(formatAmountWhole(-0)).toBe('$0'); + }); }); // ─── Per-currency exponent (decimals: 0 | 2 | 3) ─────────────────────── describe('formatAmount — currency exponent', () => { it('renders a 0-decimal currency with no decimal part (DoD 1)', () => { - // 100000 minor units in XOF (decimals 0) is 100,000 CFA - a whole amount. - expect(formatAmount(100000, 'CFA', 'dot', 0)).toBe('CFA100,000'); + // 100000 minor units in XOF (decimals 0) is 100,000 francs - a whole amount. + expect(formatAmount(100000, 'FCFA', 'dot', 0)).toBe('FCFA100,000'); }); - it('renders a 0-decimal currency under the comma preference', () => { - expect(formatAmount(100000, 'CFA', 'comma', 0)).toBe('CFA100.000'); + it('renders a 0-decimal currency under the comma profile', () => { + expect(formatAmount(100000, 'FCFA', 'comma', 0)).toBe(`100.000${NBSP}FCFA`); }); it('renders a 2-decimal currency unchanged (DoD 2)', () => { @@ -132,18 +185,57 @@ describe('formatAmount — currency exponent', () => { expect(formatAmount(1500, 'KD', 'dot', 3)).toBe('KD1.500'); }); - it('renders a 3-decimal currency under the comma preference', () => { - expect(formatAmount(1500, 'KD', 'comma', 3)).toBe('KD1,500'); + it('renders a 3-decimal currency under the comma profile', () => { + expect(formatAmount(1500, 'KD', 'comma', 3)).toBe(`1,500${NBSP}KD`); }); it('compact form honours the exponent', () => { // XOF 100000 minor = 100,000 major -> 100.0k - expect(formatAmountCompact(100000, 'CFA', 'dot', 0)).toBe('CFA100.0k'); + expect(formatAmountCompact(100000, 'FCFA', 'dot', 0)).toBe('FCFA100.0k'); }); it('whole form honours the exponent', () => { - expect(formatAmountWhole(100000, 'CFA', 'dot', 0)).toBe('CFA100,000'); + expect(formatAmountWhole(100000, 'FCFA', 'dot', 0)).toBe('FCFA100,000'); // 1.5 dinar rounds to a whole 2. expect(formatAmountWhole(1500, 'KD', 'dot', 3)).toBe('KD2'); }); }); + +// ─── The three profiles side by side, per exponent ──────────────────── +// This is the table the registry entry was raised about. Each expected string is +// written out in full, with every space named by code point, because the defect +// being fixed WAS the spacing and the symbol placement. +describe('formatAmount — profile matrix', () => { + it('renders XOF 2000 (0 decimals) per profile', () => { + // The validated target for the app's primary market. + expect(formatAmount(2000, 'FCFA', 'space', 0)).toBe(`2${NBSP}000${NBSP}FCFA`); + expect(formatAmount(2000, 'FCFA', 'dot', 0)).toBe('FCFA2,000'); + expect(formatAmount(2000, 'FCFA', 'comma', 0)).toBe(`2.000${NBSP}FCFA`); + }); + + it('renders XOF 60000 (0 decimals) per profile', () => { + // 60,000 read by a French reader as sixty was the reported defect. + expect(formatAmount(60000, 'FCFA', 'space', 0)).toBe(`60${NBSP}000${NBSP}FCFA`); + expect(formatAmount(60000, 'FCFA', 'dot', 0)).toBe('FCFA60,000'); + expect(formatAmount(60000, 'FCFA', 'comma', 0)).toBe(`60.000${NBSP}FCFA`); + }); + + it('renders USD 200050 (2 decimals) per profile', () => { + expect(formatAmount(200050, '$', 'dot', 2)).toBe('$2,000.50'); + expect(formatAmount(200050, '$', 'comma', 2)).toBe(`2.000,50${NBSP}$`); + expect(formatAmount(200050, '$', 'space', 2)).toBe(`2${NBSP}000,50${NBSP}$`); + }); + + it('renders KWD 1234567 (3 decimals) per profile', () => { + expect(formatAmount(1234567, 'KD', 'dot', 3)).toBe('KD1,234.567'); + expect(formatAmount(1234567, 'KD', 'comma', 3)).toBe(`1.234,567${NBSP}KD`); + expect(formatAmount(1234567, 'KD', 'space', 3)).toBe(`1${NBSP}234,567${NBSP}KD`); + }); + + it('emits no gap and no symbol when the caller passes an empty symbol', () => { + // Callers that format a bare number must not receive a dangling U+00A0. + expect(formatAmount(200050, '', 'space')).toBe(`2${NBSP}000,50`); + expect(formatAmount(200050, '', 'comma')).toBe('2.000,50'); + expect(formatAmount(200050, '', 'dot')).toBe('2,000.50'); + }); +}); diff --git a/src/utils/__tests__/normalizeAmount.test.ts b/src/utils/__tests__/normalizeAmount.test.ts index fa69696..c1c0e1d 100644 --- a/src/utils/__tests__/normalizeAmount.test.ts +++ b/src/utils/__tests__/normalizeAmount.test.ts @@ -2,9 +2,17 @@ * normalizeAmount Utility Tests */ +import type { NumberFormatProfile } from '../../domain/entities/Settings'; import { formatAmount } from '../formatAmount'; import { centsToMajor, normalizeAmount, parseAmountInput, parseAndNormalizeAmount } from '../normalizeAmount'; +/** Separator characters named by code point, so no assertion below can be + * satisfied by the wrong kind of space. */ +const NBSP = '\u00A0'; +const NARROW_NBSP = '\u202F'; + +const PROFILES: NumberFormatProfile[] = ['dot', 'comma', 'space']; + describe('normalizeAmount', () => { it('converts whole dollar amount to cents', () => { expect(normalizeAmount(1500)).toBe(150000); @@ -154,7 +162,7 @@ describe('parseAmountInput', () => { }); describe('parse/format round-trip', () => { - // formatAmount prefixes a currency symbol, but the parser's contract is the + // formatAmount attaches a currency symbol, but the parser's contract is the // TextInput value, which never contains one - so format with an empty currency. const CENTS = [1250, 200050, 50, 99, 100000, 1234567]; @@ -169,6 +177,105 @@ describe('parse/format round-trip', () => { expect(parseAndNormalizeAmount(formatAmount(cents, '', 'comma'), 'comma')).toBe(cents); } }); + + it('parses back what formatAmount produces under the space preference', () => { + for (const cents of CENTS) { + expect(parseAndNormalizeAmount(formatAmount(cents, '', 'space'), 'space')).toBe(cents); + } + }); + + // The full matrix: every profile against a 0-decimal currency (XOF), a + // 2-decimal one (USD/EUR) and a 3-decimal one (KWD). The magnitudes are chosen + // so that each exponent gets both grouped and ungrouped renderings, which is + // where a grouping-character change does its damage. + it('round-trips every profile at every exponent, grouped and ungrouped', () => { + const BY_EXPONENT: { decimals: number; minor: number[] }[] = [ + // XOF: 2000 -> "2 000", 999 -> ungrouped, 1234567 -> two groups. + { decimals: 0, minor: [2000, 60000, 999, 100000, 1234567] }, + // USD/EUR: 200050 -> grouped with a fraction, 99 -> fraction only. + { decimals: 2, minor: [200050, 1234567, 99, 1250, 100000] }, + // KWD: 1234567 -> grouped with a 3-digit fraction, 1500 -> ungrouped. + { decimals: 3, minor: [1234567, 1500, 999, 200050, 100000] }, + ]; + + for (const { decimals, minor } of BY_EXPONENT) { + for (const profile of PROFILES) { + for (const value of minor) { + const rendered = formatAmount(value, '', profile, decimals); + expect(parseAndNormalizeAmount(rendered, profile, decimals)).toBe(value); + } + } + } + }); + + it('round-trips a rendering that still carries its currency symbol gap', () => { + // Not the TextInput contract, but the symbol gap is U+00A0 and trim() is + // the only whitespace handling the parser has - so prove the digits still + // come back once the symbol is stripped. + const rendered = formatAmount(2000, 'FCFA', 'space', 0); + expect(rendered).toBe(`2${NBSP}000${NBSP}FCFA`); + expect(parseAndNormalizeAmount(rendered.replace('FCFA', ''), 'space', 0)).toBe(2000); + }); +}); + +// ─── Space as a grouping character (D4) ─────────────────────────────── +describe('parseAmountInput - space grouping', () => { + // U+0020 plain, U+00A0 no-break (what the space profile emits), U+202F narrow + // no-break (what French CLDR emits, so pasted text carries it). + const SPACES: [string, string][] = [ + ['U+0020', ' '], + ['U+00A0', NBSP], + ['U+202F', NARROW_NBSP], + ]; + + for (const [name, space] of SPACES) { + it(`accepts ${name} as grouping in every profile`, () => { + for (const profile of PROFILES) { + expect(parseAmountInput(`2${space}000`, profile, 0)).toBe(2000); + expect(parseAmountInput(`1${space}234${space}567`, profile, 0)).toBe(1234567); + } + }); + + it(`accepts ${name} grouping alongside the profile's own decimal character`, () => { + expect(parseAmountInput(`2${space}000.50`, 'dot', 2)).toBe(2000.5); + expect(parseAmountInput(`2${space}000,50`, 'comma', 2)).toBe(2000.5); + expect(parseAmountInput(`2${space}000,50`, 'space', 2)).toBe(2000.5); + }); + } + + it('accepts mixed space kinds in one body', () => { + // A pasted French string next to a typed space is still one number. + expect(parseAmountInput(`1${NARROW_NBSP}234${NBSP}567`, 'space', 0)).toBe(1234567); + }); + + it('rejects a space that does not form valid groups', () => { + for (const profile of PROFILES) { + expect(parseAmountInput('12 34', profile, 2)).toBeNull(); + expect(parseAmountInput('1 23 456', profile, 2)).toBeNull(); + expect(parseAmountInput('1234 5', profile, 2)).toBeNull(); + } + }); + + it('never reads a space as a decimal separator, at any exponent', () => { + // The point of routing spaces through their own branch: at 3 decimals the + // tie-break turns a lone "1,234" into 1.234, but "1 234" must stay 1234. + for (const [, space] of SPACES) { + for (const profile of PROFILES) { + expect(parseAmountInput(`1${space}234`, profile, 3)).toBe(1234); + expect(parseAmountInput(`1${space}500`, profile, 3)).toBe(1500); + } + } + }); + + it('still enforces the currency fraction width on space-grouped input', () => { + expect(parseAmountInput(`2${NBSP}000.50`, 'dot', 0)).toBeNull(); + expect(parseAmountInput(`2${NBSP}000,505`, 'comma', 2)).toBeNull(); + }); + + it('accepts a sign in front of space-grouped input', () => { + expect(parseAmountInput(`-2${NBSP}000`, 'space', 0)).toBe(-2000); + expect(parseAmountInput(`+2${NBSP}000`, 'space', 0)).toBe(2000); + }); }); describe('centsToMajor', () => { @@ -353,11 +460,22 @@ describe('separator tie-break by decimals class', () => { it('round-trips parse(format(x)) === x, proving B1 was not over-narrowed', () => { for (const minor of [1500, 1234, 500, 1234567, 999]) { - for (const separator of ['dot', 'comma'] as const) { - const rendered = formatAmount(minor, '', separator, 3); - expect(parseAndNormalizeAmount(rendered, separator, 3)).toBe(minor); + for (const profile of PROFILES) { + const rendered = formatAmount(minor, '', profile, 3); + expect(parseAndNormalizeAmount(rendered, profile, 3)).toBe(minor); } } }); + + it('is untouched by the space profile: its own lone separator still reads as the fraction', () => { + // The space profile's decimal character is a comma, so the tie-break + // applies to it exactly as it does to the comma profile. Adding spaces + // to the parser must not have widened or narrowed this. + expect(parseAmountInput('1,234', 'space', 3)).toBe(1.234); + expect(parseAmountInput('1,000', 'space', 3)).toBe(1); + expect(parseAmountInput('0,500', 'space', 3)).toBe(0.5); + expect(parseAmountInput('123,456', 'space', 3)).toBe(123.456); + expect(parseAmountInput('1234,567', 'space', 3)).toBe(1234.567); + }); }); }); diff --git a/src/utils/formatAmount.ts b/src/utils/formatAmount.ts index f2276fa..7a64fe4 100644 --- a/src/utils/formatAmount.ts +++ b/src/utils/formatAmount.ts @@ -13,50 +13,78 @@ * Callers pass the active currency's `decimals` exponent in; these functions never * read settings themselves. Defaults to 2 for ergonomics, but production paths flow * through useFormatting, which always supplies the registry exponent. + * + * Grouping character, decimal character, currency-symbol side and symbol gap all + * come from one table - NUMBER_FORMATS in domain/constants/numberFormats. None of + * the three formatters below restates any of those four rules, so a convention is + * changed in exactly one place. Grouping is inserted here rather than delegated to + * toLocaleString: that call routes through the host ICU, whose output varies by + * device, and the app needs a byte-stable string its own parser can re-read. + */ + +import { NUMBER_FORMATS } from '../domain/constants/numberFormats'; +import type { NumberFormatProfile } from '../domain/entities/Settings'; + +/** + * Insert `group` before every run of three digits that ends on a group boundary. + * Input must be integer digits only. */ +function groupIntegerDigits(digits: string, group: string): string { + return digits.replace(/\B(?=(?:\d{3})+$)/g, group); +} -import type { DecimalSeparator } from '../data/services/settingsService'; +/** + * Render a non-negative magnitude with the profile's grouping and decimal + * characters, at a fixed number of fraction digits. + */ +function renderDigits(magnitude: number, decimals: number, profile: NumberFormatProfile): string { + const { group, decimal } = NUMBER_FORMATS[profile]; + const fixed = magnitude.toFixed(decimals); + const pointIndex = fixed.indexOf('.'); + const integerPart = pointIndex === -1 ? fixed : fixed.slice(0, pointIndex); + const fractionPart = pointIndex === -1 ? '' : fixed.slice(pointIndex + 1); + const grouped = groupIntegerDigits(integerPart, group); + return fractionPart ? `${grouped}${decimal}${fractionPart}` : grouped; +} /** - * Apply decimal separator swap if needed. - * comma format: 1,000.00 -> 1.000,00 + * Attach the currency symbol on the side the profile dictates, with the gap the + * profile dictates. + * + * An empty symbol yields the bare digits: callers that format a plain number pass + * '' and must not receive a dangling gap character. */ -function applyDecimalSeparator(formatted: string, separator: DecimalSeparator): string { - if (separator === 'comma') { - // Swap . and , by using placeholder - return formatted - .replace(/,/g, '#COMMA#') - .replace(/\./g, ',') - .replace(/#COMMA#/g, '.'); - } - return formatted; +function attachSymbol(digits: string, currency: string, profile: NumberFormatProfile): string { + if (currency === '') return digits; + const { symbolPosition, symbolGap } = NUMBER_FORMATS[profile]; + return symbolPosition === 'prefix' + ? `${currency}${symbolGap}${digits}` + : `${digits}${symbolGap}${currency}`; } /** * Format a minor-unit integer for full display. * - * @example formatAmount(200050) -> "$2,000.50" - * @example formatAmount(-50) -> "-$0.50" - * @example formatAmount(0) -> "$0.00" - * @example formatAmount(99) -> "$0.99" - * @example formatAmount(200055, '€') -> "€2,000.55" - * @example formatAmount(200050, '$', 'comma') -> "$2.000,50" - * @example formatAmount(100000, 'CFA', 'dot', 0) -> "CFA100,000" - * @example formatAmount(1500, 'KD', 'dot', 3) -> "KD1.500" + * The sign always leads, on either side of the symbol, so a negative amount reads + * as negative before anything else. + * + * @example formatAmount(200050) -> "$2,000.50" + * @example formatAmount(-50) -> "-$0.50" + * @example formatAmount(0) -> "$0.00" + * @example formatAmount(200055, 'EUR', 'comma') -> "1.234,56EUR" shape + * @example formatAmount(200000, 'FCFA', 'space', 0) -> "2000FCFA" shape + * @example formatAmount(1500, 'KD', 'dot', 3) -> "KD1.500" */ export function formatAmount( amountMinor: number, currency = '$', - separator: DecimalSeparator = 'dot', + profile: NumberFormatProfile = 'dot', decimals = 2, ): string { const major = amountMinor / 10 ** decimals; const sign = major < 0 ? '-' : ''; - const formatted = Math.abs(major).toLocaleString('en-US', { - minimumFractionDigits: decimals, - maximumFractionDigits: decimals, - }); - return `${sign}${currency}${applyDecimalSeparator(formatted, separator)}`; + const digits = renderDigits(Math.abs(major), decimals, profile); + return `${sign}${attachSymbol(digits, currency, profile)}`; } /** @@ -68,13 +96,12 @@ export function formatAmount( export function formatAmountCompact( amountMinor: number, currency = '$', - separator: DecimalSeparator = 'dot', + profile: NumberFormatProfile = 'dot', decimals = 2, ): string { const major = amountMinor / 10 ** decimals; - const compact = (major / 1000).toFixed(1); - const result = `${currency}${compact}k`; - return separator === 'comma' ? result.replace('.', ',') : result; + const compact = (major / 1000).toFixed(1).replace('.', NUMBER_FORMATS[profile].decimal); + return attachSymbol(`${compact}k`, currency, profile); } /** @@ -82,18 +109,16 @@ export function formatAmountCompact( * * @example formatAmountWhole(200050) -> "$2,001" * @example formatAmountWhole(99) -> "$1" + * @example formatAmountWhole(-99) -> "-$1" */ export function formatAmountWhole( amountMinor: number, currency = '$', - separator: DecimalSeparator = 'dot', + profile: NumberFormatProfile = 'dot', decimals = 2, ): string { const major = amountMinor / 10 ** decimals; - const formatted = Math.abs(major).toLocaleString('en-US', { - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }); - // Whole numbers only have comma grouping, swap to dot for comma separator - return `${currency}${separator === 'comma' ? formatted.replace(/,/g, '.') : formatted}`; + const sign = major < 0 ? '-' : ''; + const digits = renderDigits(Math.abs(major), 0, profile); + return `${sign}${attachSymbol(digits, currency, profile)}`; } diff --git a/src/utils/normalizeAmount.ts b/src/utils/normalizeAmount.ts index d37fc4b..1b71de4 100644 --- a/src/utils/normalizeAmount.ts +++ b/src/utils/normalizeAmount.ts @@ -11,13 +11,20 @@ * - This module is the ONLY place where input->storage conversion should happen * * Parsing is the inverse of formatAmount and must accept exactly what it emits: - * dot preference renders "2,000.50", comma preference renders "2.000,50". Callers - * pass the active separator preference AND the currency's `decimals` exponent in; - * this module never reads settings itself. `decimals` defaults to 2 for ergonomics; - * production paths flow through useFormatting, which always supplies the exponent. + * the dot profile renders "2,000.50", comma renders "2.000,50", space renders + * "2000,50". The separator characters are read from NUMBER_FORMATS rather + * than restated here, so the two sides cannot drift apart. Callers pass the active + * profile AND the currency's `decimals` exponent in; this module never reads + * settings itself. `decimals` defaults to 2 for ergonomics; production paths flow + * through useFormatting, which always supplies the exponent. */ -import type { DecimalSeparator } from '../domain/entities/Settings'; +import { + GROUPING_SPACE_CLASS, + GROUPING_SPACES_GLOBAL, + NUMBER_FORMATS, +} from '../domain/constants/numberFormats'; +import type { NumberFormatProfile } from '../domain/entities/Settings'; /** * Convert a major-unit amount to integer minor units. @@ -32,14 +39,54 @@ export function normalizeAmount(majorUnits: number, decimals = 2): number { return Math.round(majorUnits * 10 ** decimals); } -const SEPARATORS: Record< - DecimalSeparator, - { decimal: string; thousands: string; decimalRe: string; thousandsRe: string } -> = { - dot: { decimal: '.', thousands: ',', decimalRe: '\\.', thousandsRe: ',' }, - comma: { decimal: ',', thousands: '.', decimalRe: ',', thousandsRe: '\\.' }, +/** Escape a single separator character for use inside a RegExp source string. */ +function escapeForRegExp(char: string): string { + return char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +interface ProfileSeparators { + decimal: string; + thousands: string; + decimalRe: string; + thousandsRe: string; +} + +/** + * Derived from NUMBER_FORMATS rather than restated, so the parser can never drift + * from the characters the formatters emit. + */ +function separatorsFor(profile: NumberFormatProfile): ProfileSeparators { + const { group, decimal } = NUMBER_FORMATS[profile]; + return { + decimal, + thousands: group, + decimalRe: escapeForRegExp(decimal), + thousandsRe: escapeForRegExp(group), + }; +} + +const SEPARATORS: Record = { + dot: separatorsFor('dot'), + comma: separatorsFor('comma'), + space: separatorsFor('space'), }; +/** True if the body carries any character that can only have meant grouping. */ +const CONTAINS_GROUPING_SPACE = new RegExp(GROUPING_SPACE_CLASS); + +/** + * Shared tail of every parse branch: enforce the currency's fraction width, then + * convert. `normalized` must already use '.' as its decimal point and carry no + * grouping. + */ +function finishParse(sign: string, normalized: string, decimals: number): number | null { + const dotIndex = normalized.indexOf('.'); + if (dotIndex !== -1 && normalized.length - dotIndex - 1 > decimals) return null; + + const parsed = Number(`${sign}${normalized}`); + return Number.isFinite(parsed) ? parsed : null; +} + /** * Parse a user-typed amount string into major units, honouring the active decimal * separator preference. Returns null for anything that is not a single clean number, @@ -65,10 +112,11 @@ const SEPARATORS: Record< * @example parseAmountInput('1,2,3', 'dot') -> null * @example parseAmountInput('12.5', 'dot', 0) -> null (more fraction digits than allowed) * @example parseAmountInput('12.555', 'dot', 2) -> null + * @example parseAmountInput('2 000,50', 'space') -> 2000.5 (any space is grouping) */ export function parseAmountInput( input: string, - separator: DecimalSeparator = 'dot', + profile: NumberFormatProfile = 'dot', decimals = 2, ): number | null { const trimmed = input.trim(); @@ -76,7 +124,24 @@ export function parseAmountInput( const sign = /^[+-]/.test(trimmed) ? trimmed[0] : ''; const body = sign ? trimmed.slice(1) : trimmed; - const { decimal, thousands, decimalRe, thousandsRe } = SEPARATORS[separator]; + const { decimal, thousands, decimalRe, thousandsRe } = SEPARATORS[profile]; + + // A space is ALWAYS grouping. No writing convention puts a fraction after a + // space, so U+0020, U+00A0 (what the space profile emits) and U+202F (what + // French CLDR emits, so pasted text carries it) are accepted as grouping in + // EVERY profile, and resolved here - before the tie-break below can run. + // Routing spaces through this branch is what keeps the tie-break untouched: + // a space can never reach it, so it can never be read as a decimal point. + // A space that does not form valid groups is not a number at all, so this + // branch rejects rather than falling through to the character-based rules. + if (CONTAINS_GROUPING_SPACE.test(body)) { + const spaceGrouped = new RegExp( + `^\\d{1,3}(?:${GROUPING_SPACE_CLASS}\\d{3})+(?:${decimalRe}\\d*)?$`, + ); + if (!spaceGrouped.test(body)) return null; + const withoutGrouping = body.replace(GROUPING_SPACES_GLOBAL, '').split(decimal).join('.'); + return finishParse(sign, withoutGrouping, decimals); + } // Tie-break: a body with a LONE grouping separator, no decimal separator, and exactly // `decimals` digits after it reads as a DECIMAL, not as grouping. This is a deliberate @@ -101,12 +166,7 @@ export function parseAmountInput( } if (cleaned === null) return null; - // Reject more fraction digits than the currency's minor unit allows. - const dotIndex = cleaned.indexOf('.'); - if (dotIndex !== -1 && cleaned.length - dotIndex - 1 > decimals) return null; - - const parsed = Number(`${sign}${cleaned}`); - return Number.isFinite(parsed) ? parsed : null; + return finishParse(sign, cleaned, decimals); } /** @@ -123,10 +183,10 @@ export function parseAmountInput( */ export function parseAndNormalizeAmount( input: string, - separator: DecimalSeparator = 'dot', + profile: NumberFormatProfile = 'dot', decimals = 2, ): number | null { - const parsed = parseAmountInput(input, separator, decimals); + const parsed = parseAmountInput(input, profile, decimals); if (parsed === null || parsed <= 0) return null; return normalizeAmount(parsed, decimals); }