@accounter/server 0.0.9-alpha-20251231123714-6cdd9de71b4672d74ece5d34c438d162987b2c93 → 0.0.9-alpha-20251231163357-33d4c33fec5e21dad2e04d1f293f6248d2550588

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.
Files changed (124) hide show
  1. package/CHANGELOG.md +27 -5
  2. package/README.md +66 -3
  3. package/dist/server/scripts/seed-admin-context.js +20 -25
  4. package/dist/server/scripts/seed-admin-context.js.map +1 -1
  5. package/dist/server/src/__tests__/db-bootstrap.test.js +7 -2
  6. package/dist/server/src/__tests__/db-bootstrap.test.js.map +1 -1
  7. package/dist/server/src/__tests__/factories/business.d.ts +1 -1
  8. package/dist/server/src/__tests__/factories/financial-account.d.ts +1 -1
  9. package/dist/server/src/__tests__/factories/index.test.js +1 -0
  10. package/dist/server/src/__tests__/factories/index.test.js.map +1 -1
  11. package/dist/server/src/__tests__/factories/tax-category.d.ts +1 -1
  12. package/dist/server/src/__tests__/factories/tax-category.js +1 -1
  13. package/dist/server/src/__tests__/factories/tax-category.js.map +1 -1
  14. package/dist/server/src/__tests__/factories/tax-category.test.js +8 -6
  15. package/dist/server/src/__tests__/factories/tax-category.test.js.map +1 -1
  16. package/dist/server/src/__tests__/helpers/fixture-loader.d.ts +1 -1
  17. package/dist/server/src/__tests__/helpers/fixture-loader.js +25 -52
  18. package/dist/server/src/__tests__/helpers/fixture-loader.js.map +1 -1
  19. package/dist/server/src/__tests__/helpers/migration-verification.d.ts +1 -1
  20. package/dist/server/src/__tests__/helpers/migration-verification.js.map +1 -1
  21. package/dist/server/src/__tests__/helpers/seed-helpers.business.test.js +4 -4
  22. package/dist/server/src/__tests__/helpers/seed-helpers.business.test.js.map +1 -1
  23. package/dist/server/src/__tests__/helpers/seed-helpers.d.ts +9 -9
  24. package/dist/server/src/__tests__/helpers/seed-helpers.js +57 -54
  25. package/dist/server/src/__tests__/helpers/seed-helpers.js.map +1 -1
  26. package/dist/server/src/__tests__/seed-admin-context.integration.test.js +2 -1
  27. package/dist/server/src/__tests__/seed-admin-context.integration.test.js.map +1 -1
  28. package/dist/server/src/demo-fixtures/__tests__/deterministic-uuid.test.js +3 -2
  29. package/dist/server/src/demo-fixtures/__tests__/deterministic-uuid.test.js.map +1 -1
  30. package/dist/server/src/demo-fixtures/__tests__/seed-and-validate.test.d.ts +1 -0
  31. package/dist/server/src/demo-fixtures/__tests__/seed-and-validate.test.js +69 -0
  32. package/dist/server/src/demo-fixtures/__tests__/seed-and-validate.test.js.map +1 -0
  33. package/dist/server/src/demo-fixtures/__tests__/use-case-registry.test.d.ts +1 -0
  34. package/dist/server/src/demo-fixtures/__tests__/use-case-registry.test.js +26 -0
  35. package/dist/server/src/demo-fixtures/__tests__/use-case-registry.test.js.map +1 -0
  36. package/dist/server/src/demo-fixtures/helpers/admin-context.d.ts +10 -0
  37. package/dist/server/src/demo-fixtures/helpers/admin-context.js +40 -0
  38. package/dist/server/src/demo-fixtures/helpers/admin-context.js.map +1 -0
  39. package/dist/server/src/demo-fixtures/helpers/placeholder.d.ts +45 -0
  40. package/dist/server/src/demo-fixtures/helpers/placeholder.js +50 -0
  41. package/dist/server/src/demo-fixtures/helpers/placeholder.js.map +1 -0
  42. package/dist/server/src/demo-fixtures/helpers/seed-exchange-rates.d.ts +21 -0
  43. package/dist/server/src/demo-fixtures/helpers/seed-exchange-rates.js +26 -0
  44. package/dist/server/src/demo-fixtures/helpers/seed-exchange-rates.js.map +1 -0
  45. package/dist/server/src/demo-fixtures/helpers/seed-vat.d.ts +20 -0
  46. package/dist/server/src/demo-fixtures/helpers/seed-vat.js +30 -0
  47. package/dist/server/src/demo-fixtures/helpers/seed-vat.js.map +1 -0
  48. package/dist/server/src/demo-fixtures/use-cases/equity/shareholder-dividend.d.ts +9 -0
  49. package/dist/server/src/demo-fixtures/use-cases/equity/shareholder-dividend.js +86 -0
  50. package/dist/server/src/demo-fixtures/use-cases/equity/shareholder-dividend.js.map +1 -0
  51. package/dist/server/src/demo-fixtures/use-cases/expenses/monthly-expense-foreign-currency.d.ts +12 -0
  52. package/dist/server/src/demo-fixtures/use-cases/expenses/monthly-expense-foreign-currency.js +375 -0
  53. package/dist/server/src/demo-fixtures/use-cases/expenses/monthly-expense-foreign-currency.js.map +1 -0
  54. package/dist/server/src/demo-fixtures/use-cases/income/client-payment-with-refund.d.ts +10 -0
  55. package/dist/server/src/demo-fixtures/use-cases/income/client-payment-with-refund.js +113 -0
  56. package/dist/server/src/demo-fixtures/use-cases/income/client-payment-with-refund.js.map +1 -0
  57. package/dist/server/src/demo-fixtures/use-cases/index.d.ts +41 -0
  58. package/dist/server/src/demo-fixtures/use-cases/index.js +50 -0
  59. package/dist/server/src/demo-fixtures/use-cases/index.js.map +1 -0
  60. package/dist/server/src/demo-fixtures/validate-demo-data.d.ts +1 -0
  61. package/dist/server/src/demo-fixtures/validate-demo-data.js +117 -0
  62. package/dist/server/src/demo-fixtures/validate-demo-data.js.map +1 -0
  63. package/dist/server/src/demo-fixtures/validators/ledger-validators.d.ts +349 -0
  64. package/dist/server/src/demo-fixtures/validators/ledger-validators.js +602 -0
  65. package/dist/server/src/demo-fixtures/validators/ledger-validators.js.map +1 -0
  66. package/dist/server/src/demo-fixtures/validators/ledger-validators.test.d.ts +1 -0
  67. package/dist/server/src/demo-fixtures/validators/ledger-validators.test.js +247 -0
  68. package/dist/server/src/demo-fixtures/validators/ledger-validators.test.js.map +1 -0
  69. package/dist/server/src/demo-fixtures/validators/types.d.ts +69 -0
  70. package/dist/server/src/demo-fixtures/validators/types.js +8 -0
  71. package/dist/server/src/demo-fixtures/validators/types.js.map +1 -0
  72. package/dist/server/src/fixtures/fixture-spec.d.ts +146 -0
  73. package/dist/server/src/fixtures/fixture-spec.js +2 -0
  74. package/dist/server/src/fixtures/fixture-spec.js.map +1 -0
  75. package/dist/server/src/modules/charges-matcher/__tests__/single-match-integration.test.js +4 -0
  76. package/dist/server/src/modules/charges-matcher/__tests__/single-match-integration.test.js.map +1 -1
  77. package/dist/server/src/modules/deel/resolvers/deel.resolvers.js +0 -3
  78. package/dist/server/src/modules/deel/resolvers/deel.resolvers.js.map +1 -1
  79. package/dist/server/src/modules/ledger/__tests__/ledger-scenario-a.integration.test.js +4 -3
  80. package/dist/server/src/modules/ledger/__tests__/ledger-scenario-a.integration.test.js.map +1 -1
  81. package/dist/server/src/modules/ledger/__tests__/ledger-scenario-b.integration.test.js +5 -3
  82. package/dist/server/src/modules/ledger/__tests__/ledger-scenario-b.integration.test.js.map +1 -1
  83. package/dist/server/src/shared/constants.d.ts +1 -0
  84. package/dist/server/src/shared/constants.js +1 -0
  85. package/dist/server/src/shared/constants.js.map +1 -1
  86. package/dist/server/src/shared/helpers/misc.js +2 -2
  87. package/dist/server/src/shared/helpers/misc.js.map +1 -1
  88. package/docs/demo-staging-guide.md +611 -0
  89. package/package.json +5 -2
  90. package/scripts/seed-admin-context.ts +22 -33
  91. package/src/__tests__/db-bootstrap.test.ts +9 -2
  92. package/src/__tests__/factories/business.ts +1 -1
  93. package/src/__tests__/factories/financial-account.ts +1 -1
  94. package/src/__tests__/factories/index.test.ts +1 -0
  95. package/src/__tests__/factories/tax-category.test.ts +8 -6
  96. package/src/__tests__/factories/tax-category.ts +2 -2
  97. package/src/__tests__/helpers/fixture-loader.ts +26 -61
  98. package/src/__tests__/helpers/migration-verification.ts +2 -2
  99. package/src/__tests__/helpers/seed-helpers.business.test.ts +4 -4
  100. package/src/__tests__/helpers/seed-helpers.ts +66 -75
  101. package/src/__tests__/seed-admin-context.integration.test.ts +2 -1
  102. package/src/demo-fixtures/__tests__/deterministic-uuid.test.ts +3 -2
  103. package/src/demo-fixtures/__tests__/seed-and-validate.test.ts +96 -0
  104. package/src/demo-fixtures/__tests__/use-case-registry.test.ts +27 -0
  105. package/src/demo-fixtures/helpers/admin-context.ts +59 -0
  106. package/src/demo-fixtures/helpers/placeholder.ts +50 -0
  107. package/src/demo-fixtures/helpers/seed-exchange-rates.ts +29 -0
  108. package/src/demo-fixtures/helpers/seed-vat.ts +35 -0
  109. package/src/demo-fixtures/use-cases/equity/shareholder-dividend.ts +88 -0
  110. package/src/demo-fixtures/use-cases/expenses/monthly-expense-foreign-currency.ts +377 -0
  111. package/src/demo-fixtures/use-cases/income/client-payment-with-refund.ts +115 -0
  112. package/src/demo-fixtures/use-cases/index.ts +52 -0
  113. package/src/demo-fixtures/validate-demo-data.ts +153 -0
  114. package/src/demo-fixtures/validators/README.md +190 -0
  115. package/src/demo-fixtures/validators/ledger-validators.test.ts +298 -0
  116. package/src/demo-fixtures/validators/ledger-validators.ts +711 -0
  117. package/src/demo-fixtures/validators/types.ts +83 -0
  118. package/src/fixtures/fixture-spec.ts +158 -0
  119. package/src/modules/charges-matcher/__tests__/single-match-integration.test.ts +6 -0
  120. package/src/modules/deel/resolvers/deel.resolvers.ts +0 -3
  121. package/src/modules/ledger/__tests__/ledger-scenario-a.integration.test.ts +4 -3
  122. package/src/modules/ledger/__tests__/ledger-scenario-b.integration.test.ts +6 -3
  123. package/src/shared/constants.ts +2 -0
  124. package/src/shared/helpers/misc.ts +2 -3
@@ -0,0 +1,711 @@
1
+ import type { EntityBalance, LedgerRecord, ValidationContext } from './types.js';
2
+
3
+ /**
4
+ * Parse a numeric amount from a string, handling null/undefined values
5
+ *
6
+ * PostgreSQL returns numeric values as strings when queried via pg driver.
7
+ * This utility safely converts them to numbers for arithmetic operations.
8
+ *
9
+ * @param value - The string value to parse (may be null or undefined)
10
+ * @returns The parsed number, or 0 if value is null/undefined/invalid
11
+ *
12
+ * @example
13
+ * parseAmount('100.50') // 100.50
14
+ * parseAmount(null) // 0
15
+ * parseAmount('invalid') // 0
16
+ */
17
+ export function parseAmount(value: string | null | undefined): number {
18
+ if (value == null) {
19
+ return 0;
20
+ }
21
+
22
+ const parsed = parseFloat(value);
23
+
24
+ if (Number.isNaN(parsed)) {
25
+ return 0;
26
+ }
27
+
28
+ return parsed;
29
+ }
30
+
31
+ /**
32
+ * Check if two numeric values are balanced within a tolerance
33
+ *
34
+ * Uses tolerance-based comparison to handle floating-point arithmetic
35
+ * imprecision. Two values are considered balanced if their absolute
36
+ * difference is less than or equal to the tolerance.
37
+ *
38
+ * @param a - First value to compare
39
+ * @param b - Second value to compare
40
+ * @param tolerance - Maximum acceptable difference (default: 0.005)
41
+ * @returns True if values are balanced within tolerance, false otherwise
42
+ *
43
+ * @example
44
+ * isBalanced(100, 100) // true
45
+ * isBalanced(100, 100.004, 0.005) // true
46
+ * isBalanced(100, 102, 0.005) // false
47
+ */
48
+ export function isBalanced(a: number, b: number, tolerance = 0.005): boolean {
49
+ return Math.abs(a - b) <= tolerance;
50
+ }
51
+
52
+ /**
53
+ * Validate per-record internal balance (FR1)
54
+ *
55
+ * Ensures each ledger record is internally balanced according to double-entry
56
+ * bookkeeping principles: total debits must equal total credits within tolerance.
57
+ * Also detects empty records where all amounts are zero.
58
+ *
59
+ * This is a fundamental validation that must pass for every record individually
60
+ * before aggregate-level validations can be meaningful.
61
+ *
62
+ * @param records - Array of ledger records to validate
63
+ * @param context - Validation context containing use-case ID and tolerance
64
+ * @returns Array of error messages (empty if all records are valid)
65
+ *
66
+ * Functional Requirement: FR1 - Per-Record Internal Balance
67
+ * - Rule: (debit_local_amount1 + debit_local_amount2) == (credit_local_amount1 + credit_local_amount2)
68
+ * - Tolerance: Specified in context (typically ±0.005 for accounting rounding)
69
+ * - Also implements FR10: Empty Ledger Detection
70
+ *
71
+ * @example
72
+ * const errors = validateRecordInternalBalance(records, {
73
+ * useCaseId: 'monthly-expense',
74
+ * defaultCurrency: 'ILS',
75
+ * tolerance: 0.005
76
+ * });
77
+ * // Returns: [] if valid, or error messages like:
78
+ * // ["monthly-expense - Record 0 (uuid-123): internal imbalance (debit=100.00, credit=99.98)"]
79
+ */
80
+ export function validateRecordInternalBalance(
81
+ records: LedgerRecord[],
82
+ context: ValidationContext,
83
+ ): string[] {
84
+ const errors: string[] = [];
85
+
86
+ records.map((record, index) => {
87
+ const totalDebit =
88
+ parseAmount(record.debit_local_amount1) + parseAmount(record.debit_local_amount2);
89
+
90
+ const totalCredit =
91
+ parseAmount(record.credit_local_amount1) + parseAmount(record.credit_local_amount2);
92
+
93
+ // FR10: Empty record detection
94
+ if (totalDebit === 0 && totalCredit === 0) {
95
+ errors.push(
96
+ `${context.useCaseId} - Record ${index} (${record.id}): empty record (all amounts zero)`,
97
+ );
98
+ return; // Skip balance check for empty records
99
+ }
100
+
101
+ // FR1: Internal balance check
102
+ if (!isBalanced(totalDebit, totalCredit, context.tolerance)) {
103
+ errors.push(
104
+ `${context.useCaseId} - Record ${index} (${record.id}): internal imbalance ` +
105
+ `(debit=${totalDebit.toFixed(2)}, credit=${totalCredit.toFixed(2)})`,
106
+ );
107
+ }
108
+ });
109
+
110
+ return errors;
111
+ }
112
+
113
+ /**
114
+ * Validate aggregate balance across all records (FR2)
115
+ *
116
+ * Validates that the sum of all debits equals the sum of all credits across
117
+ * all ledger records for a use-case. This is the second level of validation
118
+ * (after per-record balance) and ensures the entire ledger set balances.
119
+ *
120
+ * This refactors and enhances the existing aggregate balance validation logic
121
+ * that was previously only applied to a single use-case. The new implementation
122
+ * applies to all use-cases with expectations.
123
+ *
124
+ * @param records - Array of ledger records to validate
125
+ * @param context - Validation context containing use-case ID and tolerance
126
+ * @returns Array of error messages (empty if aggregate is balanced)
127
+ *
128
+ * Functional Requirement: FR2 - Aggregate Balance Validation
129
+ * - Rule: Σ(all debits) == Σ(all credits)
130
+ * - Tolerance: Specified in context (typically ±0.005)
131
+ * - Enhancement: Now applies to ALL use-cases, not just first one
132
+ *
133
+ * @example
134
+ * const errors = validateAggregateBalance(records, {
135
+ * useCaseId: 'monthly-expense',
136
+ * defaultCurrency: 'ILS',
137
+ * tolerance: 0.005
138
+ * });
139
+ * // Returns: [] if valid, or error like:
140
+ * // ["monthly-expense: aggregate ledger not balanced (debit 1000.00, credit 999.50)"]
141
+ */
142
+ export function validateAggregateBalance(
143
+ records: LedgerRecord[],
144
+ context: ValidationContext,
145
+ ): string[] {
146
+ const errors: string[] = [];
147
+
148
+ const totalDebit = records.reduce((sum, rec) => {
149
+ return sum + parseAmount(rec.debit_local_amount1) + parseAmount(rec.debit_local_amount2);
150
+ }, 0);
151
+
152
+ const totalCredit = records.reduce((sum, rec) => {
153
+ return sum + parseAmount(rec.credit_local_amount1) + parseAmount(rec.credit_local_amount2);
154
+ }, 0);
155
+
156
+ if (!isBalanced(totalDebit, totalCredit, context.tolerance)) {
157
+ errors.push(
158
+ `${context.useCaseId}: aggregate ledger not balanced ` +
159
+ `(debit ${totalDebit.toFixed(2)}, credit ${totalCredit.toFixed(2)})`,
160
+ );
161
+ }
162
+
163
+ return errors;
164
+ }
165
+
166
+ /**
167
+ * Validate entity-level balance (FR3)
168
+ *
169
+ * Validates that each financial entity's net position across all ledger records
170
+ * balances to zero (or within tolerance). This is the third level of validation
171
+ * in the hierarchy:
172
+ * 1. Per-record balance (FR1) - each record internally balanced
173
+ * 2. Aggregate balance (FR2) - all records collectively balanced
174
+ * 3. Entity balance (FR3) - each entity's position balanced across records
175
+ *
176
+ * In double-entry bookkeeping, every entity that appears in the ledger should
177
+ * have a net zero position when considering all transactions. If an entity has
178
+ * debits totaling $500 across various records, it should also have credits
179
+ * totaling $500 across those same or other records.
180
+ *
181
+ * @param records - Array of ledger records to validate
182
+ * @param context - Validation context containing use-case ID and tolerance
183
+ * @returns Array of error messages (empty if all entities are balanced)
184
+ *
185
+ * Functional Requirement: FR3 - Entity-Level Balance Validation
186
+ * - Rule: For each entity, Σ(debits) - Σ(credits) ≈ 0
187
+ * - Tolerance: Specified in context (typically ±0.005)
188
+ * - Tracks: debit/credit amounts across all 4 entity fields per record
189
+ *
190
+ * Implementation:
191
+ * - Accumulates debits and credits per entity across all records
192
+ * - Calculates net balance (totalDebit - totalCredit) for each entity
193
+ * - Validates net balance is within tolerance of zero
194
+ *
195
+ * @example
196
+ * const errors = validateEntityBalance(records, {
197
+ * useCaseId: 'monthly-expense',
198
+ * defaultCurrency: 'ILS',
199
+ * tolerance: 0.005
200
+ * });
201
+ * // Returns: [] if valid, or errors like:
202
+ * // ["monthly-expense: Entity entity-123 unbalanced (net=50.00, debit=150.00, credit=100.00, records=3)"]
203
+ */
204
+ export function validateEntityBalance(
205
+ records: LedgerRecord[],
206
+ context: ValidationContext,
207
+ ): string[] {
208
+ const errors: string[] = [];
209
+ const entityBalances = new Map<string, EntityBalance>();
210
+
211
+ /**
212
+ * Helper function to accumulate entity balance
213
+ * Adds debit/credit amounts to an entity's running totals
214
+ */
215
+ const addToEntity = (entityId: string | null, debit: number, credit: number) => {
216
+ if (!entityId) return;
217
+
218
+ const current = entityBalances.get(entityId) || {
219
+ entityId,
220
+ totalDebit: 0,
221
+ totalCredit: 0,
222
+ netBalance: 0,
223
+ recordCount: 0,
224
+ };
225
+
226
+ current.totalDebit += debit;
227
+ current.totalCredit += credit;
228
+ current.netBalance = current.totalDebit - current.totalCredit;
229
+ current.recordCount += 1;
230
+
231
+ entityBalances.set(entityId, current);
232
+ };
233
+
234
+ // Accumulate balances for all entities across all records
235
+ records.map(record => {
236
+ addToEntity(record.debit_entity1, parseAmount(record.debit_local_amount1), 0);
237
+ addToEntity(record.debit_entity2, parseAmount(record.debit_local_amount2), 0);
238
+ addToEntity(record.credit_entity1, 0, parseAmount(record.credit_local_amount1));
239
+ addToEntity(record.credit_entity2, 0, parseAmount(record.credit_local_amount2));
240
+ });
241
+
242
+ // Validate each entity balances to zero (within tolerance)
243
+ Array.from(entityBalances.values()).map(balance => {
244
+ if (!isBalanced(balance.netBalance, 0, context.tolerance)) {
245
+ errors.push(
246
+ `${context.useCaseId}: Entity ${balance.entityId} unbalanced ` +
247
+ `(net=${balance.netBalance.toFixed(2)}, debit=${balance.totalDebit.toFixed(2)}, ` +
248
+ `credit=${balance.totalCredit.toFixed(2)}, records=${balance.recordCount})`,
249
+ );
250
+ }
251
+ });
252
+
253
+ return errors;
254
+ }
255
+
256
+ /**
257
+ * Validate ledger record count (FR8)
258
+ *
259
+ * Validates that the actual number of ledger records matches the expected count
260
+ * specified in the use-case expectations. This ensures data completeness and
261
+ * detects cases where records may be missing or extra records were created.
262
+ *
263
+ * This enhances the existing record count validation by applying it to all
264
+ * use-cases systematically rather than ad-hoc checks.
265
+ *
266
+ * @param records - Array of ledger records to validate
267
+ * @param expectedCount - Expected number of ledger records for this use-case
268
+ * @param context - Validation context containing use-case ID
269
+ * @returns Array of error messages (empty if count matches)
270
+ *
271
+ * Functional Requirement: FR8 - Record Count Validation
272
+ * - Rule: Actual record count must match expected count exactly
273
+ * - Enhancement: Future support for minimum count validation for cases
274
+ * where ledger generation may create additional balancing entries
275
+ *
276
+ * @example
277
+ * const errors = validateRecordCount(records, 24, {
278
+ * useCaseId: 'monthly-expense',
279
+ * defaultCurrency: 'ILS',
280
+ * tolerance: 0.005
281
+ * });
282
+ * // Returns: [] if count matches, or error like:
283
+ * // ["monthly-expense: ledger record count mismatch (expected 24, got 23)"]
284
+ */
285
+ export function validateRecordCount(
286
+ records: LedgerRecord[],
287
+ expectedCount: number,
288
+ context: ValidationContext,
289
+ ): string[] {
290
+ const errors: string[] = [];
291
+
292
+ if (records.length !== expectedCount) {
293
+ errors.push(
294
+ `${context.useCaseId}: ledger record count mismatch ` +
295
+ `(expected ${expectedCount}, got ${records.length})`,
296
+ );
297
+ }
298
+
299
+ return errors;
300
+ }
301
+
302
+ /**
303
+ * Validate all amounts are positive (FR5)
304
+ *
305
+ * Ensures data integrity by validating that all amount fields contain
306
+ * non-negative values. Negative amounts are not allowed in the ledger
307
+ * system as they violate accounting principles where debits and credits
308
+ * must always be positive or zero.
309
+ *
310
+ * This validation checks all 8 amount fields per record:
311
+ * - Local amounts: debit_local_amount1/2, credit_local_amount1/2
312
+ * - Foreign amounts: debit_foreign_amount1/2, credit_foreign_amount1/2
313
+ *
314
+ * @param records - Array of ledger records to validate
315
+ * @param context - Validation context containing use-case ID
316
+ * @returns Array of error messages (empty if all amounts are non-negative)
317
+ *
318
+ * Functional Requirement: FR5 - Positive Amount Validation
319
+ * - Checks all amount fields for negative values
320
+ * - Reports specific field and value for any negative amounts found
321
+ *
322
+ * @example
323
+ * const errors = validatePositiveAmounts(records, {
324
+ * useCaseId: 'monthly-expense',
325
+ * defaultCurrency: 'ILS',
326
+ * tolerance: 0.005
327
+ * });
328
+ * // Returns: [] if valid, or errors like:
329
+ * // ["monthly-expense - Record 0 (uuid-123): negative amount in debit_local_amount1 (-100.00)"]
330
+ */
331
+ export function validatePositiveAmounts(
332
+ records: LedgerRecord[],
333
+ context: ValidationContext,
334
+ ): string[] {
335
+ const errors: string[] = [];
336
+
337
+ const amountFields = [
338
+ 'debit_local_amount1',
339
+ 'debit_local_amount2',
340
+ 'credit_local_amount1',
341
+ 'credit_local_amount2',
342
+ 'debit_foreign_amount1',
343
+ 'debit_foreign_amount2',
344
+ 'credit_foreign_amount1',
345
+ 'credit_foreign_amount2',
346
+ ] as const;
347
+
348
+ records.map((record, index) => {
349
+ amountFields.map(field => {
350
+ const value = parseAmount(record[field]);
351
+ if (value < 0) {
352
+ errors.push(
353
+ `${context.useCaseId} - Record ${index} (${record.id}): ` +
354
+ `negative amount in ${field} (${value.toFixed(2)})`,
355
+ );
356
+ }
357
+ });
358
+ });
359
+
360
+ return errors;
361
+ }
362
+
363
+ /**
364
+ * Validate dates (FR7)
365
+ *
366
+ * Ensures all ledger records have valid invoice_date and value_date fields
367
+ * within acceptable ranges. Proper date validation is critical for:
368
+ * - Financial reporting accuracy
369
+ * - Tax compliance and audit trails
370
+ * - Chronological transaction ordering
371
+ * - Preventing data entry errors
372
+ *
373
+ * Validation rules:
374
+ * - Both invoice_date and value_date must be present (not null)
375
+ * - Both dates must be valid Date objects (not NaN)
376
+ * - Both dates must fall within the range 2020-01-01 to 2030-12-31
377
+ *
378
+ * @param records - Array of ledger records to validate
379
+ * @param context - Validation context containing use-case ID
380
+ * @returns Array of error messages (empty if all dates are valid)
381
+ *
382
+ * Functional Requirement: FR7 - Date Validation
383
+ * - Checks for missing dates (null values)
384
+ * - Checks for invalid dates (parse errors)
385
+ * - Checks for dates outside reasonable business range
386
+ *
387
+ * @example
388
+ * const errors = validateDates(records, {
389
+ * useCaseId: 'monthly-expense',
390
+ * defaultCurrency: 'ILS',
391
+ * tolerance: 0.005
392
+ * });
393
+ * // Returns: [] if valid, or errors like:
394
+ * // ["monthly-expense - Record 0 (uuid-123): missing invoice_date"]
395
+ * // ["monthly-expense - Record 1 (uuid-456): invoice_date out of range (1999-01-01T00:00:00.000Z)"]
396
+ */
397
+ export function validateDates(records: LedgerRecord[], context: ValidationContext): string[] {
398
+ const errors: string[] = [];
399
+ const minDate = new Date('2020-01-01');
400
+ const maxDate = new Date('2030-12-31');
401
+
402
+ records.map((record, index) => {
403
+ // Check invoice_date
404
+ if (record.invoice_date) {
405
+ const invoiceDate = new Date(record.invoice_date);
406
+ if (Number.isNaN(invoiceDate.getTime())) {
407
+ errors.push(`${context.useCaseId} - Record ${index} (${record.id}): invalid invoice_date`);
408
+ } else if (invoiceDate < minDate || invoiceDate > maxDate) {
409
+ errors.push(
410
+ `${context.useCaseId} - Record ${index} (${record.id}): ` +
411
+ `invoice_date out of range (${invoiceDate.toISOString()})`,
412
+ );
413
+ }
414
+ } else {
415
+ errors.push(`${context.useCaseId} - Record ${index} (${record.id}): missing invoice_date`);
416
+ }
417
+
418
+ // Check value_date
419
+ if (record.value_date) {
420
+ const valueDate = new Date(record.value_date);
421
+ if (Number.isNaN(valueDate.getTime())) {
422
+ errors.push(`${context.useCaseId} - Record ${index} (${record.id}): invalid value_date`);
423
+ } else if (valueDate < minDate || valueDate > maxDate) {
424
+ errors.push(
425
+ `${context.useCaseId} - Record ${index} (${record.id}): ` +
426
+ `value_date out of range (${valueDate.toISOString()})`,
427
+ );
428
+ }
429
+ } else {
430
+ errors.push(`${context.useCaseId} - Record ${index} (${record.id}): missing value_date`);
431
+ }
432
+ });
433
+
434
+ return errors;
435
+ }
436
+
437
+ /**
438
+ * Validate foreign currency handling (FR6)
439
+ *
440
+ * Ensures proper handling of foreign currency transactions by validating:
441
+ * 1. Currency field consistency with foreign amount fields
442
+ * 2. Presence of foreign amounts when currency is not the default (ILS)
443
+ * 3. Absence of foreign amounts when currency is the default (ILS)
444
+ * 4. Reasonableness of implied exchange rates between local and foreign amounts
445
+ *
446
+ * Foreign currency validation is critical for:
447
+ * - Accurate financial reporting in multi-currency environments
448
+ * - Compliance with international accounting standards
449
+ * - Detection of data entry errors in currency conversion
450
+ * - Prevention of fraudulent or suspicious exchange rate manipulation
451
+ *
452
+ * Exchange rate validation:
453
+ * - Implied rate = local_amount / foreign_amount
454
+ * - Rate must be between 0.1 and 10.0 to be considered reasonable
455
+ * - Rates outside this range likely indicate data entry errors
456
+ *
457
+ * @param records - Array of ledger records to validate
458
+ * @param context - Validation context containing use-case ID and default currency
459
+ * @returns Array of error messages (empty if all currency handling is valid)
460
+ *
461
+ * Functional Requirement: FR6 - Foreign Currency Validation
462
+ * - Validates currency field matches foreign amount presence/absence
463
+ * - Checks exchange rate consistency within reasonable bounds
464
+ * - Applies to all 4 amount pairs (debit1, debit2, credit1, credit2)
465
+ *
466
+ * @example
467
+ * const errors = validateForeignCurrency(records, {
468
+ * useCaseId: 'monthly-expense',
469
+ * defaultCurrency: 'ILS',
470
+ * tolerance: 0.005
471
+ * });
472
+ * // Returns: [] if valid, or errors like:
473
+ * // ["monthly-expense - Record 0 (uuid-123): foreign currency (USD) but no foreign amounts"]
474
+ * // ["monthly-expense - Record 1 (uuid-456): local currency (ILS) but has foreign amounts"]
475
+ * // ["monthly-expense - Record 2 (uuid-789): suspicious exchange rate in debit1 (rate=15.2000)"]
476
+ */
477
+ export function validateForeignCurrency(
478
+ records: LedgerRecord[],
479
+ context: ValidationContext,
480
+ ): string[] {
481
+ const errors: string[] = [];
482
+
483
+ records.map((record, index) => {
484
+ const isForeignCurrency = record.currency !== context.defaultCurrency;
485
+
486
+ const hasForeignAmounts =
487
+ record.debit_foreign_amount1 !== null ||
488
+ record.debit_foreign_amount2 !== null ||
489
+ record.credit_foreign_amount1 !== null ||
490
+ record.credit_foreign_amount2 !== null;
491
+
492
+ // Validate currency field consistency with foreign amounts
493
+ if (isForeignCurrency && !hasForeignAmounts) {
494
+ errors.push(
495
+ `${context.useCaseId} - Record ${index} (${record.id}): ` +
496
+ `foreign currency (${record.currency}) but no foreign amounts`,
497
+ );
498
+ }
499
+
500
+ if (!isForeignCurrency && hasForeignAmounts) {
501
+ errors.push(
502
+ `${context.useCaseId} - Record ${index} (${record.id}): ` +
503
+ `local currency (${record.currency}) but has foreign amounts`,
504
+ );
505
+ }
506
+
507
+ // Validate exchange rate consistency for foreign currency records
508
+ if (isForeignCurrency) {
509
+ /**
510
+ * Helper function to check exchange rate reasonableness
511
+ * @param localAmount - Local currency amount (e.g., ILS)
512
+ * @param foreignAmount - Foreign currency amount (e.g., USD)
513
+ * @param field - Field identifier for error reporting
514
+ */
515
+ const checkExchangeRate = (
516
+ localAmount: string | null,
517
+ foreignAmount: string | null,
518
+ field: string,
519
+ ) => {
520
+ // Skip if either amount is null or zero
521
+ if (!localAmount || !foreignAmount) return;
522
+
523
+ const local = parseAmount(localAmount);
524
+ const foreign = parseAmount(foreignAmount);
525
+
526
+ if (foreign === 0) return; // Avoid division by zero
527
+
528
+ const impliedRate = local / foreign;
529
+
530
+ // Check if rate is reasonable (between 0.1 and 10.0)
531
+ // This catches obvious data entry errors like:
532
+ // - Swapped local/foreign amounts (rate would be inverted)
533
+ // - Missing decimal points (e.g., 350 instead of 3.50)
534
+ // - Completely incorrect amounts
535
+ if (impliedRate < 0.1 || impliedRate > 10.0) {
536
+ errors.push(
537
+ `${context.useCaseId} - Record ${index} (${record.id}): ` +
538
+ `suspicious exchange rate in ${field} (rate=${impliedRate.toFixed(4)})`,
539
+ );
540
+ }
541
+ };
542
+
543
+ // Check exchange rates for all 4 amount pairs
544
+ checkExchangeRate(record.debit_local_amount1, record.debit_foreign_amount1, 'debit1');
545
+ checkExchangeRate(record.debit_local_amount2, record.debit_foreign_amount2, 'debit2');
546
+ checkExchangeRate(record.credit_local_amount1, record.credit_foreign_amount1, 'credit1');
547
+ checkExchangeRate(record.credit_local_amount2, record.credit_foreign_amount2, 'credit2');
548
+ }
549
+ });
550
+
551
+ return errors;
552
+ }
553
+
554
+ /**
555
+ * Validate no orphaned amounts (FR4)
556
+ *
557
+ * Ensures data integrity by validating that every non-zero amount field has
558
+ * a corresponding entity reference. This prevents "orphaned" amounts that
559
+ * cannot be attributed to any financial entity.
560
+ *
561
+ * An "orphaned amount" is a ledger entry where an amount value exists but
562
+ * its corresponding entity field is null. This violates double-entry
563
+ * bookkeeping principles where every amount must be associated with an entity.
564
+ *
565
+ * Rules enforced:
566
+ * - Primary fields (entity1): If amount > 0, entity must be present
567
+ * - Secondary fields (entity2): If entity is null, amount must also be null
568
+ *
569
+ * @param records - Array of ledger records to validate
570
+ * @param context - Validation context containing use-case ID
571
+ * @returns Array of error messages (empty if no orphaned amounts found)
572
+ *
573
+ * Functional Requirement: FR4 - Orphaned Amount Detection
574
+ * - Checks all 4 amount/entity pairs per record
575
+ * - Detects amounts without entities
576
+ * - Detects secondary fields that should be null
577
+ *
578
+ * @example
579
+ * const errors = validateNoOrphanedAmounts(records, {
580
+ * useCaseId: 'monthly-expense',
581
+ * defaultCurrency: 'ILS',
582
+ * tolerance: 0.005
583
+ * });
584
+ * // Returns: [] if valid, or errors like:
585
+ * // ["monthly-expense - Record 0 (uuid-123): orphaned amount in debit_local_amount1/debit_entity1 (100.00 without entity)"]
586
+ */
587
+ export function validateNoOrphanedAmounts(
588
+ records: LedgerRecord[],
589
+ context: ValidationContext,
590
+ ): string[] {
591
+ const errors: string[] = [];
592
+
593
+ records.map((record, index) => {
594
+ const checks = [
595
+ // Debit entity 1 (primary - always required to have entity if amount > 0)
596
+ {
597
+ amount: parseAmount(record.debit_local_amount1),
598
+ entity: record.debit_entity1,
599
+ field: 'debit_local_amount1/debit_entity1',
600
+ },
601
+ // Debit entity 2 (secondary - both should be null or both populated)
602
+ {
603
+ amount: parseAmount(record.debit_local_amount2),
604
+ entity: record.debit_entity2,
605
+ field: 'debit_local_amount2/debit_entity2',
606
+ },
607
+ // Credit entity 1 (primary)
608
+ {
609
+ amount: parseAmount(record.credit_local_amount1),
610
+ entity: record.credit_entity1,
611
+ field: 'credit_local_amount1/credit_entity1',
612
+ },
613
+ // Credit entity 2 (secondary)
614
+ {
615
+ amount: parseAmount(record.credit_local_amount2),
616
+ entity: record.credit_entity2,
617
+ field: 'credit_local_amount2/credit_entity2',
618
+ },
619
+ ];
620
+
621
+ checks.map(({ amount, entity, field }) => {
622
+ if (amount > 0 && !entity) {
623
+ errors.push(
624
+ `${context.useCaseId} - Record ${index} (${record.id}): ` +
625
+ `orphaned amount in ${field} (${amount.toFixed(2)} without entity)`,
626
+ );
627
+ }
628
+
629
+ // Secondary fields: if entity is null, amount should also be null
630
+ if (
631
+ field.includes('2') &&
632
+ !entity &&
633
+ record[field.split('/')[0] as keyof LedgerRecord] !== null
634
+ ) {
635
+ errors.push(
636
+ `${context.useCaseId} - Record ${index} (${record.id}): ` +
637
+ `${field} should be null when entity is null`,
638
+ );
639
+ }
640
+ });
641
+ });
642
+
643
+ return errors;
644
+ }
645
+
646
+ /**
647
+ * Master validation function - runs all validators (FR1-FR10)
648
+ *
649
+ * Orchestrates comprehensive ledger validation by executing all individual
650
+ * validation functions in a logical sequence. This is the main entry point
651
+ * for validating a complete set of ledger records for a use-case.
652
+ *
653
+ * Validation hierarchy:
654
+ * 1. Per-record validation (FR1, FR10) - Each record is internally balanced
655
+ * 2. Aggregate validation (FR2) - Total debits equal total credits
656
+ * 3. Entity validation (FR3) - Each entity's position balances
657
+ * 4. Data integrity (FR4, FR5) - No orphaned amounts, all amounts positive
658
+ * 5. Business rules (FR6, FR7) - Foreign currency and date validation
659
+ * 6. Structural validation (FR8) - Record count matches expectations
660
+ *
661
+ * This function implements NFR2 (Error Reporting): Collects ALL errors before
662
+ * failing (no fail-fast), allowing comprehensive error discovery in a single run.
663
+ *
664
+ * @param records - Array of ledger records to validate
665
+ * @param expectedRecordCount - Expected number of ledger records for this use-case
666
+ * @param context - Validation context containing use-case ID, currency, and tolerance
667
+ * @returns Array of all error messages from all validators (empty if fully valid)
668
+ *
669
+ * Functional Requirements Implemented:
670
+ * - FR1: Per-Record Internal Balance
671
+ * - FR2: Aggregate Balance Validation
672
+ * - FR3: Entity-Level Balance Validation
673
+ * - FR4: Orphaned Amount Detection
674
+ * - FR5: Positive Amount Validation
675
+ * - FR6: Foreign Currency Validation
676
+ * - FR7: Date Validation
677
+ * - FR8: Record Count Validation
678
+ * - FR10: Empty Ledger Detection (within FR1)
679
+ *
680
+ * @example
681
+ * const errors = validateLedgerRecords(records, 24, {
682
+ * useCaseId: 'monthly-expense',
683
+ * defaultCurrency: 'ILS',
684
+ * tolerance: 0.005
685
+ * });
686
+ * if (errors.length > 0) {
687
+ * console.error('Validation failed:', errors);
688
+ * } else {
689
+ * console.log('All validations passed');
690
+ * }
691
+ */
692
+ export function validateLedgerRecords(
693
+ records: LedgerRecord[],
694
+ expectedRecordCount: number,
695
+ context: ValidationContext,
696
+ ): string[] {
697
+ const allErrors: string[] = [];
698
+
699
+ // Run all validators in logical order
700
+ // Each validator adds its errors to the aggregate array
701
+ allErrors.push(...validateRecordInternalBalance(records, context));
702
+ allErrors.push(...validateAggregateBalance(records, context));
703
+ allErrors.push(...validateEntityBalance(records, context));
704
+ allErrors.push(...validateNoOrphanedAmounts(records, context));
705
+ allErrors.push(...validatePositiveAmounts(records, context));
706
+ allErrors.push(...validateForeignCurrency(records, context));
707
+ allErrors.push(...validateDates(records, context));
708
+ allErrors.push(...validateRecordCount(records, expectedRecordCount, context));
709
+
710
+ return allErrors;
711
+ }