@dizzlkheinz/ynab-mcpb 0.18.0 → 0.18.2

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.
@@ -166,6 +166,19 @@ export function resolveDeltaWriteArgs(deltaCacheOrParams, knowledgeStoreOrParams
166
166
  throw new Error('resolveDeltaWriteArgs: When providing only 1 argument, it must be a params object. ' +
167
167
  `Got: ${getTypeName(deltaCacheOrParams)}`);
168
168
  }
169
+ if (sharedDeltaContext) {
170
+ if (!sharedDeltaContext.knowledgeStore) {
171
+ sharedDeltaContext.knowledgeStore = new ServerKnowledgeStore();
172
+ }
173
+ if (!sharedDeltaContext.deltaCache) {
174
+ sharedDeltaContext.deltaCache = new DeltaCache(cacheManager, sharedDeltaContext.knowledgeStore);
175
+ }
176
+ return {
177
+ deltaCache: sharedDeltaContext.deltaCache,
178
+ knowledgeStore: sharedDeltaContext.knowledgeStore,
179
+ params: deltaCacheOrParams,
180
+ };
181
+ }
169
182
  const fallbackKnowledgeStore = new ServerKnowledgeStore();
170
183
  const fallbackDeltaCache = new DeltaCache(cacheManager, fallbackKnowledgeStore);
171
184
  return {
@@ -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 {
@@ -358,10 +358,7 @@ function finalizeResponse(response) {
358
358
  const ReceiptSplitItemSchema = z
359
359
  .object({
360
360
  name: z.string().min(1, 'Item name is required'),
361
- amount: z
362
- .number()
363
- .finite('Item amount must be a finite number')
364
- .refine((value) => value >= 0, 'Item amount must be zero or greater'),
361
+ amount: z.number().finite('Item amount must be a finite number'),
365
362
  quantity: z
366
363
  .number()
367
364
  .finite('Quantity must be a finite number')
@@ -392,10 +389,7 @@ export const CreateReceiptSplitTransactionSchema = z
392
389
  .finite('Receipt subtotal must be a finite number')
393
390
  .refine((value) => value >= 0, 'Receipt subtotal must be zero or greater')
394
391
  .optional(),
395
- receipt_tax: z
396
- .number()
397
- .finite('Receipt tax must be a finite number')
398
- .refine((value) => value >= 0, 'Receipt tax must be zero or greater'),
392
+ receipt_tax: z.number().finite('Receipt tax must be a finite number'),
399
393
  receipt_total: z
400
394
  .number()
401
395
  .finite('Receipt total must be a finite number')
@@ -765,26 +759,231 @@ function buildItemMemo(item) {
765
759
  }
766
760
  return item.name;
767
761
  }
768
- function distributeTaxProportionally(subtotalMilliunits, totalTaxMilliunits, categories) {
769
- if (totalTaxMilliunits === 0) {
770
- for (const category of categories)
771
- category.tax_milliunits = 0;
772
- return;
762
+ const BIG_TICKET_THRESHOLD_MILLIUNITS = 50000;
763
+ const COLLAPSE_THRESHOLD = 5;
764
+ const MAX_ITEMS_PER_MEMO = 5;
765
+ const MAX_MEMO_LENGTH = 150;
766
+ function applySmartCollapseLogic(categoryCalculations, taxMilliunits) {
767
+ const specialItems = [];
768
+ const remainingItemsByCategory = [];
769
+ for (const category of categoryCalculations) {
770
+ const categorySpecials = [];
771
+ const categoryRemaining = [];
772
+ for (const item of category.items) {
773
+ const isNegative = item.amount_milliunits < 0;
774
+ const unitPrice = item.quantity
775
+ ? item.amount_milliunits / item.quantity
776
+ : item.amount_milliunits;
777
+ const isBigTicket = unitPrice > BIG_TICKET_THRESHOLD_MILLIUNITS;
778
+ if (isNegative || isBigTicket) {
779
+ categorySpecials.push(item);
780
+ }
781
+ else {
782
+ categoryRemaining.push(item);
783
+ }
784
+ }
785
+ for (const item of categorySpecials) {
786
+ specialItems.push({
787
+ item,
788
+ category_id: category.category_id,
789
+ category_name: category.category_name,
790
+ });
791
+ }
792
+ if (categoryRemaining.length > 0) {
793
+ remainingItemsByCategory.push({
794
+ category_id: category.category_id,
795
+ category_name: category.category_name,
796
+ items: categoryRemaining,
797
+ });
798
+ }
799
+ }
800
+ const totalRemainingItems = remainingItemsByCategory.reduce((sum, cat) => sum + cat.items.length, 0);
801
+ const shouldCollapse = totalRemainingItems >= COLLAPSE_THRESHOLD;
802
+ const subtransactions = [];
803
+ for (const special of specialItems) {
804
+ const memo = buildItemMemo({
805
+ name: special.item.name,
806
+ quantity: special.item.quantity,
807
+ memo: special.item.memo,
808
+ });
809
+ const payload = {
810
+ amount: -special.item.amount_milliunits,
811
+ category_id: special.category_id,
812
+ };
813
+ if (memo)
814
+ payload.memo = memo;
815
+ subtransactions.push(payload);
816
+ }
817
+ if (shouldCollapse) {
818
+ for (const categoryGroup of remainingItemsByCategory) {
819
+ const collapsedSubtransactions = collapseItemsByCategory(categoryGroup);
820
+ subtransactions.push(...collapsedSubtransactions);
821
+ }
773
822
  }
774
- if (subtotalMilliunits <= 0) {
775
- throw new Error('Receipt subtotal must be greater than zero to distribute tax');
823
+ else {
824
+ for (const categoryGroup of remainingItemsByCategory) {
825
+ for (const item of categoryGroup.items) {
826
+ const memo = buildItemMemo({
827
+ name: item.name,
828
+ quantity: item.quantity,
829
+ memo: item.memo,
830
+ });
831
+ const payload = {
832
+ amount: -item.amount_milliunits,
833
+ category_id: categoryGroup.category_id,
834
+ };
835
+ if (memo)
836
+ payload.memo = memo;
837
+ subtransactions.push(payload);
838
+ }
839
+ }
776
840
  }
777
- let allocated = 0;
778
- categories.forEach((category, index) => {
779
- if (index === categories.length - 1) {
780
- category.tax_milliunits = totalTaxMilliunits - allocated;
841
+ const taxSubtransactions = allocateTax(categoryCalculations, taxMilliunits);
842
+ subtransactions.push(...taxSubtransactions);
843
+ return subtransactions;
844
+ }
845
+ function collapseItemsByCategory(categoryGroup) {
846
+ const subtransactions = [];
847
+ const items = categoryGroup.items;
848
+ let currentBatch = [];
849
+ let currentBatchTotal = 0;
850
+ for (const item of items) {
851
+ if (currentBatch.length >= MAX_ITEMS_PER_MEMO) {
852
+ const memo = buildCollapsedMemo(currentBatch);
853
+ subtransactions.push({
854
+ amount: -currentBatchTotal,
855
+ category_id: categoryGroup.category_id,
856
+ memo,
857
+ });
858
+ currentBatch = [];
859
+ currentBatchTotal = 0;
860
+ }
861
+ const testBatch = [...currentBatch, item];
862
+ const testMemo = buildCollapsedMemo(testBatch);
863
+ if (testMemo.length <= MAX_MEMO_LENGTH) {
864
+ currentBatch.push(item);
865
+ currentBatchTotal += item.amount_milliunits;
781
866
  }
782
867
  else {
783
- const proportionalTax = Math.round((totalTaxMilliunits * category.subtotal_milliunits) / subtotalMilliunits);
784
- category.tax_milliunits = proportionalTax;
785
- allocated += proportionalTax;
868
+ if (currentBatch.length > 0) {
869
+ const memo = buildCollapsedMemo(currentBatch);
870
+ subtransactions.push({
871
+ amount: -currentBatchTotal,
872
+ category_id: categoryGroup.category_id,
873
+ memo,
874
+ });
875
+ currentBatch = [item];
876
+ currentBatchTotal = item.amount_milliunits;
877
+ }
878
+ else {
879
+ currentBatch = [item];
880
+ currentBatchTotal = item.amount_milliunits;
881
+ }
786
882
  }
787
- });
883
+ }
884
+ if (currentBatch.length > 0) {
885
+ const memo = buildCollapsedMemo(currentBatch);
886
+ subtransactions.push({
887
+ amount: -currentBatchTotal,
888
+ category_id: categoryGroup.category_id,
889
+ memo,
890
+ });
891
+ }
892
+ return subtransactions;
893
+ }
894
+ function buildCollapsedMemo(items) {
895
+ const parts = [];
896
+ let currentLength = 0;
897
+ for (let i = 0; i < items.length; i++) {
898
+ const item = items[i];
899
+ if (!item)
900
+ continue;
901
+ const amount = milliunitsToAmount(item.amount_milliunits);
902
+ const itemStr = `${item.name} $${amount.toFixed(2)}`;
903
+ const separator = i > 0 ? ', ' : '';
904
+ const testLength = currentLength + separator.length + itemStr.length;
905
+ if (parts.length > 0 && testLength + 4 > MAX_MEMO_LENGTH) {
906
+ break;
907
+ }
908
+ parts.push(itemStr);
909
+ currentLength = testLength;
910
+ }
911
+ let result = parts.join(', ');
912
+ if (parts.length < items.length) {
913
+ result += '...';
914
+ }
915
+ return result;
916
+ }
917
+ function allocateTax(categoryCalculations, taxMilliunits) {
918
+ const subtransactions = [];
919
+ if (taxMilliunits === 0) {
920
+ return subtransactions;
921
+ }
922
+ if (taxMilliunits < 0) {
923
+ let largestReturnCategory = undefined;
924
+ let largestReturnAmount = 0;
925
+ for (const category of categoryCalculations) {
926
+ const categoryReturnAmount = category.items
927
+ .filter((item) => item.amount_milliunits < 0)
928
+ .reduce((sum, item) => sum + Math.abs(item.amount_milliunits), 0);
929
+ if (categoryReturnAmount > largestReturnAmount) {
930
+ largestReturnAmount = categoryReturnAmount;
931
+ largestReturnCategory = category;
932
+ }
933
+ }
934
+ if (!largestReturnCategory) {
935
+ largestReturnCategory = categoryCalculations[0];
936
+ }
937
+ if (largestReturnCategory) {
938
+ subtransactions.push({
939
+ amount: -taxMilliunits,
940
+ category_id: largestReturnCategory.category_id,
941
+ memo: 'Tax refund',
942
+ });
943
+ }
944
+ return subtransactions;
945
+ }
946
+ const positiveCategorySubtotals = categoryCalculations
947
+ .map((cat) => ({
948
+ category: cat,
949
+ positiveSubtotal: cat.items
950
+ .filter((item) => item.amount_milliunits > 0)
951
+ .reduce((sum, item) => sum + item.amount_milliunits, 0),
952
+ }))
953
+ .filter((x) => x.positiveSubtotal > 0);
954
+ if (positiveCategorySubtotals.length === 0) {
955
+ return subtransactions;
956
+ }
957
+ const totalPositiveSubtotal = positiveCategorySubtotals.reduce((sum, x) => sum + x.positiveSubtotal, 0);
958
+ let allocatedTax = 0;
959
+ const taxAllocations = [];
960
+ for (let i = 0; i < positiveCategorySubtotals.length; i++) {
961
+ const entry = positiveCategorySubtotals[i];
962
+ if (!entry)
963
+ continue;
964
+ const { category, positiveSubtotal } = entry;
965
+ if (i === positiveCategorySubtotals.length - 1) {
966
+ const taxAmount = taxMilliunits - allocatedTax;
967
+ if (taxAmount > 0) {
968
+ taxAllocations.push({ category, taxAmount });
969
+ }
970
+ }
971
+ else {
972
+ const taxAmount = Math.round((taxMilliunits * positiveSubtotal) / totalPositiveSubtotal);
973
+ if (taxAmount > 0) {
974
+ taxAllocations.push({ category, taxAmount });
975
+ allocatedTax += taxAmount;
976
+ }
977
+ }
978
+ }
979
+ for (const { category, taxAmount } of taxAllocations) {
980
+ subtransactions.push({
981
+ amount: -taxAmount,
982
+ category_id: category.category_id,
983
+ memo: `Tax - ${category.category_name ?? 'Uncategorized'}`,
984
+ });
985
+ }
986
+ return subtransactions;
788
987
  }
789
988
  export async function handleCreateReceiptSplitTransaction(ynabAPI, deltaCacheOrParams, knowledgeStoreOrParams, maybeParams) {
790
989
  const { deltaCache, knowledgeStore, params } = resolveDeltaWriteArgs(deltaCacheOrParams, knowledgeStoreOrParams, maybeParams);
@@ -817,29 +1016,24 @@ export async function handleCreateReceiptSplitTransaction(ynabAPI, deltaCacheOrP
817
1016
  if (Math.abs(computedTotal - totalMilliunits) > 1) {
818
1017
  throw new Error(`Receipt total (${milliunitsToAmount(totalMilliunits)}) does not equal subtotal plus tax (${milliunitsToAmount(computedTotal)})`);
819
1018
  }
820
- distributeTaxProportionally(subtotalMilliunits, taxMilliunits, categoryCalculations);
821
- const subtransactions = categoryCalculations.flatMap((category) => {
822
- const itemSubtransactions = category.items.map((item) => {
823
- const memo = buildItemMemo({ name: item.name, quantity: item.quantity, memo: item.memo });
824
- const payload = {
825
- amount: -item.amount_milliunits,
826
- category_id: category.category_id,
827
- };
828
- if (memo)
829
- payload.memo = memo;
830
- return payload;
831
- });
832
- const taxSubtransaction = category.tax_milliunits > 0
833
- ? [
834
- {
835
- amount: -category.tax_milliunits,
836
- category_id: category.category_id,
837
- memo: `Tax - ${category.category_name ?? 'Uncategorized'}`,
838
- },
839
- ]
840
- : [];
841
- return [...itemSubtransactions, ...taxSubtransaction];
842
- });
1019
+ const subtransactions = applySmartCollapseLogic(categoryCalculations, taxMilliunits);
1020
+ if (taxMilliunits > 0) {
1021
+ const positiveSubtotal = categoryCalculations.reduce((sum, cat) => sum + Math.max(0, cat.subtotal_milliunits), 0);
1022
+ if (positiveSubtotal > 0) {
1023
+ let remainingTax = taxMilliunits;
1024
+ const positiveCats = categoryCalculations.filter((cat) => cat.subtotal_milliunits > 0);
1025
+ positiveCats.forEach((cat, index) => {
1026
+ if (index === positiveCats.length - 1) {
1027
+ cat.tax_milliunits = remainingTax;
1028
+ }
1029
+ else {
1030
+ const share = Math.round((cat.subtotal_milliunits / positiveSubtotal) * taxMilliunits);
1031
+ cat.tax_milliunits = share;
1032
+ remainingTax -= share;
1033
+ }
1034
+ });
1035
+ }
1036
+ }
843
1037
  const receiptSummary = {
844
1038
  subtotal: milliunitsToAmount(subtotalMilliunits),
845
1039
  tax: milliunitsToAmount(taxMilliunits),
@@ -0,0 +1,181 @@
1
+ # Receipt Itemization Specification
2
+
3
+ ## Overview
4
+
5
+ The `create_receipt_split_transaction` tool creates split transactions from receipts with proportional tax allocation. This spec defines the "smart collapse" behavior that reduces clutter for large receipts while preserving visibility for important items.
6
+
7
+ ## Collapsing Rules
8
+
9
+ Items are processed in this order:
10
+
11
+ ### 1. Extract Special Items (Always Get Own Subtransaction)
12
+
13
+ These items are never collapsed into a group:
14
+
15
+ | Type | Condition | Example |
16
+ |------|-----------|---------|
17
+ | **Big ticket** | Unit price > $50 | TV $500 |
18
+ | **Returns** | Negative amount | RETURN: Broken headphones -$29.99 |
19
+ | **Discounts** | Negative amount | Member discount -$5.00 |
20
+
21
+ **Clarifications:**
22
+
23
+ - **Unit price, not line total**: If a receipt shows "2x Widget @ $30 = $60", each widget is $30 (not big ticket). Only items with unit price > $50 qualify.
24
+ - **Returns and discounts** are both negative amounts. The AI calling this tool should provide appropriate memo text to distinguish them. Both always get their own subtransaction.
25
+
26
+ ### 2. Apply Threshold to Remaining Items
27
+
28
+ After extracting special items, count the remaining positive items:
29
+
30
+ - **Fewer than 5 remaining items**: Itemize each individually (one subtransaction per item)
31
+ - **5 or more remaining items**: Collapse by category (see below)
32
+
33
+ ### 3. Collapse by Category
34
+
35
+ When collapsing:
36
+
37
+ - Group items by category
38
+ - Maximum **5 items per subtransaction** memo
39
+ - If a category has more than 5 items, create multiple subtransactions for that category
40
+ - Memo format: `"Item1 $X.XX, Item2 $Y.YY, Item3 $Z.ZZ"`
41
+ - **Memo length limit**: 150 characters max. If exceeded, truncate item list and append "..."
42
+ - **Consistency rule**: If ANY category gets collapsed, ALL categories get collapsed. No mixed mode.
43
+
44
+ ### 4. Tax Handling
45
+
46
+ **Allocation:**
47
+ - Tax is allocated **proportionally** across categories based on positive subtotals only
48
+ - Returns and discounts do NOT reduce the taxable base for other items
49
+ - Returns and discounts receive NO tax allocation
50
+
51
+ **Subtransactions:**
52
+ - Each category with a positive subtotal gets its own **separate tax subtransaction**
53
+ - Memo format: `"Tax - CategoryName"`
54
+ - Categories with zero or negative subtotal after discounts: **no tax subtransaction**
55
+
56
+ **Edge cases:**
57
+ - **Tax = 0**: Skip all tax subtransactions
58
+ - **Tax < 0** (refund scenario): Create a single `"Tax refund"` subtransaction with the negative amount, allocated to the category with the largest return
59
+
60
+ ### 5. Output Ordering
61
+
62
+ Subtransactions are ordered as follows:
63
+
64
+ 1. Special items (returns, discounts, big tickets) - in receipt order
65
+ 2. Collapsed/itemized category groups - categories in input order
66
+ 3. Tax subtransactions - in category order
67
+
68
+ ## Validation Rules
69
+
70
+ - **Every item must have a category**. Uncategorized items cause validation failure.
71
+ - **Tax-exempt items**: Not supported in this version. All positive items are assumed taxable.
72
+
73
+ ## Configuration
74
+
75
+ | Parameter | Value | Notes |
76
+ |-----------|-------|-------|
77
+ | Big ticket threshold | $50.00 | Unit price, not line total |
78
+ | Collapse threshold | 5 items | Total remaining positive items after extracting specials |
79
+ | Max items per memo | 5 | Creates additional subtransactions if exceeded |
80
+ | Max memo length | 150 chars | Truncates with "..." if exceeded |
81
+
82
+ ## Examples
83
+
84
+ ### Example 1: Small Receipt (fewer than 5 items)
85
+
86
+ Input: 3 grocery items
87
+
88
+ Output:
89
+ ```
90
+ $4.99 -> Groceries | memo: "Milk"
91
+ $3.49 -> Groceries | memo: "Bread"
92
+ $5.99 -> Groceries | memo: "Eggs"
93
+ $1.16 -> Groceries | memo: "Tax - Groceries"
94
+ ```
95
+
96
+ ### Example 2: Large Single-Category Receipt
97
+
98
+ Input: 12 grocery items
99
+
100
+ Output:
101
+ ```
102
+ $24.45 -> Groceries | memo: "Milk $4.99, Bread $3.49, Eggs $5.99, Cheese $10.99, Butter $2.99"
103
+ $19.46 -> Groceries | memo: "Yogurt $6.47, Apples $4.00, Bananas $2.01, OJ $3.00, Cereal $3.98"
104
+ $8.02 -> Groceries | memo: "Rice $5.00, Pasta $3.02"
105
+ $4.15 -> Groceries | memo: "Tax - Groceries"
106
+ ```
107
+
108
+ ### Example 3: Mixed Receipt with Big Ticket Item
109
+
110
+ Input: TV ($500), 8 grocery items
111
+
112
+ Output:
113
+ ```
114
+ $500.00 -> Electronics | memo: "TV"
115
+ $24.45 -> Groceries | memo: "Milk $4.99, Bread $3.49, Eggs $5.99, Cheese $10.99, Butter $2.99"
116
+ $15.48 -> Groceries | memo: "Yogurt $6.47, Apples $4.00, Bananas $2.01, OJ $3.00"
117
+ $40.00 -> Electronics | memo: "Tax - Electronics"
118
+ $3.19 -> Groceries | memo: "Tax - Groceries"
119
+ ```
120
+
121
+ ### Example 4: Receipt with Return
122
+
123
+ Input: 6 grocery items plus a return
124
+
125
+ Output:
126
+ ```
127
+ -$29.99 -> Electronics | memo: "RETURN: Broken headphones"
128
+ $24.45 -> Groceries | memo: "Milk $4.99, Bread $3.49, Eggs $5.99, Cheese $10.99, Butter $2.99"
129
+ $10.48 -> Groceries | memo: "Yogurt $6.47, Apples $4.01"
130
+ $2.79 -> Groceries | memo: "Tax - Groceries"
131
+ ```
132
+
133
+ Note: The return receives no tax allocation. Tax is only applied to positive grocery items.
134
+
135
+ ### Example 5: Receipt with Discount
136
+
137
+ Input: 6 grocery items with member discount
138
+
139
+ Output:
140
+ ```
141
+ -$5.00 -> Groceries | memo: "Member discount"
142
+ $24.45 -> Groceries | memo: "Milk $4.99, Bread $3.49, Eggs $5.99, Cheese $10.99, Butter $2.99"
143
+ $15.48 -> Groceries | memo: "Yogurt $6.47, Apples $4.00, Bananas $2.01, OJ $3.00"
144
+ $3.19 -> Groceries | memo: "Tax - Groceries"
145
+ ```
146
+
147
+ Note: The discount is a separate line item. Tax is calculated on the positive items only.
148
+
149
+ ### Example 6: Quantity Items (Not Big Ticket)
150
+
151
+ Input: 3x Widgets @ $30 each ($90 line), plus 4 other items
152
+
153
+ Total items: 7 (3 widgets + 4 others) -> collapse mode
154
+
155
+ Output:
156
+ ```
157
+ $90.00 -> Electronics | memo: "Widget $30.00, Widget $30.00, Widget $30.00"
158
+ $45.00 -> Groceries | memo: "Milk $10.00, Bread $10.00, Eggs $10.00, Cheese $15.00"
159
+ $10.80 -> Electronics | memo: "Tax - Electronics"
160
+ $3.60 -> Groceries | memo: "Tax - Groceries"
161
+ ```
162
+
163
+ Note: Each widget is $30 (under $50 threshold), so they collapse normally.
164
+
165
+ ### Example 7: Tax Refund Scenario
166
+
167
+ Input: Return of $100 item, receipt shows -$8.00 tax
168
+
169
+ Output:
170
+ ```
171
+ -$100.00 -> Electronics | memo: "RETURN: Defective laptop"
172
+ -$8.00 -> Electronics | memo: "Tax refund"
173
+ ```
174
+
175
+ ## Implementation Notes
176
+
177
+ 1. **Rounding**: Use largest-remainder method when distributing tax to avoid penny discrepancies
178
+ 2. **Order of operations**: Extract specials -> count remaining -> decide collapse -> distribute tax
179
+ 3. **Consistency**: If ANY category gets collapsed, ALL categories get collapsed (except specials)
180
+ 4. **Negative detection**: Any item with amount < 0 is treated as return/discount (gets own subtransaction)
181
+ 5. **Unit price extraction**: When items have quantity > 1, divide total by quantity to get unit price for $50 threshold check
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dizzlkheinz/ynab-mcpb",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
4
4
  "description": "Model Context Protocol server for YNAB (You Need A Budget) integration",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -215,6 +215,10 @@ describeIntegration('Transaction Tools Integration', () => {
215
215
  const result = await handleCreateTransactions(ynabAPI, params);
216
216
  const response = parseToolResult(result);
217
217
 
218
+ if (response.error) {
219
+ console.error('Bulk Create Failed:', JSON.stringify(response.error, null, 2));
220
+ }
221
+
218
222
  if (trackCreatedIds && Array.isArray(response.results)) {
219
223
  const createdIds = response.results
220
224
  .filter(
@@ -281,7 +285,7 @@ describeIntegration('Transaction Tools Integration', () => {
281
285
  'should detect duplicates when reusing import IDs',
282
286
  { meta: { tier: 'domain', domain: 'transactions' } },
283
287
  async () => {
284
- const importId = `MCP:DUP:${randomUUID()}`;
288
+ const importId = `MCP:DUP:${randomUUID().slice(0, 20)}`;
285
289
  await executeBulkCreate({
286
290
  budget_id: testBudgetId,
287
291
  transactions: [
@@ -302,6 +306,10 @@ describeIntegration('Transaction Tools Integration', () => {
302
306
  ],
303
307
  });
304
308
 
309
+ if (!response.summary) {
310
+ console.error('Duplicate test response:', JSON.stringify(response, null, 2));
311
+ }
312
+
305
313
  expect(response.summary.duplicates).toBe(1);
306
314
  expect(response.results[0].status).toBe('duplicate');
307
315
  },
@@ -797,6 +805,8 @@ describeIntegration('Transaction Tools Integration', () => {
797
805
  {
798
806
  id: 'invalid-transaction-id-12345',
799
807
  memo: 'This should fail',
808
+ original_account_id: testAccountId,
809
+ original_date: new Date().toISOString().slice(0, 10),
800
810
  },
801
811
  ],
802
812
  });