diff --git a/content/docs/references/data/field/CurrencyConfig.mdx b/content/docs/references/data/field/CurrencyConfig.mdx new file mode 100644 index 000000000..3bf14db03 --- /dev/null +++ b/content/docs/references/data/field/CurrencyConfig.mdx @@ -0,0 +1,12 @@ +--- +title: CurrencyConfig +description: CurrencyConfig 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/CurrencyValue.mdx b/content/docs/references/data/field/CurrencyValue.mdx new file mode 100644 index 000000000..cb7ecbc0e --- /dev/null +++ b/content/docs/references/data/field/CurrencyValue.mdx @@ -0,0 +1,11 @@ +--- +title: CurrencyValue +description: CurrencyValue Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **value** | `number` | ✅ | Monetary amount | +| **currency** | `string` | ✅ | Currency code (ISO 4217) | diff --git a/content/docs/references/data/field/Field.mdx b/content/docs/references/data/field/Field.mdx index 375140cb3..71a679c1c 100644 --- a/content/docs/references/data/field/Field.mdx +++ b/content/docs/references/data/field/Field.mdx @@ -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 | +| **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/packages/spec/json-schema/data/CurrencyConfig.json b/packages/spec/json-schema/data/CurrencyConfig.json new file mode 100644 index 000000000..b2b90e72a --- /dev/null +++ b/packages/spec/json-schema/data/CurrencyConfig.json @@ -0,0 +1,35 @@ +{ + "$ref": "#/definitions/CurrencyConfig", + "definitions": { + "CurrencyConfig": { + "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/CurrencyValue.json b/packages/spec/json-schema/data/CurrencyValue.json new file mode 100644 index 000000000..b1efdec93 --- /dev/null +++ b/packages/spec/json-schema/data/CurrencyValue.json @@ -0,0 +1,26 @@ +{ + "$ref": "#/definitions/CurrencyValue", + "definitions": { + "CurrencyValue": { + "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/Field.json b/packages/spec/json-schema/data/Field.json index 820239e14..24c29ec55 100644 --- a/packages/spec/json-schema/data/Field.json +++ b/packages/spec/json-schema/data/Field.json @@ -303,6 +303,36 @@ "type": "boolean", "description": "Enable camera scanning for barcode/QR code input" }, + "currencyConfig": { + "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 currency field type" + }, "hidden": { "type": "boolean", "default": false, diff --git a/packages/spec/json-schema/data/Object.json b/packages/spec/json-schema/data/Object.json index ac50cb15a..d1efbe6a3 100644 --- a/packages/spec/json-schema/data/Object.json +++ b/packages/spec/json-schema/data/Object.json @@ -343,6 +343,36 @@ "type": "boolean", "description": "Enable camera scanning for barcode/QR code input" }, + "currencyConfig": { + "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 currency field type" + }, "hidden": { "type": "boolean", "default": false, diff --git a/packages/spec/json-schema/ui/FieldWidgetProps.json b/packages/spec/json-schema/ui/FieldWidgetProps.json index 926c21d92..a970401fb 100644 --- a/packages/spec/json-schema/ui/FieldWidgetProps.json +++ b/packages/spec/json-schema/ui/FieldWidgetProps.json @@ -323,6 +323,36 @@ "type": "boolean", "description": "Enable camera scanning for barcode/QR code input" }, + "currencyConfig": { + "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 currency 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..ad4ca68b4 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, + CurrencyConfigSchema, + CurrencyValueSchema, Field, - type SelectOption + type SelectOption, + type CurrencyConfig, + type CurrencyValue } from './field.zod'; describe('FieldType', () => { @@ -61,6 +65,107 @@ describe('SelectOptionSchema', () => { }); }); +describe('CurrencyConfigSchema', () => { + it('should accept valid currency config with all fields', () => { + const validConfig: CurrencyConfig = { + precision: 2, + currencyMode: 'dynamic', + defaultCurrency: 'USD', + }; + + expect(() => CurrencyConfigSchema.parse(validConfig)).not.toThrow(); + }); + + it('should apply default values', () => { + const config = CurrencyConfigSchema.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(() => CurrencyConfigSchema.parse({ precision })).not.toThrow(); + }); + }); + + it('should reject invalid precision values', () => { + const invalidPrecisions = [-1, 11, 15, 1.5]; + + invalidPrecisions.forEach(precision => { + expect(() => CurrencyConfigSchema.parse({ precision })).toThrow(); + }); + }); + + it('should accept both currency modes', () => { + expect(() => CurrencyConfigSchema.parse({ currencyMode: 'dynamic' })).not.toThrow(); + expect(() => CurrencyConfigSchema.parse({ currencyMode: 'fixed' })).not.toThrow(); + }); + + it('should reject invalid currency modes', () => { + expect(() => CurrencyConfigSchema.parse({ currencyMode: 'invalid' })).toThrow(); + }); + + it('should enforce 3-character currency codes', () => { + const validCodes = ['USD', 'CNY', 'EUR', 'GBP', 'JPY']; + + validCodes.forEach(code => { + expect(() => CurrencyConfigSchema.parse({ defaultCurrency: code })).not.toThrow(); + }); + }); + + it('should reject invalid currency code lengths', () => { + const invalidCodes = ['US', 'USDD', 'C', '']; + + invalidCodes.forEach(code => { + expect(() => CurrencyConfigSchema.parse({ defaultCurrency: code })).toThrow(); + }); + }); +}); + +describe('CurrencyValueSchema', () => { + it('should accept valid currency value', () => { + const validValue: CurrencyValue = { + value: 1000.50, + currency: 'USD', + }; + + expect(() => CurrencyValueSchema.parse(validValue)).not.toThrow(); + }); + + it('should accept zero value', () => { + const zeroValue: CurrencyValue = { + value: 0, + currency: 'CNY', + }; + + expect(() => CurrencyValueSchema.parse(zeroValue)).not.toThrow(); + }); + + it('should accept negative values', () => { + const negativeValue: CurrencyValue = { + value: -500.00, + currency: 'EUR', + }; + + expect(() => CurrencyValueSchema.parse(negativeValue)).not.toThrow(); + }); + + it('should reject invalid currency codes', () => { + expect(() => CurrencyValueSchema.parse({ value: 100, currency: 'US' })).toThrow(); + expect(() => CurrencyValueSchema.parse({ value: 100, currency: 'USDD' })).toThrow(); + }); + + it('should reject missing required fields', () => { + expect(() => CurrencyValueSchema.parse({ value: 100 })).toThrow(); + expect(() => CurrencyValueSchema.parse({ currency: 'USD' })).toThrow(); + expect(() => CurrencyValueSchema.parse({})).toThrow(); + }); +}); + describe('FieldSchema', () => { describe('Basic Field Properties', () => { it('should accept valid field with minimal properties', () => { @@ -556,4 +661,193 @@ describe('Field Factory Helpers', () => { expect(geolocationField.allowGeocoding).toBe(false); }); }); + + 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(currencyField.type).toBe('currency'); + expect(currencyField.name).toBe('contract_amount'); + expect(currencyField.label).toBe('Contract Amount'); + }); + + it('should create currency field with dynamic currency mode (default)', () => { + const currencyField = Field.currency({ + name: 'price', + label: 'Price', + currencyConfig: { + precision: 2, + currencyMode: 'dynamic', + defaultCurrency: 'USD', + }, + }); + + 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 currency field with fixed currency mode', () => { + const currencyField = Field.currency({ + name: 'salary', + label: 'Salary', + currencyConfig: { + precision: 2, + currencyMode: 'fixed', + defaultCurrency: 'CNY', + }, + }); + + expect(currencyField.type).toBe('currency'); + expect(currencyField.currencyConfig?.currencyMode).toBe('fixed'); + expect(currencyField.currencyConfig?.defaultCurrency).toBe('CNY'); + }); + + it('should validate currency field with valid config', () => { + const validField = { + name: 'revenue', + label: 'Revenue', + type: 'currency' as const, + currencyConfig: { + precision: 4, + currencyMode: 'dynamic' as const, + defaultCurrency: 'EUR', + }, + }; + + const result = FieldSchema.safeParse(validField); + expect(result.success).toBe(true); + if (result.success) { + 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 currency config', () => { + const field = { + name: 'amount', + label: 'Amount', + type: 'currency' as const, + currencyConfig: {}, + }; + + const result = FieldSchema.parse(field); + 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: 'currency' as const, + currencyConfig: { + precision: -1, + }, + }; + + expect(() => FieldSchema.parse(invalidField)).toThrow(); + + const tooHighPrecision = { + name: 'amount', + label: 'Amount', + type: 'currency' as const, + currencyConfig: { + precision: 11, + }, + }; + + expect(() => FieldSchema.parse(tooHighPrecision)).toThrow(); + }); + + it('should reject invalid currency codes', () => { + const invalidField = { + name: 'price', + label: 'Price', + type: 'currency' as const, + currencyConfig: { + defaultCurrency: 'US', // Must be 3 characters + }, + }; + + expect(() => FieldSchema.parse(invalidField)).toThrow(); + + const tooLongCurrency = { + name: 'price', + label: 'Price', + type: 'currency' as const, + currencyConfig: { + defaultCurrency: 'USDD', + }, + }; + + expect(() => FieldSchema.parse(tooLongCurrency)).toThrow(); + }); + + it('should accept valid currency modes', () => { + const dynamicMode = { + name: 'price1', + label: 'Price 1', + type: 'currency' as const, + currencyConfig: { + currencyMode: 'dynamic' as const, + }, + }; + + const fixedMode = { + name: 'price2', + label: 'Price 2', + type: 'currency' as const, + currencyConfig: { + currencyMode: 'fixed' as const, + }, + }; + + expect(() => FieldSchema.parse(dynamicMode)).not.toThrow(); + expect(() => FieldSchema.parse(fixedMode)).not.toThrow(); + }); + + it('should work with other field properties', () => { + const currencyField = Field.currency({ + name: 'budget', + label: 'Project Budget', + required: true, + readonly: false, + description: 'Total budget for the project', + currencyConfig: { + precision: 2, + currencyMode: 'dynamic', + defaultCurrency: 'USD', + }, + }); + + 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 8abd1ddaa..f5904d611 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -57,6 +57,34 @@ export const LocationCoordinatesSchema = z.object({ accuracy: z.number().optional().describe('Accuracy in meters'), }); +/** + * 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.) + * - Cryptocurrency codes (BTC, ETH, etc.) + * - Custom business-specific codes + * Stricter validation can be implemented at the application layer based on business requirements. + */ +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)'), +}); + +/** + * Currency Value Schema + * Runtime value structure for currency fields + * + * Note: Currency codes are validated by length only (3 characters) to support flexibility. + * See CurrencyConfigSchema for details on currency code validation strategy. + */ +export const CurrencyValueSchema = 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 +180,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'), + // Currency field config + currencyConfig: CurrencyConfigSchema.optional().describe('Configuration for currency 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 +197,8 @@ export type Field = z.infer; export type SelectOption = z.infer; export type LocationCoordinates = z.infer; export type Address = z.infer; +export type CurrencyConfig = z.infer; +export type CurrencyValue = z.infer; /** * Field Factory Helper