@dizzlkheinz/ynab-mcpb 0.26.2 → 0.26.4

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.
@@ -4,335 +4,169 @@ import {
4
4
  ReconcileAccountOutputSchema,
5
5
  } from "../reconciliationOutputs.js";
6
6
 
7
- /**
8
- * Test suite for discrepancy_direction validation refinement.
9
- *
10
- * The schema enforces that discrepancy_direction matches the numeric discrepancy.amount:
11
- * - If |amount| < 0.01: direction must be 'balanced'
12
- * - If amount >= 0.01: direction must be 'ynab_higher'
13
- * - If amount <= -0.01: direction must be 'bank_higher'
14
- */
15
- describe("ReconcileAccountOutputSchema - discrepancy_direction validation", () => {
16
- const makeMoneyValue = (dollarAmount: number) => ({
17
- value_milliunits: Math.round(dollarAmount * 1000),
18
- value: dollarAmount,
19
- value_display: `$${Math.abs(dollarAmount).toFixed(2)}`,
20
- currency: "USD",
21
- direction: (dollarAmount > 0
22
- ? "credit"
23
- : dollarAmount < 0
24
- ? "debit"
25
- : "balanced") as "credit" | "debit" | "balanced",
26
- });
27
-
28
- const createMinimalStructuredOutput = (
29
- discrepancyAmount: number,
30
- direction: "balanced" | "ynab_higher" | "bank_higher",
31
- ) => ({
32
- human: "Reconciliation complete",
33
- structured: {
34
- version: "1.0.0",
35
- schema_url: "https://example.com/schema",
36
- generated_at: "2025-11-18T10:00:00Z",
37
- account: {
38
- id: "acct-123",
39
- name: "Checking",
40
- },
41
- summary: {
42
- statement_date_range: "2025-10-01 to 2025-10-31",
43
- bank_transactions_count: 50,
44
- ynab_transactions_count: 50,
45
- auto_matched: 50,
46
- suggested_matches: 0,
47
- unmatched_bank: 0,
48
- unmatched_ynab: 0,
49
- current_cleared_balance: makeMoneyValue(1000),
50
- target_statement_balance: makeMoneyValue(1000 + discrepancyAmount),
51
- discrepancy: makeMoneyValue(discrepancyAmount),
52
- discrepancy_explanation: "Test",
53
- },
54
- balance: {
55
- current_cleared: makeMoneyValue(1000),
56
- current_uncleared: makeMoneyValue(0),
57
- current_total: makeMoneyValue(1000),
58
- target_statement: makeMoneyValue(1000 + discrepancyAmount),
59
- discrepancy: makeMoneyValue(discrepancyAmount),
60
- on_track: Math.abs(discrepancyAmount) < 0.01,
61
- discrepancy_direction: direction,
7
+ describe("ReconcileAccountOutputSchema", () => {
8
+ it("accepts valid unmatched_only structured output", () => {
9
+ const output = {
10
+ human: "Reconciliation complete",
11
+ structured: {
12
+ unmatched_bank: [],
13
+ unmatched_ynab: [],
14
+ suggestions: [],
62
15
  },
63
- insights: [],
64
- next_steps: [],
65
- matches: {
66
- auto: [],
67
- suggested: [],
68
- },
69
- unmatched: {
70
- bank: [],
71
- ynab: [],
72
- },
73
- },
16
+ };
17
+ const result = ReconcileAccountOutputSchema.safeParse(output);
18
+ expect(result.success).toBe(true);
74
19
  });
75
20
 
76
- describe("balanced direction (|amount| < 0.01)", () => {
77
- it('should accept direction "balanced" when amount is 0', () => {
78
- const output = createMinimalStructuredOutput(0, "balanced");
79
- const result = ReconcileAccountOutputSchema.safeParse(output);
80
- expect(result.success).toBe(true);
81
- });
82
-
83
- it('should accept direction "balanced" when amount is 0.001', () => {
84
- const output = createMinimalStructuredOutput(0.001, "balanced");
85
- const result = ReconcileAccountOutputSchema.safeParse(output);
86
- expect(result.success).toBe(true);
87
- });
88
-
89
- it('should accept direction "balanced" when amount is -0.009', () => {
90
- const output = createMinimalStructuredOutput(-0.009, "balanced");
91
- const result = ReconcileAccountOutputSchema.safeParse(output);
92
- expect(result.success).toBe(true);
93
- });
94
-
95
- it('should reject direction "ynab_higher" when amount is 0', () => {
96
- const output = createMinimalStructuredOutput(0, "ynab_higher");
97
- const result = ReconcileAccountOutputSchema.safeParse(output);
98
- expect(result.success).toBe(false);
99
- if (!result.success) {
100
- expect(result.error.issues[0]?.path).toEqual([
101
- "balance",
102
- "discrepancy_direction",
103
- ]);
104
- expect(result.error.issues[0]?.message).toContain(
105
- "Discrepancy direction mismatch",
106
- );
107
- }
108
- });
109
-
110
- it('should reject direction "bank_higher" when amount is 0.005', () => {
111
- const output = createMinimalStructuredOutput(0.005, "bank_higher");
112
- const result = ReconcileAccountOutputSchema.safeParse(output);
113
- expect(result.success).toBe(false);
114
- if (!result.success) {
115
- expect(result.error.issues[0]?.path).toEqual([
116
- "balance",
117
- "discrepancy_direction",
118
- ]);
119
- }
120
- });
21
+ it("rejects output without structured field", () => {
22
+ const output = { human: "Reconciliation complete" };
23
+ const result = ReconcileAccountOutputSchema.safeParse(output);
24
+ expect(result.success).toBe(false);
121
25
  });
122
26
 
123
- describe("ynab_higher direction (amount >= 0.01)", () => {
124
- it('should accept direction "ynab_higher" when amount is 25.50', () => {
125
- const output = createMinimalStructuredOutput(25.5, "ynab_higher");
126
- const result = ReconcileAccountOutputSchema.safeParse(output);
127
- expect(result.success).toBe(true);
128
- });
129
-
130
- it('should accept direction "ynab_higher" when amount is 0.02 (just above threshold)', () => {
131
- const output = createMinimalStructuredOutput(0.02, "ynab_higher");
132
- const result = ReconcileAccountOutputSchema.safeParse(output);
133
- expect(result.success).toBe(true);
134
- });
135
-
136
- it('should reject direction "balanced" when amount is 100', () => {
137
- const output = createMinimalStructuredOutput(100, "balanced");
138
- const result = ReconcileAccountOutputSchema.safeParse(output);
139
- expect(result.success).toBe(false);
140
- if (!result.success) {
141
- expect(result.error.issues[0]?.path).toEqual([
142
- "balance",
143
- "discrepancy_direction",
144
- ]);
145
- expect(result.error.issues[0]?.message).toContain(
146
- "Discrepancy direction mismatch",
147
- );
148
- }
149
- });
150
-
151
- it('should reject direction "bank_higher" when amount is 50', () => {
152
- const output = createMinimalStructuredOutput(50, "bank_higher");
153
- const result = ReconcileAccountOutputSchema.safeParse(output);
154
- expect(result.success).toBe(false);
155
- if (!result.success) {
156
- expect(result.error.issues[0]?.path).toEqual([
157
- "balance",
158
- "discrepancy_direction",
159
- ]);
160
- }
161
- });
27
+ it("rejects output with unknown fields in structured", () => {
28
+ const output = {
29
+ human: "Reconciliation complete",
30
+ structured: {
31
+ unmatched_bank: [],
32
+ unmatched_ynab: [],
33
+ suggestions: [],
34
+ extra_field: "not allowed",
35
+ },
36
+ };
37
+ const result = ReconcileAccountOutputSchema.safeParse(output);
38
+ expect(result.success).toBe(false);
162
39
  });
163
40
 
164
- describe("bank_higher direction (amount <= -0.01)", () => {
165
- it('should accept direction "bank_higher" when amount is -25.50', () => {
166
- const output = createMinimalStructuredOutput(-25.5, "bank_higher");
167
- const result = ReconcileAccountOutputSchema.safeParse(output);
168
- expect(result.success).toBe(true);
169
- });
170
-
171
- it('should accept direction "bank_higher" when amount is -0.02 (just below threshold)', () => {
172
- const output = createMinimalStructuredOutput(-0.02, "bank_higher");
173
- const result = ReconcileAccountOutputSchema.safeParse(output);
174
- expect(result.success).toBe(true);
175
- });
176
-
177
- it('should reject direction "balanced" when amount is -100', () => {
178
- const output = createMinimalStructuredOutput(-100, "balanced");
179
- const result = ReconcileAccountOutputSchema.safeParse(output);
180
- expect(result.success).toBe(false);
181
- if (!result.success) {
182
- expect(result.error.issues[0]?.path).toEqual([
183
- "balance",
184
- "discrepancy_direction",
185
- ]);
186
- expect(result.error.issues[0]?.message).toContain(
187
- "Discrepancy direction mismatch",
188
- );
189
- }
190
- });
191
-
192
- it('should reject direction "ynab_higher" when amount is -50', () => {
193
- const output = createMinimalStructuredOutput(-50, "ynab_higher");
194
- const result = ReconcileAccountOutputSchema.safeParse(output);
195
- expect(result.success).toBe(false);
196
- if (!result.success) {
197
- expect(result.error.issues[0]?.path).toEqual([
198
- "balance",
199
- "discrepancy_direction",
200
- ]);
201
- }
202
- });
41
+ it("accepts output with execution_summary", () => {
42
+ const output = {
43
+ human: "Reconciliation complete",
44
+ structured: {
45
+ unmatched_bank: [],
46
+ unmatched_ynab: [],
47
+ suggestions: [],
48
+ execution_summary: {
49
+ transactions_created: 5,
50
+ transactions_updated: 2,
51
+ dates_adjusted: 0,
52
+ dry_run: false,
53
+ balance_status: "balanced",
54
+ recommendations: ["Review matched transactions"],
55
+ },
56
+ },
57
+ };
58
+ const result = ReconcileAccountOutputSchema.safeParse(output);
59
+ expect(result.success).toBe(true);
203
60
  });
204
61
 
205
- describe("edge cases", () => {
206
- it('should accept exactly 0.01 as requiring "ynab_higher"', () => {
207
- const output = createMinimalStructuredOutput(0.01, "ynab_higher");
208
- const result = ReconcileAccountOutputSchema.safeParse(output);
209
- expect(result.success).toBe(true);
210
- });
211
-
212
- it('should accept exactly -0.01 as requiring "bank_higher"', () => {
213
- const output = createMinimalStructuredOutput(-0.01, "bank_higher");
214
- const result = ReconcileAccountOutputSchema.safeParse(output);
215
- expect(result.success).toBe(true);
216
- });
217
-
218
- it('should reject "balanced" for exactly 0.01', () => {
219
- const output = createMinimalStructuredOutput(0.01, "balanced");
220
- const result = ReconcileAccountOutputSchema.safeParse(output);
221
- expect(result.success).toBe(false);
222
- });
223
-
224
- it('should reject "balanced" for exactly -0.01', () => {
225
- const output = createMinimalStructuredOutput(-0.01, "balanced");
226
- const result = ReconcileAccountOutputSchema.safeParse(output);
227
- expect(result.success).toBe(false);
228
- });
62
+ it("accepts output without execution_summary", () => {
63
+ const output = {
64
+ human: "Reconciliation complete",
65
+ structured: {
66
+ unmatched_bank: [],
67
+ unmatched_ynab: [],
68
+ suggestions: [],
69
+ },
70
+ };
71
+ const result = ReconcileAccountOutputSchema.safeParse(output);
72
+ expect(result.success).toBe(true);
229
73
  });
74
+ });
230
75
 
231
- describe("human-only output (no validation)", () => {
232
- it("should accept human-only output without structured data", () => {
233
- const output = {
234
- human: "Reconciliation complete - everything balanced",
235
- };
236
- const result = ReconcileAccountOutputSchema.safeParse(output);
237
- expect(result.success).toBe(true);
238
- });
76
+ describe("MoneyValueSchema - non-finite value validation", () => {
77
+ it("should reject NaN value", () => {
78
+ const invalid = {
79
+ value_milliunits: 0,
80
+ value: Number.NaN,
81
+ value_display: "$NaN",
82
+ currency: "USD",
83
+ direction: "balanced",
84
+ };
85
+ const result = MoneyValueSchema.safeParse(invalid);
86
+ expect(result.success).toBe(false);
87
+ if (!result.success) {
88
+ expect(result.error.issues[0]?.path).toEqual(["value"]);
89
+ }
239
90
  });
240
91
 
241
- describe("MoneyValueSchema - non-finite value validation", () => {
242
- it("should reject NaN value", () => {
243
- const invalid = {
244
- value_milliunits: 0,
245
- value: Number.NaN,
246
- value_display: "$NaN",
247
- currency: "USD",
248
- direction: "balanced",
249
- };
250
- const result = MoneyValueSchema.safeParse(invalid);
251
- expect(result.success).toBe(false);
252
- if (!result.success) {
253
- expect(result.error.issues[0]?.path).toEqual(["value"]);
254
- }
255
- });
256
-
257
- it("should reject positive Infinity value", () => {
258
- const invalid = {
259
- value_milliunits: 0,
260
- value: Number.POSITIVE_INFINITY,
261
- value_display: "$Infinity",
262
- currency: "USD",
263
- direction: "credit",
264
- };
265
- const result = MoneyValueSchema.safeParse(invalid);
266
- expect(result.success).toBe(false);
267
- if (!result.success) {
268
- expect(result.error.issues[0]?.path).toEqual(["value"]);
269
- }
270
- });
92
+ it("should reject positive Infinity value", () => {
93
+ const invalid = {
94
+ value_milliunits: 0,
95
+ value: Number.POSITIVE_INFINITY,
96
+ value_display: "$Infinity",
97
+ currency: "USD",
98
+ direction: "credit",
99
+ };
100
+ const result = MoneyValueSchema.safeParse(invalid);
101
+ expect(result.success).toBe(false);
102
+ if (!result.success) {
103
+ expect(result.error.issues[0]?.path).toEqual(["value"]);
104
+ }
105
+ });
271
106
 
272
- it("should reject negative Infinity value", () => {
273
- const invalid = {
274
- value_milliunits: 0,
275
- value: Number.NEGATIVE_INFINITY,
276
- value_display: "-$Infinity",
277
- currency: "USD",
278
- direction: "debit",
279
- };
280
- const result = MoneyValueSchema.safeParse(invalid);
281
- expect(result.success).toBe(false);
282
- if (!result.success) {
283
- expect(result.error.issues[0]?.path).toEqual(["value"]);
284
- }
285
- });
107
+ it("should reject negative Infinity value", () => {
108
+ const invalid = {
109
+ value_milliunits: 0,
110
+ value: Number.NEGATIVE_INFINITY,
111
+ value_display: "-$Infinity",
112
+ currency: "USD",
113
+ direction: "debit",
114
+ };
115
+ const result = MoneyValueSchema.safeParse(invalid);
116
+ expect(result.success).toBe(false);
117
+ if (!result.success) {
118
+ expect(result.error.issues[0]?.path).toEqual(["value"]);
119
+ }
120
+ });
286
121
 
287
- it("should reject non-integer value_milliunits", () => {
288
- const invalid = {
289
- value_milliunits: 25.5,
290
- value: 0.0255,
291
- value_display: "$0.03",
292
- currency: "USD",
293
- direction: "credit",
294
- };
295
- const result = MoneyValueSchema.safeParse(invalid);
296
- expect(result.success).toBe(false);
297
- if (!result.success) {
298
- expect(result.error.issues[0]?.path).toEqual(["value_milliunits"]);
299
- }
300
- });
122
+ it("should reject non-integer value_milliunits", () => {
123
+ const invalid = {
124
+ value_milliunits: 25.5,
125
+ value: 0.0255,
126
+ value_display: "$0.03",
127
+ currency: "USD",
128
+ direction: "credit",
129
+ };
130
+ const result = MoneyValueSchema.safeParse(invalid);
131
+ expect(result.success).toBe(false);
132
+ if (!result.success) {
133
+ expect(result.error.issues[0]?.path).toEqual(["value_milliunits"]);
134
+ }
135
+ });
301
136
 
302
- it("should accept finite positive amounts", () => {
303
- const valid = {
304
- value_milliunits: 25500,
305
- value: 25.5,
306
- value_display: "$25.50",
307
- currency: "USD",
308
- direction: "credit",
309
- };
310
- const result = MoneyValueSchema.safeParse(valid);
311
- expect(result.success).toBe(true);
312
- });
137
+ it("should accept finite positive amounts", () => {
138
+ const valid = {
139
+ value_milliunits: 25500,
140
+ value: 25.5,
141
+ value_display: "$25.50",
142
+ currency: "USD",
143
+ direction: "credit",
144
+ };
145
+ const result = MoneyValueSchema.safeParse(valid);
146
+ expect(result.success).toBe(true);
147
+ });
313
148
 
314
- it("should accept finite negative amounts", () => {
315
- const valid = {
316
- value_milliunits: -25500,
317
- value: -25.5,
318
- value_display: "-$25.50",
319
- currency: "USD",
320
- direction: "debit",
321
- };
322
- const result = MoneyValueSchema.safeParse(valid);
323
- expect(result.success).toBe(true);
324
- });
149
+ it("should accept finite negative amounts", () => {
150
+ const valid = {
151
+ value_milliunits: -25500,
152
+ value: -25.5,
153
+ value_display: "-$25.50",
154
+ currency: "USD",
155
+ direction: "debit",
156
+ };
157
+ const result = MoneyValueSchema.safeParse(valid);
158
+ expect(result.success).toBe(true);
159
+ });
325
160
 
326
- it("should accept zero", () => {
327
- const valid = {
328
- value_milliunits: 0,
329
- value: 0,
330
- value_display: "$0.00",
331
- currency: "USD",
332
- direction: "balanced",
333
- };
334
- const result = MoneyValueSchema.safeParse(valid);
335
- expect(result.success).toBe(true);
336
- });
161
+ it("should accept zero", () => {
162
+ const valid = {
163
+ value_milliunits: 0,
164
+ value: 0,
165
+ value_display: "$0.00",
166
+ currency: "USD",
167
+ direction: "balanced",
168
+ };
169
+ const result = MoneyValueSchema.safeParse(valid);
170
+ expect(result.success).toBe(true);
337
171
  });
338
172
  });
@@ -495,6 +495,36 @@ export const ExecutionActionRecordSchema = z.discriminatedUnion("type", [
495
495
  reason: z.string(),
496
496
  bulk_chunk_index: z.number(),
497
497
  }),
498
+ // Bulk update chunk failure
499
+ z.object({
500
+ type: z.literal("batch_update_failed"),
501
+ transaction: z.null(),
502
+ reason: z.string(),
503
+ }),
504
+ // Bulk reconcile chunk failure
505
+ z.object({
506
+ type: z.literal("batch_reconcile_failed"),
507
+ transaction: z.null(),
508
+ reason: z.string(),
509
+ }),
510
+ // All matched transactions marked reconciled
511
+ z.object({
512
+ type: z.literal("reconciliation_complete"),
513
+ transaction: z.null(),
514
+ reason: z.string(),
515
+ }),
516
+ // Diagnostic: STEP 3 entry metadata
517
+ z.object({
518
+ type: z.literal("diagnostic_step3_entry"),
519
+ transaction: z.null(),
520
+ reason: z.string(),
521
+ }),
522
+ // Diagnostic: unmatched YNAB transaction details
523
+ z.object({
524
+ type: z.literal("diagnostic_unmatched_ynab"),
525
+ transaction: z.record(z.string(), z.unknown()),
526
+ reason: z.string(),
527
+ }),
498
528
  ]);
499
529
 
500
530
  export type ExecutionActionRecord = z.infer<typeof ExecutionActionRecordSchema>;
@@ -693,41 +723,25 @@ export const CsvFormatMetadataSchema = z.object({
693
723
 
694
724
  export type CsvFormatMetadata = z.infer<typeof CsvFormatMetadataSchema>;
695
725
 
696
- // Define the structured data schema without refinement first
697
- const StructuredReconciliationDataBaseSchema = z.object({
698
- version: z.string(),
699
- schema_url: z.string(),
700
- generated_at: z.string(),
701
- account: z.object({
702
- id: z.string().optional(),
703
- name: z.string().optional(),
704
- }),
705
- summary: ReconciliationSummarySchema,
706
- balance: BalanceInfoSchema.extend({
707
- discrepancy_direction: z.enum(["balanced", "ynab_higher", "bank_higher"]),
708
- }),
709
- insights: z.array(ReconciliationInsightSchema),
710
- next_steps: z.array(z.string()),
711
- matches: z.object({
712
- auto: z.array(TransactionMatchSchema),
713
- suggested: z.array(TransactionMatchSchema),
714
- }),
715
- unmatched: z.object({
716
- bank: z.array(BankTransactionSchema),
717
- ynab: z.array(YNABTransactionSimpleSchema),
718
- ynab_outside_date_range: z.array(YNABTransactionSimpleSchema).optional(),
719
- }),
720
- recommendations: z.array(ActionableRecommendationSchema).optional(),
721
- csv_format: CsvFormatMetadataSchema.optional(),
722
- execution: ExecutionResultSchema.optional(),
723
- audit: AuditMetadataSchema.optional(),
726
+ export const ExecutionSummaryOutputSchema = z.object({
727
+ transactions_created: z.number().int(),
728
+ transactions_updated: z.number().int(),
729
+ dates_adjusted: z.number().int(),
730
+ dry_run: z.boolean(),
731
+ balance_status: z.enum(["balanced", "unbalanced", "not_verified"]).optional(),
732
+ recommendations: z.array(z.string()).optional(),
724
733
  });
725
734
 
735
+ export type ExecutionSummaryOutput = z.infer<
736
+ typeof ExecutionSummaryOutputSchema
737
+ >;
738
+
726
739
  export const StructuredReconciliationUnmatchedOnlySchema = z
727
740
  .object({
728
741
  unmatched_bank: z.array(BankTransactionSchema),
729
742
  unmatched_ynab: z.array(YNABTransactionSimpleSchema),
730
743
  suggestions: z.array(TransactionMatchSchema),
744
+ execution_summary: ExecutionSummaryOutputSchema.optional(),
731
745
  })
732
746
  .strict();
733
747
 
@@ -736,62 +750,11 @@ export type StructuredReconciliationUnmatchedOnly = z.infer<
736
750
  >;
737
751
 
738
752
  export const ReconcileAccountOutputSchema = z
739
- .union([
740
- // Human + structured data (when include_structured_data=true) - check this FIRST
741
- z
742
- .object({
743
- human: z.string(),
744
- structured: z.union([
745
- StructuredReconciliationDataBaseSchema,
746
- StructuredReconciliationUnmatchedOnlySchema,
747
- ]),
748
- })
749
- .strict(),
750
- // Human narrative only (default mode) - check this SECOND
751
- z
752
- .object({
753
- human: z.string(),
754
- })
755
- .strict(),
756
- ])
757
- .refine(
758
- (data) => {
759
- // Only validate if this is the structured variant (has 'structured' property)
760
- if (
761
- "structured" in data &&
762
- data.structured &&
763
- "balance" in data.structured &&
764
- typeof data.structured.balance === "object" &&
765
- data.structured.balance !== null
766
- ) {
767
- const discrepancyAmount = data.structured.balance.discrepancy.value;
768
- const direction = data.structured.balance.discrepancy_direction;
769
-
770
- // If absolute discrepancy < 0.01, direction must be 'balanced'
771
- if (Math.abs(discrepancyAmount) < 0.01) {
772
- return direction === "balanced";
773
- }
774
-
775
- // If discrepancy > 0, direction must be 'ynab_higher'
776
- if (discrepancyAmount > 0) {
777
- return direction === "ynab_higher";
778
- }
779
-
780
- // If discrepancy < 0, direction must be 'bank_higher'
781
- if (discrepancyAmount < 0) {
782
- return direction === "bank_higher";
783
- }
784
- }
785
-
786
- // Human-only variant always passes validation
787
- return true;
788
- },
789
- {
790
- message:
791
- "Discrepancy direction mismatch: direction must match the numeric discrepancy amount",
792
- path: ["balance", "discrepancy_direction"],
793
- },
794
- );
753
+ .object({
754
+ human: z.string(),
755
+ structured: StructuredReconciliationUnmatchedOnlySchema,
756
+ })
757
+ .strict();
795
758
 
796
759
  export type ReconcileAccountOutput = z.infer<
797
760
  typeof ReconcileAccountOutputSchema