From 4bb5d217917f36a8f95a09b9aafdb319bbbd39bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 08:12:46 +0000 Subject: [PATCH 1/4] Initial plan From 1fe31dccacf9e55330d164bdd7bf99b632a2b82b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 08:17:32 +0000 Subject: [PATCH 2/4] Implement Money field type with multi-currency support - Add 'money' to FieldType enum - Create MoneyConfigSchema with precision, currencyMode, and defaultCurrency - Create MoneyValueSchema for runtime value structure - Extend FieldSchema with optional moneyConfig property - Add Field.money() helper factory function - Add comprehensive tests (71 total field tests, all passing) - Generate JSON schemas and documentation - All 1638 tests passing Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- content/docs/references/data/field/Field.mdx | 3 +- .../docs/references/data/field/FieldType.mdx | 1 + .../references/data/field/MoneyConfig.mdx | 12 + .../docs/references/data/field/MoneyValue.mdx | 11 + .../docs/references/ui/action/ActionParam.mdx | 2 +- packages/spec/json-schema/data/Field.json | 31 ++ packages/spec/json-schema/data/FieldType.json | 1 + .../spec/json-schema/data/MoneyConfig.json | 35 +++ .../spec/json-schema/data/MoneyValue.json | 26 ++ packages/spec/json-schema/data/Object.json | 31 ++ packages/spec/json-schema/ui/Action.json | 1 + packages/spec/json-schema/ui/ActionParam.json | 1 + .../spec/json-schema/ui/FieldWidgetProps.json | 31 ++ packages/spec/package-lock.json | 4 +- packages/spec/src/data/field.test.ts | 285 +++++++++++++++++- packages/spec/src/data/field.zod.ts | 42 ++- 16 files changed, 510 insertions(+), 7 deletions(-) create mode 100644 content/docs/references/data/field/MoneyConfig.mdx create mode 100644 content/docs/references/data/field/MoneyValue.mdx create mode 100644 packages/spec/json-schema/data/MoneyConfig.json create mode 100644 packages/spec/json-schema/data/MoneyValue.json diff --git a/content/docs/references/data/field/Field.mdx b/content/docs/references/data/field/Field.mdx index 375140cb3..6ec6afd5d 100644 --- a/content/docs/references/data/field/Field.mdx +++ b/content/docs/references/data/field/Field.mdx @@ -9,7 +9,7 @@ description: Field Schema Reference | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Machine name (snake_case) | | **label** | `string` | optional | Human readable label | -| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'select' \| 'lookup' \| 'master_detail' \| 'image' \| 'file' \| 'avatar' \| 'formula' \| 'summary' \| 'autonumber' \| 'location' \| 'geolocation' \| 'address' \| 'code' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode'>` | ✅ | Field Data Type | +| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'money' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'select' \| 'lookup' \| 'master_detail' \| 'image' \| 'file' \| 'avatar' \| 'formula' \| 'summary' \| 'autonumber' \| 'location' \| 'geolocation' \| 'address' \| 'code' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode'>` | ✅ | Field Data Type | | **description** | `string` | optional | Tooltip/Help text | | **format** | `string` | optional | Format string (e.g. email, phone) | | **required** | `boolean` | optional | Is required | @@ -49,6 +49,7 @@ 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 | +| **moneyConfig** | `object` | optional | Configuration for money field type | | **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/field/FieldType.mdx b/content/docs/references/data/field/FieldType.mdx index 2a05aa758..3acd0b032 100644 --- a/content/docs/references/data/field/FieldType.mdx +++ b/content/docs/references/data/field/FieldType.mdx @@ -17,6 +17,7 @@ description: FieldType Schema Reference * `number` * `currency` * `percent` +* `money` * `date` * `datetime` * `time` diff --git a/content/docs/references/data/field/MoneyConfig.mdx b/content/docs/references/data/field/MoneyConfig.mdx new file mode 100644 index 000000000..4c478773b --- /dev/null +++ b/content/docs/references/data/field/MoneyConfig.mdx @@ -0,0 +1,12 @@ +--- +title: MoneyConfig +description: MoneyConfig Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **precision** | `integer` | optional | Decimal precision (default: 2) | +| **currencyMode** | `Enum<'dynamic' \| 'fixed'>` | optional | Currency mode: dynamic (user selectable) or fixed (single currency) | +| **defaultCurrency** | `string` | optional | Default or fixed currency code (ISO 4217, e.g., USD, CNY, EUR) | diff --git a/content/docs/references/data/field/MoneyValue.mdx b/content/docs/references/data/field/MoneyValue.mdx new file mode 100644 index 000000000..d33aa8a24 --- /dev/null +++ b/content/docs/references/data/field/MoneyValue.mdx @@ -0,0 +1,11 @@ +--- +title: MoneyValue +description: MoneyValue Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **value** | `number` | ✅ | Monetary amount | +| **currency** | `string` | ✅ | Currency code (ISO 4217) | diff --git a/content/docs/references/ui/action/ActionParam.mdx b/content/docs/references/ui/action/ActionParam.mdx index 54cbb6ed5..eff673737 100644 --- a/content/docs/references/ui/action/ActionParam.mdx +++ b/content/docs/references/ui/action/ActionParam.mdx @@ -9,6 +9,6 @@ description: ActionParam Schema Reference | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | | | **label** | `string` | ✅ | | -| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'select' \| 'lookup' \| 'master_detail' \| 'image' \| 'file' \| 'avatar' \| 'formula' \| 'summary' \| 'autonumber' \| 'location' \| 'geolocation' \| 'address' \| 'code' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode'>` | ✅ | | +| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'money' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'select' \| 'lookup' \| 'master_detail' \| 'image' \| 'file' \| 'avatar' \| 'formula' \| 'summary' \| 'autonumber' \| 'location' \| 'geolocation' \| 'address' \| 'code' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode'>` | ✅ | | | **required** | `boolean` | optional | | | **options** | `object[]` | optional | | diff --git a/packages/spec/json-schema/data/Field.json b/packages/spec/json-schema/data/Field.json index 820239e14..b19cb806e 100644 --- a/packages/spec/json-schema/data/Field.json +++ b/packages/spec/json-schema/data/Field.json @@ -28,6 +28,7 @@ "number", "currency", "percent", + "money", "date", "datetime", "time", @@ -303,6 +304,36 @@ "type": "boolean", "description": "Enable camera scanning for barcode/QR code input" }, + "moneyConfig": { + "type": "object", + "properties": { + "precision": { + "type": "integer", + "minimum": 0, + "maximum": 10, + "default": 2, + "description": "Decimal precision (default: 2)" + }, + "currencyMode": { + "type": "string", + "enum": [ + "dynamic", + "fixed" + ], + "default": "dynamic", + "description": "Currency mode: dynamic (user selectable) or fixed (single currency)" + }, + "defaultCurrency": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "default": "CNY", + "description": "Default or fixed currency code (ISO 4217, e.g., USD, CNY, EUR)" + } + }, + "additionalProperties": false, + "description": "Configuration for money field type" + }, "hidden": { "type": "boolean", "default": false, diff --git a/packages/spec/json-schema/data/FieldType.json b/packages/spec/json-schema/data/FieldType.json index 3b5fbe801..e19fdc2a8 100644 --- a/packages/spec/json-schema/data/FieldType.json +++ b/packages/spec/json-schema/data/FieldType.json @@ -16,6 +16,7 @@ "number", "currency", "percent", + "money", "date", "datetime", "time", diff --git a/packages/spec/json-schema/data/MoneyConfig.json b/packages/spec/json-schema/data/MoneyConfig.json new file mode 100644 index 000000000..7223f4ae1 --- /dev/null +++ b/packages/spec/json-schema/data/MoneyConfig.json @@ -0,0 +1,35 @@ +{ + "$ref": "#/definitions/MoneyConfig", + "definitions": { + "MoneyConfig": { + "type": "object", + "properties": { + "precision": { + "type": "integer", + "minimum": 0, + "maximum": 10, + "default": 2, + "description": "Decimal precision (default: 2)" + }, + "currencyMode": { + "type": "string", + "enum": [ + "dynamic", + "fixed" + ], + "default": "dynamic", + "description": "Currency mode: dynamic (user selectable) or fixed (single currency)" + }, + "defaultCurrency": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "default": "CNY", + "description": "Default or fixed currency code (ISO 4217, e.g., USD, CNY, EUR)" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/data/MoneyValue.json b/packages/spec/json-schema/data/MoneyValue.json new file mode 100644 index 000000000..72586a016 --- /dev/null +++ b/packages/spec/json-schema/data/MoneyValue.json @@ -0,0 +1,26 @@ +{ + "$ref": "#/definitions/MoneyValue", + "definitions": { + "MoneyValue": { + "type": "object", + "properties": { + "value": { + "type": "number", + "description": "Monetary amount" + }, + "currency": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "description": "Currency code (ISO 4217)" + } + }, + "required": [ + "value", + "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/Object.json b/packages/spec/json-schema/data/Object.json index ac50cb15a..20b14b93b 100644 --- a/packages/spec/json-schema/data/Object.json +++ b/packages/spec/json-schema/data/Object.json @@ -68,6 +68,7 @@ "number", "currency", "percent", + "money", "date", "datetime", "time", @@ -343,6 +344,36 @@ "type": "boolean", "description": "Enable camera scanning for barcode/QR code input" }, + "moneyConfig": { + "type": "object", + "properties": { + "precision": { + "type": "integer", + "minimum": 0, + "maximum": 10, + "default": 2, + "description": "Decimal precision (default: 2)" + }, + "currencyMode": { + "type": "string", + "enum": [ + "dynamic", + "fixed" + ], + "default": "dynamic", + "description": "Currency mode: dynamic (user selectable) or fixed (single currency)" + }, + "defaultCurrency": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "default": "CNY", + "description": "Default or fixed currency code (ISO 4217, e.g., USD, CNY, EUR)" + } + }, + "additionalProperties": false, + "description": "Configuration for money field type" + }, "hidden": { "type": "boolean", "default": false, diff --git a/packages/spec/json-schema/ui/Action.json b/packages/spec/json-schema/ui/Action.json index b57f91faf..7e9e38aeb 100644 --- a/packages/spec/json-schema/ui/Action.json +++ b/packages/spec/json-schema/ui/Action.json @@ -79,6 +79,7 @@ "number", "currency", "percent", + "money", "date", "datetime", "time", diff --git a/packages/spec/json-schema/ui/ActionParam.json b/packages/spec/json-schema/ui/ActionParam.json index 1564e188c..0cd56be30 100644 --- a/packages/spec/json-schema/ui/ActionParam.json +++ b/packages/spec/json-schema/ui/ActionParam.json @@ -25,6 +25,7 @@ "number", "currency", "percent", + "money", "date", "datetime", "time", diff --git a/packages/spec/json-schema/ui/FieldWidgetProps.json b/packages/spec/json-schema/ui/FieldWidgetProps.json index 926c21d92..7a924fb82 100644 --- a/packages/spec/json-schema/ui/FieldWidgetProps.json +++ b/packages/spec/json-schema/ui/FieldWidgetProps.json @@ -48,6 +48,7 @@ "number", "currency", "percent", + "money", "date", "datetime", "time", @@ -323,6 +324,36 @@ "type": "boolean", "description": "Enable camera scanning for barcode/QR code input" }, + "moneyConfig": { + "type": "object", + "properties": { + "precision": { + "type": "integer", + "minimum": 0, + "maximum": 10, + "default": 2, + "description": "Decimal precision (default: 2)" + }, + "currencyMode": { + "type": "string", + "enum": [ + "dynamic", + "fixed" + ], + "default": "dynamic", + "description": "Currency mode: dynamic (user selectable) or fixed (single currency)" + }, + "defaultCurrency": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "default": "CNY", + "description": "Default or fixed currency code (ISO 4217, e.g., USD, CNY, EUR)" + } + }, + "additionalProperties": false, + "description": "Configuration for money field type" + }, "hidden": { "type": "boolean", "default": false, diff --git a/packages/spec/package-lock.json b/packages/spec/package-lock.json index 43ffd2417..ae1f010e3 100644 --- a/packages/spec/package-lock.json +++ b/packages/spec/package-lock.json @@ -1,12 +1,12 @@ { "name": "@objectstack/spec", - "version": "0.3.1", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@objectstack/spec", - "version": "0.3.1", + "version": "0.3.2", "license": "Apache-2.0", "dependencies": { "zod": "^3.22.4" diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index a34f71c19..cdbd0324a 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -3,8 +3,12 @@ import { FieldSchema, FieldType, SelectOptionSchema, + MoneyConfigSchema, + MoneyValueSchema, Field, - type SelectOption + type SelectOption, + type MoneyConfig, + type MoneyValue } from './field.zod'; describe('FieldType', () => { @@ -12,7 +16,7 @@ describe('FieldType', () => { const validTypes = [ 'text', 'textarea', 'email', 'url', 'phone', 'password', 'markdown', 'html', 'richtext', - 'number', 'currency', 'percent', + 'number', 'currency', 'percent', 'money', 'date', 'datetime', 'time', 'boolean', 'select', @@ -61,6 +65,107 @@ describe('SelectOptionSchema', () => { }); }); +describe('MoneyConfigSchema', () => { + it('should accept valid money config with all fields', () => { + const validConfig: MoneyConfig = { + precision: 2, + currencyMode: 'dynamic', + defaultCurrency: 'USD', + }; + + expect(() => MoneyConfigSchema.parse(validConfig)).not.toThrow(); + }); + + it('should apply default values', () => { + const config = MoneyConfigSchema.parse({}); + + expect(config.precision).toBe(2); + expect(config.currencyMode).toBe('dynamic'); + expect(config.defaultCurrency).toBe('CNY'); + }); + + it('should accept precision from 0 to 10', () => { + const validPrecisions = [0, 2, 4, 8, 10]; + + validPrecisions.forEach(precision => { + expect(() => MoneyConfigSchema.parse({ precision })).not.toThrow(); + }); + }); + + it('should reject invalid precision values', () => { + const invalidPrecisions = [-1, 11, 15, 1.5]; + + invalidPrecisions.forEach(precision => { + expect(() => MoneyConfigSchema.parse({ precision })).toThrow(); + }); + }); + + it('should accept both currency modes', () => { + expect(() => MoneyConfigSchema.parse({ currencyMode: 'dynamic' })).not.toThrow(); + expect(() => MoneyConfigSchema.parse({ currencyMode: 'fixed' })).not.toThrow(); + }); + + it('should reject invalid currency modes', () => { + expect(() => MoneyConfigSchema.parse({ currencyMode: 'invalid' })).toThrow(); + }); + + it('should enforce 3-character currency codes', () => { + const validCodes = ['USD', 'CNY', 'EUR', 'GBP', 'JPY']; + + validCodes.forEach(code => { + expect(() => MoneyConfigSchema.parse({ defaultCurrency: code })).not.toThrow(); + }); + }); + + it('should reject invalid currency code lengths', () => { + const invalidCodes = ['US', 'USDD', 'C', '']; + + invalidCodes.forEach(code => { + expect(() => MoneyConfigSchema.parse({ defaultCurrency: code })).toThrow(); + }); + }); +}); + +describe('MoneyValueSchema', () => { + it('should accept valid money value', () => { + const validValue: MoneyValue = { + value: 1000.50, + currency: 'USD', + }; + + expect(() => MoneyValueSchema.parse(validValue)).not.toThrow(); + }); + + it('should accept zero value', () => { + const zeroValue: MoneyValue = { + value: 0, + currency: 'CNY', + }; + + expect(() => MoneyValueSchema.parse(zeroValue)).not.toThrow(); + }); + + it('should accept negative values', () => { + const negativeValue: MoneyValue = { + value: -500.00, + currency: 'EUR', + }; + + expect(() => MoneyValueSchema.parse(negativeValue)).not.toThrow(); + }); + + it('should reject invalid currency codes', () => { + expect(() => MoneyValueSchema.parse({ value: 100, currency: 'US' })).toThrow(); + expect(() => MoneyValueSchema.parse({ value: 100, currency: 'USDD' })).toThrow(); + }); + + it('should reject missing required fields', () => { + expect(() => MoneyValueSchema.parse({ value: 100 })).toThrow(); + expect(() => MoneyValueSchema.parse({ currency: 'USD' })).toThrow(); + expect(() => MoneyValueSchema.parse({})).toThrow(); + }); +}); + describe('FieldSchema', () => { describe('Basic Field Properties', () => { it('should accept valid field with minimal properties', () => { @@ -556,4 +661,180 @@ describe('Field Factory Helpers', () => { expect(geolocationField.allowGeocoding).toBe(false); }); }); + + describe('Money Field Type', () => { + it('should accept money field type', () => { + expect(() => FieldType.parse('money')).not.toThrow(); + }); + + it('should create money field with default config', () => { + const moneyField = Field.money({ + name: 'contract_amount', + label: 'Contract Amount', + }); + + expect(moneyField.type).toBe('money'); + expect(moneyField.name).toBe('contract_amount'); + expect(moneyField.label).toBe('Contract Amount'); + }); + + it('should create money field with dynamic currency mode (default)', () => { + const moneyField = Field.money({ + name: 'price', + label: 'Price', + moneyConfig: { + precision: 2, + currencyMode: 'dynamic', + defaultCurrency: 'USD', + }, + }); + + expect(moneyField.type).toBe('money'); + expect(moneyField.moneyConfig?.currencyMode).toBe('dynamic'); + expect(moneyField.moneyConfig?.defaultCurrency).toBe('USD'); + expect(moneyField.moneyConfig?.precision).toBe(2); + }); + + it('should create money field with fixed currency mode', () => { + const moneyField = Field.money({ + name: 'salary', + label: 'Salary', + moneyConfig: { + precision: 2, + currencyMode: 'fixed', + defaultCurrency: 'CNY', + }, + }); + + expect(moneyField.type).toBe('money'); + expect(moneyField.moneyConfig?.currencyMode).toBe('fixed'); + expect(moneyField.moneyConfig?.defaultCurrency).toBe('CNY'); + }); + + it('should validate money field with valid config', () => { + const validField = { + name: 'revenue', + label: 'Revenue', + type: 'money' as const, + moneyConfig: { + precision: 4, + currencyMode: 'dynamic' as const, + defaultCurrency: 'EUR', + }, + }; + + const result = FieldSchema.safeParse(validField); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.moneyConfig?.precision).toBe(4); + expect(result.data.moneyConfig?.currencyMode).toBe('dynamic'); + expect(result.data.moneyConfig?.defaultCurrency).toBe('EUR'); + } + }); + + it('should apply default values for money config', () => { + const field = { + name: 'amount', + label: 'Amount', + type: 'money' as const, + moneyConfig: {}, + }; + + const result = FieldSchema.parse(field); + expect(result.moneyConfig?.precision).toBe(2); + expect(result.moneyConfig?.currencyMode).toBe('dynamic'); + expect(result.moneyConfig?.defaultCurrency).toBe('CNY'); + }); + + it('should reject invalid precision values', () => { + const invalidField = { + name: 'amount', + label: 'Amount', + type: 'money' as const, + moneyConfig: { + precision: -1, + }, + }; + + expect(() => FieldSchema.parse(invalidField)).toThrow(); + + const tooHighPrecision = { + name: 'amount', + label: 'Amount', + type: 'money' as const, + moneyConfig: { + precision: 11, + }, + }; + + expect(() => FieldSchema.parse(tooHighPrecision)).toThrow(); + }); + + it('should reject invalid currency codes', () => { + const invalidField = { + name: 'price', + label: 'Price', + type: 'money' as const, + moneyConfig: { + defaultCurrency: 'US', // Must be 3 characters + }, + }; + + expect(() => FieldSchema.parse(invalidField)).toThrow(); + + const tooLongCurrency = { + name: 'price', + label: 'Price', + type: 'money' as const, + moneyConfig: { + defaultCurrency: 'USDD', + }, + }; + + expect(() => FieldSchema.parse(tooLongCurrency)).toThrow(); + }); + + it('should accept valid currency modes', () => { + const dynamicMode = { + name: 'price1', + label: 'Price 1', + type: 'money' as const, + moneyConfig: { + currencyMode: 'dynamic' as const, + }, + }; + + const fixedMode = { + name: 'price2', + label: 'Price 2', + type: 'money' as const, + moneyConfig: { + currencyMode: 'fixed' as const, + }, + }; + + expect(() => FieldSchema.parse(dynamicMode)).not.toThrow(); + expect(() => FieldSchema.parse(fixedMode)).not.toThrow(); + }); + + it('should work with other field properties', () => { + const moneyField = Field.money({ + name: 'budget', + label: 'Project Budget', + required: true, + readonly: false, + description: 'Total budget for the project', + moneyConfig: { + precision: 2, + currencyMode: 'dynamic', + defaultCurrency: 'USD', + }, + }); + + expect(moneyField.type).toBe('money'); + expect(moneyField.required).toBe(true); + expect(moneyField.readonly).toBe(false); + expect(moneyField.description).toBe('Total budget for the project'); + }); + }); }); diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 8abd1ddaa..3e17553e3 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -9,7 +9,7 @@ export const FieldType = z.enum([ // Rich Content 'markdown', 'html', 'richtext', // Numbers - 'number', 'currency', 'percent', + 'number', 'currency', 'percent', 'money', // Date & Time 'date', 'datetime', 'time', // Logic @@ -57,6 +57,25 @@ export const LocationCoordinatesSchema = z.object({ accuracy: z.number().optional().describe('Accuracy in meters'), }); +/** + * Money Configuration Schema + * Configuration for money field type supporting multi-currency + */ +export const MoneyConfigSchema = z.object({ + precision: z.number().int().min(0).max(10).default(2).describe('Decimal precision (default: 2)'), + currencyMode: z.enum(['dynamic', 'fixed']).default('dynamic').describe('Currency mode: dynamic (user selectable) or fixed (single currency)'), + defaultCurrency: z.string().length(3).default('CNY').describe('Default or fixed currency code (ISO 4217, e.g., USD, CNY, EUR)'), +}); + +/** + * Money Value Schema + * Runtime value structure for money fields + */ +export const MoneyValueSchema = z.object({ + value: z.number().describe('Monetary amount'), + currency: z.string().length(3).describe('Currency code (ISO 4217)'), +}); + /** * Address Schema * Structured address for address field type @@ -152,6 +171,9 @@ export const FieldSchema = z.object({ 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'), + // Money field config + moneyConfig: MoneyConfigSchema.optional().describe('Configuration for money field type'), + /** Security & Visibility */ hidden: z.boolean().default(false).describe('Hidden from default UI'), readonly: z.boolean().default(false).describe('Read-only in UI'), @@ -166,6 +188,8 @@ export type Field = z.infer; export type SelectOption = z.infer; export type LocationCoordinates = z.infer; export type Address = z.infer; +export type MoneyConfig = z.infer; +export type MoneyValue = z.infer; /** * Field Factory Helper @@ -290,4 +314,20 @@ export const Field = { type: 'geolocation', ...config } as const), + + /** + * Money field helper with multi-currency support + * + * @example Dynamic currency mode (default) + * Field.money({ label: 'Contract Amount', precision: 2 }) + * + * @example Fixed currency mode + * Field.money({ label: 'Price', moneyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }) + * + * @param config - Field configuration including optional moneyConfig + */ + money: (config: FieldInput = {}) => ({ + type: 'money', + ...config + } as const), }; From 71261ecceefdd441cb15ac712073c986fc9bce9f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 08:20:35 +0000 Subject: [PATCH 3/4] Add documentation for currency code validation strategy - Document flexible currency code validation approach - Support ISO 4217, cryptocurrency, and custom codes - Explain validation can be stricter at application layer - All tests passing, no security vulnerabilities Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/spec/src/data/field.zod.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 3e17553e3..3511484f8 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -60,6 +60,12 @@ export const LocationCoordinatesSchema = z.object({ /** * Money Configuration Schema * Configuration for money field type supporting multi-currency + * + * Note: Currency codes are validated by length only (3 characters) to support: + * - Standard ISO 4217 codes (USD, EUR, CNY, etc.) + * - Cryptocurrency codes (BTC, ETH, etc.) + * - Custom business-specific codes + * Stricter validation can be implemented at the application layer based on business requirements. */ export const MoneyConfigSchema = z.object({ precision: z.number().int().min(0).max(10).default(2).describe('Decimal precision (default: 2)'), @@ -70,6 +76,9 @@ export const MoneyConfigSchema = z.object({ /** * Money Value Schema * Runtime value structure for money fields + * + * Note: Currency codes are validated by length only (3 characters) to support flexibility. + * See MoneyConfigSchema for details on currency code validation strategy. */ export const MoneyValueSchema = z.object({ value: z.number().describe('Monetary amount'), From 0a470ebfe2488f5448e3024107638baabd8d3823 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 08:30:13 +0000 Subject: [PATCH 4/4] Refactor: Apply multi-currency support to standard currency field type - Remove 'money' field type from enum (use existing 'currency' instead) - Rename MoneyConfig to CurrencyConfig - Rename MoneyValue to CurrencyValue - Change moneyConfig to currencyConfig in FieldSchema - Update all tests to use currency type instead of money type - Remove Field.money() helper (use Field.currency() instead) - Delete MoneyConfig/MoneyValue schemas and docs - Generate new CurrencyConfig/CurrencyValue schemas and docs - All 1638 tests passing Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../{MoneyConfig.mdx => CurrencyConfig.mdx} | 4 +- .../{MoneyValue.mdx => CurrencyValue.mdx} | 4 +- content/docs/references/data/field/Field.mdx | 4 +- .../docs/references/data/field/FieldType.mdx | 1 - .../docs/references/ui/action/ActionParam.mdx | 2 +- .../{MoneyConfig.json => CurrencyConfig.json} | 4 +- .../{MoneyValue.json => CurrencyValue.json} | 4 +- packages/spec/json-schema/data/Field.json | 5 +- packages/spec/json-schema/data/FieldType.json | 1 - packages/spec/json-schema/data/Object.json | 5 +- packages/spec/json-schema/ui/Action.json | 1 - packages/spec/json-schema/ui/ActionParam.json | 1 - .../spec/json-schema/ui/FieldWidgetProps.json | 5 +- packages/spec/src/data/field.test.ts | 179 ++++++++++-------- packages/spec/src/data/field.zod.ts | 40 ++-- 15 files changed, 125 insertions(+), 135 deletions(-) rename content/docs/references/data/field/{MoneyConfig.mdx => CurrencyConfig.mdx} (86%) rename content/docs/references/data/field/{MoneyValue.mdx => CurrencyValue.mdx} (76%) rename packages/spec/json-schema/data/{MoneyConfig.json => CurrencyConfig.json} (93%) rename packages/spec/json-schema/data/{MoneyValue.json => CurrencyValue.json} (88%) diff --git a/content/docs/references/data/field/MoneyConfig.mdx b/content/docs/references/data/field/CurrencyConfig.mdx similarity index 86% rename from content/docs/references/data/field/MoneyConfig.mdx rename to content/docs/references/data/field/CurrencyConfig.mdx index 4c478773b..3bf14db03 100644 --- a/content/docs/references/data/field/MoneyConfig.mdx +++ b/content/docs/references/data/field/CurrencyConfig.mdx @@ -1,6 +1,6 @@ --- -title: MoneyConfig -description: MoneyConfig Schema Reference +title: CurrencyConfig +description: CurrencyConfig Schema Reference --- ## Properties diff --git a/content/docs/references/data/field/MoneyValue.mdx b/content/docs/references/data/field/CurrencyValue.mdx similarity index 76% rename from content/docs/references/data/field/MoneyValue.mdx rename to content/docs/references/data/field/CurrencyValue.mdx index d33aa8a24..cb7ecbc0e 100644 --- a/content/docs/references/data/field/MoneyValue.mdx +++ b/content/docs/references/data/field/CurrencyValue.mdx @@ -1,6 +1,6 @@ --- -title: MoneyValue -description: MoneyValue Schema Reference +title: CurrencyValue +description: CurrencyValue Schema Reference --- ## Properties diff --git a/content/docs/references/data/field/Field.mdx b/content/docs/references/data/field/Field.mdx index 6ec6afd5d..71a679c1c 100644 --- a/content/docs/references/data/field/Field.mdx +++ b/content/docs/references/data/field/Field.mdx @@ -9,7 +9,7 @@ description: Field Schema Reference | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Machine name (snake_case) | | **label** | `string` | optional | Human readable label | -| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'money' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'select' \| 'lookup' \| 'master_detail' \| 'image' \| 'file' \| 'avatar' \| 'formula' \| 'summary' \| 'autonumber' \| 'location' \| 'geolocation' \| 'address' \| 'code' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode'>` | ✅ | Field Data Type | +| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'select' \| 'lookup' \| 'master_detail' \| 'image' \| 'file' \| 'avatar' \| 'formula' \| 'summary' \| 'autonumber' \| 'location' \| 'geolocation' \| 'address' \| 'code' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode'>` | ✅ | Field Data Type | | **description** | `string` | optional | Tooltip/Help text | | **format** | `string` | optional | Format string (e.g. email, phone) | | **required** | `boolean` | optional | Is required | @@ -49,7 +49,7 @@ 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 | -| **moneyConfig** | `object` | optional | Configuration for money field type | +| **currencyConfig** | `object` | optional | Configuration for currency field type | | **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/field/FieldType.mdx b/content/docs/references/data/field/FieldType.mdx index 3acd0b032..2a05aa758 100644 --- a/content/docs/references/data/field/FieldType.mdx +++ b/content/docs/references/data/field/FieldType.mdx @@ -17,7 +17,6 @@ description: FieldType Schema Reference * `number` * `currency` * `percent` -* `money` * `date` * `datetime` * `time` diff --git a/content/docs/references/ui/action/ActionParam.mdx b/content/docs/references/ui/action/ActionParam.mdx index eff673737..54cbb6ed5 100644 --- a/content/docs/references/ui/action/ActionParam.mdx +++ b/content/docs/references/ui/action/ActionParam.mdx @@ -9,6 +9,6 @@ description: ActionParam Schema Reference | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | | | **label** | `string` | ✅ | | -| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'money' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'select' \| 'lookup' \| 'master_detail' \| 'image' \| 'file' \| 'avatar' \| 'formula' \| 'summary' \| 'autonumber' \| 'location' \| 'geolocation' \| 'address' \| 'code' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode'>` | ✅ | | +| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'select' \| 'lookup' \| 'master_detail' \| 'image' \| 'file' \| 'avatar' \| 'formula' \| 'summary' \| 'autonumber' \| 'location' \| 'geolocation' \| 'address' \| 'code' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode'>` | ✅ | | | **required** | `boolean` | optional | | | **options** | `object[]` | optional | | diff --git a/packages/spec/json-schema/data/MoneyConfig.json b/packages/spec/json-schema/data/CurrencyConfig.json similarity index 93% rename from packages/spec/json-schema/data/MoneyConfig.json rename to packages/spec/json-schema/data/CurrencyConfig.json index 7223f4ae1..b2b90e72a 100644 --- a/packages/spec/json-schema/data/MoneyConfig.json +++ b/packages/spec/json-schema/data/CurrencyConfig.json @@ -1,7 +1,7 @@ { - "$ref": "#/definitions/MoneyConfig", + "$ref": "#/definitions/CurrencyConfig", "definitions": { - "MoneyConfig": { + "CurrencyConfig": { "type": "object", "properties": { "precision": { diff --git a/packages/spec/json-schema/data/MoneyValue.json b/packages/spec/json-schema/data/CurrencyValue.json similarity index 88% rename from packages/spec/json-schema/data/MoneyValue.json rename to packages/spec/json-schema/data/CurrencyValue.json index 72586a016..b1efdec93 100644 --- a/packages/spec/json-schema/data/MoneyValue.json +++ b/packages/spec/json-schema/data/CurrencyValue.json @@ -1,7 +1,7 @@ { - "$ref": "#/definitions/MoneyValue", + "$ref": "#/definitions/CurrencyValue", "definitions": { - "MoneyValue": { + "CurrencyValue": { "type": "object", "properties": { "value": { diff --git a/packages/spec/json-schema/data/Field.json b/packages/spec/json-schema/data/Field.json index b19cb806e..24c29ec55 100644 --- a/packages/spec/json-schema/data/Field.json +++ b/packages/spec/json-schema/data/Field.json @@ -28,7 +28,6 @@ "number", "currency", "percent", - "money", "date", "datetime", "time", @@ -304,7 +303,7 @@ "type": "boolean", "description": "Enable camera scanning for barcode/QR code input" }, - "moneyConfig": { + "currencyConfig": { "type": "object", "properties": { "precision": { @@ -332,7 +331,7 @@ } }, "additionalProperties": false, - "description": "Configuration for money field type" + "description": "Configuration for currency field type" }, "hidden": { "type": "boolean", diff --git a/packages/spec/json-schema/data/FieldType.json b/packages/spec/json-schema/data/FieldType.json index e19fdc2a8..3b5fbe801 100644 --- a/packages/spec/json-schema/data/FieldType.json +++ b/packages/spec/json-schema/data/FieldType.json @@ -16,7 +16,6 @@ "number", "currency", "percent", - "money", "date", "datetime", "time", diff --git a/packages/spec/json-schema/data/Object.json b/packages/spec/json-schema/data/Object.json index 20b14b93b..d1efbe6a3 100644 --- a/packages/spec/json-schema/data/Object.json +++ b/packages/spec/json-schema/data/Object.json @@ -68,7 +68,6 @@ "number", "currency", "percent", - "money", "date", "datetime", "time", @@ -344,7 +343,7 @@ "type": "boolean", "description": "Enable camera scanning for barcode/QR code input" }, - "moneyConfig": { + "currencyConfig": { "type": "object", "properties": { "precision": { @@ -372,7 +371,7 @@ } }, "additionalProperties": false, - "description": "Configuration for money field type" + "description": "Configuration for currency field type" }, "hidden": { "type": "boolean", diff --git a/packages/spec/json-schema/ui/Action.json b/packages/spec/json-schema/ui/Action.json index 7e9e38aeb..b57f91faf 100644 --- a/packages/spec/json-schema/ui/Action.json +++ b/packages/spec/json-schema/ui/Action.json @@ -79,7 +79,6 @@ "number", "currency", "percent", - "money", "date", "datetime", "time", diff --git a/packages/spec/json-schema/ui/ActionParam.json b/packages/spec/json-schema/ui/ActionParam.json index 0cd56be30..1564e188c 100644 --- a/packages/spec/json-schema/ui/ActionParam.json +++ b/packages/spec/json-schema/ui/ActionParam.json @@ -25,7 +25,6 @@ "number", "currency", "percent", - "money", "date", "datetime", "time", diff --git a/packages/spec/json-schema/ui/FieldWidgetProps.json b/packages/spec/json-schema/ui/FieldWidgetProps.json index 7a924fb82..a970401fb 100644 --- a/packages/spec/json-schema/ui/FieldWidgetProps.json +++ b/packages/spec/json-schema/ui/FieldWidgetProps.json @@ -48,7 +48,6 @@ "number", "currency", "percent", - "money", "date", "datetime", "time", @@ -324,7 +323,7 @@ "type": "boolean", "description": "Enable camera scanning for barcode/QR code input" }, - "moneyConfig": { + "currencyConfig": { "type": "object", "properties": { "precision": { @@ -352,7 +351,7 @@ } }, "additionalProperties": false, - "description": "Configuration for money field type" + "description": "Configuration for currency field type" }, "hidden": { "type": "boolean", diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index cdbd0324a..ad4ca68b4 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -3,12 +3,12 @@ import { FieldSchema, FieldType, SelectOptionSchema, - MoneyConfigSchema, - MoneyValueSchema, + CurrencyConfigSchema, + CurrencyValueSchema, Field, type SelectOption, - type MoneyConfig, - type MoneyValue + type CurrencyConfig, + type CurrencyValue } from './field.zod'; describe('FieldType', () => { @@ -16,7 +16,7 @@ describe('FieldType', () => { const validTypes = [ 'text', 'textarea', 'email', 'url', 'phone', 'password', 'markdown', 'html', 'richtext', - 'number', 'currency', 'percent', 'money', + 'number', 'currency', 'percent', 'date', 'datetime', 'time', 'boolean', 'select', @@ -65,19 +65,19 @@ describe('SelectOptionSchema', () => { }); }); -describe('MoneyConfigSchema', () => { - it('should accept valid money config with all fields', () => { - const validConfig: MoneyConfig = { +describe('CurrencyConfigSchema', () => { + it('should accept valid currency config with all fields', () => { + const validConfig: CurrencyConfig = { precision: 2, currencyMode: 'dynamic', defaultCurrency: 'USD', }; - expect(() => MoneyConfigSchema.parse(validConfig)).not.toThrow(); + expect(() => CurrencyConfigSchema.parse(validConfig)).not.toThrow(); }); it('should apply default values', () => { - const config = MoneyConfigSchema.parse({}); + const config = CurrencyConfigSchema.parse({}); expect(config.precision).toBe(2); expect(config.currencyMode).toBe('dynamic'); @@ -88,7 +88,7 @@ describe('MoneyConfigSchema', () => { const validPrecisions = [0, 2, 4, 8, 10]; validPrecisions.forEach(precision => { - expect(() => MoneyConfigSchema.parse({ precision })).not.toThrow(); + expect(() => CurrencyConfigSchema.parse({ precision })).not.toThrow(); }); }); @@ -96,24 +96,24 @@ describe('MoneyConfigSchema', () => { const invalidPrecisions = [-1, 11, 15, 1.5]; invalidPrecisions.forEach(precision => { - expect(() => MoneyConfigSchema.parse({ precision })).toThrow(); + expect(() => CurrencyConfigSchema.parse({ precision })).toThrow(); }); }); it('should accept both currency modes', () => { - expect(() => MoneyConfigSchema.parse({ currencyMode: 'dynamic' })).not.toThrow(); - expect(() => MoneyConfigSchema.parse({ currencyMode: 'fixed' })).not.toThrow(); + expect(() => CurrencyConfigSchema.parse({ currencyMode: 'dynamic' })).not.toThrow(); + expect(() => CurrencyConfigSchema.parse({ currencyMode: 'fixed' })).not.toThrow(); }); it('should reject invalid currency modes', () => { - expect(() => MoneyConfigSchema.parse({ currencyMode: 'invalid' })).toThrow(); + expect(() => CurrencyConfigSchema.parse({ currencyMode: 'invalid' })).toThrow(); }); it('should enforce 3-character currency codes', () => { const validCodes = ['USD', 'CNY', 'EUR', 'GBP', 'JPY']; validCodes.forEach(code => { - expect(() => MoneyConfigSchema.parse({ defaultCurrency: code })).not.toThrow(); + expect(() => CurrencyConfigSchema.parse({ defaultCurrency: code })).not.toThrow(); }); }); @@ -121,48 +121,48 @@ describe('MoneyConfigSchema', () => { const invalidCodes = ['US', 'USDD', 'C', '']; invalidCodes.forEach(code => { - expect(() => MoneyConfigSchema.parse({ defaultCurrency: code })).toThrow(); + expect(() => CurrencyConfigSchema.parse({ defaultCurrency: code })).toThrow(); }); }); }); -describe('MoneyValueSchema', () => { - it('should accept valid money value', () => { - const validValue: MoneyValue = { +describe('CurrencyValueSchema', () => { + it('should accept valid currency value', () => { + const validValue: CurrencyValue = { value: 1000.50, currency: 'USD', }; - expect(() => MoneyValueSchema.parse(validValue)).not.toThrow(); + expect(() => CurrencyValueSchema.parse(validValue)).not.toThrow(); }); it('should accept zero value', () => { - const zeroValue: MoneyValue = { + const zeroValue: CurrencyValue = { value: 0, currency: 'CNY', }; - expect(() => MoneyValueSchema.parse(zeroValue)).not.toThrow(); + expect(() => CurrencyValueSchema.parse(zeroValue)).not.toThrow(); }); it('should accept negative values', () => { - const negativeValue: MoneyValue = { + const negativeValue: CurrencyValue = { value: -500.00, currency: 'EUR', }; - expect(() => MoneyValueSchema.parse(negativeValue)).not.toThrow(); + expect(() => CurrencyValueSchema.parse(negativeValue)).not.toThrow(); }); it('should reject invalid currency codes', () => { - expect(() => MoneyValueSchema.parse({ value: 100, currency: 'US' })).toThrow(); - expect(() => MoneyValueSchema.parse({ value: 100, currency: 'USDD' })).toThrow(); + expect(() => CurrencyValueSchema.parse({ value: 100, currency: 'US' })).toThrow(); + expect(() => CurrencyValueSchema.parse({ value: 100, currency: 'USDD' })).toThrow(); }); it('should reject missing required fields', () => { - expect(() => MoneyValueSchema.parse({ value: 100 })).toThrow(); - expect(() => MoneyValueSchema.parse({ currency: 'USD' })).toThrow(); - expect(() => MoneyValueSchema.parse({})).toThrow(); + expect(() => CurrencyValueSchema.parse({ value: 100 })).toThrow(); + expect(() => CurrencyValueSchema.parse({ currency: 'USD' })).toThrow(); + expect(() => CurrencyValueSchema.parse({})).toThrow(); }); }); @@ -662,61 +662,57 @@ describe('Field Factory Helpers', () => { }); }); - describe('Money Field Type', () => { - it('should accept money field type', () => { - expect(() => FieldType.parse('money')).not.toThrow(); - }); - - it('should create money field with default config', () => { - const moneyField = Field.money({ + describe('Currency Field Type with Multi-Currency Support', () => { + it('should create currency field with default config', () => { + const currencyField = Field.currency({ name: 'contract_amount', label: 'Contract Amount', }); - expect(moneyField.type).toBe('money'); - expect(moneyField.name).toBe('contract_amount'); - expect(moneyField.label).toBe('Contract Amount'); + expect(currencyField.type).toBe('currency'); + expect(currencyField.name).toBe('contract_amount'); + expect(currencyField.label).toBe('Contract Amount'); }); - it('should create money field with dynamic currency mode (default)', () => { - const moneyField = Field.money({ + it('should create currency field with dynamic currency mode (default)', () => { + const currencyField = Field.currency({ name: 'price', label: 'Price', - moneyConfig: { + currencyConfig: { precision: 2, currencyMode: 'dynamic', defaultCurrency: 'USD', }, }); - expect(moneyField.type).toBe('money'); - expect(moneyField.moneyConfig?.currencyMode).toBe('dynamic'); - expect(moneyField.moneyConfig?.defaultCurrency).toBe('USD'); - expect(moneyField.moneyConfig?.precision).toBe(2); + expect(currencyField.type).toBe('currency'); + expect(currencyField.currencyConfig?.currencyMode).toBe('dynamic'); + expect(currencyField.currencyConfig?.defaultCurrency).toBe('USD'); + expect(currencyField.currencyConfig?.precision).toBe(2); }); - it('should create money field with fixed currency mode', () => { - const moneyField = Field.money({ + it('should create currency field with fixed currency mode', () => { + const currencyField = Field.currency({ name: 'salary', label: 'Salary', - moneyConfig: { + currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'CNY', }, }); - expect(moneyField.type).toBe('money'); - expect(moneyField.moneyConfig?.currencyMode).toBe('fixed'); - expect(moneyField.moneyConfig?.defaultCurrency).toBe('CNY'); + expect(currencyField.type).toBe('currency'); + expect(currencyField.currencyConfig?.currencyMode).toBe('fixed'); + expect(currencyField.currencyConfig?.defaultCurrency).toBe('CNY'); }); - it('should validate money field with valid config', () => { + it('should validate currency field with valid config', () => { const validField = { name: 'revenue', label: 'Revenue', - type: 'money' as const, - moneyConfig: { + type: 'currency' as const, + currencyConfig: { precision: 4, currencyMode: 'dynamic' as const, defaultCurrency: 'EUR', @@ -726,32 +722,32 @@ describe('Field Factory Helpers', () => { const result = FieldSchema.safeParse(validField); expect(result.success).toBe(true); if (result.success) { - expect(result.data.moneyConfig?.precision).toBe(4); - expect(result.data.moneyConfig?.currencyMode).toBe('dynamic'); - expect(result.data.moneyConfig?.defaultCurrency).toBe('EUR'); + expect(result.data.currencyConfig?.precision).toBe(4); + expect(result.data.currencyConfig?.currencyMode).toBe('dynamic'); + expect(result.data.currencyConfig?.defaultCurrency).toBe('EUR'); } }); - it('should apply default values for money config', () => { + it('should apply default values for currency config', () => { const field = { name: 'amount', label: 'Amount', - type: 'money' as const, - moneyConfig: {}, + type: 'currency' as const, + currencyConfig: {}, }; const result = FieldSchema.parse(field); - expect(result.moneyConfig?.precision).toBe(2); - expect(result.moneyConfig?.currencyMode).toBe('dynamic'); - expect(result.moneyConfig?.defaultCurrency).toBe('CNY'); + expect(result.currencyConfig?.precision).toBe(2); + expect(result.currencyConfig?.currencyMode).toBe('dynamic'); + expect(result.currencyConfig?.defaultCurrency).toBe('CNY'); }); it('should reject invalid precision values', () => { const invalidField = { name: 'amount', label: 'Amount', - type: 'money' as const, - moneyConfig: { + type: 'currency' as const, + currencyConfig: { precision: -1, }, }; @@ -761,8 +757,8 @@ describe('Field Factory Helpers', () => { const tooHighPrecision = { name: 'amount', label: 'Amount', - type: 'money' as const, - moneyConfig: { + type: 'currency' as const, + currencyConfig: { precision: 11, }, }; @@ -774,8 +770,8 @@ describe('Field Factory Helpers', () => { const invalidField = { name: 'price', label: 'Price', - type: 'money' as const, - moneyConfig: { + type: 'currency' as const, + currencyConfig: { defaultCurrency: 'US', // Must be 3 characters }, }; @@ -785,8 +781,8 @@ describe('Field Factory Helpers', () => { const tooLongCurrency = { name: 'price', label: 'Price', - type: 'money' as const, - moneyConfig: { + type: 'currency' as const, + currencyConfig: { defaultCurrency: 'USDD', }, }; @@ -798,8 +794,8 @@ describe('Field Factory Helpers', () => { const dynamicMode = { name: 'price1', label: 'Price 1', - type: 'money' as const, - moneyConfig: { + type: 'currency' as const, + currencyConfig: { currencyMode: 'dynamic' as const, }, }; @@ -807,8 +803,8 @@ describe('Field Factory Helpers', () => { const fixedMode = { name: 'price2', label: 'Price 2', - type: 'money' as const, - moneyConfig: { + type: 'currency' as const, + currencyConfig: { currencyMode: 'fixed' as const, }, }; @@ -818,23 +814,40 @@ describe('Field Factory Helpers', () => { }); it('should work with other field properties', () => { - const moneyField = Field.money({ + const currencyField = Field.currency({ name: 'budget', label: 'Project Budget', required: true, readonly: false, description: 'Total budget for the project', - moneyConfig: { + currencyConfig: { precision: 2, currencyMode: 'dynamic', defaultCurrency: 'USD', }, }); - expect(moneyField.type).toBe('money'); - expect(moneyField.required).toBe(true); - expect(moneyField.readonly).toBe(false); - expect(moneyField.description).toBe('Total budget for the project'); + expect(currencyField.type).toBe('currency'); + expect(currencyField.required).toBe(true); + expect(currencyField.readonly).toBe(false); + expect(currencyField.description).toBe('Total budget for the project'); + }); + + it('should support high precision for cryptocurrency', () => { + const cryptoField = Field.currency({ + name: 'btc_balance', + label: 'Bitcoin Balance', + currencyConfig: { + precision: 8, // Bitcoin uses 8 decimal places + currencyMode: 'fixed', + defaultCurrency: 'BTC', + }, + }); + + expect(cryptoField.type).toBe('currency'); + expect(cryptoField.currencyConfig?.precision).toBe(8); + expect(cryptoField.currencyConfig?.currencyMode).toBe('fixed'); + expect(cryptoField.currencyConfig?.defaultCurrency).toBe('BTC'); }); }); }); diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 3511484f8..f5904d611 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -9,7 +9,7 @@ export const FieldType = z.enum([ // Rich Content 'markdown', 'html', 'richtext', // Numbers - 'number', 'currency', 'percent', 'money', + 'number', 'currency', 'percent', // Date & Time 'date', 'datetime', 'time', // Logic @@ -58,8 +58,8 @@ export const LocationCoordinatesSchema = z.object({ }); /** - * Money Configuration Schema - * Configuration for money field type supporting multi-currency + * Currency Configuration Schema + * Configuration for currency field type supporting multi-currency * * Note: Currency codes are validated by length only (3 characters) to support: * - Standard ISO 4217 codes (USD, EUR, CNY, etc.) @@ -67,20 +67,20 @@ export const LocationCoordinatesSchema = z.object({ * - Custom business-specific codes * Stricter validation can be implemented at the application layer based on business requirements. */ -export const MoneyConfigSchema = z.object({ +export const CurrencyConfigSchema = z.object({ precision: z.number().int().min(0).max(10).default(2).describe('Decimal precision (default: 2)'), currencyMode: z.enum(['dynamic', 'fixed']).default('dynamic').describe('Currency mode: dynamic (user selectable) or fixed (single currency)'), defaultCurrency: z.string().length(3).default('CNY').describe('Default or fixed currency code (ISO 4217, e.g., USD, CNY, EUR)'), }); /** - * Money Value Schema - * Runtime value structure for money fields + * Currency Value Schema + * Runtime value structure for currency fields * * Note: Currency codes are validated by length only (3 characters) to support flexibility. - * See MoneyConfigSchema for details on currency code validation strategy. + * See CurrencyConfigSchema for details on currency code validation strategy. */ -export const MoneyValueSchema = z.object({ +export const CurrencyValueSchema = z.object({ value: z.number().describe('Monetary amount'), currency: z.string().length(3).describe('Currency code (ISO 4217)'), }); @@ -180,8 +180,8 @@ export const FieldSchema = z.object({ 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'), - // Money field config - moneyConfig: MoneyConfigSchema.optional().describe('Configuration for money field type'), + // Currency field config + currencyConfig: CurrencyConfigSchema.optional().describe('Configuration for currency field type'), /** Security & Visibility */ hidden: z.boolean().default(false).describe('Hidden from default UI'), @@ -197,8 +197,8 @@ export type Field = z.infer; export type SelectOption = z.infer; export type LocationCoordinates = z.infer; export type Address = z.infer; -export type MoneyConfig = z.infer; -export type MoneyValue = z.infer; +export type CurrencyConfig = z.infer; +export type CurrencyValue = z.infer; /** * Field Factory Helper @@ -323,20 +323,4 @@ export const Field = { type: 'geolocation', ...config } as const), - - /** - * Money field helper with multi-currency support - * - * @example Dynamic currency mode (default) - * Field.money({ label: 'Contract Amount', precision: 2 }) - * - * @example Fixed currency mode - * Field.money({ label: 'Price', moneyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }) - * - * @param config - Field configuration including optional moneyConfig - */ - money: (config: FieldInput = {}) => ({ - type: 'money', - ...config - } as const), };