Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 21 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -240,15 +246,21 @@ 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<string>` of all fields (includes common fields when applicable)
- `getRequiredFields(schemaKey)` → `Set<string>` of required fields (includes common required fields)
- `stripInvalidFields(schemaKey, data)` → new object with only schema-defined keys
- `schemas` → map of schema JSON objects
- `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
Expand Down
20 changes: 0 additions & 20 deletions examples/luca-schema-example.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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,
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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,
Expand All @@ -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",
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -563,7 +552,6 @@
"amount": -8000,
"description": "Planned grocery shopping",
"memo": null,
"counterparty": null,
"categoryId": "25c7c73e-ce71-4aaf-8154-ea525c9d0616",
"statementId": null,
"aggregationServiceId": null,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -639,7 +624,6 @@
"amount": 100000,
"description": "Monthly savings transfer",
"memo": "Building emergency fund",
"counterparty": null,
"categoryId": null,
"statementId": null,
"aggregationServiceId": "plaid_txn_016",
Expand All @@ -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,
Expand All @@ -677,7 +660,6 @@
"amount": -4500,
"description": "Duplicate transaction - deleted",
"memo": "Accidentally entered twice",
"counterparty": null,
"categoryId": null,
"statementId": null,
"aggregationServiceId": null,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
58 changes: 58 additions & 0 deletions src/dateUtils.js
Original file line number Diff line number Diff line change
@@ -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;
}
33 changes: 31 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -26,6 +51,10 @@ export {
schemas,
validate,
validateCollection,
normalizeDateString,
isDateStringFixable,
getDateFieldPaths,
getDateFieldPathsByCollection,
getValidFields,
getRequiredFields,
stripInvalidFields
Expand Down
Loading