@dizzlkheinz/ynab-mcpb 0.18.2 → 0.18.3

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.
@@ -749,20 +749,31 @@ export async function handleCreateTransaction(ynabAPI, deltaCacheOrParams, knowl
749
749
  return handleTransactionError(error, 'Failed to create transaction');
750
750
  }
751
751
  }
752
+ const BIG_TICKET_THRESHOLD_MILLIUNITS = 50000;
753
+ const COLLAPSE_THRESHOLD = 5;
754
+ const MAX_ITEMS_PER_MEMO = 5;
755
+ const MAX_MEMO_LENGTH = 150;
756
+ function truncateToLength(str, maxLength) {
757
+ if (str.length <= maxLength) {
758
+ return str;
759
+ }
760
+ const ellipsis = '...';
761
+ return str.substring(0, maxLength - ellipsis.length) + ellipsis;
762
+ }
752
763
  function buildItemMemo(item) {
753
764
  const quantitySuffix = item.quantity ? ` (x${item.quantity})` : '';
765
+ let result;
754
766
  if (item.memo && item.memo.trim().length > 0) {
755
- return `${item.name}${quantitySuffix} - ${item.memo}`;
767
+ result = `${item.name}${quantitySuffix} - ${item.memo}`;
756
768
  }
757
- if (quantitySuffix) {
758
- return `${item.name}${quantitySuffix}`;
769
+ else if (quantitySuffix) {
770
+ result = `${item.name}${quantitySuffix}`;
771
+ }
772
+ else {
773
+ result = item.name;
759
774
  }
760
- return item.name;
775
+ return truncateToLength(result, MAX_MEMO_LENGTH);
761
776
  }
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
777
  function applySmartCollapseLogic(categoryCalculations, taxMilliunits) {
767
778
  const specialItems = [];
768
779
  const remainingItemsByCategory = [];
@@ -891,6 +902,14 @@ function collapseItemsByCategory(categoryGroup) {
891
902
  }
892
903
  return subtransactions;
893
904
  }
905
+ function truncateItemName(name, amountSuffix, maxLength) {
906
+ const ellipsis = '...';
907
+ const availableForName = maxLength - ellipsis.length - amountSuffix.length;
908
+ if (availableForName <= 0) {
909
+ return amountSuffix.substring(0, maxLength);
910
+ }
911
+ return name.substring(0, availableForName) + ellipsis + amountSuffix;
912
+ }
894
913
  function buildCollapsedMemo(items) {
895
914
  const parts = [];
896
915
  let currentLength = 0;
@@ -899,8 +918,12 @@ function buildCollapsedMemo(items) {
899
918
  if (!item)
900
919
  continue;
901
920
  const amount = milliunitsToAmount(item.amount_milliunits);
902
- const itemStr = `${item.name} $${amount.toFixed(2)}`;
921
+ const amountSuffix = ` $${amount.toFixed(2)}`;
922
+ let itemStr = `${item.name}${amountSuffix}`;
903
923
  const separator = i > 0 ? ', ' : '';
924
+ if (parts.length === 0 && itemStr.length > MAX_MEMO_LENGTH) {
925
+ itemStr = truncateItemName(item.name, amountSuffix, MAX_MEMO_LENGTH);
926
+ }
904
927
  const testLength = currentLength + separator.length + itemStr.length;
905
928
  if (parts.length > 0 && testLength + 4 > MAX_MEMO_LENGTH) {
906
929
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dizzlkheinz/ynab-mcpb",
3
- "version": "0.18.2",
3
+ "version": "0.18.3",
4
4
  "description": "Model Context Protocol server for YNAB (You Need A Budget) integration",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -2107,6 +2107,89 @@ describe('transactionTools', () => {
2107
2107
  expect(collapsedSub.memo).toContain('Cheap Item');
2108
2108
  expect(collapsedSub.amount).toBe(25); // dry_run returns dollars
2109
2109
  });
2110
+
2111
+ it('should truncate single very long item name in itemized mode', async () => {
2112
+ // Create a name that's way longer than 150 chars
2113
+ const veryLongName = 'A'.repeat(200);
2114
+ const params = {
2115
+ budget_id: 'budget-123',
2116
+ account_id: 'account-456',
2117
+ payee_name: 'Store',
2118
+ date: '2025-10-13',
2119
+ receipt_tax: 1.0,
2120
+ receipt_total: 11.0,
2121
+ categories: [
2122
+ {
2123
+ category_id: 'category-groceries',
2124
+ category_name: 'Groceries',
2125
+ items: [{ name: veryLongName, amount: 10.0 }],
2126
+ },
2127
+ ],
2128
+ receipt_subtotal: 10.0,
2129
+ dry_run: true,
2130
+ } as const;
2131
+
2132
+ const result = await handleCreateReceiptSplitTransaction(mockYnabAPI, params);
2133
+ const parsed = JSON.parse(result.content[0].text);
2134
+
2135
+ // Should have 2 subtransactions: 1 itemized + 1 tax
2136
+ // (only 1 item, so no collapse, but name gets truncated)
2137
+ expect(parsed.subtransactions).toHaveLength(2);
2138
+
2139
+ const itemMemo = parsed.subtransactions[0].memo;
2140
+ // Memo should be truncated to exactly 150 chars
2141
+ expect(itemMemo.length).toBeLessThanOrEqual(150);
2142
+ // Should contain truncation indicator
2143
+ expect(itemMemo).toContain('...');
2144
+ // Should start with part of the original name
2145
+ expect(itemMemo.startsWith('AAA')).toBe(true);
2146
+ });
2147
+
2148
+ it('should truncate very long item name in collapsed mode while preserving amount', async () => {
2149
+ // Create a name that's way longer than 150 chars
2150
+ const veryLongName = 'B'.repeat(200);
2151
+ const params = {
2152
+ budget_id: 'budget-123',
2153
+ account_id: 'account-456',
2154
+ payee_name: 'Store',
2155
+ date: '2025-10-13',
2156
+ receipt_tax: 1.0,
2157
+ receipt_total: 61.0,
2158
+ categories: [
2159
+ {
2160
+ category_id: 'category-groceries',
2161
+ category_name: 'Groceries',
2162
+ items: [
2163
+ { name: veryLongName, amount: 10.0 }, // This one has very long name
2164
+ { name: 'Item2', amount: 10.0 },
2165
+ { name: 'Item3', amount: 10.0 },
2166
+ { name: 'Item4', amount: 10.0 },
2167
+ { name: 'Item5', amount: 10.0 },
2168
+ { name: 'Item6', amount: 10.0 },
2169
+ ],
2170
+ },
2171
+ ],
2172
+ receipt_subtotal: 60.0,
2173
+ dry_run: true,
2174
+ } as const;
2175
+
2176
+ const result = await handleCreateReceiptSplitTransaction(mockYnabAPI, params);
2177
+ const parsed = JSON.parse(result.content[0].text);
2178
+
2179
+ // 6 items -> collapse mode, long item first creates its own subtransaction
2180
+ // that gets truncated
2181
+ expect(parsed.subtransactions.length).toBeGreaterThanOrEqual(2);
2182
+
2183
+ // First subtransaction should be the truncated long item
2184
+ const firstMemo = parsed.subtransactions[0].memo;
2185
+ expect(firstMemo.length).toBeLessThanOrEqual(150);
2186
+ // In collapsed mode, should preserve the amount
2187
+ expect(firstMemo).toContain('$10.00');
2188
+ // Should contain truncation indicator
2189
+ expect(firstMemo).toContain('...');
2190
+ // Should start with part of the original name
2191
+ expect(firstMemo.startsWith('BBB')).toBe(true);
2192
+ });
2110
2193
  });
2111
2194
  });
2112
2195
  });
@@ -1112,29 +1112,43 @@ interface SubtransactionInput {
1112
1112
  memo?: string;
1113
1113
  }
1114
1114
 
1115
+ /**
1116
+ * Constants for smart collapse logic
1117
+ */
1118
+ const BIG_TICKET_THRESHOLD_MILLIUNITS = 50000; // $50.00
1119
+ const COLLAPSE_THRESHOLD = 5; // Collapse if 5 or more remaining items
1120
+ const MAX_ITEMS_PER_MEMO = 5;
1121
+ const MAX_MEMO_LENGTH = 150;
1122
+
1123
+ /**
1124
+ * Truncates a string to fit within maxLength, adding ellipsis if truncated
1125
+ */
1126
+ function truncateToLength(str: string, maxLength: number): string {
1127
+ if (str.length <= maxLength) {
1128
+ return str;
1129
+ }
1130
+ const ellipsis = '...';
1131
+ return str.substring(0, maxLength - ellipsis.length) + ellipsis;
1132
+ }
1133
+
1115
1134
  function buildItemMemo(item: {
1116
1135
  name: string;
1117
1136
  quantity: number | undefined;
1118
1137
  memo: string | undefined;
1119
1138
  }): string | undefined {
1120
1139
  const quantitySuffix = item.quantity ? ` (x${item.quantity})` : '';
1140
+ let result: string;
1121
1141
  if (item.memo && item.memo.trim().length > 0) {
1122
- return `${item.name}${quantitySuffix} - ${item.memo}`;
1123
- }
1124
- if (quantitySuffix) {
1125
- return `${item.name}${quantitySuffix}`;
1142
+ result = `${item.name}${quantitySuffix} - ${item.memo}`;
1143
+ } else if (quantitySuffix) {
1144
+ result = `${item.name}${quantitySuffix}`;
1145
+ } else {
1146
+ result = item.name;
1126
1147
  }
1127
- return item.name;
1148
+ // Truncate to MAX_MEMO_LENGTH if needed
1149
+ return truncateToLength(result, MAX_MEMO_LENGTH);
1128
1150
  }
1129
1151
 
1130
- /**
1131
- * Constants for smart collapse logic
1132
- */
1133
- const BIG_TICKET_THRESHOLD_MILLIUNITS = 50000; // $50.00
1134
- const COLLAPSE_THRESHOLD = 5; // Collapse if 5 or more remaining items
1135
- const MAX_ITEMS_PER_MEMO = 5;
1136
- const MAX_MEMO_LENGTH = 150;
1137
-
1138
1152
  /**
1139
1153
  * Applies smart collapse logic to receipt items according to the specification:
1140
1154
  * 1. Extract special items (big ticket, returns, discounts)
@@ -1327,10 +1341,27 @@ function collapseItemsByCategory(categoryGroup: {
1327
1341
  return subtransactions;
1328
1342
  }
1329
1343
 
1344
+ /**
1345
+ * Truncates an item name to fit within available space
1346
+ * Preserves the amount suffix and adds "..." to indicate truncation
1347
+ */
1348
+ function truncateItemName(name: string, amountSuffix: string, maxLength: number): string {
1349
+ const ellipsis = '...';
1350
+ // We need: truncatedName + ellipsis + amountSuffix <= maxLength
1351
+ const availableForName = maxLength - ellipsis.length - amountSuffix.length;
1352
+
1353
+ if (availableForName <= 0) {
1354
+ // Edge case: amount suffix alone is too long, just return what we can
1355
+ return amountSuffix.substring(0, maxLength);
1356
+ }
1357
+
1358
+ return name.substring(0, availableForName) + ellipsis + amountSuffix;
1359
+ }
1360
+
1330
1361
  /**
1331
1362
  * Builds a collapsed memo from a list of items
1332
1363
  * Format: "Item1 $X.XX, Item2 $Y.YY, Item3 $Z.ZZ"
1333
- * Truncates with "..." if needed
1364
+ * Truncates with "..." if needed (either individual items or the list)
1334
1365
  */
1335
1366
  function buildCollapsedMemo(items: ReceiptCategoryCalculation['items'][0][]): string {
1336
1367
  const parts: string[] = [];
@@ -1340,8 +1371,15 @@ function buildCollapsedMemo(items: ReceiptCategoryCalculation['items'][0][]): st
1340
1371
  const item = items[i];
1341
1372
  if (!item) continue;
1342
1373
  const amount = milliunitsToAmount(item.amount_milliunits);
1343
- const itemStr = `${item.name} $${amount.toFixed(2)}`;
1374
+ const amountSuffix = ` $${amount.toFixed(2)}`;
1375
+ let itemStr = `${item.name}${amountSuffix}`;
1344
1376
  const separator = i > 0 ? ', ' : '';
1377
+
1378
+ // For the first item, check if it alone exceeds the limit
1379
+ if (parts.length === 0 && itemStr.length > MAX_MEMO_LENGTH) {
1380
+ itemStr = truncateItemName(item.name, amountSuffix, MAX_MEMO_LENGTH);
1381
+ }
1382
+
1345
1383
  const testLength = currentLength + separator.length + itemStr.length;
1346
1384
 
1347
1385
  // Check if adding this item would exceed limit