@dizzlkheinz/ynab-mcpb 0.18.0 → 0.18.1
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 +2 -0
- package/CLAUDE.md +1 -1
- package/dist/bundle/index.cjs +35 -35
- package/dist/tools/reconciliation/executor.js +0 -8
- package/package.json +1 -1
- package/src/tools/reconciliation/__tests__/executor.integration.test.ts +12 -26
- package/src/tools/reconciliation/__tests__/executor.test.ts +36 -31
- package/src/tools/reconciliation/executor.ts +1 -26
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createHash } from 'crypto';
|
|
2
1
|
import { YNABAPIError } from '../../server/errorHandler.js';
|
|
3
2
|
import { toMilli, toMoneyValue, addMilli } from '../../utils/money.js';
|
|
4
3
|
import { generateCorrelationKey, correlateResults, toCorrelationPayload, } from '../transactionTools.js';
|
|
@@ -29,12 +28,6 @@ function truncateMemo(memo) {
|
|
|
29
28
|
return memo;
|
|
30
29
|
return memo.substring(0, MAX_MEMO_LENGTH - 3) + '...';
|
|
31
30
|
}
|
|
32
|
-
function generateBulkImportId(accountId, date, amountMilli, payee) {
|
|
33
|
-
const normalizedPayee = (payee ?? '').trim().toLowerCase();
|
|
34
|
-
const raw = `${accountId}|${date}|${amountMilli}|${normalizedPayee}`;
|
|
35
|
-
const digest = createHash('sha256').update(raw).digest('hex').slice(0, 24);
|
|
36
|
-
return `YNAB:bulk:${digest}`;
|
|
37
|
-
}
|
|
38
31
|
function parseISODate(dateStr) {
|
|
39
32
|
if (!dateStr)
|
|
40
33
|
return undefined;
|
|
@@ -160,7 +153,6 @@ export async function executeReconciliation(options) {
|
|
|
160
153
|
memo: truncateMemo(bankTxn.memo),
|
|
161
154
|
cleared: 'cleared',
|
|
162
155
|
approved: true,
|
|
163
|
-
import_id: generateBulkImportId(accountId, bankTxn.date, amountMilli, bankTxn.payee),
|
|
164
156
|
};
|
|
165
157
|
const correlationKey = generateCorrelationKey(toCorrelationPayload(saveTransaction));
|
|
166
158
|
return {
|
package/package.json
CHANGED
|
@@ -78,9 +78,12 @@ describeIntegration('Reconciliation Executor - Bulk Create Integration', () => {
|
|
|
78
78
|
);
|
|
79
79
|
|
|
80
80
|
it(
|
|
81
|
-
'
|
|
81
|
+
'creates transactions without import_id to allow bank matching',
|
|
82
82
|
{ meta: { tier: 'domain', domain: 'reconciliation' } },
|
|
83
83
|
async function () {
|
|
84
|
+
// Note: import_id is intentionally omitted from reconciliation-created transactions
|
|
85
|
+
// so they can match with bank-imported transactions. YNAB-side duplicate detection
|
|
86
|
+
// is no longer used; the reconciliation matcher handles duplicate prevention.
|
|
84
87
|
const analysis = buildIntegrationAnalysis(accountSnapshot, 2, 9);
|
|
85
88
|
const params = buildIntegrationParams(
|
|
86
89
|
accountId,
|
|
@@ -88,23 +91,7 @@ describeIntegration('Reconciliation Executor - Bulk Create Integration', () => {
|
|
|
88
91
|
analysis.summary.target_statement_balance,
|
|
89
92
|
);
|
|
90
93
|
|
|
91
|
-
const
|
|
92
|
-
() =>
|
|
93
|
-
executeReconciliation({
|
|
94
|
-
ynabAPI,
|
|
95
|
-
analysis,
|
|
96
|
-
params,
|
|
97
|
-
budgetId,
|
|
98
|
-
accountId,
|
|
99
|
-
initialAccount: accountSnapshot,
|
|
100
|
-
currencyCode: 'USD',
|
|
101
|
-
}),
|
|
102
|
-
this,
|
|
103
|
-
);
|
|
104
|
-
if (!firstRun) return;
|
|
105
|
-
trackCreatedTransactions(firstRun);
|
|
106
|
-
|
|
107
|
-
const duplicateAttempt = await skipOnRateLimit(
|
|
94
|
+
const result = await skipOnRateLimit(
|
|
108
95
|
() =>
|
|
109
96
|
executeReconciliation({
|
|
110
97
|
ynabAPI,
|
|
@@ -117,15 +104,14 @@ describeIntegration('Reconciliation Executor - Bulk Create Integration', () => {
|
|
|
117
104
|
}),
|
|
118
105
|
this,
|
|
119
106
|
);
|
|
120
|
-
if (!
|
|
121
|
-
|
|
107
|
+
if (!result) return;
|
|
108
|
+
trackCreatedTransactions(result);
|
|
109
|
+
if (containsRateLimitFailure(result)) return;
|
|
122
110
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
)
|
|
126
|
-
expect(
|
|
127
|
-
expect(duplicateAttempt.bulk_operation_details?.duplicates_detected).toBeGreaterThan(0);
|
|
128
|
-
expect(duplicateAttempt.summary.transactions_created).toBe(0);
|
|
111
|
+
// Verify transactions were created successfully
|
|
112
|
+
expect(result.summary.transactions_created).toBe(2);
|
|
113
|
+
// Verify no YNAB-side duplicate detection occurred (because no import_id)
|
|
114
|
+
expect(result.bulk_operation_details?.duplicates_detected).toBe(0);
|
|
129
115
|
},
|
|
130
116
|
60000,
|
|
131
117
|
);
|
|
@@ -484,9 +484,10 @@ describe('executeReconciliation - bulk create mode', () => {
|
|
|
484
484
|
account_id: txn.account_id,
|
|
485
485
|
amount: txn.amount,
|
|
486
486
|
date: txn.date,
|
|
487
|
+
payee_name: txn.payee_name,
|
|
488
|
+
memo: txn.memo,
|
|
487
489
|
cleared: 'cleared',
|
|
488
490
|
approved: true,
|
|
489
|
-
import_id: txn.import_id, // Include import_id for correlation
|
|
490
491
|
}));
|
|
491
492
|
return { data: { transactions, duplicate_import_ids: [] } };
|
|
492
493
|
});
|
|
@@ -638,11 +639,12 @@ describe('executeReconciliation - bulk create mode', () => {
|
|
|
638
639
|
account_id: txn.account_id,
|
|
639
640
|
amount: txn.amount,
|
|
640
641
|
date: txn.date,
|
|
642
|
+
payee_name: txn.payee_name,
|
|
643
|
+
memo: txn.memo,
|
|
641
644
|
cleared: 'cleared',
|
|
642
645
|
approved: true,
|
|
643
|
-
import_id: txn.import_id, // Include import_id for correlation
|
|
644
646
|
}));
|
|
645
|
-
return { data: { transactions } };
|
|
647
|
+
return { data: { transactions, duplicate_import_ids: [] } };
|
|
646
648
|
});
|
|
647
649
|
|
|
648
650
|
const result = await executeReconciliation({
|
|
@@ -690,33 +692,35 @@ describe('executeReconciliation - bulk create mode', () => {
|
|
|
690
692
|
).rejects.toMatchObject({ status: 404 });
|
|
691
693
|
});
|
|
692
694
|
|
|
693
|
-
it('
|
|
694
|
-
|
|
695
|
+
it('creates all transactions without import_id (no YNAB-side duplicate detection)', async () => {
|
|
696
|
+
// Note: import_id is intentionally omitted from reconciliation-created transactions
|
|
697
|
+
// so they can match with bank-imported transactions. This means YNAB won't detect
|
|
698
|
+
// duplicates via import_id - the reconciliation matcher is responsible for that.
|
|
699
|
+
// Use amount of 100 to avoid early balance halting (tolerance is 10 milliunits)
|
|
700
|
+
const analysis = buildBulkAnalysis(3, 100);
|
|
695
701
|
const params = buildBulkParams(analysis.summary.target_statement_balance);
|
|
696
702
|
const initialAccount = { ...defaultAccountSnapshot };
|
|
697
703
|
const { api, mocks } = createMockYnabAPI(initialAccount);
|
|
698
704
|
|
|
699
705
|
mocks.createTransactions.mockImplementation(async (_budgetId, body: any) => {
|
|
700
|
-
const transactions = (body.transactions ?? []).map((txn: any, index: number) => {
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
}
|
|
714
|
-
const filtered = transactions.filter(Boolean);
|
|
715
|
-
const duplicateImportId = body.transactions?.[1]?.import_id;
|
|
706
|
+
const transactions = (body.transactions ?? []).map((txn: any, index: number) => ({
|
|
707
|
+
id: `created-${index}`,
|
|
708
|
+
account_id: txn.account_id,
|
|
709
|
+
amount: txn.amount,
|
|
710
|
+
date: txn.date,
|
|
711
|
+
payee_name: txn.payee_name,
|
|
712
|
+
memo: txn.memo,
|
|
713
|
+
cleared: 'cleared',
|
|
714
|
+
approved: true,
|
|
715
|
+
}));
|
|
716
|
+
// Verify no import_id is being sent
|
|
717
|
+
for (const txn of body.transactions ?? []) {
|
|
718
|
+
expect(txn.import_id).toBeUndefined();
|
|
719
|
+
}
|
|
716
720
|
return {
|
|
717
721
|
data: {
|
|
718
|
-
transactions
|
|
719
|
-
duplicate_import_ids:
|
|
722
|
+
transactions,
|
|
723
|
+
duplicate_import_ids: [],
|
|
720
724
|
},
|
|
721
725
|
};
|
|
722
726
|
});
|
|
@@ -731,10 +735,9 @@ describe('executeReconciliation - bulk create mode', () => {
|
|
|
731
735
|
currencyCode: 'USD',
|
|
732
736
|
});
|
|
733
737
|
|
|
734
|
-
|
|
735
|
-
expect(
|
|
736
|
-
expect(result.
|
|
737
|
-
expect(result.summary.transactions_created).toBe(2);
|
|
738
|
+
// Without import_id, YNAB creates all transactions (no duplicate detection)
|
|
739
|
+
expect(result.bulk_operation_details?.duplicates_detected).toBe(0);
|
|
740
|
+
expect(result.summary.transactions_created).toBe(3);
|
|
738
741
|
});
|
|
739
742
|
|
|
740
743
|
it('honors halting logic when balance aligns mid-batch', async () => {
|
|
@@ -749,11 +752,12 @@ describe('executeReconciliation - bulk create mode', () => {
|
|
|
749
752
|
account_id: txn.account_id,
|
|
750
753
|
amount: txn.amount,
|
|
751
754
|
date: txn.date,
|
|
755
|
+
payee_name: txn.payee_name,
|
|
756
|
+
memo: txn.memo,
|
|
752
757
|
cleared: 'cleared',
|
|
753
758
|
approved: true,
|
|
754
|
-
import_id: txn.import_id, // Include import_id for correlation
|
|
755
759
|
}));
|
|
756
|
-
return { data: { transactions } };
|
|
760
|
+
return { data: { transactions, duplicate_import_ids: [] } };
|
|
757
761
|
});
|
|
758
762
|
|
|
759
763
|
const result = await executeReconciliation({
|
|
@@ -794,11 +798,12 @@ describe('executeReconciliation - bulk create mode', () => {
|
|
|
794
798
|
account_id: txn.account_id,
|
|
795
799
|
amount: txn.amount,
|
|
796
800
|
date: txn.date,
|
|
801
|
+
payee_name: txn.payee_name,
|
|
802
|
+
memo: txn.memo,
|
|
797
803
|
cleared: 'cleared',
|
|
798
804
|
approved: true,
|
|
799
|
-
import_id: txn.import_id,
|
|
800
805
|
}));
|
|
801
|
-
return { data: { transactions } };
|
|
806
|
+
return { data: { transactions, duplicate_import_ids: [] } };
|
|
802
807
|
});
|
|
803
808
|
|
|
804
809
|
const result = await executeReconciliation({
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createHash } from 'crypto';
|
|
2
1
|
import type * as ynab from 'ynab';
|
|
3
2
|
import type { SaveTransaction } from 'ynab/dist/models/SaveTransaction.js';
|
|
4
3
|
import { YNABAPIError, YNABErrorCode } from '../../server/errorHandler.js';
|
|
@@ -131,30 +130,6 @@ interface PreparedBulkCreateEntry {
|
|
|
131
130
|
correlationKey: string;
|
|
132
131
|
}
|
|
133
132
|
|
|
134
|
-
/**
|
|
135
|
-
* Generates a deterministic import_id for reconciliation-created transactions.
|
|
136
|
-
*
|
|
137
|
-
* Uses a dedicated `YNAB:bulk:` prefix to distinguish reconciliation-created transactions
|
|
138
|
-
* from manual bulk creates. This namespace separation is intentional:
|
|
139
|
-
* - Reconciliation operations are automated and system-generated
|
|
140
|
-
* - Manual bulk creates via create_transactions tool can use custom import_id formats
|
|
141
|
-
* - Both interact with YNAB's global duplicate detection via the same import_id mechanism
|
|
142
|
-
*
|
|
143
|
-
* The hash-based correlation in transactionTools.ts uses `hash:` prefix for correlation
|
|
144
|
-
* (when no import_id provided), which is separate from this import_id generation.
|
|
145
|
-
*/
|
|
146
|
-
function generateBulkImportId(
|
|
147
|
-
accountId: string,
|
|
148
|
-
date: string,
|
|
149
|
-
amountMilli: number,
|
|
150
|
-
payee?: string | null,
|
|
151
|
-
): string {
|
|
152
|
-
const normalizedPayee = (payee ?? '').trim().toLowerCase();
|
|
153
|
-
const raw = `${accountId}|${date}|${amountMilli}|${normalizedPayee}`;
|
|
154
|
-
const digest = createHash('sha256').update(raw).digest('hex').slice(0, 24);
|
|
155
|
-
return `YNAB:bulk:${digest}`;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
133
|
function parseISODate(dateStr: string | undefined): Date | undefined {
|
|
159
134
|
if (!dateStr) return undefined;
|
|
160
135
|
const d = new Date(dateStr);
|
|
@@ -313,7 +288,7 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
|
|
|
313
288
|
memo: truncateMemo(bankTxn.memo),
|
|
314
289
|
cleared: 'cleared',
|
|
315
290
|
approved: true,
|
|
316
|
-
|
|
291
|
+
// Note: import_id intentionally omitted so transactions can match with bank imports
|
|
317
292
|
};
|
|
318
293
|
const correlationKey = generateCorrelationKey(toCorrelationPayload(saveTransaction));
|
|
319
294
|
return {
|