@luca-financial/luca-schema 2.3.4 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.0.0] - 2026-02-13
9
+
10
+ ### Changed
11
+
12
+ - Enforce strict unknown-field validation across schemas via `unevaluatedProperties: false`.
13
+ - Add date normalization helpers and expose date-specific validation metadata from validator APIs.
14
+ - Expand validator exports for date utilities and schema date-field path discovery.
15
+ - Remove `counterparty` from transaction schema/examples and align fixtures/docs.
16
+ - Update example and test data to match strict schema behavior.
17
+
18
+ ### Breaking
19
+
20
+ - Payloads containing undeclared properties now fail validation.
21
+ - `counterparty` is no longer accepted on transactions.
22
+
23
+ ### Notes
24
+
25
+ - 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.
26
+
8
27
  ## [2.3.4] - 2026-02-06
9
28
 
10
29
  ### Changed
package/README.md CHANGED
@@ -84,9 +84,10 @@ const transaction = {
84
84
  authorizedAt: string | null;
85
85
  postedAt: string | null;
86
86
  currency: string | null;
87
- amount: number;
87
+ amount: number; // integer minor units
88
88
  date: string;
89
89
  description: string;
90
+ memo: string | null;
90
91
  aggregationServiceId: string | null;
91
92
  transactionState:
92
93
  | 'PLANNED'
@@ -115,7 +116,7 @@ const recurringTransaction = {
115
116
  id: string;
116
117
  accountId: string;
117
118
  categoryId: string | null;
118
- amount: number;
119
+ amount: number; // integer minor units
119
120
  description: string;
120
121
  frequency: 'DAY' | 'WEEK' | 'MONTH' | 'YEAR';
121
122
  interval: number;
@@ -176,10 +177,10 @@ const statement = {
176
177
  accountId: string;
177
178
  startDate: string;
178
179
  endDate: string;
179
- startingBalance: number;
180
- endingBalance: number;
181
- totalCharges: number;
182
- totalPayments: number;
180
+ startingBalance: number; // integer minor units
181
+ endingBalance: number; // integer minor units
182
+ totalCharges: number; // integer minor units
183
+ totalPayments: number; // integer minor units
183
184
  isLocked: boolean;
184
185
  createdAt: string;
185
186
  updatedAt: string | null;
@@ -196,9 +197,10 @@ Validates splits within a transaction.
196
197
  const transactionSplit = {
197
198
  id: string;
198
199
  transactionId: string;
199
- amount: number;
200
+ amount: number; // integer minor units
200
201
  categoryId: string | null;
201
202
  description: string | null;
203
+ memo: string | null;
202
204
  createdAt: string;
203
205
  updatedAt: string | null;
204
206
  deletedAt?: string | null;
@@ -231,6 +233,10 @@ This module exports helper utilities to inspect schemas and validate data:
231
233
  import {
232
234
  validate,
233
235
  validateCollection,
236
+ normalizeDateString,
237
+ isDateStringFixable,
238
+ getDateFieldPaths,
239
+ getDateFieldPathsByCollection,
234
240
  getValidFields,
235
241
  getRequiredFields,
236
242
  stripInvalidFields,
@@ -240,8 +246,12 @@ import {
240
246
  } from '@luca-financial/luca-schema';
241
247
  ```
242
248
 
243
- - `validate(schemaKey, data)` → `{ valid: boolean, errors: AjvError[] }`
244
- - `validateCollection(schemaKey, array)` → `{ valid: boolean, errors: [{ index, entity, errors }] }`
249
+ - `validate(schemaKey, data)` → `{ valid: boolean, errors: AjvError[], metadata: { dateFormatIssues, hasFixableDateFormatIssues } }`
250
+ - `validateCollection(schemaKey, array)` → `{ valid: boolean, errors: [{ index, entity, errors, metadata }], metadata: { hasFixableDateFormatIssues } }`
251
+ - `normalizeDateString(value)` → normalized `YYYY-MM-DD` for unambiguous date strings (`YYYY-MM-DD` or `YYYY/MM/DD`), else `null`
252
+ - `isDateStringFixable(value)` → `true` only for unambiguous slash date strings that can be safely normalized
253
+ - `getDateFieldPaths(schemaKey)` → `string[]` of `format: date` fields for a schema key
254
+ - `getDateFieldPathsByCollection()` → `{ accounts, categories, statements, recurringTransactions, recurringTransactionEvents, transactions, transactionSplits }`
245
255
  - `getValidFields(schemaKey)` → `Set<string>` of all fields (includes common fields when applicable)
246
256
  - `getRequiredFields(schemaKey)` → `Set<string>` of required fields (includes common required fields)
247
257
  - `stripInvalidFields(schemaKey, data)` → new object with only schema-defined keys
@@ -249,6 +259,8 @@ import {
249
259
  - `enums` → enum definitions (including `LucaSchemas` keys)
250
260
  - `LucaSchemas` → names for schema keys (e.g., `LucaSchemas.TRANSACTION`)
251
261
 
262
+ All entity schemas and the top-level `lucaSchema` reject unknown properties.
263
+
252
264
  ## Development
253
265
 
254
266
  ```bash
@@ -534,6 +534,7 @@ export type Transaction = Common5 & {
534
534
  currency?: Currency;
535
535
  amount: Amount1;
536
536
  description: Description2;
537
+ memo?: Memo;
537
538
  categoryId?: CategoryID1;
538
539
  statementId?: StatementID;
539
540
  aggregationServiceId?: AggregationServiceID1;
@@ -600,6 +601,10 @@ export type Amount1 = number;
600
601
  * Description of the transaction
601
602
  */
602
603
  export type Description2 = string;
604
+ /**
605
+ * Optional memo for the transaction.
606
+ */
607
+ export type Memo = string | null;
603
608
  /**
604
609
  * Category UUID for this transaction
605
610
  */
@@ -620,6 +625,7 @@ export type TransactionSplit = Common6 & {
620
625
  amount: Amount2;
621
626
  categoryId: CategoryID2;
622
627
  description?: Description3;
628
+ memo?: Memo1;
623
629
  };
624
630
  /**
625
631
  * UUID for the item
@@ -657,6 +663,10 @@ export type CategoryID2 = string | null;
657
663
  * Optional description for the split.
658
664
  */
659
665
  export type Description3 = string | null;
666
+ /**
667
+ * Optional memo for this split line.
668
+ */
669
+ export type Memo1 = string | null;
660
670
 
661
671
  /**
662
672
  * Schema for the luca ledger
@@ -1001,6 +1011,7 @@ export type Transaction = Common & {
1001
1011
  currency?: Currency;
1002
1012
  amount: Amount;
1003
1013
  description: Description;
1014
+ memo?: Memo;
1004
1015
  categoryId?: CategoryID;
1005
1016
  statementId?: StatementID;
1006
1017
  aggregationServiceId?: AggregationServiceID;
@@ -1067,6 +1078,10 @@ export type Amount = number;
1067
1078
  * Description of the transaction
1068
1079
  */
1069
1080
  export type Description = string;
1081
+ /**
1082
+ * Optional memo for the transaction.
1083
+ */
1084
+ export type Memo = string | null;
1070
1085
  /**
1071
1086
  * Category UUID for this transaction
1072
1087
  */
@@ -1099,6 +1114,7 @@ export type TransactionSplit = Common & {
1099
1114
  amount: Amount;
1100
1115
  categoryId: CategoryID;
1101
1116
  description?: Description;
1117
+ memo?: Memo;
1102
1118
  };
1103
1119
  /**
1104
1120
  * UUID for the item
@@ -1136,6 +1152,10 @@ export type CategoryID = string | null;
1136
1152
  * Optional description for the split.
1137
1153
  */
1138
1154
  export type Description = string | null;
1155
+ /**
1156
+ * Optional memo for this split line.
1157
+ */
1158
+ export type Memo = string | null;
1139
1159
 
1140
1160
  /**
1141
1161
  * Common properties for all schemas
package/dist/esm/index.js CHANGED
@@ -1,14 +1,39 @@
1
- import * as schemaIndex from './schemas/index.js';
1
+ import {
2
+ account,
3
+ category,
4
+ common,
5
+ enums as enumsSchema,
6
+ lucaSchema as lucaSchemaJson,
7
+ recurringTransaction,
8
+ recurringTransactionEvent,
9
+ statement,
10
+ transaction,
11
+ transactionSplit
12
+ } from './schemas/index.js';
2
13
  import { enums, LucaSchemas } from './enums.js';
3
14
  import {
15
+ getDateFieldPaths,
16
+ getDateFieldPathsByCollection,
4
17
  getRequiredFields,
5
18
  getValidFields,
6
19
  stripInvalidFields,
7
20
  validate,
8
21
  validateCollection
9
22
  } from './lucaValidator.js';
23
+ import { isDateStringFixable, normalizeDateString } from './dateUtils.js';
10
24
 
11
- const schemas = { ...schemaIndex, enums: schemaIndex.enums };
25
+ const schemas = {
26
+ account,
27
+ category,
28
+ common,
29
+ lucaSchema: lucaSchemaJson,
30
+ statement,
31
+ recurringTransaction,
32
+ recurringTransactionEvent,
33
+ transaction,
34
+ transactionSplit,
35
+ enums: enumsSchema
36
+ };
12
37
 
13
38
  export const accountSchema = schemas.account;
14
39
  export const categorySchema = schemas.category;
@@ -26,6 +51,10 @@ export {
26
51
  schemas,
27
52
  validate,
28
53
  validateCollection,
54
+ normalizeDateString,
55
+ isDateStringFixable,
56
+ getDateFieldPaths,
57
+ getDateFieldPathsByCollection,
29
58
  getValidFields,
30
59
  getRequiredFields,
31
60
  stripInvalidFields
@@ -1,5 +1,6 @@
1
1
  import Ajv2020 from 'ajv/dist/2020.js';
2
2
  import addFormats from 'ajv-formats';
3
+ import { isDateStringFixable, normalizeDateString } from './dateUtils.js';
3
4
  import accountSchemaJson from './schemas/account.json' with { type: 'json' };
4
5
  import categorySchemaJson from './schemas/category.json' with { type: 'json' };
5
6
  import commonSchemaJson from './schemas/common.json' with { type: 'json' };
@@ -27,6 +28,7 @@ const supportSchemas = [commonSchemaJson, enumsSchemaJson];
27
28
  let sharedAjv;
28
29
  const validFieldsCache = new Map();
29
30
  const requiredFieldsCache = new Map();
31
+ const dateFieldPathsCache = new Map();
30
32
 
31
33
  function getValidator() {
32
34
  if (sharedAjv) return sharedAjv;
@@ -89,11 +91,111 @@ function isPlainObject(value) {
89
91
  return proto === Object.prototype || proto === null;
90
92
  }
91
93
 
94
+ function decodePointerToken(token) {
95
+ return token.replace(/~1/g, '/').replace(/~0/g, '~');
96
+ }
97
+
98
+ function getValueAtInstancePath(data, instancePath) {
99
+ if (!instancePath) return data;
100
+ if (typeof instancePath !== 'string' || !instancePath.startsWith('/')) {
101
+ return undefined;
102
+ }
103
+
104
+ const tokens = instancePath
105
+ .slice(1)
106
+ .split('/')
107
+ .filter(token => token.length > 0)
108
+ .map(decodePointerToken);
109
+
110
+ let current = data;
111
+ for (const token of tokens) {
112
+ if (current === null || current === undefined) return undefined;
113
+ if (Array.isArray(current)) {
114
+ const index = Number.parseInt(token, 10);
115
+ if (!Number.isInteger(index) || index < 0 || index >= current.length) {
116
+ return undefined;
117
+ }
118
+ current = current[index];
119
+ continue;
120
+ }
121
+ if (typeof current !== 'object') return undefined;
122
+ current = current[token];
123
+ }
124
+
125
+ return current;
126
+ }
127
+
128
+ function createDateFormatIssue(error, data) {
129
+ const instancePath =
130
+ typeof error?.instancePath === 'string' ? error.instancePath : '';
131
+ const value = getValueAtInstancePath(data, instancePath);
132
+ const normalizedValue = normalizeDateString(value);
133
+ const fixable = isDateStringFixable(value);
134
+
135
+ return {
136
+ instancePath,
137
+ schemaPath: typeof error?.schemaPath === 'string' ? error.schemaPath : '',
138
+ keyword: 'format',
139
+ format: 'date',
140
+ value,
141
+ fixable,
142
+ normalizedValue: fixable ? normalizedValue : null
143
+ };
144
+ }
145
+
146
+ function buildValidationMetadata(errors, data) {
147
+ const dateFormatIssues = [];
148
+ for (const error of errors) {
149
+ if (error?.keyword !== 'format') continue;
150
+ if (error?.params?.format !== 'date') continue;
151
+ dateFormatIssues.push(createDateFormatIssue(error, data));
152
+ }
153
+
154
+ return {
155
+ dateFormatIssues,
156
+ hasFixableDateFormatIssues: dateFormatIssues.some(issue => issue.fixable)
157
+ };
158
+ }
159
+
160
+ function collectDatePathsFromSchemaFragment(schemaFragment, prefix = '') {
161
+ if (!schemaFragment || typeof schemaFragment !== 'object') return [];
162
+
163
+ const paths = [];
164
+ const properties = isPlainObject(schemaFragment.properties)
165
+ ? schemaFragment.properties
166
+ : {};
167
+
168
+ for (const [fieldName, fieldSchema] of Object.entries(properties)) {
169
+ const path = prefix ? `${prefix}.${fieldName}` : fieldName;
170
+ if (!fieldSchema || typeof fieldSchema !== 'object') continue;
171
+
172
+ if (fieldSchema.format === 'date') {
173
+ paths.push(path);
174
+ }
175
+
176
+ paths.push(...collectDatePathsFromSchemaFragment(fieldSchema, path));
177
+
178
+ if (fieldSchema.items && typeof fieldSchema.items === 'object') {
179
+ const itemPath = `${path}[]`;
180
+ paths.push(
181
+ ...collectDatePathsFromSchemaFragment(fieldSchema.items, itemPath)
182
+ );
183
+ }
184
+ }
185
+
186
+ return paths;
187
+ }
188
+
92
189
  export function validate(schemaKey, data) {
93
190
  const ajv = getValidator();
94
191
  const schema = getSchema(schemaKey);
95
192
  const isValid = ajv.validate(schema, data);
96
- return { valid: isValid, errors: ajv.errors ?? [] };
193
+ const errors = ajv.errors ?? [];
194
+ return {
195
+ valid: isValid,
196
+ errors,
197
+ metadata: buildValidationMetadata(errors, data)
198
+ };
97
199
  }
98
200
 
99
201
  /**
@@ -130,6 +232,50 @@ export function getRequiredFields(schemaKey) {
130
232
  return fields;
131
233
  }
132
234
 
235
+ /**
236
+ * Returns cached date-format field paths for a schema key.
237
+ * Paths are dot-delimited and include [] for array item traversal.
238
+ * @param {string} schemaKey
239
+ * @returns {Array<string>}
240
+ */
241
+ export function getDateFieldPaths(schemaKey) {
242
+ if (dateFieldPathsCache.has(schemaKey)) {
243
+ return dateFieldPathsCache.get(schemaKey);
244
+ }
245
+
246
+ const schema = getSchema(schemaKey);
247
+ const paths = collectDatePathsFromSchemaFragment({
248
+ properties: getSchemaProperties(schema)
249
+ });
250
+ const deduped = [...new Set(paths)];
251
+ dateFieldPathsCache.set(schemaKey, deduped);
252
+ return deduped;
253
+ }
254
+
255
+ /**
256
+ * Returns date-format field paths keyed by collection name.
257
+ * @returns {{
258
+ * accounts: Array<string>,
259
+ * categories: Array<string>,
260
+ * statements: Array<string>,
261
+ * recurringTransactions: Array<string>,
262
+ * recurringTransactionEvents: Array<string>,
263
+ * transactions: Array<string>,
264
+ * transactionSplits: Array<string>
265
+ * }}
266
+ */
267
+ export function getDateFieldPathsByCollection() {
268
+ return {
269
+ accounts: getDateFieldPaths('account'),
270
+ categories: getDateFieldPaths('category'),
271
+ statements: getDateFieldPaths('statement'),
272
+ recurringTransactions: getDateFieldPaths('recurringTransaction'),
273
+ recurringTransactionEvents: getDateFieldPaths('recurringTransactionEvent'),
274
+ transactions: getDateFieldPaths('transaction'),
275
+ transactionSplits: getDateFieldPaths('transactionSplit')
276
+ };
277
+ }
278
+
133
279
  /**
134
280
  * Returns a new object containing only fields defined in the schema.
135
281
  * @param {string} schemaKey
@@ -167,15 +313,25 @@ export function validateCollection(schemaKey, arrayOfEntities) {
167
313
  arrayOfEntities.forEach((entity, index) => {
168
314
  const isValid = validateFn(entity);
169
315
  if (!isValid) {
316
+ const entityErrors = validateFn.errors ?? [];
170
317
  errors.push({
171
318
  index,
172
319
  entity,
173
- errors: validateFn.errors ?? []
320
+ errors: entityErrors,
321
+ metadata: buildValidationMetadata(entityErrors, entity)
174
322
  });
175
323
  }
176
324
  });
177
325
 
178
- return { valid: errors.length === 0, errors };
326
+ return {
327
+ valid: errors.length === 0,
328
+ errors,
329
+ metadata: {
330
+ hasFixableDateFormatIssues: errors.some(
331
+ entityError => entityError.metadata.hasFixableDateFormatIssues
332
+ )
333
+ }
334
+ };
179
335
  }
180
336
 
181
337
  export { schemas };
@@ -66,5 +66,6 @@
66
66
  "description": "Timestamp when the account was closed, if applicable (UTC)."
67
67
  }
68
68
  },
69
+ "unevaluatedProperties": false,
69
70
  "required": ["name", "type"]
70
71
  }
@@ -34,5 +34,6 @@
34
34
  "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)."
35
35
  }
36
36
  },
37
+ "unevaluatedProperties": false,
37
38
  "required": ["slug", "name", "parentId"]
38
39
  }
@@ -75,5 +75,6 @@
75
75
  "$ref": "./transactionSplit.json"
76
76
  }
77
77
  }
78
- }
78
+ },
79
+ "unevaluatedProperties": false
79
80
  }
@@ -74,6 +74,7 @@
74
74
  "description": "Current state of the recurring transaction series."
75
75
  }
76
76
  },
77
+ "unevaluatedProperties": false,
77
78
  "required": [
78
79
  "accountId",
79
80
  "categoryId",
@@ -60,5 +60,6 @@
60
60
  "transactionId"
61
61
  ]
62
62
  }
63
- ]
63
+ ],
64
+ "unevaluatedProperties": false
64
65
  }
@@ -57,6 +57,7 @@
57
57
  "description": "Whether the statement is locked for editing"
58
58
  }
59
59
  },
60
+ "unevaluatedProperties": false,
60
61
  "required": [
61
62
  "accountId",
62
63
  "startDate",
@@ -52,6 +52,11 @@
52
52
  "minLength": 1,
53
53
  "description": "Description of the transaction"
54
54
  },
55
+ "memo": {
56
+ "type": ["string", "null"],
57
+ "title": "Memo",
58
+ "description": "Optional memo for the transaction."
59
+ },
55
60
  "categoryId": {
56
61
  "type": ["string", "null"],
57
62
  "title": "Category ID",
@@ -78,5 +83,6 @@
78
83
  "description": "The current state of the transaction."
79
84
  }
80
85
  },
86
+ "unevaluatedProperties": false,
81
87
  "required": ["accountId", "date", "amount", "description", "transactionState"]
82
88
  }
@@ -33,7 +33,13 @@
33
33
  "type": ["string", "null"],
34
34
  "title": "Description",
35
35
  "description": "Optional description for the split."
36
+ },
37
+ "memo": {
38
+ "type": ["string", "null"],
39
+ "title": "Memo",
40
+ "description": "Optional memo for this split line."
36
41
  }
37
42
  },
43
+ "unevaluatedProperties": false,
38
44
  "required": ["transactionId", "amount", "categoryId"]
39
45
  }
package/dist/index.d.ts CHANGED
@@ -534,6 +534,7 @@ export type Transaction = Common5 & {
534
534
  currency?: Currency;
535
535
  amount: Amount1;
536
536
  description: Description2;
537
+ memo?: Memo;
537
538
  categoryId?: CategoryID1;
538
539
  statementId?: StatementID;
539
540
  aggregationServiceId?: AggregationServiceID1;
@@ -600,6 +601,10 @@ export type Amount1 = number;
600
601
  * Description of the transaction
601
602
  */
602
603
  export type Description2 = string;
604
+ /**
605
+ * Optional memo for the transaction.
606
+ */
607
+ export type Memo = string | null;
603
608
  /**
604
609
  * Category UUID for this transaction
605
610
  */
@@ -620,6 +625,7 @@ export type TransactionSplit = Common6 & {
620
625
  amount: Amount2;
621
626
  categoryId: CategoryID2;
622
627
  description?: Description3;
628
+ memo?: Memo1;
623
629
  };
624
630
  /**
625
631
  * UUID for the item
@@ -657,6 +663,10 @@ export type CategoryID2 = string | null;
657
663
  * Optional description for the split.
658
664
  */
659
665
  export type Description3 = string | null;
666
+ /**
667
+ * Optional memo for this split line.
668
+ */
669
+ export type Memo1 = string | null;
660
670
 
661
671
  /**
662
672
  * Schema for the luca ledger
@@ -1001,6 +1011,7 @@ export type Transaction = Common & {
1001
1011
  currency?: Currency;
1002
1012
  amount: Amount;
1003
1013
  description: Description;
1014
+ memo?: Memo;
1004
1015
  categoryId?: CategoryID;
1005
1016
  statementId?: StatementID;
1006
1017
  aggregationServiceId?: AggregationServiceID;
@@ -1067,6 +1078,10 @@ export type Amount = number;
1067
1078
  * Description of the transaction
1068
1079
  */
1069
1080
  export type Description = string;
1081
+ /**
1082
+ * Optional memo for the transaction.
1083
+ */
1084
+ export type Memo = string | null;
1070
1085
  /**
1071
1086
  * Category UUID for this transaction
1072
1087
  */
@@ -1099,6 +1114,7 @@ export type TransactionSplit = Common & {
1099
1114
  amount: Amount;
1100
1115
  categoryId: CategoryID;
1101
1116
  description?: Description;
1117
+ memo?: Memo;
1102
1118
  };
1103
1119
  /**
1104
1120
  * UUID for the item
@@ -1136,6 +1152,10 @@ export type CategoryID = string | null;
1136
1152
  * Optional description for the split.
1137
1153
  */
1138
1154
  export type Description = string | null;
1155
+ /**
1156
+ * Optional memo for this split line.
1157
+ */
1158
+ export type Memo = string | null;
1139
1159
 
1140
1160
  /**
1141
1161
  * Common properties for all schemas
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luca-financial/luca-schema",
3
- "version": "2.3.4",
3
+ "version": "3.0.0",
4
4
  "description": "Schemas for the Luca Ledger application",
5
5
  "author": "Johnathan Aspinwall",
6
6
  "main": "dist/esm/index.js",