@dizzlkheinz/ynab-mcpb 0.18.1 → 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.
@@ -283,6 +283,24 @@ export function resolveDeltaWriteArgs<TParams extends Record<string, unknown>>(
283
283
  );
284
284
  }
285
285
 
286
+ // Use shared context if available, otherwise create new fallback instances
287
+ if (sharedDeltaContext) {
288
+ if (!sharedDeltaContext.knowledgeStore) {
289
+ sharedDeltaContext.knowledgeStore = new ServerKnowledgeStore();
290
+ }
291
+ if (!sharedDeltaContext.deltaCache) {
292
+ sharedDeltaContext.deltaCache = new DeltaCache(
293
+ cacheManager,
294
+ sharedDeltaContext.knowledgeStore,
295
+ );
296
+ }
297
+ return {
298
+ deltaCache: sharedDeltaContext.deltaCache,
299
+ knowledgeStore: sharedDeltaContext.knowledgeStore,
300
+ params: deltaCacheOrParams,
301
+ };
302
+ }
303
+
286
304
  const fallbackKnowledgeStore = new ServerKnowledgeStore();
287
305
  const fallbackDeltaCache = new DeltaCache(cacheManager, fallbackKnowledgeStore);
288
306
  return {
@@ -535,10 +535,7 @@ function finalizeResponse(response: BulkCreateResponse): BulkCreateResponse {
535
535
  const ReceiptSplitItemSchema = z
536
536
  .object({
537
537
  name: z.string().min(1, 'Item name is required'),
538
- amount: z
539
- .number()
540
- .finite('Item amount must be a finite number')
541
- .refine((value) => value >= 0, 'Item amount must be zero or greater'),
538
+ amount: z.number().finite('Item amount must be a finite number'),
542
539
  quantity: z
543
540
  .number()
544
541
  .finite('Quantity must be a finite number')
@@ -571,10 +568,7 @@ export const CreateReceiptSplitTransactionSchema = z
571
568
  .finite('Receipt subtotal must be a finite number')
572
569
  .refine((value) => value >= 0, 'Receipt subtotal must be zero or greater')
573
570
  .optional(),
574
- receipt_tax: z
575
- .number()
576
- .finite('Receipt tax must be a finite number')
577
- .refine((value) => value >= 0, 'Receipt tax must be zero or greater'),
571
+ receipt_tax: z.number().finite('Receipt tax must be a finite number'),
578
572
  receipt_total: z
579
573
  .number()
580
574
  .finite('Receipt total must be a finite number')
@@ -1118,47 +1112,403 @@ interface SubtransactionInput {
1118
1112
  memo?: string;
1119
1113
  }
1120
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
+
1121
1134
  function buildItemMemo(item: {
1122
1135
  name: string;
1123
1136
  quantity: number | undefined;
1124
1137
  memo: string | undefined;
1125
1138
  }): string | undefined {
1126
1139
  const quantitySuffix = item.quantity ? ` (x${item.quantity})` : '';
1140
+ let result: string;
1127
1141
  if (item.memo && item.memo.trim().length > 0) {
1128
- return `${item.name}${quantitySuffix} - ${item.memo}`;
1142
+ result = `${item.name}${quantitySuffix} - ${item.memo}`;
1143
+ } else if (quantitySuffix) {
1144
+ result = `${item.name}${quantitySuffix}`;
1145
+ } else {
1146
+ result = item.name;
1129
1147
  }
1130
- if (quantitySuffix) {
1131
- return `${item.name}${quantitySuffix}`;
1148
+ // Truncate to MAX_MEMO_LENGTH if needed
1149
+ return truncateToLength(result, MAX_MEMO_LENGTH);
1150
+ }
1151
+
1152
+ /**
1153
+ * Applies smart collapse logic to receipt items according to the specification:
1154
+ * 1. Extract special items (big ticket, returns, discounts)
1155
+ * 2. Apply threshold to remaining items
1156
+ * 3. Collapse by category if needed
1157
+ * 4. Handle tax allocation
1158
+ */
1159
+ function applySmartCollapseLogic(
1160
+ categoryCalculations: ReceiptCategoryCalculation[],
1161
+ taxMilliunits: number,
1162
+ ): SubtransactionInput[] {
1163
+ // Step 1: Extract special items and classify remaining items
1164
+ interface SpecialItem {
1165
+ item: ReceiptCategoryCalculation['items'][0];
1166
+ category_id: string;
1167
+ category_name: string | undefined;
1132
1168
  }
1133
- return item.name;
1169
+
1170
+ interface CategoryItems {
1171
+ category_id: string;
1172
+ category_name: string | undefined;
1173
+ items: ReceiptCategoryCalculation['items'][0][];
1174
+ }
1175
+
1176
+ const specialItems: SpecialItem[] = [];
1177
+ const remainingItemsByCategory: CategoryItems[] = [];
1178
+
1179
+ for (const category of categoryCalculations) {
1180
+ const categorySpecials: ReceiptCategoryCalculation['items'][0][] = [];
1181
+ const categoryRemaining: ReceiptCategoryCalculation['items'][0][] = [];
1182
+
1183
+ for (const item of category.items) {
1184
+ const isNegative = item.amount_milliunits < 0;
1185
+ const unitPrice = item.quantity
1186
+ ? item.amount_milliunits / item.quantity
1187
+ : item.amount_milliunits;
1188
+ const isBigTicket = unitPrice > BIG_TICKET_THRESHOLD_MILLIUNITS;
1189
+
1190
+ if (isNegative || isBigTicket) {
1191
+ categorySpecials.push(item);
1192
+ } else {
1193
+ categoryRemaining.push(item);
1194
+ }
1195
+ }
1196
+
1197
+ // Add specials to the special items list (preserving category order)
1198
+ for (const item of categorySpecials) {
1199
+ specialItems.push({
1200
+ item,
1201
+ category_id: category.category_id,
1202
+ category_name: category.category_name,
1203
+ });
1204
+ }
1205
+
1206
+ // Track remaining items by category
1207
+ if (categoryRemaining.length > 0) {
1208
+ remainingItemsByCategory.push({
1209
+ category_id: category.category_id,
1210
+ category_name: category.category_name,
1211
+ items: categoryRemaining,
1212
+ });
1213
+ }
1214
+ }
1215
+
1216
+ // Step 2: Count total remaining positive items
1217
+ const totalRemainingItems = remainingItemsByCategory.reduce(
1218
+ (sum, cat) => sum + cat.items.length,
1219
+ 0,
1220
+ );
1221
+
1222
+ // Step 3: Decide whether to collapse
1223
+ const shouldCollapse = totalRemainingItems >= COLLAPSE_THRESHOLD;
1224
+
1225
+ // Build subtransactions
1226
+ const subtransactions: SubtransactionInput[] = [];
1227
+
1228
+ // Add special items first (returns, discounts, big tickets)
1229
+ for (const special of specialItems) {
1230
+ const memo = buildItemMemo({
1231
+ name: special.item.name,
1232
+ quantity: special.item.quantity,
1233
+ memo: special.item.memo,
1234
+ });
1235
+ const payload: SubtransactionInput = {
1236
+ amount: -special.item.amount_milliunits,
1237
+ category_id: special.category_id,
1238
+ };
1239
+ if (memo) payload.memo = memo;
1240
+ subtransactions.push(payload);
1241
+ }
1242
+
1243
+ // Add remaining items (collapsed or itemized)
1244
+ if (shouldCollapse) {
1245
+ // Collapse by category
1246
+ for (const categoryGroup of remainingItemsByCategory) {
1247
+ const collapsedSubtransactions = collapseItemsByCategory(categoryGroup);
1248
+ subtransactions.push(...collapsedSubtransactions);
1249
+ }
1250
+ } else {
1251
+ // Itemize each remaining item individually
1252
+ for (const categoryGroup of remainingItemsByCategory) {
1253
+ for (const item of categoryGroup.items) {
1254
+ const memo = buildItemMemo({
1255
+ name: item.name,
1256
+ quantity: item.quantity,
1257
+ memo: item.memo,
1258
+ });
1259
+ const payload: SubtransactionInput = {
1260
+ amount: -item.amount_milliunits,
1261
+ category_id: categoryGroup.category_id,
1262
+ };
1263
+ if (memo) payload.memo = memo;
1264
+ subtransactions.push(payload);
1265
+ }
1266
+ }
1267
+ }
1268
+
1269
+ // Step 4: Handle tax allocation
1270
+ const taxSubtransactions = allocateTax(categoryCalculations, taxMilliunits);
1271
+ subtransactions.push(...taxSubtransactions);
1272
+
1273
+ return subtransactions;
1134
1274
  }
1135
1275
 
1136
- function distributeTaxProportionally(
1137
- subtotalMilliunits: number,
1138
- totalTaxMilliunits: number,
1139
- categories: ReceiptCategoryCalculation[],
1140
- ): void {
1141
- if (totalTaxMilliunits === 0) {
1142
- for (const category of categories) category.tax_milliunits = 0;
1143
- return;
1276
+ /**
1277
+ * Collapses items within a category into groups of up to MAX_ITEMS_PER_MEMO
1278
+ */
1279
+ function collapseItemsByCategory(categoryGroup: {
1280
+ category_id: string;
1281
+ category_name: string | undefined;
1282
+ items: ReceiptCategoryCalculation['items'][0][];
1283
+ }): SubtransactionInput[] {
1284
+ const subtransactions: SubtransactionInput[] = [];
1285
+ const items = categoryGroup.items;
1286
+
1287
+ let currentBatch: ReceiptCategoryCalculation['items'][0][] = [];
1288
+ let currentBatchTotal = 0;
1289
+
1290
+ for (const item of items) {
1291
+ // Check if we've hit the max items per memo
1292
+ if (currentBatch.length >= MAX_ITEMS_PER_MEMO) {
1293
+ // Flush current batch
1294
+ const memo = buildCollapsedMemo(currentBatch);
1295
+ subtransactions.push({
1296
+ amount: -currentBatchTotal,
1297
+ category_id: categoryGroup.category_id,
1298
+ memo,
1299
+ });
1300
+ currentBatch = [];
1301
+ currentBatchTotal = 0;
1302
+ }
1303
+
1304
+ // Try adding this item to the current batch
1305
+ const testBatch = [...currentBatch, item];
1306
+ const testMemo = buildCollapsedMemo(testBatch);
1307
+
1308
+ if (testMemo.length <= MAX_MEMO_LENGTH) {
1309
+ // Fits - add to batch
1310
+ currentBatch.push(item);
1311
+ currentBatchTotal += item.amount_milliunits;
1312
+ } else {
1313
+ // Doesn't fit - flush current batch and start new one
1314
+ if (currentBatch.length > 0) {
1315
+ const memo = buildCollapsedMemo(currentBatch);
1316
+ subtransactions.push({
1317
+ amount: -currentBatchTotal,
1318
+ category_id: categoryGroup.category_id,
1319
+ memo,
1320
+ });
1321
+ currentBatch = [item];
1322
+ currentBatchTotal = item.amount_milliunits;
1323
+ } else {
1324
+ // Edge case: single item is too long, use it anyway
1325
+ currentBatch = [item];
1326
+ currentBatchTotal = item.amount_milliunits;
1327
+ }
1328
+ }
1144
1329
  }
1145
1330
 
1146
- if (subtotalMilliunits <= 0) {
1147
- throw new Error('Receipt subtotal must be greater than zero to distribute tax');
1331
+ // Flush remaining batch
1332
+ if (currentBatch.length > 0) {
1333
+ const memo = buildCollapsedMemo(currentBatch);
1334
+ subtransactions.push({
1335
+ amount: -currentBatchTotal,
1336
+ category_id: categoryGroup.category_id,
1337
+ memo,
1338
+ });
1148
1339
  }
1149
1340
 
1150
- let allocated = 0;
1151
- categories.forEach((category, index) => {
1152
- if (index === categories.length - 1) {
1153
- category.tax_milliunits = totalTaxMilliunits - allocated;
1341
+ return subtransactions;
1342
+ }
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
+
1361
+ /**
1362
+ * Builds a collapsed memo from a list of items
1363
+ * Format: "Item1 $X.XX, Item2 $Y.YY, Item3 $Z.ZZ"
1364
+ * Truncates with "..." if needed (either individual items or the list)
1365
+ */
1366
+ function buildCollapsedMemo(items: ReceiptCategoryCalculation['items'][0][]): string {
1367
+ const parts: string[] = [];
1368
+ let currentLength = 0;
1369
+
1370
+ for (let i = 0; i < items.length; i++) {
1371
+ const item = items[i];
1372
+ if (!item) continue;
1373
+ const amount = milliunitsToAmount(item.amount_milliunits);
1374
+ const amountSuffix = ` $${amount.toFixed(2)}`;
1375
+ let itemStr = `${item.name}${amountSuffix}`;
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
+
1383
+ const testLength = currentLength + separator.length + itemStr.length;
1384
+
1385
+ // Check if adding this item would exceed limit
1386
+ if (parts.length > 0 && testLength + 4 > MAX_MEMO_LENGTH) {
1387
+ // Would exceed - stop here and add "..."
1388
+ break;
1389
+ }
1390
+
1391
+ parts.push(itemStr);
1392
+ currentLength = testLength;
1393
+ }
1394
+
1395
+ let result = parts.join(', ');
1396
+
1397
+ // Add "..." if we didn't include all items
1398
+ if (parts.length < items.length) {
1399
+ result += '...';
1400
+ }
1401
+
1402
+ return result;
1403
+ }
1404
+
1405
+ /**
1406
+ * Allocates tax across categories
1407
+ * - Positive categories get proportional tax subtransactions
1408
+ * - Negative tax creates a single tax refund subtransaction
1409
+ */
1410
+ function allocateTax(
1411
+ categoryCalculations: ReceiptCategoryCalculation[],
1412
+ taxMilliunits: number,
1413
+ ): SubtransactionInput[] {
1414
+ const subtransactions: SubtransactionInput[] = [];
1415
+
1416
+ // Handle tax = 0
1417
+ if (taxMilliunits === 0) {
1418
+ return subtransactions;
1419
+ }
1420
+
1421
+ // Handle negative tax (refund)
1422
+ if (taxMilliunits < 0) {
1423
+ // Find category with largest return
1424
+ let largestReturnCategory: ReceiptCategoryCalculation | undefined = undefined;
1425
+ let largestReturnAmount = 0;
1426
+
1427
+ for (const category of categoryCalculations) {
1428
+ const categoryReturnAmount = category.items
1429
+ .filter((item) => item.amount_milliunits < 0)
1430
+ .reduce((sum, item) => sum + Math.abs(item.amount_milliunits), 0);
1431
+
1432
+ if (categoryReturnAmount > largestReturnAmount) {
1433
+ largestReturnAmount = categoryReturnAmount;
1434
+ largestReturnCategory = category;
1435
+ }
1436
+ }
1437
+
1438
+ // Default to first category if no returns found
1439
+ if (!largestReturnCategory) {
1440
+ largestReturnCategory = categoryCalculations[0];
1441
+ }
1442
+
1443
+ if (largestReturnCategory) {
1444
+ subtransactions.push({
1445
+ amount: -taxMilliunits,
1446
+ category_id: largestReturnCategory.category_id,
1447
+ memo: 'Tax refund',
1448
+ });
1449
+ }
1450
+
1451
+ return subtransactions;
1452
+ }
1453
+
1454
+ // Positive tax - allocate proportionally to positive categories only
1455
+ const positiveCategorySubtotals = categoryCalculations
1456
+ .map((cat) => ({
1457
+ category: cat,
1458
+ positiveSubtotal: cat.items
1459
+ .filter((item) => item.amount_milliunits > 0)
1460
+ .reduce((sum, item) => sum + item.amount_milliunits, 0),
1461
+ }))
1462
+ .filter((x) => x.positiveSubtotal > 0);
1463
+
1464
+ if (positiveCategorySubtotals.length === 0) {
1465
+ // No positive items, no tax allocation
1466
+ return subtransactions;
1467
+ }
1468
+
1469
+ const totalPositiveSubtotal = positiveCategorySubtotals.reduce(
1470
+ (sum, x) => sum + x.positiveSubtotal,
1471
+ 0,
1472
+ );
1473
+
1474
+ // Distribute tax using largest remainder method
1475
+ let allocatedTax = 0;
1476
+ const taxAllocations: {
1477
+ category: ReceiptCategoryCalculation;
1478
+ taxAmount: number;
1479
+ }[] = [];
1480
+
1481
+ for (let i = 0; i < positiveCategorySubtotals.length; i++) {
1482
+ const entry = positiveCategorySubtotals[i];
1483
+ if (!entry) continue;
1484
+
1485
+ const { category, positiveSubtotal } = entry;
1486
+
1487
+ if (i === positiveCategorySubtotals.length - 1) {
1488
+ // Last category gets remainder
1489
+ const taxAmount = taxMilliunits - allocatedTax;
1490
+ if (taxAmount > 0) {
1491
+ taxAllocations.push({ category, taxAmount });
1492
+ }
1154
1493
  } else {
1155
- const proportionalTax = Math.round(
1156
- (totalTaxMilliunits * category.subtotal_milliunits) / subtotalMilliunits,
1157
- );
1158
- category.tax_milliunits = proportionalTax;
1159
- allocated += proportionalTax;
1494
+ const taxAmount = Math.round((taxMilliunits * positiveSubtotal) / totalPositiveSubtotal);
1495
+ if (taxAmount > 0) {
1496
+ taxAllocations.push({ category, taxAmount });
1497
+ allocatedTax += taxAmount;
1498
+ }
1160
1499
  }
1161
- });
1500
+ }
1501
+
1502
+ // Create tax subtransactions
1503
+ for (const { category, taxAmount } of taxAllocations) {
1504
+ subtransactions.push({
1505
+ amount: -taxAmount,
1506
+ category_id: category.category_id,
1507
+ memo: `Tax - ${category.category_name ?? 'Uncategorized'}`,
1508
+ });
1509
+ }
1510
+
1511
+ return subtransactions;
1162
1512
  }
1163
1513
 
1164
1514
  export async function handleCreateReceiptSplitTransaction(
@@ -1226,32 +1576,29 @@ export async function handleCreateReceiptSplitTransaction(
1226
1576
  );
1227
1577
  }
1228
1578
 
1229
- distributeTaxProportionally(subtotalMilliunits, taxMilliunits, categoryCalculations);
1579
+ // Apply smart collapse logic
1580
+ const subtransactions = applySmartCollapseLogic(categoryCalculations, taxMilliunits);
1230
1581
 
1231
- const subtransactions: SubtransactionInput[] = categoryCalculations.flatMap((category) => {
1232
- const itemSubtransactions: SubtransactionInput[] = category.items.map((item) => {
1233
- const memo = buildItemMemo({ name: item.name, quantity: item.quantity, memo: item.memo });
1234
- const payload: SubtransactionInput = {
1235
- amount: -item.amount_milliunits,
1236
- category_id: category.category_id,
1237
- };
1238
- if (memo) payload.memo = memo;
1239
- return payload;
1240
- });
1241
-
1242
- const taxSubtransaction: SubtransactionInput[] =
1243
- category.tax_milliunits > 0
1244
- ? [
1245
- {
1246
- amount: -category.tax_milliunits,
1247
- category_id: category.category_id,
1248
- memo: `Tax - ${category.category_name ?? 'Uncategorized'}`,
1249
- },
1250
- ]
1251
- : [];
1252
-
1253
- return [...itemSubtransactions, ...taxSubtransaction];
1254
- });
1582
+ // Distribute tax proportionally for receipt_summary (only for positive categories)
1583
+ if (taxMilliunits > 0) {
1584
+ const positiveSubtotal = categoryCalculations.reduce(
1585
+ (sum, cat) => sum + Math.max(0, cat.subtotal_milliunits),
1586
+ 0,
1587
+ );
1588
+ if (positiveSubtotal > 0) {
1589
+ let remainingTax = taxMilliunits;
1590
+ const positiveCats = categoryCalculations.filter((cat) => cat.subtotal_milliunits > 0);
1591
+ positiveCats.forEach((cat, index) => {
1592
+ if (index === positiveCats.length - 1) {
1593
+ cat.tax_milliunits = remainingTax;
1594
+ } else {
1595
+ const share = Math.round((cat.subtotal_milliunits / positiveSubtotal) * taxMilliunits);
1596
+ cat.tax_milliunits = share;
1597
+ remainingTax -= share;
1598
+ }
1599
+ });
1600
+ }
1601
+ }
1255
1602
 
1256
1603
  const receiptSummary = {
1257
1604
  subtotal: milliunitsToAmount(subtotalMilliunits),