diff --git a/CHANGELOG.md b/CHANGELOG.md index 5400146..48c92ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0] - 2026-02-13 + +### Changed + +- Enforce strict unknown-field validation across schemas via `unevaluatedProperties: false`. +- Add date normalization helpers and expose date-specific validation metadata from validator APIs. +- Expand validator exports for date utilities and schema date-field path discovery. +- Remove `counterparty` from transaction schema/examples and align fixtures/docs. +- Update example and test data to match strict schema behavior. + +### Breaking + +- Payloads containing undeclared properties now fail validation. +- `counterparty` is no longer accepted on transactions. + +### Notes + +- During this transition, test fixtures derive `schemaVersion` from package version to keep LucaLedger and LucaSchema on a synchronized `3.x` baseline. Contract-specific schema versioning and enforcement will be addressed in a follow-up cross-repo migration pass. + ## [2.3.4] - 2026-02-06 ### Changed diff --git a/README.md b/README.md index 5f8460c..05acfbd 100644 --- a/README.md +++ b/README.md @@ -84,9 +84,10 @@ const transaction = { authorizedAt: string | null; postedAt: string | null; currency: string | null; - amount: number; + amount: number; // integer minor units date: string; description: string; + memo: string | null; aggregationServiceId: string | null; transactionState: | 'PLANNED' @@ -115,7 +116,7 @@ const recurringTransaction = { id: string; accountId: string; categoryId: string | null; - amount: number; + amount: number; // integer minor units description: string; frequency: 'DAY' | 'WEEK' | 'MONTH' | 'YEAR'; interval: number; @@ -176,10 +177,10 @@ const statement = { accountId: string; startDate: string; endDate: string; - startingBalance: number; - endingBalance: number; - totalCharges: number; - totalPayments: number; + startingBalance: number; // integer minor units + endingBalance: number; // integer minor units + totalCharges: number; // integer minor units + totalPayments: number; // integer minor units isLocked: boolean; createdAt: string; updatedAt: string | null; @@ -196,9 +197,10 @@ Validates splits within a transaction. const transactionSplit = { id: string; transactionId: string; - amount: number; + amount: number; // integer minor units categoryId: string | null; description: string | null; + memo: string | null; createdAt: string; updatedAt: string | null; deletedAt?: string | null; @@ -231,6 +233,10 @@ This module exports helper utilities to inspect schemas and validate data: import { validate, validateCollection, + normalizeDateString, + isDateStringFixable, + getDateFieldPaths, + getDateFieldPathsByCollection, getValidFields, getRequiredFields, stripInvalidFields, @@ -240,8 +246,12 @@ import { } from '@luca-financial/luca-schema'; ``` -- `validate(schemaKey, data)` → `{ valid: boolean, errors: AjvError[] }` -- `validateCollection(schemaKey, array)` → `{ valid: boolean, errors: [{ index, entity, errors }] }` +- `validate(schemaKey, data)` → `{ valid: boolean, errors: AjvError[], metadata: { dateFormatIssues, hasFixableDateFormatIssues } }` +- `validateCollection(schemaKey, array)` → `{ valid: boolean, errors: [{ index, entity, errors, metadata }], metadata: { hasFixableDateFormatIssues } }` +- `normalizeDateString(value)` → normalized `YYYY-MM-DD` for unambiguous date strings (`YYYY-MM-DD` or `YYYY/MM/DD`), else `null` +- `isDateStringFixable(value)` → `true` only for unambiguous slash date strings that can be safely normalized +- `getDateFieldPaths(schemaKey)` → `string[]` of `format: date` fields for a schema key +- `getDateFieldPathsByCollection()` → `{ accounts, categories, statements, recurringTransactions, recurringTransactionEvents, transactions, transactionSplits }` - `getValidFields(schemaKey)` → `Set` of all fields (includes common fields when applicable) - `getRequiredFields(schemaKey)` → `Set` of required fields (includes common required fields) - `stripInvalidFields(schemaKey, data)` → new object with only schema-defined keys @@ -249,6 +259,8 @@ import { - `enums` → enum definitions (including `LucaSchemas` keys) - `LucaSchemas` → names for schema keys (e.g., `LucaSchemas.TRANSACTION`) +All entity schemas and the top-level `lucaSchema` reject unknown properties. + ## Development ```bash diff --git a/examples/luca-schema-example.json b/examples/luca-schema-example.json index 86a5dea..7991ae6 100644 --- a/examples/luca-schema-example.json +++ b/examples/luca-schema-example.json @@ -354,7 +354,6 @@ "amount": 350000, "description": "Acme Corp - Paycheck", "memo": "Bi-weekly salary deposit", - "counterparty": "Acme Corp", "categoryId": "c4ba4b42-486e-4941-885b-099165695137", "statementId": null, "aggregationServiceId": "plaid_txn_001", @@ -373,7 +372,6 @@ "amount": -180000, "description": "Monthly rent payment", "memo": null, - "counterparty": "Property Management LLC", "categoryId": "63576b55-ba37-4f63-9e36-98ea446b60ac", "statementId": null, "aggregationServiceId": "plaid_txn_002", @@ -392,7 +390,6 @@ "amount": -8500, "description": "Fresh Market Grocery", "memo": null, - "counterparty": "Fresh Market", "categoryId": "25c7c73e-ce71-4aaf-8154-ea525c9d0616", "statementId": "480a0402-213e-42de-bdf2-b0154e14efe1", "aggregationServiceId": "plaid_txn_003", @@ -411,7 +408,6 @@ "amount": -4200, "description": "Pizza Palace", "memo": null, - "counterparty": "Pizza Palace", "categoryId": "db0c0a4a-83b0-4b37-a097-f72912cca2ab", "statementId": "480a0402-213e-42de-bdf2-b0154e14efe1", "aggregationServiceId": null, @@ -430,7 +426,6 @@ "amount": -1599, "description": "Netflix", "memo": "Monthly subscription", - "counterparty": "Netflix Inc", "categoryId": "d06fe492-a18f-4d3c-948d-f595c804c9b0", "statementId": "480a0402-213e-42de-bdf2-b0154e14efe1", "aggregationServiceId": "plaid_txn_005", @@ -449,7 +444,6 @@ "amount": -12000, "description": "City Utilities - Electric", "memo": null, - "counterparty": "City Utilities Department", "categoryId": "0453d898-7615-4544-8911-5dc9a4d937df", "statementId": null, "aggregationServiceId": "plaid_txn_006", @@ -468,7 +462,6 @@ "amount": -7500, "description": "Shell Gas Station", "memo": null, - "counterparty": "Shell", "categoryId": "82be18ba-7886-457d-8cf7-3ab278c4f67e", "statementId": "480a0402-213e-42de-bdf2-b0154e14efe1", "aggregationServiceId": null, @@ -487,7 +480,6 @@ "amount": 350000, "description": "Acme Corp - Paycheck", "memo": "Bi-weekly salary deposit", - "counterparty": "Acme Corp", "categoryId": "c4ba4b42-486e-4941-885b-099165695137", "statementId": null, "aggregationServiceId": "plaid_txn_008", @@ -506,7 +498,6 @@ "amount": -2500, "description": "Credit card payment", "memo": "Payment from checking", - "counterparty": "Big Bank Corp", "categoryId": null, "statementId": "480a0402-213e-42de-bdf2-b0154e14efe1", "aggregationServiceId": null, @@ -525,7 +516,6 @@ "amount": -15000, "description": "Online shopping purchase", "memo": "Split between categories", - "counterparty": "MegaStore Online", "categoryId": null, "statementId": "07cbbf3a-601e-46b9-a533-1e6cf9521afd", "aggregationServiceId": null, @@ -544,7 +534,6 @@ "amount": 350000, "description": "Acme Corp - Paycheck", "memo": "Bi-weekly salary deposit", - "counterparty": "Acme Corp", "categoryId": "c4ba4b42-486e-4941-885b-099165695137", "statementId": null, "aggregationServiceId": null, @@ -563,7 +552,6 @@ "amount": -8000, "description": "Planned grocery shopping", "memo": null, - "counterparty": null, "categoryId": "25c7c73e-ce71-4aaf-8154-ea525c9d0616", "statementId": null, "aggregationServiceId": null, @@ -582,7 +570,6 @@ "amount": -5000, "description": "Cancelled gym membership", "memo": "Accidentally charged, disputed", - "counterparty": "FitLife Gym", "categoryId": "09ea8a51-88b7-42de-9b09-c6698a4e1a68", "statementId": null, "aggregationServiceId": null, @@ -601,7 +588,6 @@ "amount": -3000, "description": "Coffee shop duplicate charge", "memo": "Refunded after dispute", - "counterparty": "Coffee Corner", "categoryId": "db0c0a4a-83b0-4b37-a097-f72912cca2ab", "statementId": "480a0402-213e-42de-bdf2-b0154e14efe1", "aggregationServiceId": null, @@ -620,7 +606,6 @@ "amount": -185000, "description": "Monthly rent payment (adjusted for late fee)", "memo": "Modified from recurring transaction", - "counterparty": "Property Management LLC", "categoryId": "63576b55-ba37-4f63-9e36-98ea446b60ac", "statementId": null, "aggregationServiceId": null, @@ -639,7 +624,6 @@ "amount": 100000, "description": "Monthly savings transfer", "memo": "Building emergency fund", - "counterparty": null, "categoryId": null, "statementId": null, "aggregationServiceId": "plaid_txn_016", @@ -658,7 +642,6 @@ "amount": -2000, "description": "Payment to credit card", "memo": null, - "counterparty": "Big Bank Corp", "categoryId": null, "statementId": "07cbbf3a-601e-46b9-a533-1e6cf9521afd", "aggregationServiceId": null, @@ -677,7 +660,6 @@ "amount": -4500, "description": "Duplicate transaction - deleted", "memo": "Accidentally entered twice", - "counterparty": null, "categoryId": null, "statementId": null, "aggregationServiceId": null, @@ -696,7 +678,6 @@ "amount": -5000, "description": "Upcoming restaurant reservation", "memo": "Anniversary dinner", - "counterparty": "Fancy Restaurant", "categoryId": "db0c0a4a-83b0-4b37-a097-f72912cca2ab", "statementId": null, "aggregationServiceId": null, @@ -715,7 +696,6 @@ "amount": -13500, "description": "City Utilities - Electric (higher usage)", "memo": "Modified from recurring due to cold weather", - "counterparty": "City Utilities Department", "categoryId": "0453d898-7615-4544-8911-5dc9a4d937df", "statementId": null, "aggregationServiceId": null, diff --git a/package.json b/package.json index bd83865..083251d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@luca-financial/luca-schema", - "version": "2.3.4", + "version": "3.0.0", "description": "Schemas for the Luca Ledger application", "author": "Johnathan Aspinwall", "main": "dist/esm/index.js", diff --git a/src/dateUtils.js b/src/dateUtils.js new file mode 100644 index 0000000..ff76eb8 --- /dev/null +++ b/src/dateUtils.js @@ -0,0 +1,58 @@ +const CANONICAL_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; +const SLASH_DATE_PATTERN = /^(\d{4})\/(\d{2})\/(\d{2})$/; + +function isValidDateParts(year, month, day) { + if (month < 1 || month > 12) return false; + if (day < 1 || day > 31) return false; + + const candidate = new Date(Date.UTC(year, month - 1, day)); + return ( + candidate.getUTCFullYear() === year && + candidate.getUTCMonth() === month - 1 && + candidate.getUTCDate() === day + ); +} + +function parseDateParts(value, pattern) { + const match = value.match(pattern); + if (!match) return null; + + const year = Number.parseInt(match[1], 10); + const month = Number.parseInt(match[2], 10); + const day = Number.parseInt(match[3], 10); + + if (!isValidDateParts(year, month, day)) return null; + return { year, month, day }; +} + +/** + * Returns a normalized YYYY-MM-DD date string for unambiguous values. + * Accepts canonical YYYY-MM-DD and slash YYYY/MM/DD input. + * Returns null for non-date or ambiguous strings. + * @param {unknown} value + * @returns {string | null} + */ +export function normalizeDateString(value) { + if (typeof value !== 'string') return null; + + const canonicalParts = parseDateParts(value, CANONICAL_DATE_PATTERN); + if (canonicalParts) return value; + + const slashParts = parseDateParts(value, SLASH_DATE_PATTERN); + if (!slashParts) return null; + + return `${slashParts.year.toString().padStart(4, '0')}-${slashParts.month + .toString() + .padStart(2, '0')}-${slashParts.day.toString().padStart(2, '0')}`; +} + +/** + * Indicates whether a value is a fixable slash-form date (YYYY/MM/DD). + * @param {unknown} value + * @returns {boolean} + */ +export function isDateStringFixable(value) { + if (typeof value !== 'string') return false; + const normalized = normalizeDateString(value); + return normalized !== null && normalized !== value; +} diff --git a/src/index.js b/src/index.js index d9a210c..5d70a8d 100644 --- a/src/index.js +++ b/src/index.js @@ -1,14 +1,39 @@ -import * as schemaIndex from './schemas/index.js'; +import { + account, + category, + common, + enums as enumsSchema, + lucaSchema as lucaSchemaJson, + recurringTransaction, + recurringTransactionEvent, + statement, + transaction, + transactionSplit +} from './schemas/index.js'; import { enums, LucaSchemas } from './enums.js'; import { + getDateFieldPaths, + getDateFieldPathsByCollection, getRequiredFields, getValidFields, stripInvalidFields, validate, validateCollection } from './lucaValidator.js'; +import { isDateStringFixable, normalizeDateString } from './dateUtils.js'; -const schemas = { ...schemaIndex, enums: schemaIndex.enums }; +const schemas = { + account, + category, + common, + lucaSchema: lucaSchemaJson, + statement, + recurringTransaction, + recurringTransactionEvent, + transaction, + transactionSplit, + enums: enumsSchema +}; export const accountSchema = schemas.account; export const categorySchema = schemas.category; @@ -26,6 +51,10 @@ export { schemas, validate, validateCollection, + normalizeDateString, + isDateStringFixable, + getDateFieldPaths, + getDateFieldPathsByCollection, getValidFields, getRequiredFields, stripInvalidFields diff --git a/src/lucaValidator.js b/src/lucaValidator.js index 00e2b6a..31c191c 100644 --- a/src/lucaValidator.js +++ b/src/lucaValidator.js @@ -1,5 +1,6 @@ import Ajv2020 from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; +import { isDateStringFixable, normalizeDateString } from './dateUtils.js'; import accountSchemaJson from './schemas/account.json' with { type: 'json' }; import categorySchemaJson from './schemas/category.json' with { type: 'json' }; import commonSchemaJson from './schemas/common.json' with { type: 'json' }; @@ -27,6 +28,7 @@ const supportSchemas = [commonSchemaJson, enumsSchemaJson]; let sharedAjv; const validFieldsCache = new Map(); const requiredFieldsCache = new Map(); +const dateFieldPathsCache = new Map(); function getValidator() { if (sharedAjv) return sharedAjv; @@ -89,11 +91,111 @@ function isPlainObject(value) { return proto === Object.prototype || proto === null; } +function decodePointerToken(token) { + return token.replace(/~1/g, '/').replace(/~0/g, '~'); +} + +function getValueAtInstancePath(data, instancePath) { + if (!instancePath) return data; + if (typeof instancePath !== 'string' || !instancePath.startsWith('/')) { + return undefined; + } + + const tokens = instancePath + .slice(1) + .split('/') + .filter(token => token.length > 0) + .map(decodePointerToken); + + let current = data; + for (const token of tokens) { + if (current === null || current === undefined) return undefined; + if (Array.isArray(current)) { + const index = Number.parseInt(token, 10); + if (!Number.isInteger(index) || index < 0 || index >= current.length) { + return undefined; + } + current = current[index]; + continue; + } + if (typeof current !== 'object') return undefined; + current = current[token]; + } + + return current; +} + +function createDateFormatIssue(error, data) { + const instancePath = + typeof error?.instancePath === 'string' ? error.instancePath : ''; + const value = getValueAtInstancePath(data, instancePath); + const normalizedValue = normalizeDateString(value); + const fixable = isDateStringFixable(value); + + return { + instancePath, + schemaPath: typeof error?.schemaPath === 'string' ? error.schemaPath : '', + keyword: 'format', + format: 'date', + value, + fixable, + normalizedValue: fixable ? normalizedValue : null + }; +} + +function buildValidationMetadata(errors, data) { + const dateFormatIssues = []; + for (const error of errors) { + if (error?.keyword !== 'format') continue; + if (error?.params?.format !== 'date') continue; + dateFormatIssues.push(createDateFormatIssue(error, data)); + } + + return { + dateFormatIssues, + hasFixableDateFormatIssues: dateFormatIssues.some(issue => issue.fixable) + }; +} + +function collectDatePathsFromSchemaFragment(schemaFragment, prefix = '') { + if (!schemaFragment || typeof schemaFragment !== 'object') return []; + + const paths = []; + const properties = isPlainObject(schemaFragment.properties) + ? schemaFragment.properties + : {}; + + for (const [fieldName, fieldSchema] of Object.entries(properties)) { + const path = prefix ? `${prefix}.${fieldName}` : fieldName; + if (!fieldSchema || typeof fieldSchema !== 'object') continue; + + if (fieldSchema.format === 'date') { + paths.push(path); + } + + paths.push(...collectDatePathsFromSchemaFragment(fieldSchema, path)); + + if (fieldSchema.items && typeof fieldSchema.items === 'object') { + const itemPath = `${path}[]`; + paths.push( + ...collectDatePathsFromSchemaFragment(fieldSchema.items, itemPath) + ); + } + } + + return paths; +} + export function validate(schemaKey, data) { const ajv = getValidator(); const schema = getSchema(schemaKey); const isValid = ajv.validate(schema, data); - return { valid: isValid, errors: ajv.errors ?? [] }; + const errors = ajv.errors ?? []; + return { + valid: isValid, + errors, + metadata: buildValidationMetadata(errors, data) + }; } /** @@ -130,6 +232,50 @@ export function getRequiredFields(schemaKey) { return fields; } +/** + * Returns cached date-format field paths for a schema key. + * Paths are dot-delimited and include [] for array item traversal. + * @param {string} schemaKey + * @returns {Array} + */ +export function getDateFieldPaths(schemaKey) { + if (dateFieldPathsCache.has(schemaKey)) { + return dateFieldPathsCache.get(schemaKey); + } + + const schema = getSchema(schemaKey); + const paths = collectDatePathsFromSchemaFragment({ + properties: getSchemaProperties(schema) + }); + const deduped = [...new Set(paths)]; + dateFieldPathsCache.set(schemaKey, deduped); + return deduped; +} + +/** + * Returns date-format field paths keyed by collection name. + * @returns {{ + * accounts: Array, + * categories: Array, + * statements: Array, + * recurringTransactions: Array, + * recurringTransactionEvents: Array, + * transactions: Array, + * transactionSplits: Array + * }} + */ +export function getDateFieldPathsByCollection() { + return { + accounts: getDateFieldPaths('account'), + categories: getDateFieldPaths('category'), + statements: getDateFieldPaths('statement'), + recurringTransactions: getDateFieldPaths('recurringTransaction'), + recurringTransactionEvents: getDateFieldPaths('recurringTransactionEvent'), + transactions: getDateFieldPaths('transaction'), + transactionSplits: getDateFieldPaths('transactionSplit') + }; +} + /** * Returns a new object containing only fields defined in the schema. * @param {string} schemaKey @@ -167,15 +313,25 @@ export function validateCollection(schemaKey, arrayOfEntities) { arrayOfEntities.forEach((entity, index) => { const isValid = validateFn(entity); if (!isValid) { + const entityErrors = validateFn.errors ?? []; errors.push({ index, entity, - errors: validateFn.errors ?? [] + errors: entityErrors, + metadata: buildValidationMetadata(entityErrors, entity) }); } }); - return { valid: errors.length === 0, errors }; + return { + valid: errors.length === 0, + errors, + metadata: { + hasFixableDateFormatIssues: errors.some( + entityError => entityError.metadata.hasFixableDateFormatIssues + ) + } + }; } export { schemas }; diff --git a/src/schemas/account.json b/src/schemas/account.json index 5c00640..cf04899 100644 --- a/src/schemas/account.json +++ b/src/schemas/account.json @@ -66,5 +66,6 @@ "description": "Timestamp when the account was closed, if applicable (UTC)." } }, + "unevaluatedProperties": false, "required": ["name", "type"] } diff --git a/src/schemas/category.json b/src/schemas/category.json index 8b57f7e..16f4994 100644 --- a/src/schemas/category.json +++ b/src/schemas/category.json @@ -34,5 +34,6 @@ "description": "The identifier of the parent category, if any. Null if the category is top-level. Parents must be top-level (no deeper than two levels; enforced outside JSON Schema)." } }, + "unevaluatedProperties": false, "required": ["slug", "name", "parentId"] } diff --git a/src/schemas/lucaSchema.json b/src/schemas/lucaSchema.json index 30d70ad..4a811c7 100644 --- a/src/schemas/lucaSchema.json +++ b/src/schemas/lucaSchema.json @@ -75,5 +75,6 @@ "$ref": "./transactionSplit.json" } } - } + }, + "unevaluatedProperties": false } diff --git a/src/schemas/recurringTransaction.json b/src/schemas/recurringTransaction.json index efe9ac4..831f059 100644 --- a/src/schemas/recurringTransaction.json +++ b/src/schemas/recurringTransaction.json @@ -74,6 +74,7 @@ "description": "Current state of the recurring transaction series." } }, + "unevaluatedProperties": false, "required": [ "accountId", "categoryId", diff --git a/src/schemas/recurringTransactionEvent.json b/src/schemas/recurringTransactionEvent.json index e715a8a..1ee4608 100644 --- a/src/schemas/recurringTransactionEvent.json +++ b/src/schemas/recurringTransactionEvent.json @@ -60,5 +60,6 @@ "transactionId" ] } - ] + ], + "unevaluatedProperties": false } diff --git a/src/schemas/statement.json b/src/schemas/statement.json index c2bbf6c..a369a9b 100644 --- a/src/schemas/statement.json +++ b/src/schemas/statement.json @@ -57,6 +57,7 @@ "description": "Whether the statement is locked for editing" } }, + "unevaluatedProperties": false, "required": [ "accountId", "startDate", diff --git a/src/schemas/transaction.json b/src/schemas/transaction.json index 81b9f35..0a0fe4e 100644 --- a/src/schemas/transaction.json +++ b/src/schemas/transaction.json @@ -52,6 +52,11 @@ "minLength": 1, "description": "Description of the transaction" }, + "memo": { + "type": ["string", "null"], + "title": "Memo", + "description": "Optional memo for the transaction." + }, "categoryId": { "type": ["string", "null"], "title": "Category ID", @@ -78,5 +83,6 @@ "description": "The current state of the transaction." } }, + "unevaluatedProperties": false, "required": ["accountId", "date", "amount", "description", "transactionState"] } diff --git a/src/schemas/transactionSplit.json b/src/schemas/transactionSplit.json index fe94172..b8bfc36 100644 --- a/src/schemas/transactionSplit.json +++ b/src/schemas/transactionSplit.json @@ -33,7 +33,13 @@ "type": ["string", "null"], "title": "Description", "description": "Optional description for the split." + }, + "memo": { + "type": ["string", "null"], + "title": "Memo", + "description": "Optional memo for this split line." } }, + "unevaluatedProperties": false, "required": ["transactionId", "amount", "categoryId"] } diff --git a/src/tests/lucaSchema.test.js b/src/tests/lucaSchema.test.js index c328533..f3e4ebd 100644 --- a/src/tests/lucaSchema.test.js +++ b/src/tests/lucaSchema.test.js @@ -36,4 +36,9 @@ describe('lucaSchema aggregate', () => { }); expectInvalid(validate, 'lucaSchema', doc); }); + + test('unknown top-level fields are invalid', () => { + const doc = makeLucaSchemaDoc({ unexpectedField: true }); + expectInvalid(validate, 'lucaSchema', doc); + }); }); diff --git a/src/tests/statement.test.js b/src/tests/statement.test.js index 5a15f44..d137c32 100644 --- a/src/tests/statement.test.js +++ b/src/tests/statement.test.js @@ -1,5 +1,11 @@ -import { describe, test } from '@jest/globals'; -import { validate } from '../index.js'; +import { describe, expect, test } from '@jest/globals'; +import { + getDateFieldPaths, + getDateFieldPathsByCollection, + isDateStringFixable, + normalizeDateString, + validate +} from '../index.js'; import { makeStatement, expectValid, expectInvalid } from './test-fixtures.js'; describe('statement schema', () => { @@ -25,4 +31,49 @@ describe('statement schema', () => { delete statement.startDate; expectInvalid(validate, 'statement', statement); }); + + test('slash formatted date returns fixable format metadata', () => { + const statement = makeStatement({ startDate: '2024/01/02' }); + const result = validate('statement', statement); + + expect(result.valid).toBe(false); + expect(result.metadata.dateFormatIssues).toHaveLength(1); + expect(result.metadata.dateFormatIssues[0]).toMatchObject({ + instancePath: '/startDate', + format: 'date', + fixable: true, + normalizedValue: '2024-01-02' + }); + expect(result.metadata.hasFixableDateFormatIssues).toBe(true); + }); + + test('ambiguous date string is non-fixable', () => { + const statement = makeStatement({ startDate: '01/02/2024' }); + const result = validate('statement', statement); + + expect(result.valid).toBe(false); + expect(result.metadata.dateFormatIssues).toHaveLength(1); + expect(result.metadata.dateFormatIssues[0]).toMatchObject({ + instancePath: '/startDate', + format: 'date', + fixable: false, + normalizedValue: null + }); + expect(result.metadata.hasFixableDateFormatIssues).toBe(false); + }); + + test('date path helpers expose statement date fields', () => { + expect(getDateFieldPaths('statement')).toEqual(['startDate', 'endDate']); + expect(getDateFieldPathsByCollection().statements).toEqual([ + 'startDate', + 'endDate' + ]); + }); + + test('date utility normalizes only unambiguous slash dates', () => { + expect(normalizeDateString('2024/12/05')).toBe('2024-12-05'); + expect(normalizeDateString('12/05/2024')).toBeNull(); + expect(isDateStringFixable('2024/12/05')).toBe(true); + expect(isDateStringFixable('2024-12-05')).toBe(false); + }); }); diff --git a/src/tests/test-fixtures.js b/src/tests/test-fixtures.js index 245e31a..4f401e2 100644 --- a/src/tests/test-fixtures.js +++ b/src/tests/test-fixtures.js @@ -1,4 +1,5 @@ import { expect } from '@jest/globals'; +import packageJson from '../../package.json' with { type: 'json' }; export const commonBase = { createdAt: '2024-01-01T00:00:00Z', @@ -64,7 +65,6 @@ const transactionTemplate = { amount: -20, description: 'Coffee', memo: null, - counterparty: null, categoryId: null, statementId: null, transactionState: 'COMPLETED', @@ -99,8 +99,7 @@ const statementTemplate = { }; const lucaSchemaDocTemplate = { - id: ids.lucaSchemaId, - schemaVersion: '2.2.0' + schemaVersion: packageJson.version }; export const makeAccount = (overrides = {}) => ({ diff --git a/src/tests/transaction.test.js b/src/tests/transaction.test.js index 5674884..fa97c0e 100644 --- a/src/tests/transaction.test.js +++ b/src/tests/transaction.test.js @@ -17,4 +17,9 @@ describe('transaction schema', () => { delete transaction.transactionState; expectInvalid(validate, 'transaction', transaction); }); + + test('unknown fields are invalid', () => { + const transaction = makeTransaction({ unexpectedField: 'x' }); + expectInvalid(validate, 'transaction', transaction); + }); });