From e598950bf0a68c80591b6d06c2392b0e1cabe7d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 07:45:52 +0000 Subject: [PATCH 1/5] Initial plan From 693940f2dba2d955927d1b2b4cd3067d640e2062 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 07:51:51 +0000 Subject: [PATCH 2/5] Implement comprehensive multi-currency support Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../currency/CurrencyCalculationResult.mdx | 13 + .../data/currency/CurrencyValidation.mdx | 16 + .../data/currency/CurrencyValue.mdx | 15 + .../currency/MultiCurrencyFieldConfig.mdx | 15 + .../docs/references/data/currency/meta.json | 3 + content/docs/references/data/field/Field.mdx | 5 + content/docs/references/data/meta.json | 1 + .../references/system/currency/Currency.mdx | 17 + .../system/currency/CurrencyCode.mdx | 7 + .../system/currency/CurrencyFormatting.mdx | 15 + .../system/currency/CurrencySettings.mdx | 19 + .../system/currency/ExchangeRate.mdx | 15 + .../currency/UserCurrencyPreferences.mdx | 12 + .../docs/references/system/currency/meta.json | 3 + content/docs/references/system/meta.json | 1 + .../data/CurrencyCalculationResult.json | 102 ++++ .../json-schema/data/CurrencyValidation.json | 49 ++ .../spec/json-schema/data/CurrencyValue.json | 48 ++ packages/spec/json-schema/data/Field.json | 37 ++ .../data/MultiCurrencyFieldConfig.json | 57 +++ packages/spec/json-schema/data/Object.json | 37 ++ .../spec/json-schema/system/Currency.json | 60 +++ .../spec/json-schema/system/CurrencyCode.json | 13 + .../system/CurrencyFormatting.json | 58 +++ .../json-schema/system/CurrencySettings.json | 229 +++++++++ .../spec/json-schema/system/ExchangeRate.json | 57 +++ .../system/UserCurrencyPreferences.json | 80 +++ .../spec/json-schema/ui/FieldWidgetProps.json | 37 ++ packages/spec/src/data/currency.test.ts | 363 ++++++++++++++ packages/spec/src/data/currency.zod.ts | 232 +++++++++ packages/spec/src/data/field.zod.ts | 8 + packages/spec/src/data/index.ts | 1 + packages/spec/src/system/currency.test.ts | 469 ++++++++++++++++++ packages/spec/src/system/currency.zod.ts | 299 +++++++++++ packages/spec/src/system/index.ts | 1 + 35 files changed, 2394 insertions(+) create mode 100644 content/docs/references/data/currency/CurrencyCalculationResult.mdx create mode 100644 content/docs/references/data/currency/CurrencyValidation.mdx create mode 100644 content/docs/references/data/currency/CurrencyValue.mdx create mode 100644 content/docs/references/data/currency/MultiCurrencyFieldConfig.mdx create mode 100644 content/docs/references/data/currency/meta.json create mode 100644 content/docs/references/system/currency/Currency.mdx create mode 100644 content/docs/references/system/currency/CurrencyCode.mdx create mode 100644 content/docs/references/system/currency/CurrencyFormatting.mdx create mode 100644 content/docs/references/system/currency/CurrencySettings.mdx create mode 100644 content/docs/references/system/currency/ExchangeRate.mdx create mode 100644 content/docs/references/system/currency/UserCurrencyPreferences.mdx create mode 100644 content/docs/references/system/currency/meta.json create mode 100644 packages/spec/json-schema/data/CurrencyCalculationResult.json create mode 100644 packages/spec/json-schema/data/CurrencyValidation.json create mode 100644 packages/spec/json-schema/data/CurrencyValue.json create mode 100644 packages/spec/json-schema/data/MultiCurrencyFieldConfig.json create mode 100644 packages/spec/json-schema/system/Currency.json create mode 100644 packages/spec/json-schema/system/CurrencyCode.json create mode 100644 packages/spec/json-schema/system/CurrencyFormatting.json create mode 100644 packages/spec/json-schema/system/CurrencySettings.json create mode 100644 packages/spec/json-schema/system/ExchangeRate.json create mode 100644 packages/spec/json-schema/system/UserCurrencyPreferences.json create mode 100644 packages/spec/src/data/currency.test.ts create mode 100644 packages/spec/src/data/currency.zod.ts create mode 100644 packages/spec/src/system/currency.test.ts create mode 100644 packages/spec/src/system/currency.zod.ts diff --git a/content/docs/references/data/currency/CurrencyCalculationResult.mdx b/content/docs/references/data/currency/CurrencyCalculationResult.mdx new file mode 100644 index 0000000000..f79e4cd756 --- /dev/null +++ b/content/docs/references/data/currency/CurrencyCalculationResult.mdx @@ -0,0 +1,13 @@ +--- +title: CurrencyCalculationResult +description: CurrencyCalculationResult Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **value** | `object` | ✅ | Calculated currency value | +| **crossCurrency** | `boolean` | ✅ | Whether cross-currency calculation was performed | +| **sources** | `object[]` | optional | Source values | +| **method** | `Enum<'sum' \| 'average' \| 'min' \| 'max' \| 'count'>` | optional | Calculation method | diff --git a/content/docs/references/data/currency/CurrencyValidation.mdx b/content/docs/references/data/currency/CurrencyValidation.mdx new file mode 100644 index 0000000000..ab8b180f2a --- /dev/null +++ b/content/docs/references/data/currency/CurrencyValidation.mdx @@ -0,0 +1,16 @@ +--- +title: CurrencyValidation +description: CurrencyValidation Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **minAmount** | `number` | optional | Minimum amount | +| **maxAmount** | `number` | optional | Maximum amount | +| **minConvertedAmount** | `number` | optional | Minimum amount in corporate currency | +| **maxConvertedAmount** | `number` | optional | Maximum amount in corporate currency | +| **allowedCurrencies** | `string[]` | optional | Allowed currency codes | +| **positiveOnly** | `boolean` | optional | Require positive amounts only | +| **nonZero** | `boolean` | optional | Require non-zero amounts | diff --git a/content/docs/references/data/currency/CurrencyValue.mdx b/content/docs/references/data/currency/CurrencyValue.mdx new file mode 100644 index 0000000000..daf18344df --- /dev/null +++ b/content/docs/references/data/currency/CurrencyValue.mdx @@ -0,0 +1,15 @@ +--- +title: CurrencyValue +description: CurrencyValue Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **amount** | `number` | ✅ | Numeric amount | +| **currency** | `string` | ✅ | Currency code (ISO 4217) | +| **convertedAmount** | `number` | optional | Converted amount in corporate currency | +| **convertedCurrency** | `string` | optional | Corporate currency code | +| **exchangeRate** | `number` | optional | Exchange rate used for conversion | +| **conversionDate** | `string` | optional | When the conversion was performed | diff --git a/content/docs/references/data/currency/MultiCurrencyFieldConfig.mdx b/content/docs/references/data/currency/MultiCurrencyFieldConfig.mdx new file mode 100644 index 0000000000..05017a0c16 --- /dev/null +++ b/content/docs/references/data/currency/MultiCurrencyFieldConfig.mdx @@ -0,0 +1,15 @@ +--- +title: MultiCurrencyFieldConfig +description: MultiCurrencyFieldConfig Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **defaultCurrency** | `string` | optional | Default currency for new records | +| **alwaysStoreConverted** | `boolean` | optional | Always store converted values | +| **allowCurrencyChange** | `boolean` | optional | Allow changing currency after creation | +| **restrictedCurrencies** | `string[]` | optional | Restrict to specific currencies | +| **showConversion** | `boolean` | optional | Show conversion information in UI | +| **roundingMode** | `Enum<'half_up' \| 'half_down' \| 'half_even' \| 'up' \| 'down'>` | optional | Rounding mode for calculations | diff --git a/content/docs/references/data/currency/meta.json b/content/docs/references/data/currency/meta.json new file mode 100644 index 0000000000..dc745482b8 --- /dev/null +++ b/content/docs/references/data/currency/meta.json @@ -0,0 +1,3 @@ +{ + "title": "Currency" +} \ No newline at end of file diff --git a/content/docs/references/data/field/Field.mdx b/content/docs/references/data/field/Field.mdx index 375140cb3d..307f96c219 100644 --- a/content/docs/references/data/field/Field.mdx +++ b/content/docs/references/data/field/Field.mdx @@ -49,6 +49,11 @@ description: Field Schema Reference | **qrErrorCorrection** | `Enum<'L' \| 'M' \| 'Q' \| 'H'>` | optional | QR code error correction level (L=7%, M=15%, Q=25%, H=30%). Only applicable when barcodeFormat is "qr" | | **displayValue** | `boolean` | optional | Display human-readable value below barcode/QR code | | **allowScanning** | `boolean` | optional | Enable camera scanning for barcode/QR code input | +| **currencyCode** | `string` | optional | Default currency code for this field (ISO 4217) | +| **allowCurrencyChange** | `boolean` | optional | Allow users to change currency on records (multi-currency only) | +| **restrictedCurrencies** | `string[]` | optional | Restrict field to specific currencies (multi-currency only) | +| **showConversion** | `boolean` | optional | Display conversion to corporate/user currency (multi-currency only) | +| **roundingMode** | `Enum<'half_up' \| 'half_down' \| 'half_even' \| 'up' \| 'down'>` | optional | Rounding mode for currency calculations | | **hidden** | `boolean` | optional | Hidden from default UI | | **readonly** | `boolean` | optional | Read-only in UI | | **encryption** | `boolean` | optional | Encrypt at rest | diff --git a/content/docs/references/data/meta.json b/content/docs/references/data/meta.json index a29ba933cb..0be99818cf 100644 --- a/content/docs/references/data/meta.json +++ b/content/docs/references/data/meta.json @@ -2,6 +2,7 @@ "title": "Data Protocol", "root": true, "pages": [ + "currency", "dataset", "field", "filter", diff --git a/content/docs/references/system/currency/Currency.mdx b/content/docs/references/system/currency/Currency.mdx new file mode 100644 index 0000000000..0fea521f96 --- /dev/null +++ b/content/docs/references/system/currency/Currency.mdx @@ -0,0 +1,17 @@ +--- +title: Currency +description: Currency Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **code** | `string` | ✅ | ISO 4217 currency code | +| **name** | `string` | ✅ | Currency name (e.g., US Dollar, Euro) | +| **symbol** | `string` | ✅ | Currency symbol (e.g., $, €, £, ¥) | +| **decimalPlaces** | `integer` | optional | Number of decimal places | +| **isActive** | `boolean` | optional | Whether this currency is active | +| **isCorporate** | `boolean` | optional | Whether this is the corporate/base currency | +| **conversionRate** | `number` | optional | Conversion rate to corporate currency | +| **conversionRateUpdatedAt** | `string` | optional | Last conversion rate update timestamp | diff --git a/content/docs/references/system/currency/CurrencyCode.mdx b/content/docs/references/system/currency/CurrencyCode.mdx new file mode 100644 index 0000000000..97679df9de --- /dev/null +++ b/content/docs/references/system/currency/CurrencyCode.mdx @@ -0,0 +1,7 @@ +--- +title: CurrencyCode +description: ISO 4217 currency code (e.g., USD, EUR, GBP) +--- + +ISO 4217 currency code (e.g., USD, EUR, GBP) + diff --git a/content/docs/references/system/currency/CurrencyFormatting.mdx b/content/docs/references/system/currency/CurrencyFormatting.mdx new file mode 100644 index 0000000000..b0e63112a5 --- /dev/null +++ b/content/docs/references/system/currency/CurrencyFormatting.mdx @@ -0,0 +1,15 @@ +--- +title: CurrencyFormatting +description: CurrencyFormatting Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **format** | `Enum<'symbol' \| 'code' \| 'name' \| 'symbol_code'>` | optional | Currency display format | +| **locale** | `string` | optional | Locale for number formatting (e.g., en-US, fr-FR, zh-CN) | +| **showSymbol** | `boolean` | optional | Show currency symbol | +| **showCode** | `boolean` | optional | Show currency code | +| **decimalSeparator** | `Enum<'.' \| ','>` | optional | Decimal separator | +| **thousandsSeparator** | `Enum<',' \| '.' \| ' ' \| ''>` | optional | Thousands separator | diff --git a/content/docs/references/system/currency/CurrencySettings.mdx b/content/docs/references/system/currency/CurrencySettings.mdx new file mode 100644 index 0000000000..802439a76a --- /dev/null +++ b/content/docs/references/system/currency/CurrencySettings.mdx @@ -0,0 +1,19 @@ +--- +title: CurrencySettings +description: CurrencySettings Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **enabled** | `boolean` | optional | Enable multi-currency support | +| **corporateCurrency** | `string` | ✅ | Corporate/base currency code | +| **activeCurrencies** | `object[]` | ✅ | Active currencies | +| **defaultFormatting** | `object` | optional | Default currency formatting | +| **allowUserCurrency** | `boolean` | optional | Allow users to select preferred currency | +| **enableAutoConversion** | `boolean` | optional | Enable automatic currency conversion | +| **enableDatedExchangeRates** | `boolean` | optional | Enable dated exchange rates | +| **exchangeRates** | `object[]` | optional | Historical exchange rates | +| **aggregationStrategy** | `Enum<'convert_to_corporate' \| 'convert_to_user' \| 'group_by_currency'>` | optional | Currency conversion strategy for aggregations | +| **sortingStrategy** | `Enum<'convert_to_corporate' \| 'convert_to_user' \| 'native_value'>` | optional | Currency conversion strategy for sorting | diff --git a/content/docs/references/system/currency/ExchangeRate.mdx b/content/docs/references/system/currency/ExchangeRate.mdx new file mode 100644 index 0000000000..35b7d1e83a --- /dev/null +++ b/content/docs/references/system/currency/ExchangeRate.mdx @@ -0,0 +1,15 @@ +--- +title: ExchangeRate +description: ExchangeRate Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **fromCurrency** | `string` | ✅ | Source currency code | +| **toCurrency** | `string` | ✅ | Target currency code | +| **rate** | `number` | ✅ | Exchange rate | +| **effectiveDate** | `string` | ✅ | Effective start date for this rate | +| **expiryDate** | `string` | optional | Expiry date for this rate | +| **source** | `Enum<'manual' \| 'api' \| 'system'>` | optional | Exchange rate source | diff --git a/content/docs/references/system/currency/UserCurrencyPreferences.mdx b/content/docs/references/system/currency/UserCurrencyPreferences.mdx new file mode 100644 index 0000000000..0b4acb8291 --- /dev/null +++ b/content/docs/references/system/currency/UserCurrencyPreferences.mdx @@ -0,0 +1,12 @@ +--- +title: UserCurrencyPreferences +description: UserCurrencyPreferences Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **preferredCurrency** | `string` | ✅ | Preferred display currency | +| **formatting** | `object` | optional | User-specific formatting preferences | +| **alwaysConvert** | `boolean` | optional | Always convert to preferred currency | diff --git a/content/docs/references/system/currency/meta.json b/content/docs/references/system/currency/meta.json new file mode 100644 index 0000000000..dc745482b8 --- /dev/null +++ b/content/docs/references/system/currency/meta.json @@ -0,0 +1,3 @@ +{ + "title": "Currency" +} \ No newline at end of file diff --git a/content/docs/references/system/meta.json b/content/docs/references/system/meta.json index 660a69097d..9326ed9202 100644 --- a/content/docs/references/system/meta.json +++ b/content/docs/references/system/meta.json @@ -5,6 +5,7 @@ "api", "audit", "auth", + "currency", "datasource", "discovery", "driver", diff --git a/packages/spec/json-schema/data/CurrencyCalculationResult.json b/packages/spec/json-schema/data/CurrencyCalculationResult.json new file mode 100644 index 0000000000..4a3b01c09c --- /dev/null +++ b/packages/spec/json-schema/data/CurrencyCalculationResult.json @@ -0,0 +1,102 @@ +{ + "$ref": "#/definitions/CurrencyCalculationResult", + "definitions": { + "CurrencyCalculationResult": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "description": "Numeric amount" + }, + "currency": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "Currency code (ISO 4217)" + }, + "convertedAmount": { + "type": "number", + "description": "Converted amount in corporate currency" + }, + "convertedCurrency": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "Corporate currency code" + }, + "exchangeRate": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Exchange rate used for conversion" + }, + "conversionDate": { + "type": "string", + "format": "date-time", + "description": "When the conversion was performed" + } + }, + "required": [ + "amount", + "currency" + ], + "additionalProperties": false, + "description": "Calculated currency value" + }, + "crossCurrency": { + "type": "boolean", + "description": "Whether cross-currency calculation was performed" + }, + "sources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "currency": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "ISO 4217 currency code (e.g., USD, EUR, GBP)" + }, + "weight": { + "type": "number", + "description": "Weight/contribution to final result" + } + }, + "required": [ + "amount", + "currency" + ], + "additionalProperties": false + }, + "description": "Source values" + }, + "method": { + "type": "string", + "enum": [ + "sum", + "average", + "min", + "max", + "count" + ], + "description": "Calculation method" + } + }, + "required": [ + "value", + "crossCurrency" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/data/CurrencyValidation.json b/packages/spec/json-schema/data/CurrencyValidation.json new file mode 100644 index 0000000000..a252807146 --- /dev/null +++ b/packages/spec/json-schema/data/CurrencyValidation.json @@ -0,0 +1,49 @@ +{ + "$ref": "#/definitions/CurrencyValidation", + "definitions": { + "CurrencyValidation": { + "type": "object", + "properties": { + "minAmount": { + "type": "number", + "description": "Minimum amount" + }, + "maxAmount": { + "type": "number", + "description": "Maximum amount" + }, + "minConvertedAmount": { + "type": "number", + "description": "Minimum amount in corporate currency" + }, + "maxConvertedAmount": { + "type": "number", + "description": "Maximum amount in corporate currency" + }, + "allowedCurrencies": { + "type": "array", + "items": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "ISO 4217 currency code (e.g., USD, EUR, GBP)" + }, + "description": "Allowed currency codes" + }, + "positiveOnly": { + "type": "boolean", + "default": false, + "description": "Require positive amounts only" + }, + "nonZero": { + "type": "boolean", + "default": false, + "description": "Require non-zero amounts" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/data/CurrencyValue.json b/packages/spec/json-schema/data/CurrencyValue.json new file mode 100644 index 0000000000..b0f242470c --- /dev/null +++ b/packages/spec/json-schema/data/CurrencyValue.json @@ -0,0 +1,48 @@ +{ + "$ref": "#/definitions/CurrencyValue", + "definitions": { + "CurrencyValue": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "description": "Numeric amount" + }, + "currency": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "Currency code (ISO 4217)" + }, + "convertedAmount": { + "type": "number", + "description": "Converted amount in corporate currency" + }, + "convertedCurrency": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "Corporate currency code" + }, + "exchangeRate": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Exchange rate used for conversion" + }, + "conversionDate": { + "type": "string", + "format": "date-time", + "description": "When the conversion was performed" + } + }, + "required": [ + "amount", + "currency" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/data/Field.json b/packages/spec/json-schema/data/Field.json index 820239e14e..71f01845d8 100644 --- a/packages/spec/json-schema/data/Field.json +++ b/packages/spec/json-schema/data/Field.json @@ -303,6 +303,43 @@ "type": "boolean", "description": "Enable camera scanning for barcode/QR code input" }, + "currencyCode": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "Default currency code for this field (ISO 4217)" + }, + "allowCurrencyChange": { + "type": "boolean", + "description": "Allow users to change currency on records (multi-currency only)" + }, + "restrictedCurrencies": { + "type": "array", + "items": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "ISO 4217 currency code (e.g., USD, EUR, GBP)" + }, + "description": "Restrict field to specific currencies (multi-currency only)" + }, + "showConversion": { + "type": "boolean", + "description": "Display conversion to corporate/user currency (multi-currency only)" + }, + "roundingMode": { + "type": "string", + "enum": [ + "half_up", + "half_down", + "half_even", + "up", + "down" + ], + "description": "Rounding mode for currency calculations" + }, "hidden": { "type": "boolean", "default": false, diff --git a/packages/spec/json-schema/data/MultiCurrencyFieldConfig.json b/packages/spec/json-schema/data/MultiCurrencyFieldConfig.json new file mode 100644 index 0000000000..dabdb4518e --- /dev/null +++ b/packages/spec/json-schema/data/MultiCurrencyFieldConfig.json @@ -0,0 +1,57 @@ +{ + "$ref": "#/definitions/MultiCurrencyFieldConfig", + "definitions": { + "MultiCurrencyFieldConfig": { + "type": "object", + "properties": { + "defaultCurrency": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "Default currency for new records" + }, + "alwaysStoreConverted": { + "type": "boolean", + "default": true, + "description": "Always store converted values" + }, + "allowCurrencyChange": { + "type": "boolean", + "default": true, + "description": "Allow changing currency after creation" + }, + "restrictedCurrencies": { + "type": "array", + "items": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "ISO 4217 currency code (e.g., USD, EUR, GBP)" + }, + "description": "Restrict to specific currencies" + }, + "showConversion": { + "type": "boolean", + "default": false, + "description": "Show conversion information in UI" + }, + "roundingMode": { + "type": "string", + "enum": [ + "half_up", + "half_down", + "half_even", + "up", + "down" + ], + "default": "half_up", + "description": "Rounding mode for calculations" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/data/Object.json b/packages/spec/json-schema/data/Object.json index ac50cb15ae..95a35418bd 100644 --- a/packages/spec/json-schema/data/Object.json +++ b/packages/spec/json-schema/data/Object.json @@ -343,6 +343,43 @@ "type": "boolean", "description": "Enable camera scanning for barcode/QR code input" }, + "currencyCode": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "Default currency code for this field (ISO 4217)" + }, + "allowCurrencyChange": { + "type": "boolean", + "description": "Allow users to change currency on records (multi-currency only)" + }, + "restrictedCurrencies": { + "type": "array", + "items": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "ISO 4217 currency code (e.g., USD, EUR, GBP)" + }, + "description": "Restrict field to specific currencies (multi-currency only)" + }, + "showConversion": { + "type": "boolean", + "description": "Display conversion to corporate/user currency (multi-currency only)" + }, + "roundingMode": { + "type": "string", + "enum": [ + "half_up", + "half_down", + "half_even", + "up", + "down" + ], + "description": "Rounding mode for currency calculations" + }, "hidden": { "type": "boolean", "default": false, diff --git a/packages/spec/json-schema/system/Currency.json b/packages/spec/json-schema/system/Currency.json new file mode 100644 index 0000000000..7006b6635d --- /dev/null +++ b/packages/spec/json-schema/system/Currency.json @@ -0,0 +1,60 @@ +{ + "$ref": "#/definitions/Currency", + "definitions": { + "Currency": { + "type": "object", + "properties": { + "code": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "ISO 4217 currency code" + }, + "name": { + "type": "string", + "description": "Currency name (e.g., US Dollar, Euro)" + }, + "symbol": { + "type": "string", + "description": "Currency symbol (e.g., $, €, £, ¥)" + }, + "decimalPlaces": { + "type": "integer", + "minimum": 0, + "maximum": 4, + "default": 2, + "description": "Number of decimal places" + }, + "isActive": { + "type": "boolean", + "default": true, + "description": "Whether this currency is active" + }, + "isCorporate": { + "type": "boolean", + "default": false, + "description": "Whether this is the corporate/base currency" + }, + "conversionRate": { + "type": "number", + "exclusiveMinimum": 0, + "default": 1, + "description": "Conversion rate to corporate currency" + }, + "conversionRateUpdatedAt": { + "type": "string", + "format": "date-time", + "description": "Last conversion rate update timestamp" + } + }, + "required": [ + "code", + "name", + "symbol" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/CurrencyCode.json b/packages/spec/json-schema/system/CurrencyCode.json new file mode 100644 index 0000000000..2469bd1578 --- /dev/null +++ b/packages/spec/json-schema/system/CurrencyCode.json @@ -0,0 +1,13 @@ +{ + "$ref": "#/definitions/CurrencyCode", + "definitions": { + "CurrencyCode": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "ISO 4217 currency code (e.g., USD, EUR, GBP)" + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/CurrencyFormatting.json b/packages/spec/json-schema/system/CurrencyFormatting.json new file mode 100644 index 0000000000..060036f9e4 --- /dev/null +++ b/packages/spec/json-schema/system/CurrencyFormatting.json @@ -0,0 +1,58 @@ +{ + "$ref": "#/definitions/CurrencyFormatting", + "definitions": { + "CurrencyFormatting": { + "type": "object", + "properties": { + "format": { + "type": "string", + "enum": [ + "symbol", + "code", + "name", + "symbol_code" + ], + "default": "symbol", + "description": "Currency display format" + }, + "locale": { + "type": "string", + "default": "en-US", + "description": "Locale for number formatting (e.g., en-US, fr-FR, zh-CN)" + }, + "showSymbol": { + "type": "boolean", + "default": true, + "description": "Show currency symbol" + }, + "showCode": { + "type": "boolean", + "default": false, + "description": "Show currency code" + }, + "decimalSeparator": { + "type": "string", + "enum": [ + ".", + "," + ], + "default": ".", + "description": "Decimal separator" + }, + "thousandsSeparator": { + "type": "string", + "enum": [ + ",", + ".", + " ", + "" + ], + "default": ",", + "description": "Thousands separator" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/CurrencySettings.json b/packages/spec/json-schema/system/CurrencySettings.json new file mode 100644 index 0000000000..f458596230 --- /dev/null +++ b/packages/spec/json-schema/system/CurrencySettings.json @@ -0,0 +1,229 @@ +{ + "$ref": "#/definitions/CurrencySettings", + "definitions": { + "CurrencySettings": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable multi-currency support" + }, + "corporateCurrency": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "Corporate/base currency code" + }, + "activeCurrencies": { + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "ISO 4217 currency code" + }, + "name": { + "type": "string", + "description": "Currency name (e.g., US Dollar, Euro)" + }, + "symbol": { + "type": "string", + "description": "Currency symbol (e.g., $, €, £, ¥)" + }, + "decimalPlaces": { + "type": "integer", + "minimum": 0, + "maximum": 4, + "default": 2, + "description": "Number of decimal places" + }, + "isActive": { + "type": "boolean", + "default": true, + "description": "Whether this currency is active" + }, + "isCorporate": { + "type": "boolean", + "default": false, + "description": "Whether this is the corporate/base currency" + }, + "conversionRate": { + "type": "number", + "exclusiveMinimum": 0, + "default": 1, + "description": "Conversion rate to corporate currency" + }, + "conversionRateUpdatedAt": { + "type": "string", + "format": "date-time", + "description": "Last conversion rate update timestamp" + } + }, + "required": [ + "code", + "name", + "symbol" + ], + "additionalProperties": false + }, + "description": "Active currencies" + }, + "defaultFormatting": { + "type": "object", + "properties": { + "format": { + "type": "string", + "enum": [ + "symbol", + "code", + "name", + "symbol_code" + ], + "default": "symbol", + "description": "Currency display format" + }, + "locale": { + "type": "string", + "default": "en-US", + "description": "Locale for number formatting (e.g., en-US, fr-FR, zh-CN)" + }, + "showSymbol": { + "type": "boolean", + "default": true, + "description": "Show currency symbol" + }, + "showCode": { + "type": "boolean", + "default": false, + "description": "Show currency code" + }, + "decimalSeparator": { + "type": "string", + "enum": [ + ".", + "," + ], + "default": ".", + "description": "Decimal separator" + }, + "thousandsSeparator": { + "type": "string", + "enum": [ + ",", + ".", + " ", + "" + ], + "default": ",", + "description": "Thousands separator" + } + }, + "additionalProperties": false, + "description": "Default currency formatting" + }, + "allowUserCurrency": { + "type": "boolean", + "default": true, + "description": "Allow users to select preferred currency" + }, + "enableAutoConversion": { + "type": "boolean", + "default": true, + "description": "Enable automatic currency conversion" + }, + "enableDatedExchangeRates": { + "type": "boolean", + "default": false, + "description": "Enable dated exchange rates" + }, + "exchangeRates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "fromCurrency": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "Source currency code" + }, + "toCurrency": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "Target currency code" + }, + "rate": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Exchange rate" + }, + "effectiveDate": { + "type": "string", + "format": "date-time", + "description": "Effective start date for this rate" + }, + "expiryDate": { + "type": "string", + "format": "date-time", + "description": "Expiry date for this rate" + }, + "source": { + "type": "string", + "enum": [ + "manual", + "api", + "system" + ], + "default": "manual", + "description": "Exchange rate source" + } + }, + "required": [ + "fromCurrency", + "toCurrency", + "rate", + "effectiveDate" + ], + "additionalProperties": false + }, + "description": "Historical exchange rates" + }, + "aggregationStrategy": { + "type": "string", + "enum": [ + "convert_to_corporate", + "convert_to_user", + "group_by_currency" + ], + "default": "convert_to_corporate", + "description": "Currency conversion strategy for aggregations" + }, + "sortingStrategy": { + "type": "string", + "enum": [ + "convert_to_corporate", + "convert_to_user", + "native_value" + ], + "default": "convert_to_corporate", + "description": "Currency conversion strategy for sorting" + } + }, + "required": [ + "corporateCurrency", + "activeCurrencies" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/ExchangeRate.json b/packages/spec/json-schema/system/ExchangeRate.json new file mode 100644 index 0000000000..cb40954338 --- /dev/null +++ b/packages/spec/json-schema/system/ExchangeRate.json @@ -0,0 +1,57 @@ +{ + "$ref": "#/definitions/ExchangeRate", + "definitions": { + "ExchangeRate": { + "type": "object", + "properties": { + "fromCurrency": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "Source currency code" + }, + "toCurrency": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "Target currency code" + }, + "rate": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Exchange rate" + }, + "effectiveDate": { + "type": "string", + "format": "date-time", + "description": "Effective start date for this rate" + }, + "expiryDate": { + "type": "string", + "format": "date-time", + "description": "Expiry date for this rate" + }, + "source": { + "type": "string", + "enum": [ + "manual", + "api", + "system" + ], + "default": "manual", + "description": "Exchange rate source" + } + }, + "required": [ + "fromCurrency", + "toCurrency", + "rate", + "effectiveDate" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/UserCurrencyPreferences.json b/packages/spec/json-schema/system/UserCurrencyPreferences.json new file mode 100644 index 0000000000..f1b0c895e2 --- /dev/null +++ b/packages/spec/json-schema/system/UserCurrencyPreferences.json @@ -0,0 +1,80 @@ +{ + "$ref": "#/definitions/UserCurrencyPreferences", + "definitions": { + "UserCurrencyPreferences": { + "type": "object", + "properties": { + "preferredCurrency": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "Preferred display currency" + }, + "formatting": { + "type": "object", + "properties": { + "format": { + "type": "string", + "enum": [ + "symbol", + "code", + "name", + "symbol_code" + ], + "default": "symbol", + "description": "Currency display format" + }, + "locale": { + "type": "string", + "default": "en-US", + "description": "Locale for number formatting (e.g., en-US, fr-FR, zh-CN)" + }, + "showSymbol": { + "type": "boolean", + "default": true, + "description": "Show currency symbol" + }, + "showCode": { + "type": "boolean", + "default": false, + "description": "Show currency code" + }, + "decimalSeparator": { + "type": "string", + "enum": [ + ".", + "," + ], + "default": ".", + "description": "Decimal separator" + }, + "thousandsSeparator": { + "type": "string", + "enum": [ + ",", + ".", + " ", + "" + ], + "default": ",", + "description": "Thousands separator" + } + }, + "additionalProperties": false, + "description": "User-specific formatting preferences" + }, + "alwaysConvert": { + "type": "boolean", + "default": false, + "description": "Always convert to preferred currency" + } + }, + "required": [ + "preferredCurrency" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/ui/FieldWidgetProps.json b/packages/spec/json-schema/ui/FieldWidgetProps.json index 926c21d92e..596af94101 100644 --- a/packages/spec/json-schema/ui/FieldWidgetProps.json +++ b/packages/spec/json-schema/ui/FieldWidgetProps.json @@ -323,6 +323,43 @@ "type": "boolean", "description": "Enable camera scanning for barcode/QR code input" }, + "currencyCode": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "Default currency code for this field (ISO 4217)" + }, + "allowCurrencyChange": { + "type": "boolean", + "description": "Allow users to change currency on records (multi-currency only)" + }, + "restrictedCurrencies": { + "type": "array", + "items": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "ISO 4217 currency code (e.g., USD, EUR, GBP)" + }, + "description": "Restrict field to specific currencies (multi-currency only)" + }, + "showConversion": { + "type": "boolean", + "description": "Display conversion to corporate/user currency (multi-currency only)" + }, + "roundingMode": { + "type": "string", + "enum": [ + "half_up", + "half_down", + "half_even", + "up", + "down" + ], + "description": "Rounding mode for currency calculations" + }, "hidden": { "type": "boolean", "default": false, diff --git a/packages/spec/src/data/currency.test.ts b/packages/spec/src/data/currency.test.ts new file mode 100644 index 0000000000..d55c90ec59 --- /dev/null +++ b/packages/spec/src/data/currency.test.ts @@ -0,0 +1,363 @@ +import { describe, it, expect } from 'vitest'; +import { + CurrencyValueSchema, + MultiCurrencyFieldConfigSchema, + CurrencyCalculationResultSchema, + CurrencyValidationSchema, + CurrencyValue, + type CurrencyValue as CurrencyValueType, + type MultiCurrencyFieldConfig, + type CurrencyCalculationResult, + type CurrencyValidation, +} from './currency.zod'; + +describe('CurrencyValueSchema', () => { + it('should accept simple currency value', () => { + const value: CurrencyValueType = { + amount: 1234.56, + currency: 'USD', + }; + + expect(() => CurrencyValueSchema.parse(value)).not.toThrow(); + }); + + it('should accept currency value with conversion', () => { + const value: CurrencyValueType = { + amount: 1000.00, + currency: 'EUR', + convertedAmount: 1176.47, + convertedCurrency: 'USD', + exchangeRate: 1.17647, + conversionDate: new Date('2024-01-15'), + }; + + expect(() => CurrencyValueSchema.parse(value)).not.toThrow(); + }); + + it('should handle negative amounts', () => { + const value: CurrencyValueType = { + amount: -500.00, + currency: 'GBP', + }; + + expect(() => CurrencyValueSchema.parse(value)).not.toThrow(); + }); + + it('should handle zero amounts', () => { + const value: CurrencyValueType = { + amount: 0, + currency: 'JPY', + }; + + expect(() => CurrencyValueSchema.parse(value)).not.toThrow(); + }); + + it('should store exchange rate for audit trail', () => { + const value: CurrencyValueType = { + amount: 100.00, + currency: 'EUR', + convertedAmount: 85.00, + convertedCurrency: 'GBP', + exchangeRate: 0.85, + conversionDate: new Date(), + }; + + const result = CurrencyValueSchema.parse(value); + expect(result.exchangeRate).toBe(0.85); + expect(result.conversionDate).toBeInstanceOf(Date); + }); +}); + +describe('MultiCurrencyFieldConfigSchema', () => { + it('should accept valid field configuration', () => { + const config: MultiCurrencyFieldConfig = { + defaultCurrency: 'USD', + alwaysStoreConverted: true, + allowCurrencyChange: false, + restrictedCurrencies: ['USD', 'EUR', 'GBP'], + showConversion: true, + roundingMode: 'half_up', + }; + + expect(() => MultiCurrencyFieldConfigSchema.parse(config)).not.toThrow(); + }); + + it('should apply default values', () => { + const minimal = {}; + + const result = MultiCurrencyFieldConfigSchema.parse(minimal); + expect(result.alwaysStoreConverted).toBe(true); + expect(result.allowCurrencyChange).toBe(true); + expect(result.showConversion).toBe(false); + expect(result.roundingMode).toBe('half_up'); + }); + + it('should support currency restrictions', () => { + const config = { + restrictedCurrencies: ['USD', 'EUR'], + }; + + const result = MultiCurrencyFieldConfigSchema.parse(config); + expect(result.restrictedCurrencies).toEqual(['USD', 'EUR']); + }); + + it('should support different rounding modes', () => { + const modes = ['half_up', 'half_down', 'half_even', 'up', 'down'] as const; + + modes.forEach(roundingMode => { + const config = { roundingMode }; + expect(() => MultiCurrencyFieldConfigSchema.parse(config)).not.toThrow(); + }); + }); + + it('should allow locking currency after creation', () => { + const config = { + allowCurrencyChange: false, + }; + + const result = MultiCurrencyFieldConfigSchema.parse(config); + expect(result.allowCurrencyChange).toBe(false); + }); +}); + +describe('CurrencyCalculationResultSchema', () => { + it('should accept calculation result', () => { + const result: CurrencyCalculationResult = { + value: { + amount: 5000.00, + currency: 'USD', + }, + crossCurrency: false, + method: 'sum', + }; + + expect(() => CurrencyCalculationResultSchema.parse(result)).not.toThrow(); + }); + + it('should support cross-currency calculations', () => { + const result: CurrencyCalculationResult = { + value: { + amount: 5000.00, + currency: 'USD', + convertedAmount: 5000.00, + convertedCurrency: 'USD', + }, + crossCurrency: true, + sources: [ + { amount: 1000.00, currency: 'USD' }, + { amount: 2000.00, currency: 'EUR', weight: 1.18 }, + { amount: 1500.00, currency: 'GBP', weight: 1.37 }, + ], + method: 'sum', + }; + + expect(() => CurrencyCalculationResultSchema.parse(result)).not.toThrow(); + }); + + it('should support different calculation methods', () => { + const methods = ['sum', 'average', 'min', 'max', 'count'] as const; + + methods.forEach(method => { + const result = { + value: { amount: 100, currency: 'USD' }, + crossCurrency: false, + method, + }; + + expect(() => CurrencyCalculationResultSchema.parse(result)).not.toThrow(); + }); + }); + + it('should track source values in aggregations', () => { + const result: CurrencyCalculationResult = { + value: { amount: 150.00, currency: 'USD' }, + crossCurrency: true, + sources: [ + { amount: 100.00, currency: 'USD', weight: 1.0 }, + { amount: 50.00, currency: 'EUR', weight: 1.18 }, + ], + method: 'sum', + }; + + const parsed = CurrencyCalculationResultSchema.parse(result); + expect(parsed.sources).toHaveLength(2); + expect(parsed.crossCurrency).toBe(true); + }); +}); + +describe('CurrencyValidationSchema', () => { + it('should accept validation rules', () => { + const validation: CurrencyValidation = { + minAmount: 0, + maxAmount: 1000000, + minConvertedAmount: 0, + maxConvertedAmount: 1000000, + allowedCurrencies: ['USD', 'EUR', 'GBP'], + positiveOnly: true, + nonZero: false, + }; + + expect(() => CurrencyValidationSchema.parse(validation)).not.toThrow(); + }); + + it('should support min/max amount validation', () => { + const validation = { + minAmount: 100, + maxAmount: 10000, + }; + + const result = CurrencyValidationSchema.parse(validation); + expect(result.minAmount).toBe(100); + expect(result.maxAmount).toBe(10000); + }); + + it('should support corporate currency limits', () => { + const validation = { + minConvertedAmount: 1000, + maxConvertedAmount: 50000, + }; + + const result = CurrencyValidationSchema.parse(validation); + expect(result.minConvertedAmount).toBe(1000); + expect(result.maxConvertedAmount).toBe(50000); + }); + + it('should support currency restrictions', () => { + const validation = { + allowedCurrencies: ['USD', 'CAD'], + }; + + const result = CurrencyValidationSchema.parse(validation); + expect(result.allowedCurrencies).toEqual(['USD', 'CAD']); + }); + + it('should support positive-only constraint', () => { + const validation = { + positiveOnly: true, + }; + + const result = CurrencyValidationSchema.parse(validation); + expect(result.positiveOnly).toBe(true); + }); + + it('should support non-zero constraint', () => { + const validation = { + nonZero: true, + }; + + const result = CurrencyValidationSchema.parse(validation); + expect(result.nonZero).toBe(true); + }); +}); + +describe('CurrencyValue Helper', () => { + it('should create simple currency value', () => { + const value = CurrencyValue.create(1234.56, 'USD'); + + expect(value.amount).toBe(1234.56); + expect(value.currency).toBe('USD'); + expect(value.convertedAmount).toBeUndefined(); + }); + + it('should create currency value with conversion', () => { + const value = CurrencyValue.createWithConversion( + 1000.00, + 'EUR', + 1176.47, + 'USD', + 1.17647 + ); + + expect(value.amount).toBe(1000.00); + expect(value.currency).toBe('EUR'); + expect(value.convertedAmount).toBe(1176.47); + expect(value.convertedCurrency).toBe('USD'); + expect(value.exchangeRate).toBe(1.17647); + expect(value.conversionDate).toBeInstanceOf(Date); + }); + + it('should create zero value', () => { + const usdZero = CurrencyValue.zero('USD'); + const defaultZero = CurrencyValue.zero(); + + expect(usdZero.amount).toBe(0); + expect(usdZero.currency).toBe('USD'); + + expect(defaultZero.amount).toBe(0); + expect(defaultZero.currency).toBe('USD'); + }); +}); + +describe('Multi-Currency Data Layer Scenarios', () => { + it('should support storing opportunity amount in EUR', () => { + const opportunityAmount = CurrencyValue.createWithConversion( + 50000.00, + 'EUR', + 58823.53, + 'USD', + 1.176471 + ); + + expect(() => CurrencyValueSchema.parse(opportunityAmount)).not.toThrow(); + }); + + it('should support field with restricted currencies', () => { + const fieldConfig: MultiCurrencyFieldConfig = { + defaultCurrency: 'USD', + restrictedCurrencies: ['USD', 'CAD'], + allowCurrencyChange: false, + showConversion: true, + }; + + expect(() => MultiCurrencyFieldConfigSchema.parse(fieldConfig)).not.toThrow(); + }); + + it('should support cross-currency sum calculation', () => { + const sumResult: CurrencyCalculationResult = { + value: { + amount: 10000.00, + currency: 'USD', + }, + crossCurrency: true, + sources: [ + { amount: 5000.00, currency: 'USD' }, + { amount: 3000.00, currency: 'EUR', weight: 1.18 }, + { amount: 2000.00, currency: 'GBP', weight: 1.37 }, + ], + method: 'sum', + }; + + expect(() => CurrencyCalculationResultSchema.parse(sumResult)).not.toThrow(); + }); + + it('should enforce minimum deal size in corporate currency', () => { + const validation: CurrencyValidation = { + minConvertedAmount: 10000, // $10,000 USD minimum + positiveOnly: true, + nonZero: true, + }; + + expect(() => CurrencyValidationSchema.parse(validation)).not.toThrow(); + }); + + it('should support Japanese Yen with zero decimal places', () => { + const jpyValue = CurrencyValue.create(150000, 'JPY'); + + expect(() => CurrencyValueSchema.parse(jpyValue)).not.toThrow(); + expect(jpyValue.amount).toBe(150000); + expect(jpyValue.currency).toBe('JPY'); + }); + + it('should support historical conversion rates', () => { + const historicalValue: CurrencyValueType = { + amount: 1000.00, + currency: 'EUR', + convertedAmount: 1100.00, + convertedCurrency: 'USD', + exchangeRate: 1.10, + conversionDate: new Date('2023-01-15'), + }; + + expect(() => CurrencyValueSchema.parse(historicalValue)).not.toThrow(); + }); +}); diff --git a/packages/spec/src/data/currency.zod.ts b/packages/spec/src/data/currency.zod.ts new file mode 100644 index 0000000000..d065f4aaf6 --- /dev/null +++ b/packages/spec/src/data/currency.zod.ts @@ -0,0 +1,232 @@ +import { z } from 'zod'; +import { CurrencyCodeSchema } from '../system/currency.zod'; + +/** + * Currency Value Schema (Data Layer) + * + * Defines how currency values are stored in the data layer. + * This is a compound value object that stores both the amount and currency code. + * + * Similar to Salesforce's approach, we store: + * - The numeric value + * - The currency ISO code + * - Optionally, the converted value in corporate currency + * + * Storage Format Examples: + * - Simple: { amount: 1234.56, currency: 'USD' } + * - With conversion: { amount: 1234.56, currency: 'EUR', convertedAmount: 1450.88, convertedCurrency: 'USD' } + */ + +/** + * Currency Value Schema + * Value object for currency field type + */ +export const CurrencyValueSchema = z.object({ + /** + * The numeric amount + */ + amount: z.number().describe('Numeric amount'), + + /** + * Currency code for this value + */ + currency: CurrencyCodeSchema.describe('Currency code (ISO 4217)'), + + /** + * Converted amount in corporate/base currency + * Automatically calculated based on exchange rates + */ + convertedAmount: z.number().optional().describe('Converted amount in corporate currency'), + + /** + * Corporate currency code + * The currency that convertedAmount is expressed in + */ + convertedCurrency: CurrencyCodeSchema.optional().describe('Corporate currency code'), + + /** + * Exchange rate used for conversion + * Stored for audit trail and historical accuracy + */ + exchangeRate: z.number().positive().optional().describe('Exchange rate used for conversion'), + + /** + * When the conversion was performed + * Important for dated exchange rates + */ + conversionDate: z.date().optional().describe('When the conversion was performed'), +}); + +export type CurrencyValue = z.infer; + +/** + * Multi-Currency Field Configuration Schema + * Additional configuration for currency fields when multi-currency is enabled + */ +export const MultiCurrencyFieldConfigSchema = z.object({ + /** + * Default currency code for new records + * If not specified, uses user's preferred currency or organization's corporate currency + */ + defaultCurrency: CurrencyCodeSchema.optional().describe('Default currency for new records'), + + /** + * Whether to always store converted values + * If true, convertedAmount is always calculated and stored + * If false, conversion is done on-demand + */ + alwaysStoreConverted: z.boolean().default(true).describe('Always store converted values'), + + /** + * Whether users can change the currency on existing records + * If false, currency is locked after record creation + */ + allowCurrencyChange: z.boolean().default(true).describe('Allow changing currency after creation'), + + /** + * Restricted currencies for this field + * If specified, only these currencies can be used + * If empty/undefined, all active organization currencies are allowed + */ + restrictedCurrencies: z.array(CurrencyCodeSchema).optional().describe('Restrict to specific currencies'), + + /** + * Whether to show conversion information in the UI + */ + showConversion: z.boolean().default(false).describe('Show conversion information in UI'), + + /** + * Rounding mode for calculations + */ + roundingMode: z.enum([ + 'half_up', // Round half away from zero (default) + 'half_down', // Round half toward zero + 'half_even', // Round half to nearest even (banker's rounding) + 'up', // Always round away from zero + 'down', // Always round toward zero (truncate) + ]).default('half_up').describe('Rounding mode for calculations'), +}); + +export type MultiCurrencyFieldConfig = z.infer; + +/** + * Currency Calculation Result Schema + * Result of currency calculations (for formulas, rollups, etc.) + */ +export const CurrencyCalculationResultSchema = z.object({ + /** + * Calculated value + */ + value: CurrencyValueSchema.describe('Calculated currency value'), + + /** + * Whether this result involved cross-currency calculations + */ + crossCurrency: z.boolean().describe('Whether cross-currency calculation was performed'), + + /** + * Source values that contributed to this calculation + */ + sources: z.array(z.object({ + amount: z.number(), + currency: CurrencyCodeSchema, + weight: z.number().optional().describe('Weight/contribution to final result'), + })).optional().describe('Source values'), + + /** + * Calculation method used + */ + method: z.enum([ + 'sum', + 'average', + 'min', + 'max', + 'count', + ]).optional().describe('Calculation method'), +}); + +export type CurrencyCalculationResult = z.infer; + +/** + * Currency Field Validation Rules + * Additional validation rules specific to currency fields + */ +export const CurrencyValidationSchema = z.object({ + /** + * Minimum amount (in any currency) + */ + minAmount: z.number().optional().describe('Minimum amount'), + + /** + * Maximum amount (in any currency) + */ + maxAmount: z.number().optional().describe('Maximum amount'), + + /** + * Minimum amount in corporate currency + * Useful for cross-currency validation (e.g., all deals must be > $10,000 USD equivalent) + */ + minConvertedAmount: z.number().optional().describe('Minimum amount in corporate currency'), + + /** + * Maximum amount in corporate currency + */ + maxConvertedAmount: z.number().optional().describe('Maximum amount in corporate currency'), + + /** + * Allowed currencies + * Validates that the currency code is in this list + */ + allowedCurrencies: z.array(CurrencyCodeSchema).optional().describe('Allowed currency codes'), + + /** + * Require positive amounts only + */ + positiveOnly: z.boolean().default(false).describe('Require positive amounts only'), + + /** + * Require non-zero amounts + */ + nonZero: z.boolean().default(false).describe('Require non-zero amounts'), +}); + +export type CurrencyValidation = z.infer; + +/** + * Currency Helper Functions + */ +export const CurrencyValue = { + /** + * Create a simple currency value + */ + create: (amount: number, currency: string): CurrencyValue => ({ + amount, + currency, + }), + + /** + * Create a currency value with conversion + */ + createWithConversion: ( + amount: number, + currency: string, + convertedAmount: number, + convertedCurrency: string, + exchangeRate: number, + ): CurrencyValue => ({ + amount, + currency, + convertedAmount, + convertedCurrency, + exchangeRate, + conversionDate: new Date(), + }), + + /** + * Zero value in specified currency + */ + zero: (currency: string = 'USD'): CurrencyValue => ({ + amount: 0, + currency, + }), +}; diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 8abd1ddaad..7619eb7360 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { CurrencyCodeSchema } from '../system/currency.zod'; /** * Field Type Enum @@ -151,6 +152,13 @@ export const FieldSchema = z.object({ qrErrorCorrection: z.enum(['L', 'M', 'Q', 'H']).optional().describe('QR code error correction level (L=7%, M=15%, Q=25%, H=30%). Only applicable when barcodeFormat is "qr"'), displayValue: z.boolean().optional().describe('Display human-readable value below barcode/QR code'), allowScanning: z.boolean().optional().describe('Enable camera scanning for barcode/QR code input'), + + // Currency field config (Multi-Currency Support) + currencyCode: CurrencyCodeSchema.optional().describe('Default currency code for this field (ISO 4217)'), + allowCurrencyChange: z.boolean().optional().describe('Allow users to change currency on records (multi-currency only)'), + restrictedCurrencies: z.array(CurrencyCodeSchema).optional().describe('Restrict field to specific currencies (multi-currency only)'), + showConversion: z.boolean().optional().describe('Display conversion to corporate/user currency (multi-currency only)'), + roundingMode: z.enum(['half_up', 'half_down', 'half_even', 'up', 'down']).optional().describe('Rounding mode for currency calculations'), /** Security & Visibility */ hidden: z.boolean().default(false).describe('Hidden from default UI'), diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index 4fcc54f33b..5ae2daf7cc 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -19,3 +19,4 @@ export * from './query.zod'; export * from './filter.zod'; export * from './mapping.zod'; export * from './trigger.zod'; +export * from './currency.zod'; diff --git a/packages/spec/src/system/currency.test.ts b/packages/spec/src/system/currency.test.ts new file mode 100644 index 0000000000..a1f767a473 --- /dev/null +++ b/packages/spec/src/system/currency.test.ts @@ -0,0 +1,469 @@ +import { describe, it, expect } from 'vitest'; +import { + CurrencyCodeSchema, + CurrencySchema, + ExchangeRateSchema, + CurrencyFormattingSchema, + CurrencySettingsSchema, + UserCurrencyPreferencesSchema, + Currency, + type Currency as CurrencyType, + type ExchangeRate, + type CurrencySettings, +} from './currency.zod'; + +describe('CurrencyCodeSchema', () => { + it('should accept valid ISO 4217 currency codes', () => { + const validCodes = ['USD', 'EUR', 'GBP', 'JPY', 'CNY', 'AUD', 'CAD', 'CHF', 'INR']; + + validCodes.forEach(code => { + expect(() => CurrencyCodeSchema.parse(code)).not.toThrow(); + }); + }); + + it('should convert lowercase to uppercase', () => { + const result = CurrencyCodeSchema.parse('usd'); + expect(result).toBe('USD'); + }); + + it('should reject invalid currency codes', () => { + expect(() => CurrencyCodeSchema.parse('US')).toThrow(); // Too short + expect(() => CurrencyCodeSchema.parse('USDA')).toThrow(); // Too long + expect(() => CurrencyCodeSchema.parse('U$D')).toThrow(); // Invalid characters + expect(() => CurrencyCodeSchema.parse('123')).toThrow(); // Numbers + }); +}); + +describe('CurrencySchema', () => { + it('should accept valid currency definition', () => { + const currency: CurrencyType = { + code: 'USD', + name: 'US Dollar', + symbol: '$', + decimalPlaces: 2, + isActive: true, + isCorporate: false, + conversionRate: 1.0, + }; + + expect(() => CurrencySchema.parse(currency)).not.toThrow(); + }); + + it('should apply default values', () => { + const minimal = { + code: 'EUR', + name: 'Euro', + symbol: '€', + }; + + const result = CurrencySchema.parse(minimal); + expect(result.decimalPlaces).toBe(2); + expect(result.isActive).toBe(true); + expect(result.isCorporate).toBe(false); + expect(result.conversionRate).toBe(1.0); + }); + + it('should support currencies with 0 decimal places (like JPY)', () => { + const jpy: CurrencyType = { + code: 'JPY', + name: 'Japanese Yen', + symbol: '¥', + decimalPlaces: 0, + isActive: true, + isCorporate: false, + conversionRate: 110.25, + }; + + expect(() => CurrencySchema.parse(jpy)).not.toThrow(); + }); + + it('should support corporate currency flag', () => { + const corporate: CurrencyType = { + code: 'USD', + name: 'US Dollar', + symbol: '$', + isCorporate: true, + conversionRate: 1.0, + }; + + const result = CurrencySchema.parse(corporate); + expect(result.isCorporate).toBe(true); + expect(result.conversionRate).toBe(1.0); + }); + + it('should include conversion rate metadata', () => { + const currency: CurrencyType = { + code: 'GBP', + name: 'British Pound', + symbol: '£', + conversionRate: 0.73, + conversionRateUpdatedAt: new Date('2024-01-15'), + }; + + const result = CurrencySchema.parse(currency); + expect(result.conversionRate).toBe(0.73); + expect(result.conversionRateUpdatedAt).toBeInstanceOf(Date); + }); +}); + +describe('ExchangeRateSchema', () => { + it('should accept valid exchange rate', () => { + const rate: ExchangeRate = { + fromCurrency: 'USD', + toCurrency: 'EUR', + rate: 0.85, + effectiveDate: new Date('2024-01-01'), + source: 'api', + }; + + expect(() => ExchangeRateSchema.parse(rate)).not.toThrow(); + }); + + it('should support expiry dates for historical rates', () => { + const rate: ExchangeRate = { + fromCurrency: 'USD', + toCurrency: 'GBP', + rate: 0.73, + effectiveDate: new Date('2024-01-01'), + expiryDate: new Date('2024-01-31'), + source: 'manual', + }; + + const result = ExchangeRateSchema.parse(rate); + expect(result.expiryDate).toBeInstanceOf(Date); + }); + + it('should enforce positive exchange rates', () => { + const invalidRate = { + fromCurrency: 'USD', + toCurrency: 'EUR', + rate: -0.85, + effectiveDate: new Date(), + }; + + expect(() => ExchangeRateSchema.parse(invalidRate)).toThrow(); + }); + + it('should support different rate sources', () => { + const sources = ['manual', 'api', 'system'] as const; + + sources.forEach(source => { + const rate = { + fromCurrency: 'USD', + toCurrency: 'EUR', + rate: 0.85, + effectiveDate: new Date(), + source, + }; + + expect(() => ExchangeRateSchema.parse(rate)).not.toThrow(); + }); + }); +}); + +describe('CurrencyFormattingSchema', () => { + it('should accept valid formatting options', () => { + const formatting = { + format: 'symbol', + locale: 'en-US', + showSymbol: true, + showCode: false, + decimalSeparator: '.', + thousandsSeparator: ',', + }; + + expect(() => CurrencyFormattingSchema.parse(formatting)).not.toThrow(); + }); + + it('should apply default values', () => { + const result = CurrencyFormattingSchema.parse({}); + + expect(result.format).toBe('symbol'); + expect(result.locale).toBe('en-US'); + expect(result.showSymbol).toBe(true); + expect(result.showCode).toBe(false); + expect(result.decimalSeparator).toBe('.'); + expect(result.thousandsSeparator).toBe(','); + }); + + it('should support different display formats', () => { + const formats = ['symbol', 'code', 'name', 'symbol_code'] as const; + + formats.forEach(format => { + const config = { format }; + expect(() => CurrencyFormattingSchema.parse(config)).not.toThrow(); + }); + }); + + it('should support different locales', () => { + const locales = ['en-US', 'fr-FR', 'de-DE', 'zh-CN', 'ja-JP']; + + locales.forEach(locale => { + const config = { locale }; + expect(() => CurrencyFormattingSchema.parse(config)).not.toThrow(); + }); + }); + + it('should support European number formatting', () => { + const europeanFormat = { + decimalSeparator: ',', + thousandsSeparator: '.', + }; + + const result = CurrencyFormattingSchema.parse(europeanFormat); + expect(result.decimalSeparator).toBe(','); + expect(result.thousandsSeparator).toBe('.'); + }); +}); + +describe('CurrencySettingsSchema', () => { + it('should accept valid organization currency settings', () => { + const settings: CurrencySettings = { + enabled: true, + corporateCurrency: 'USD', + activeCurrencies: [ + { + code: 'USD', + name: 'US Dollar', + symbol: '$', + decimalPlaces: 2, + isActive: true, + isCorporate: true, + conversionRate: 1.0, + }, + { + code: 'EUR', + name: 'Euro', + symbol: '€', + decimalPlaces: 2, + isActive: true, + isCorporate: false, + conversionRate: 0.85, + }, + ], + allowUserCurrency: true, + enableAutoConversion: true, + aggregationStrategy: 'convert_to_corporate', + sortingStrategy: 'convert_to_corporate', + }; + + expect(() => CurrencySettingsSchema.parse(settings)).not.toThrow(); + }); + + it('should default to multi-currency disabled', () => { + const minimal = { + corporateCurrency: 'USD', + activeCurrencies: [Currency.USD()], + }; + + const result = CurrencySettingsSchema.parse(minimal); + expect(result.enabled).toBe(false); + }); + + it('should support dated exchange rates', () => { + const settings: CurrencySettings = { + enabled: true, + corporateCurrency: 'USD', + activeCurrencies: [Currency.USD()], + enableDatedExchangeRates: true, + exchangeRates: [ + { + fromCurrency: 'USD', + toCurrency: 'EUR', + rate: 0.85, + effectiveDate: new Date('2024-01-01'), + source: 'api', + }, + ], + }; + + expect(() => CurrencySettingsSchema.parse(settings)).not.toThrow(); + }); + + it('should support different aggregation strategies', () => { + const strategies = ['convert_to_corporate', 'convert_to_user', 'group_by_currency'] as const; + + strategies.forEach(aggregationStrategy => { + const settings = { + corporateCurrency: 'USD', + activeCurrencies: [Currency.USD()], + aggregationStrategy, + }; + + expect(() => CurrencySettingsSchema.parse(settings)).not.toThrow(); + }); + }); + + it('should support different sorting strategies', () => { + const strategies = ['convert_to_corporate', 'convert_to_user', 'native_value'] as const; + + strategies.forEach(sortingStrategy => { + const settings = { + corporateCurrency: 'USD', + activeCurrencies: [Currency.USD()], + sortingStrategy, + }; + + expect(() => CurrencySettingsSchema.parse(settings)).not.toThrow(); + }); + }); +}); + +describe('UserCurrencyPreferencesSchema', () => { + it('should accept valid user preferences', () => { + const preferences = { + preferredCurrency: 'EUR', + alwaysConvert: true, + formatting: { + format: 'symbol_code', + locale: 'fr-FR', + }, + }; + + expect(() => UserCurrencyPreferencesSchema.parse(preferences)).not.toThrow(); + }); + + it('should default alwaysConvert to false', () => { + const minimal = { + preferredCurrency: 'USD', + }; + + const result = UserCurrencyPreferencesSchema.parse(minimal); + expect(result.alwaysConvert).toBe(false); + }); +}); + +describe('Currency Helper', () => { + it('should create currency definitions with helper', () => { + const usd = Currency.USD(); + + expect(usd.code).toBe('USD'); + expect(usd.name).toBe('US Dollar'); + expect(usd.symbol).toBe('$'); + expect(usd.isCorporate).toBe(true); + expect(usd.decimalPlaces).toBe(2); + }); + + it('should provide common currency helpers', () => { + const currencies = [ + Currency.USD(), + Currency.EUR(), + Currency.GBP(), + Currency.JPY(), + Currency.CNY(), + Currency.AUD(), + Currency.CAD(), + Currency.CHF(), + Currency.INR(), + Currency.BRL(), + ]; + + currencies.forEach(currency => { + expect(currency.code).toBeTruthy(); + expect(currency.name).toBeTruthy(); + expect(currency.symbol).toBeTruthy(); + expect(() => CurrencySchema.parse(currency)).not.toThrow(); + }); + }); + + it('should support custom currency with options', () => { + const custom = Currency.define('XYZ', 'Custom Currency', 'X', { + decimalPlaces: 3, + isActive: false, + conversionRate: 1.5, + }); + + expect(custom.code).toBe('XYZ'); + expect(custom.decimalPlaces).toBe(3); + expect(custom.isActive).toBe(false); + expect(custom.conversionRate).toBe(1.5); + }); + + it('should handle JPY with 0 decimal places', () => { + const jpy = Currency.JPY(); + + expect(jpy.decimalPlaces).toBe(0); + expect(() => CurrencySchema.parse(jpy)).not.toThrow(); + }); +}); + +describe('Multi-Currency Integration Scenarios', () => { + it('should support a complete multi-currency setup', () => { + const settings: CurrencySettings = { + enabled: true, + corporateCurrency: 'USD', + activeCurrencies: [ + Currency.USD(), + { ...Currency.EUR(), conversionRate: 0.85 }, + { ...Currency.GBP(), conversionRate: 0.73 }, + { ...Currency.JPY(), conversionRate: 110.25 }, + ], + defaultFormatting: { + format: 'symbol', + locale: 'en-US', + }, + allowUserCurrency: true, + enableAutoConversion: true, + enableDatedExchangeRates: false, + aggregationStrategy: 'convert_to_corporate', + sortingStrategy: 'convert_to_corporate', + }; + + expect(() => CurrencySettingsSchema.parse(settings)).not.toThrow(); + }); + + it('should support dated exchange rates for historical reporting', () => { + const settings: CurrencySettings = { + enabled: true, + corporateCurrency: 'USD', + activeCurrencies: [Currency.USD(), Currency.EUR()], + enableDatedExchangeRates: true, + exchangeRates: [ + { + fromCurrency: 'USD', + toCurrency: 'EUR', + rate: 0.85, + effectiveDate: new Date('2024-01-01'), + expiryDate: new Date('2024-01-31'), + source: 'api', + }, + { + fromCurrency: 'USD', + toCurrency: 'EUR', + rate: 0.87, + effectiveDate: new Date('2024-02-01'), + source: 'api', + }, + ], + }; + + expect(() => CurrencySettingsSchema.parse(settings)).not.toThrow(); + }); + + it('should support per-user currency preferences', () => { + const orgSettings: CurrencySettings = { + enabled: true, + corporateCurrency: 'USD', + activeCurrencies: [ + Currency.USD(), + Currency.EUR(), + Currency.GBP(), + ], + allowUserCurrency: true, + }; + + const userPreferences = { + preferredCurrency: 'EUR', + alwaysConvert: true, + formatting: { + format: 'symbol_code', + locale: 'fr-FR', + decimalSeparator: ',', + thousandsSeparator: '.', + }, + }; + + expect(() => CurrencySettingsSchema.parse(orgSettings)).not.toThrow(); + expect(() => UserCurrencyPreferencesSchema.parse(userPreferences)).not.toThrow(); + }); +}); diff --git a/packages/spec/src/system/currency.zod.ts b/packages/spec/src/system/currency.zod.ts new file mode 100644 index 0000000000..eaaf576ff6 --- /dev/null +++ b/packages/spec/src/system/currency.zod.ts @@ -0,0 +1,299 @@ +import { z } from 'zod'; + +/** + * Currency Schema (Multi-Currency Support) + * + * Implements a Salesforce-like multi-currency system for international business scenarios. + * Supports multiple currencies within a single organization with automatic conversion, + * proper formatting, and handling of exchange rates. + * + * Key Features: + * - Organization can enable multiple currencies + * - Each user/record can have a default currency + * - Automatic currency conversion based on exchange rates + * - Proper sorting and aggregation across currencies + * - ISO 4217 standard currency codes + */ + +/** + * Currency Code Type + * ISO 4217 three-letter currency codes + * + * Common examples: USD, EUR, GBP, JPY, CNY, AUD, CAD, CHF, INR, etc. + * Full list: https://www.iso.org/iso-4217-currency-codes.html + */ +export const CurrencyCodeSchema = z.string() + .length(3) + .toUpperCase() + .regex(/^[A-Z]{3}$/) + .describe('ISO 4217 currency code (e.g., USD, EUR, GBP)'); + +export type CurrencyCode = z.infer; + +/** + * Currency Definition Schema + * Defines properties of a specific currency + */ +export const CurrencySchema = z.object({ + /** + * ISO 4217 currency code + */ + code: CurrencyCodeSchema.describe('ISO 4217 currency code'), + + /** + * Human-readable currency name + */ + name: z.string().describe('Currency name (e.g., US Dollar, Euro)'), + + /** + * Currency symbol + */ + symbol: z.string().describe('Currency symbol (e.g., $, €, £, ¥)'), + + /** + * Number of decimal places for this currency + * Most currencies use 2, but some like JPY use 0, and some use 3 + */ + decimalPlaces: z.number().int().min(0).max(4).default(2).describe('Number of decimal places'), + + /** + * Whether this currency is active in the organization + */ + isActive: z.boolean().default(true).describe('Whether this currency is active'), + + /** + * Whether this is the corporate/base currency + * Only one currency can be the corporate currency + */ + isCorporate: z.boolean().default(false).describe('Whether this is the corporate/base currency'), + + /** + * Conversion rate to the corporate currency + * For the corporate currency itself, this should be 1.0 + * For other currencies, this represents: 1 unit of this currency = X units of corporate currency + */ + conversionRate: z.number().positive().default(1.0).describe('Conversion rate to corporate currency'), + + /** + * When the conversion rate was last updated + */ + conversionRateUpdatedAt: z.date().optional().describe('Last conversion rate update timestamp'), +}); + +export type Currency = z.infer; + +/** + * Exchange Rate Schema + * Defines conversion rate between two currencies + * Useful for maintaining dated exchange rates or manual overrides + */ +export const ExchangeRateSchema = z.object({ + /** + * Source currency code + */ + fromCurrency: CurrencyCodeSchema.describe('Source currency code'), + + /** + * Target currency code + */ + toCurrency: CurrencyCodeSchema.describe('Target currency code'), + + /** + * Exchange rate: 1 unit of fromCurrency = rate units of toCurrency + */ + rate: z.number().positive().describe('Exchange rate'), + + /** + * Date when this exchange rate is effective from + */ + effectiveDate: z.date().describe('Effective start date for this rate'), + + /** + * Date when this exchange rate expires + */ + expiryDate: z.date().optional().describe('Expiry date for this rate'), + + /** + * Source of the exchange rate (manual, api, etc.) + */ + source: z.enum(['manual', 'api', 'system']).default('manual').describe('Exchange rate source'), +}); + +export type ExchangeRate = z.infer; + +/** + * Currency Formatting Options Schema + * Defines how currencies should be displayed + */ +export const CurrencyFormattingSchema = z.object({ + /** + * Display format for currency + */ + format: z.enum([ + 'symbol', // $1,234.56 + 'code', // USD 1,234.56 + 'name', // 1,234.56 US Dollars + 'symbol_code', // $1,234.56 USD + ]).default('symbol').describe('Currency display format'), + + /** + * Locale for number formatting + * Uses Intl.NumberFormat compatible locale strings + */ + locale: z.string().default('en-US').describe('Locale for number formatting (e.g., en-US, fr-FR, zh-CN)'), + + /** + * Whether to show currency symbol + */ + showSymbol: z.boolean().default(true).describe('Show currency symbol'), + + /** + * Whether to show currency code + */ + showCode: z.boolean().default(false).describe('Show currency code'), + + /** + * Decimal separator + */ + decimalSeparator: z.enum(['.', ',']).default('.').describe('Decimal separator'), + + /** + * Thousands separator + */ + thousandsSeparator: z.enum([',', '.', ' ', '']).default(',').describe('Thousands separator'), +}); + +export type CurrencyFormatting = z.infer; + +/** + * Organization Currency Settings Schema + * Organization-wide multi-currency configuration + */ +export const CurrencySettingsSchema = z.object({ + /** + * Whether multi-currency is enabled for the organization + */ + enabled: z.boolean().default(false).describe('Enable multi-currency support'), + + /** + * Corporate/base currency code + * All conversions are relative to this currency + */ + corporateCurrency: CurrencyCodeSchema.describe('Corporate/base currency code'), + + /** + * List of active currencies in the organization + */ + activeCurrencies: z.array(CurrencySchema).describe('Active currencies'), + + /** + * Default formatting options + */ + defaultFormatting: CurrencyFormattingSchema.optional().describe('Default currency formatting'), + + /** + * Whether to allow users to select their preferred currency + */ + allowUserCurrency: z.boolean().default(true).describe('Allow users to select preferred currency'), + + /** + * Whether to enable automatic currency conversion in reports/dashboards + */ + enableAutoConversion: z.boolean().default(true).describe('Enable automatic currency conversion'), + + /** + * Whether to enable dated exchange rates + * If false, only current exchange rates are used + * If true, historical exchange rates are maintained + */ + enableDatedExchangeRates: z.boolean().default(false).describe('Enable dated exchange rates'), + + /** + * Historical exchange rates + * Only used when enableDatedExchangeRates is true + */ + exchangeRates: z.array(ExchangeRateSchema).optional().describe('Historical exchange rates'), + + /** + * How to handle currency conversion in aggregations + */ + aggregationStrategy: z.enum([ + 'convert_to_corporate', // Convert all values to corporate currency before aggregating + 'convert_to_user', // Convert all values to user's currency before aggregating + 'group_by_currency', // Group results by currency (no conversion) + ]).default('convert_to_corporate').describe('Currency conversion strategy for aggregations'), + + /** + * How to handle currency in sorting + */ + sortingStrategy: z.enum([ + 'convert_to_corporate', // Convert to corporate currency for sorting + 'convert_to_user', // Convert to user's currency for sorting + 'native_value', // Sort by raw value (ignore currency) + ]).default('convert_to_corporate').describe('Currency conversion strategy for sorting'), +}); + +export type CurrencySettings = z.infer; + +/** + * User Currency Preferences Schema + * User-specific currency settings + */ +export const UserCurrencyPreferencesSchema = z.object({ + /** + * User's preferred display currency + */ + preferredCurrency: CurrencyCodeSchema.describe('Preferred display currency'), + + /** + * Formatting preferences + */ + formatting: CurrencyFormattingSchema.optional().describe('User-specific formatting preferences'), + + /** + * Whether to always show values in preferred currency + * If false, values are shown in their native currency + */ + alwaysConvert: z.boolean().default(false).describe('Always convert to preferred currency'), +}); + +export type UserCurrencyPreferences = z.infer; + +/** + * Currency Helper - Factory Functions + * + * Helper functions to create common currency configurations + */ +export const Currency = { + /** + * Create a currency definition + */ + define: (code: string, name: string, symbol: string, options?: { + decimalPlaces?: number; + isActive?: boolean; + isCorporate?: boolean; + conversionRate?: number; + }) => ({ + code, + name, + symbol, + decimalPlaces: options?.decimalPlaces ?? 2, + isActive: options?.isActive ?? true, + isCorporate: options?.isCorporate ?? false, + conversionRate: options?.conversionRate ?? 1.0, + }), + + /** + * Common currency definitions + */ + USD: () => Currency.define('USD', 'US Dollar', '$', { isCorporate: true }), + EUR: () => Currency.define('EUR', 'Euro', '€'), + GBP: () => Currency.define('GBP', 'British Pound', '£'), + JPY: () => Currency.define('JPY', 'Japanese Yen', '¥', { decimalPlaces: 0 }), + CNY: () => Currency.define('CNY', 'Chinese Yuan', '¥'), + AUD: () => Currency.define('AUD', 'Australian Dollar', 'A$'), + CAD: () => Currency.define('CAD', 'Canadian Dollar', 'C$'), + CHF: () => Currency.define('CHF', 'Swiss Franc', 'CHF'), + INR: () => Currency.define('INR', 'Indian Rupee', '₹'), + BRL: () => Currency.define('BRL', 'Brazilian Real', 'R$'), +}; diff --git a/packages/spec/src/system/index.ts b/packages/spec/src/system/index.ts index bbf8892759..78abf4aaa0 100644 --- a/packages/spec/src/system/index.ts +++ b/packages/spec/src/system/index.ts @@ -23,6 +23,7 @@ export * from './audit.zod'; export * from './license.zod'; export * from './webhook.zod'; export * from './translation.zod'; +export * from './currency.zod'; export * from './driver.zod'; export * from './discovery.zod'; export * from './plugin.zod'; From 293319a529db77f2df961700e22dc0d467f3f5e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 07:53:47 +0000 Subject: [PATCH 3/5] Fix type safety in currency helper functions Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/spec/src/data/currency.zod.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/spec/src/data/currency.zod.ts b/packages/spec/src/data/currency.zod.ts index d065f4aaf6..25d1388c21 100644 --- a/packages/spec/src/data/currency.zod.ts +++ b/packages/spec/src/data/currency.zod.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { CurrencyCodeSchema } from '../system/currency.zod'; +import { CurrencyCodeSchema, type CurrencyCode } from '../system/currency.zod'; /** * Currency Value Schema (Data Layer) @@ -199,7 +199,7 @@ export const CurrencyValue = { /** * Create a simple currency value */ - create: (amount: number, currency: string): CurrencyValue => ({ + create: (amount: number, currency: CurrencyCode): CurrencyValue => ({ amount, currency, }), @@ -209,9 +209,9 @@ export const CurrencyValue = { */ createWithConversion: ( amount: number, - currency: string, + currency: CurrencyCode, convertedAmount: number, - convertedCurrency: string, + convertedCurrency: CurrencyCode, exchangeRate: number, ): CurrencyValue => ({ amount, @@ -225,7 +225,7 @@ export const CurrencyValue = { /** * Zero value in specified currency */ - zero: (currency: string = 'USD'): CurrencyValue => ({ + zero: (currency: CurrencyCode = 'USD'): CurrencyValue => ({ amount: 0, currency, }), From 60ab46e2793dbb050eb916f7444051eca6b542cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 07:55:16 +0000 Subject: [PATCH 4/5] Improve type safety in Currency helper functions Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/spec/src/system/currency.zod.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/spec/src/system/currency.zod.ts b/packages/spec/src/system/currency.zod.ts index eaaf576ff6..a5c400bef0 100644 --- a/packages/spec/src/system/currency.zod.ts +++ b/packages/spec/src/system/currency.zod.ts @@ -268,12 +268,12 @@ export const Currency = { /** * Create a currency definition */ - define: (code: string, name: string, symbol: string, options?: { + define: (code: CurrencyCode, name: string, symbol: string, options?: { decimalPlaces?: number; isActive?: boolean; isCorporate?: boolean; conversionRate?: number; - }) => ({ + }): Currency => ({ code, name, symbol, @@ -286,14 +286,14 @@ export const Currency = { /** * Common currency definitions */ - USD: () => Currency.define('USD', 'US Dollar', '$', { isCorporate: true }), - EUR: () => Currency.define('EUR', 'Euro', '€'), - GBP: () => Currency.define('GBP', 'British Pound', '£'), - JPY: () => Currency.define('JPY', 'Japanese Yen', '¥', { decimalPlaces: 0 }), - CNY: () => Currency.define('CNY', 'Chinese Yuan', '¥'), - AUD: () => Currency.define('AUD', 'Australian Dollar', 'A$'), - CAD: () => Currency.define('CAD', 'Canadian Dollar', 'C$'), - CHF: () => Currency.define('CHF', 'Swiss Franc', 'CHF'), - INR: () => Currency.define('INR', 'Indian Rupee', '₹'), - BRL: () => Currency.define('BRL', 'Brazilian Real', 'R$'), + USD: (): Currency => Currency.define('USD' as CurrencyCode, 'US Dollar', '$', { isCorporate: true }), + EUR: (): Currency => Currency.define('EUR' as CurrencyCode, 'Euro', '€'), + GBP: (): Currency => Currency.define('GBP' as CurrencyCode, 'British Pound', '£'), + JPY: (): Currency => Currency.define('JPY' as CurrencyCode, 'Japanese Yen', '¥', { decimalPlaces: 0 }), + CNY: (): Currency => Currency.define('CNY' as CurrencyCode, 'Chinese Yuan', '¥'), + AUD: (): Currency => Currency.define('AUD' as CurrencyCode, 'Australian Dollar', 'A$'), + CAD: (): Currency => Currency.define('CAD' as CurrencyCode, 'Canadian Dollar', 'C$'), + CHF: (): Currency => Currency.define('CHF' as CurrencyCode, 'Swiss Franc', 'CHF'), + INR: (): Currency => Currency.define('INR' as CurrencyCode, 'Indian Rupee', '₹'), + BRL: (): Currency => Currency.define('BRL' as CurrencyCode, 'Brazilian Real', 'R$'), }; From f915bacfeb71ec07bcc65b6b929a2dafe69d3a00 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 07:58:38 +0000 Subject: [PATCH 5/5] Add multi-currency documentation and examples Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- docs/MULTI_CURRENCY.md | 144 +++++++++++++++ examples/multi-currency-example.ts | 281 +++++++++++++++++++++++++++++ 2 files changed, 425 insertions(+) create mode 100644 docs/MULTI_CURRENCY.md create mode 100644 examples/multi-currency-example.ts diff --git a/docs/MULTI_CURRENCY.md b/docs/MULTI_CURRENCY.md new file mode 100644 index 0000000000..aeeccff9fe --- /dev/null +++ b/docs/MULTI_CURRENCY.md @@ -0,0 +1,144 @@ +# Multi-Currency Support + +ObjectStack provides comprehensive multi-currency support following Salesforce best practices for international business scenarios. + +## Features + +### 🌍 Organization-Level Configuration +- Enable/disable multi-currency at organization level +- Configure corporate (base) currency +- Manage active currencies and exchange rates +- Support for 150+ currencies (ISO 4217 standard) +- Dated exchange rates for historical accuracy + +### 💱 Field-Level Control +- Default currency per field +- Restrict currencies for specific fields +- Control currency mutability after record creation +- Configurable rounding modes +- Display conversion information in UI + +### 👤 User Preferences +- User-specific preferred display currency +- Custom formatting options per user +- Automatic currency conversion +- Locale-specific number formatting + +### 📊 Reporting & Analytics +- Multiple aggregation strategies: + - Convert to corporate currency + - Convert to user's preferred currency + - Group by currency (no conversion) +- Cross-currency calculations with source tracking +- Proper sorting across different currencies + +## Quick Start + +### 1. Enable Multi-Currency + +```typescript +import { Currency, CurrencySettingsSchema } from '@objectstack/spec'; + +const settings = { + enabled: true, + corporateCurrency: 'USD', + activeCurrencies: [ + Currency.USD(), + { ...Currency.EUR(), conversionRate: 0.85 }, + { ...Currency.GBP(), conversionRate: 0.73 }, + ], + aggregationStrategy: 'convert_to_corporate', + sortingStrategy: 'convert_to_corporate', +}; + +CurrencySettingsSchema.parse(settings); +``` + +### 2. Define Currency Fields + +```typescript +import { Field } from '@objectstack/spec'; + +const amountField = Field.currency({ + name: 'amount', + label: 'Amount', + currencyCode: 'USD', + allowCurrencyChange: true, + restrictedCurrencies: ['USD', 'EUR', 'GBP'], + showConversion: true, + roundingMode: 'half_up', +}); +``` + +### 3. Store Currency Values + +```typescript +import { CurrencyValue } from '@objectstack/spec'; + +// Simple value +const value = CurrencyValue.create(1000.00, 'EUR'); + +// Value with conversion +const valueWithConversion = CurrencyValue.createWithConversion( + 1000.00, // Amount + 'EUR', // Currency + 1176.47, // Converted amount in USD + 'USD', // Corporate currency + 1.17647 // Exchange rate +); +``` + +### 4. Display with Intl Formatting + +```typescript +const formatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', +}); + +console.log(formatter.format(1234.56)); // "$1,234.56" +``` + +## Architecture + +### System Layer (`src/system/currency.zod.ts`) + +**Core Schemas:** +- `CurrencySchema` - Individual currency definition +- `ExchangeRateSchema` - Exchange rate between currencies +- `CurrencySettingsSchema` - Organization-wide configuration +- `CurrencyFormattingSchema` - Display formatting options +- `UserCurrencyPreferencesSchema` - User-specific preferences + +**Helper Functions:** +- `Currency.define()` - Create custom currency +- `Currency.USD()`, `Currency.EUR()`, etc. - Pre-configured currencies + +### Data Layer (`src/data/currency.zod.ts`) + +**Core Schemas:** +- `CurrencyValueSchema` - Compound value object (amount + currency + conversion) +- `MultiCurrencyFieldConfigSchema` - Field-level configuration +- `CurrencyCalculationResultSchema` - Cross-currency calculation results +- `CurrencyValidationSchema` - Validation rules for currency fields + +**Helper Functions:** +- `CurrencyValue.create()` - Create simple currency value +- `CurrencyValue.createWithConversion()` - Create with conversion +- `CurrencyValue.zero()` - Create zero value + +## Examples + +See [`examples/multi-currency-example.ts`](../examples/multi-currency-example.ts) for comprehensive examples. + +## Related Documentation + +- [Field Types](../content/docs/specifications/field-types.mdx) +- [Data Protocol](../content/docs/specifications/architecture/data-layer.mdx) +- [System Configuration](../content/docs/specifications/architecture/system-layer.mdx) + +## References + +- [ISO 4217 Currency Codes](https://www.iso.org/iso-4217-currency-codes.html) +- [Intl.NumberFormat MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat) +- [Salesforce Multi-Currency](https://help.salesforce.com/s/articleView?id=sf.admin_enable_multicurrency.htm) diff --git a/examples/multi-currency-example.ts b/examples/multi-currency-example.ts new file mode 100644 index 0000000000..c319a0d9a8 --- /dev/null +++ b/examples/multi-currency-example.ts @@ -0,0 +1,281 @@ +/** + * Multi-Currency Implementation Example + * + * This example demonstrates how to use the multi-currency feature + * in ObjectStack, following Salesforce-like patterns. + */ + +import { + // System-level imports + Currency, + CurrencySettings, + CurrencySettingsSchema, + ExchangeRate, + UserCurrencyPreferences, + + // Data-level imports + CurrencyValue, + CurrencyValueSchema, + MultiCurrencyFieldConfig, + Field, +} from '@objectstack/spec'; + +// ======================================== +// 1. Organization Setup: Enable Multi-Currency +// ======================================== + +const organizationCurrencySettings: CurrencySettings = { + // Enable multi-currency for the organization + enabled: true, + + // Set the corporate (base) currency + corporateCurrency: 'USD', + + // Define active currencies + activeCurrencies: [ + // Corporate currency (USD) with conversion rate of 1.0 + Currency.USD(), + + // Other active currencies with conversion rates + { + ...Currency.EUR(), + conversionRate: 0.85, // 1 EUR = 0.85 USD + conversionRateUpdatedAt: new Date('2024-01-15'), + }, + { + ...Currency.GBP(), + conversionRate: 0.73, // 1 GBP = 0.73 USD + conversionRateUpdatedAt: new Date('2024-01-15'), + }, + { + ...Currency.JPY(), + conversionRate: 110.25, // 1 JPY = 110.25 USD + conversionRateUpdatedAt: new Date('2024-01-15'), + }, + { + ...Currency.CNY(), + conversionRate: 6.45, // 1 CNY = 6.45 USD + conversionRateUpdatedAt: new Date('2024-01-15'), + }, + ], + + // Default formatting options + defaultFormatting: { + format: 'symbol', // Display as: $1,234.56 + locale: 'en-US', + showSymbol: true, + showCode: false, + }, + + // Allow users to select their preferred display currency + allowUserCurrency: true, + + // Enable automatic currency conversion in reports + enableAutoConversion: true, + + // How to handle sorting: convert to corporate currency first + sortingStrategy: 'convert_to_corporate', + + // How to handle aggregations: convert to corporate currency first + aggregationStrategy: 'convert_to_corporate', + + // Enable dated exchange rates for historical accuracy + enableDatedExchangeRates: false, +}; + +// Validate the configuration +CurrencySettingsSchema.parse(organizationCurrencySettings); + +// ======================================== +// 2. User Preferences: Set Display Currency +// ======================================== + +const userPreferences: UserCurrencyPreferences = { + // User prefers to see amounts in EUR + preferredCurrency: 'EUR', + + // Custom formatting for this user + formatting: { + format: 'symbol_code', // Display as: €1,234.56 EUR + locale: 'fr-FR', + decimalSeparator: ',', + thousandsSeparator: '.', + }, + + // Always convert amounts to preferred currency + alwaysConvert: true, +}; + +// ======================================== +// 3. Field Definition: Currency Field with Multi-Currency +// ======================================== + +const opportunityAmountField = Field.currency({ + name: 'amount', + label: 'Opportunity Amount', + required: true, + + // Currency-specific configuration + currencyCode: 'USD', // Default currency for new records + allowCurrencyChange: true, // Users can change currency + restrictedCurrencies: ['USD', 'EUR', 'GBP'], // Restrict to these currencies + showConversion: true, // Show conversion to corporate/user currency + roundingMode: 'half_up', // Standard rounding + + // Number constraints + precision: 18, + scale: 2, + min: 0, + max: 999999999999.99, +}); + +// ======================================== +// 4. Storing Currency Values +// ======================================== + +// Example 1: Simple currency value (EUR) +const opportunityEUR = CurrencyValue.create(50000.00, 'EUR'); +console.log('Opportunity in EUR:', opportunityEUR); +// Output: { amount: 50000.00, currency: 'EUR' } + +// Example 2: Currency value with conversion to corporate currency +const opportunityWithConversion = CurrencyValue.createWithConversion( + 50000.00, // Amount in EUR + 'EUR', // Currency + 58823.53, // Converted amount in USD (50000 / 0.85) + 'USD', // Corporate currency + 1.176471 // Exchange rate (1 EUR = 1.176471 USD) +); + +console.log('Opportunity with conversion:', opportunityWithConversion); +// Output: { +// amount: 50000.00, +// currency: 'EUR', +// convertedAmount: 58823.53, +// convertedCurrency: 'USD', +// exchangeRate: 1.176471, +// conversionDate: Date +// } + +// Validate the value +CurrencyValueSchema.parse(opportunityWithConversion); + +// ======================================== +// 5. Real-World Example: CRM Opportunity +// ======================================== + +const crmOpportunity = { + id: 'OPP-001', + name: 'Enterprise License - Acme Corp', + + // Currency field with multi-currency support + amount: { + amount: 100000.00, + currency: 'GBP', + convertedAmount: 137000.00, // Converted to USD at rate 1.37 + convertedCurrency: 'USD', + exchangeRate: 1.37, + conversionDate: new Date('2024-01-15'), + }, + + // Other fields + stage: 'Negotiation', + probability: 75, + closeDate: new Date('2024-03-31'), +}; + +// ======================================== +// 6. Reporting & Aggregation +// ======================================== + +// When reporting on opportunities across multiple currencies: +// - Convert all amounts to corporate currency (USD) +// - Then perform aggregation +const opportunities = [ + { name: 'Deal 1', amount: 50000, currency: 'USD' }, + { name: 'Deal 2', amount: 40000, currency: 'EUR', convertedAmount: 47058.82 }, + { name: 'Deal 3', amount: 30000, currency: 'GBP', convertedAmount: 41100.00 }, +]; + +// Total in corporate currency (USD) +const totalUSD = opportunities.reduce((sum, opp) => { + const amount = opp.convertedAmount ?? opp.amount; + return sum + amount; +}, 0); + +console.log('Total Pipeline (USD):', totalUSD); +// Output: Total Pipeline (USD): 138158.82 + +// ======================================== +// 7. Dated Exchange Rates (Historical Accuracy) +// ======================================== + +const historicalExchangeRates: ExchangeRate[] = [ + { + fromCurrency: 'USD', + toCurrency: 'EUR', + rate: 0.85, + effectiveDate: new Date('2024-01-01'), + expiryDate: new Date('2024-01-31'), + source: 'api', + }, + { + fromCurrency: 'USD', + toCurrency: 'EUR', + rate: 0.87, + effectiveDate: new Date('2024-02-01'), + source: 'api', + }, +]; + +// When calculating historical reports, use the exchange rate +// that was effective at the time of the transaction + +// ======================================== +// 8. Display Formatting Examples +// ======================================== + +function formatCurrency(value: typeof opportunityEUR, locale: string = 'en-US') { + const formatter = new Intl.NumberFormat(locale, { + style: 'currency', + currency: value.currency, + }); + + return formatter.format(value.amount); +} + +console.log('USD format:', formatCurrency({ amount: 1234.56, currency: 'USD' })); +// Output: USD format: $1,234.56 + +console.log('EUR format (France):', formatCurrency({ amount: 1234.56, currency: 'EUR' }, 'fr-FR')); +// Output: EUR format (France): 1 234,56 € + +console.log('GBP format (UK):', formatCurrency({ amount: 1234.56, currency: 'GBP' }, 'en-GB')); +// Output: GBP format (UK): £1,234.56 + +console.log('JPY format (Japan):', formatCurrency({ amount: 150000, currency: 'JPY' }, 'ja-JP')); +// Output: JPY format (Japan): ¥150,000 + +// ======================================== +// Summary +// ======================================== + +/** + * Key Takeaways: + * + * 1. **Storage**: Values are stored with both native currency and converted amount + * 2. **Display**: Use Intl.NumberFormat for locale-specific formatting + * 3. **Sorting**: Configure strategy (convert_to_corporate, convert_to_user, or native_value) + * 4. **Aggregation**: Always convert to a common currency before aggregating + * 5. **Validation**: Use CurrencyValidation schema for business rules + * 6. **Historical Accuracy**: Use dated exchange rates when needed + * 7. **Type Safety**: All helper functions use CurrencyCode type for compile-time validation + * + * This implementation follows Salesforce best practices and supports: + * - International business scenarios + * - Multiple currencies per organization + * - User-specific currency preferences + * - Accurate reporting and analytics + * - Historical exchange rate tracking + * - Flexible formatting options + */