@dizzlkheinz/ynab-mcpb 0.18.1 → 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.
@@ -1462,6 +1462,653 @@ describe('transactionTools', () => {
1462
1462
  expect(homeCategory.tax).toBeCloseTo(3);
1463
1463
  expect(homeCategory.total).toBeCloseTo(33);
1464
1464
  });
1465
+
1466
+ describe('Smart Collapse Logic', () => {
1467
+ describe('Scenario 1: Small Receipt (fewer than 5 items) - No Collapse', () => {
1468
+ it('should itemize each item individually with separate tax per category', async () => {
1469
+ // 3 items < 5, so no collapse
1470
+ const params = {
1471
+ budget_id: 'budget-123',
1472
+ account_id: 'account-456',
1473
+ payee_name: 'Grocery Store',
1474
+ date: '2025-10-13',
1475
+ receipt_tax: 1.16,
1476
+ receipt_total: 15.63, // 14.47 + 1.16
1477
+ categories: [
1478
+ {
1479
+ category_id: 'category-groceries',
1480
+ category_name: 'Groceries',
1481
+ items: [
1482
+ { name: 'Milk', amount: 4.99 },
1483
+ { name: 'Bread', amount: 3.49 },
1484
+ { name: 'Eggs', amount: 5.99 },
1485
+ ],
1486
+ },
1487
+ ],
1488
+ receipt_subtotal: 14.47, // 4.99 + 3.49 + 5.99
1489
+ dry_run: true,
1490
+ } as const;
1491
+
1492
+ const result = await handleCreateReceiptSplitTransaction(mockYnabAPI, params);
1493
+ const parsed = JSON.parse(result.content[0].text);
1494
+
1495
+ expect(parsed.subtransactions).toHaveLength(4);
1496
+ // Each item should be separate (dry_run returns dollars)
1497
+ expect(parsed.subtransactions[0].memo).toBe('Milk');
1498
+ expect(parsed.subtransactions[0].amount).toBe(4.99);
1499
+ expect(parsed.subtransactions[1].memo).toBe('Bread');
1500
+ expect(parsed.subtransactions[1].amount).toBe(3.49);
1501
+ expect(parsed.subtransactions[2].memo).toBe('Eggs');
1502
+ expect(parsed.subtransactions[2].amount).toBe(5.99);
1503
+ // Tax separate
1504
+ expect(parsed.subtransactions[3].memo).toBe('Tax - Groceries');
1505
+ expect(parsed.subtransactions[3].amount).toBe(1.16);
1506
+ });
1507
+ });
1508
+
1509
+ describe('Scenario 2: Large Single-Category Receipt - Collapse Mode', () => {
1510
+ it('should collapse items into batches of max 5', async () => {
1511
+ // 12 items >= 5, so collapse mode
1512
+ // Subtotal: 4.99+3.49+5.99+10.99+2.99+6.47+4.0+2.01+3.0+3.98+5.0+3.02 = 55.93
1513
+ const params = {
1514
+ budget_id: 'budget-123',
1515
+ account_id: 'account-456',
1516
+ payee_name: 'Grocery Store',
1517
+ date: '2025-10-13',
1518
+ receipt_tax: 4.15,
1519
+ receipt_total: 60.08, // 55.93 + 4.15
1520
+ categories: [
1521
+ {
1522
+ category_id: 'category-groceries',
1523
+ category_name: 'Groceries',
1524
+ items: [
1525
+ { name: 'Milk', amount: 4.99 },
1526
+ { name: 'Bread', amount: 3.49 },
1527
+ { name: 'Eggs', amount: 5.99 },
1528
+ { name: 'Cheese', amount: 10.99 },
1529
+ { name: 'Butter', amount: 2.99 },
1530
+ { name: 'Yogurt', amount: 6.47 },
1531
+ { name: 'Apples', amount: 4.0 },
1532
+ { name: 'Bananas', amount: 2.01 },
1533
+ { name: 'OJ', amount: 3.0 },
1534
+ { name: 'Cereal', amount: 3.98 },
1535
+ { name: 'Rice', amount: 5.0 },
1536
+ { name: 'Pasta', amount: 3.02 },
1537
+ ],
1538
+ },
1539
+ ],
1540
+ receipt_subtotal: 55.93,
1541
+ dry_run: true,
1542
+ } as const;
1543
+
1544
+ const result = await handleCreateReceiptSplitTransaction(mockYnabAPI, params);
1545
+ const parsed = JSON.parse(result.content[0].text);
1546
+
1547
+ // Should have 4 subtransactions: 3 collapsed batches + 1 tax
1548
+ expect(parsed.subtransactions).toHaveLength(4);
1549
+
1550
+ // First batch: 5 items (dry_run returns dollars)
1551
+ expect(parsed.subtransactions[0].memo).toContain('Milk');
1552
+ expect(parsed.subtransactions[0].memo).toContain('Bread');
1553
+ expect(parsed.subtransactions[0].memo).toContain('Eggs');
1554
+ expect(parsed.subtransactions[0].memo).toContain('Cheese');
1555
+ expect(parsed.subtransactions[0].memo).toContain('Butter');
1556
+ expect(parsed.subtransactions[0].amount).toBe(28.45); // 4.99 + 3.49 + 5.99 + 10.99 + 2.99
1557
+
1558
+ // Second batch: next 5 items
1559
+ expect(parsed.subtransactions[1].memo).toContain('Yogurt');
1560
+ expect(parsed.subtransactions[1].memo).toContain('Apples');
1561
+ expect(parsed.subtransactions[1].memo).toContain('Bananas');
1562
+ expect(parsed.subtransactions[1].memo).toContain('OJ');
1563
+ expect(parsed.subtransactions[1].memo).toContain('Cereal');
1564
+ expect(parsed.subtransactions[1].amount).toBe(19.46); // 6.47 + 4.00 + 2.01 + 3.00 + 3.98
1565
+
1566
+ // Third batch: remaining 2 items
1567
+ expect(parsed.subtransactions[2].memo).toContain('Rice');
1568
+ expect(parsed.subtransactions[2].memo).toContain('Pasta');
1569
+ expect(parsed.subtransactions[2].amount).toBe(8.02); // 5.00 + 3.02
1570
+
1571
+ // Tax separate
1572
+ expect(parsed.subtransactions[3].memo).toBe('Tax - Groceries');
1573
+ expect(parsed.subtransactions[3].amount).toBe(4.15);
1574
+ });
1575
+ });
1576
+
1577
+ describe('Scenario 3: Mixed Receipt with Big Ticket Item', () => {
1578
+ it('should separate big ticket item, collapse remaining items', async () => {
1579
+ // TV is > $50 so big ticket (own subtransaction)
1580
+ // Remaining: 8 grocery items >= 5, so collapse
1581
+ // Groceries: 4.99+3.49+5.99+10.99+2.99+6.47+4.0+2.01 = 40.93
1582
+ // Subtotal: 500 + 40.93 = 540.93
1583
+ const params = {
1584
+ budget_id: 'budget-123',
1585
+ account_id: 'account-456',
1586
+ payee_name: 'Electronics Store',
1587
+ date: '2025-10-13',
1588
+ receipt_tax: 43.19,
1589
+ receipt_total: 584.12, // 540.93 + 43.19
1590
+ categories: [
1591
+ {
1592
+ category_id: 'category-electronics',
1593
+ category_name: 'Electronics',
1594
+ items: [{ name: 'TV', amount: 500.0 }],
1595
+ },
1596
+ {
1597
+ category_id: 'category-groceries',
1598
+ category_name: 'Groceries',
1599
+ items: [
1600
+ { name: 'Milk', amount: 4.99 },
1601
+ { name: 'Bread', amount: 3.49 },
1602
+ { name: 'Eggs', amount: 5.99 },
1603
+ { name: 'Cheese', amount: 10.99 },
1604
+ { name: 'Butter', amount: 2.99 },
1605
+ { name: 'Yogurt', amount: 6.47 },
1606
+ { name: 'Apples', amount: 4.0 },
1607
+ { name: 'Bananas', amount: 2.01 },
1608
+ ],
1609
+ },
1610
+ ],
1611
+ receipt_subtotal: 540.93,
1612
+ dry_run: true,
1613
+ } as const;
1614
+
1615
+ const result = await handleCreateReceiptSplitTransaction(mockYnabAPI, params);
1616
+ const parsed = JSON.parse(result.content[0].text);
1617
+
1618
+ // Should have: 1 big ticket + 2 collapsed grocery batches + 2 tax subtransactions
1619
+ expect(parsed.subtransactions).toHaveLength(5);
1620
+
1621
+ // Big ticket item first (dry_run returns dollars)
1622
+ expect(parsed.subtransactions[0].memo).toBe('TV');
1623
+ expect(parsed.subtransactions[0].amount).toBe(500);
1624
+ expect(parsed.subtransactions[0].category_id).toBe('category-electronics');
1625
+
1626
+ // Collapsed grocery batch 1: 5 items
1627
+ expect(parsed.subtransactions[1].memo).toContain('Milk');
1628
+ expect(parsed.subtransactions[1].memo).toContain('Butter');
1629
+ expect(parsed.subtransactions[1].category_id).toBe('category-groceries');
1630
+ expect(parsed.subtransactions[1].amount).toBe(28.45); // 4.99+3.49+5.99+10.99+2.99
1631
+
1632
+ // Collapsed grocery batch 2: remaining 3 items
1633
+ expect(parsed.subtransactions[2].memo).toContain('Yogurt');
1634
+ expect(parsed.subtransactions[2].memo).toContain('Apples');
1635
+ expect(parsed.subtransactions[2].memo).toContain('Bananas');
1636
+ expect(parsed.subtransactions[2].category_id).toBe('category-groceries');
1637
+ expect(parsed.subtransactions[2].amount).toBe(12.48); // 6.47+4.0+2.01
1638
+
1639
+ // Tax for electronics (proportional)
1640
+ expect(parsed.subtransactions[3].memo).toBe('Tax - Electronics');
1641
+ expect(parsed.subtransactions[3].category_id).toBe('category-electronics');
1642
+
1643
+ // Tax for groceries (proportional)
1644
+ expect(parsed.subtransactions[4].memo).toBe('Tax - Groceries');
1645
+ expect(parsed.subtransactions[4].category_id).toBe('category-groceries');
1646
+ });
1647
+ });
1648
+
1649
+ describe('Scenario 4: Receipt with Return', () => {
1650
+ it('should separate return, collapse positive items, exclude return from tax', async () => {
1651
+ // Return is negative, gets own subtransaction
1652
+ // Groceries: 4.99+3.49+5.99+10.99+2.99+6.47+4.01 = 38.93 (7 items >= 5, collapse)
1653
+ // Subtotal: -29.99 + 38.93 = 8.94
1654
+ const params = {
1655
+ budget_id: 'budget-123',
1656
+ account_id: 'account-456',
1657
+ payee_name: 'Store',
1658
+ date: '2025-10-13',
1659
+ receipt_tax: 2.79,
1660
+ receipt_total: 11.73, // 8.94 + 2.79
1661
+ categories: [
1662
+ {
1663
+ category_id: 'category-electronics',
1664
+ category_name: 'Electronics',
1665
+ items: [{ name: 'RETURN: Broken headphones', amount: -29.99 }],
1666
+ },
1667
+ {
1668
+ category_id: 'category-groceries',
1669
+ category_name: 'Groceries',
1670
+ items: [
1671
+ { name: 'Milk', amount: 4.99 },
1672
+ { name: 'Bread', amount: 3.49 },
1673
+ { name: 'Eggs', amount: 5.99 },
1674
+ { name: 'Cheese', amount: 10.99 },
1675
+ { name: 'Butter', amount: 2.99 },
1676
+ { name: 'Yogurt', amount: 6.47 },
1677
+ { name: 'Apples', amount: 4.01 },
1678
+ ],
1679
+ },
1680
+ ],
1681
+ receipt_subtotal: 8.94, // -29.99 + 38.93
1682
+ dry_run: true,
1683
+ } as const;
1684
+
1685
+ const result = await handleCreateReceiptSplitTransaction(mockYnabAPI, params);
1686
+ const parsed = JSON.parse(result.content[0].text);
1687
+
1688
+ // Should have: 1 return + 2 collapsed grocery batches + 1 tax (groceries only)
1689
+ expect(parsed.subtransactions).toHaveLength(4);
1690
+
1691
+ // Return first (dry_run returns dollars, negative for return)
1692
+ expect(parsed.subtransactions[0].memo).toBe('RETURN: Broken headphones');
1693
+ expect(parsed.subtransactions[0].amount).toBe(-29.99);
1694
+ expect(parsed.subtransactions[0].category_id).toBe('category-electronics');
1695
+
1696
+ // Collapsed grocery batch 1
1697
+ expect(parsed.subtransactions[1].memo).toContain('Milk');
1698
+ expect(parsed.subtransactions[1].category_id).toBe('category-groceries');
1699
+
1700
+ // Collapsed grocery batch 2
1701
+ expect(parsed.subtransactions[2].memo).toContain('Yogurt');
1702
+ expect(parsed.subtransactions[2].category_id).toBe('category-groceries');
1703
+
1704
+ // Tax only on groceries (return receives no tax)
1705
+ expect(parsed.subtransactions[3].memo).toBe('Tax - Groceries');
1706
+ expect(parsed.subtransactions[3].category_id).toBe('category-groceries');
1707
+ expect(parsed.subtransactions[3].amount).toBe(2.79);
1708
+ });
1709
+ });
1710
+
1711
+ describe('Scenario 5: Receipt with Discount', () => {
1712
+ it('should separate discount, collapse positive items, calculate tax on positive only', async () => {
1713
+ // Discount: -5.0 (negative, gets own subtransaction)
1714
+ // Positive: 4.99+3.49+5.99+10.99+2.99+6.47+4.0+2.01+3.0 = 43.93 (9 items >= 5, collapse)
1715
+ // Subtotal: -5.0 + 43.93 = 38.93
1716
+ const params = {
1717
+ budget_id: 'budget-123',
1718
+ account_id: 'account-456',
1719
+ payee_name: 'Grocery Store',
1720
+ date: '2025-10-13',
1721
+ receipt_tax: 3.19,
1722
+ receipt_total: 42.12, // 38.93 + 3.19
1723
+ categories: [
1724
+ {
1725
+ category_id: 'category-groceries',
1726
+ category_name: 'Groceries',
1727
+ items: [
1728
+ { name: 'Member discount', amount: -5.0 },
1729
+ { name: 'Milk', amount: 4.99 },
1730
+ { name: 'Bread', amount: 3.49 },
1731
+ { name: 'Eggs', amount: 5.99 },
1732
+ { name: 'Cheese', amount: 10.99 },
1733
+ { name: 'Butter', amount: 2.99 },
1734
+ { name: 'Yogurt', amount: 6.47 },
1735
+ { name: 'Apples', amount: 4.0 },
1736
+ { name: 'Bananas', amount: 2.01 },
1737
+ { name: 'OJ', amount: 3.0 },
1738
+ ],
1739
+ },
1740
+ ],
1741
+ receipt_subtotal: 38.93, // -5.0 + 43.93
1742
+ dry_run: true,
1743
+ } as const;
1744
+
1745
+ const result = await handleCreateReceiptSplitTransaction(mockYnabAPI, params);
1746
+ const parsed = JSON.parse(result.content[0].text);
1747
+
1748
+ // Should have: 1 discount + 2 collapsed batches + 1 tax
1749
+ expect(parsed.subtransactions).toHaveLength(4);
1750
+
1751
+ // Discount first (negative amount, dry_run returns dollars)
1752
+ expect(parsed.subtransactions[0].memo).toBe('Member discount');
1753
+ expect(parsed.subtransactions[0].amount).toBe(-5); // negative
1754
+ expect(parsed.subtransactions[0].category_id).toBe('category-groceries');
1755
+
1756
+ // Collapsed batch 1 (5 items)
1757
+ expect(parsed.subtransactions[1].memo).toContain('Milk');
1758
+ expect(parsed.subtransactions[1].memo).toContain('Butter');
1759
+ expect(parsed.subtransactions[1].amount).toBe(28.45); // 4.99+3.49+5.99+10.99+2.99
1760
+
1761
+ // Collapsed batch 2 (4 items)
1762
+ expect(parsed.subtransactions[2].memo).toContain('Yogurt');
1763
+ expect(parsed.subtransactions[2].memo).toContain('OJ');
1764
+ expect(parsed.subtransactions[2].amount).toBe(15.48); // 6.47+4.0+2.01+3.0
1765
+
1766
+ // Tax only on positive items (not reduced by discount)
1767
+ expect(parsed.subtransactions[3].memo).toBe('Tax - Groceries');
1768
+ expect(parsed.subtransactions[3].amount).toBe(3.19);
1769
+ });
1770
+ });
1771
+
1772
+ describe('Scenario 6: Quantity Items (Not Big Ticket)', () => {
1773
+ it('should treat quantity items by unit price, not line total', async () => {
1774
+ // Widget: amount=90 (line total), quantity=3, so unit price = 90/3 = $30 < $50 (not big ticket)
1775
+ const params = {
1776
+ budget_id: 'budget-123',
1777
+ account_id: 'account-456',
1778
+ payee_name: 'Store',
1779
+ date: '2025-10-13',
1780
+ receipt_tax: 13.5,
1781
+ receipt_total: 148.5, // 135 + 13.5
1782
+ categories: [
1783
+ {
1784
+ category_id: 'category-electronics',
1785
+ category_name: 'Electronics',
1786
+ items: [
1787
+ { name: 'Widget', amount: 90.0, quantity: 3 }, // line total $90, unit price $30 < $50
1788
+ ],
1789
+ },
1790
+ {
1791
+ category_id: 'category-groceries',
1792
+ category_name: 'Groceries',
1793
+ items: [
1794
+ { name: 'Milk', amount: 10.0 },
1795
+ { name: 'Bread', amount: 10.0 },
1796
+ { name: 'Eggs', amount: 10.0 },
1797
+ { name: 'Cheese', amount: 15.0 },
1798
+ ],
1799
+ },
1800
+ ],
1801
+ receipt_subtotal: 135.0, // 90 + 45
1802
+ dry_run: true,
1803
+ } as const;
1804
+
1805
+ const result = await handleCreateReceiptSplitTransaction(mockYnabAPI, params);
1806
+ const parsed = JSON.parse(result.content[0].text);
1807
+
1808
+ // Total items: 1 widget entry + 4 groceries = 5 items -> collapse mode (>= 5)
1809
+ // Widget has unit price $30 < $50, so NOT big ticket
1810
+ // Should have: 1 collapsed electronics + 1 collapsed groceries + 2 tax
1811
+ expect(parsed.subtransactions).toHaveLength(4);
1812
+
1813
+ // Widgets collapsed (single entry with quantity shown)
1814
+ expect(parsed.subtransactions[0].memo).toContain('Widget');
1815
+ expect(parsed.subtransactions[0].amount).toBe(90); // dry_run returns dollars
1816
+ expect(parsed.subtransactions[0].category_id).toBe('category-electronics');
1817
+
1818
+ // Groceries collapsed
1819
+ expect(parsed.subtransactions[1].memo).toContain('Milk');
1820
+ expect(parsed.subtransactions[1].memo).toContain('Cheese');
1821
+ expect(parsed.subtransactions[1].amount).toBe(45); // dry_run returns dollars
1822
+ expect(parsed.subtransactions[1].category_id).toBe('category-groceries');
1823
+
1824
+ // Tax for electronics
1825
+ expect(parsed.subtransactions[2].memo).toBe('Tax - Electronics');
1826
+ expect(parsed.subtransactions[2].category_id).toBe('category-electronics');
1827
+
1828
+ // Tax for groceries
1829
+ expect(parsed.subtransactions[3].memo).toBe('Tax - Groceries');
1830
+ expect(parsed.subtransactions[3].category_id).toBe('category-groceries');
1831
+ });
1832
+ });
1833
+
1834
+ describe('Scenario 7: Tax Refund Scenario', () => {
1835
+ it('should create a single tax refund subtransaction for negative tax', async () => {
1836
+ const params = {
1837
+ budget_id: 'budget-123',
1838
+ account_id: 'account-456',
1839
+ payee_name: 'Store',
1840
+ date: '2025-10-13',
1841
+ receipt_tax: -8.0,
1842
+ receipt_total: -108.0,
1843
+ categories: [
1844
+ {
1845
+ category_id: 'category-electronics',
1846
+ category_name: 'Electronics',
1847
+ items: [{ name: 'RETURN: Defective laptop', amount: -100.0 }],
1848
+ },
1849
+ ],
1850
+ receipt_subtotal: -100.0,
1851
+ dry_run: true,
1852
+ } as const;
1853
+
1854
+ const result = await handleCreateReceiptSplitTransaction(mockYnabAPI, params);
1855
+ const parsed = JSON.parse(result.content[0].text);
1856
+
1857
+ // Should have: 1 return + 1 tax refund
1858
+ expect(parsed.subtransactions).toHaveLength(2);
1859
+
1860
+ // Return (negative amount, dry_run returns dollars)
1861
+ expect(parsed.subtransactions[0].memo).toBe('RETURN: Defective laptop');
1862
+ expect(parsed.subtransactions[0].amount).toBe(-100); // negative, in dollars
1863
+ expect(parsed.subtransactions[0].category_id).toBe('category-electronics');
1864
+
1865
+ // Tax refund (negative amount, dry_run returns dollars)
1866
+ expect(parsed.subtransactions[1].memo).toBe('Tax refund');
1867
+ expect(parsed.subtransactions[1].amount).toBe(-8); // negative, in dollars
1868
+ expect(parsed.subtransactions[1].category_id).toBe('category-electronics');
1869
+ });
1870
+ });
1871
+
1872
+ describe('Edge Cases', () => {
1873
+ it('should handle zero tax (no tax subtransactions)', async () => {
1874
+ const params = {
1875
+ budget_id: 'budget-123',
1876
+ account_id: 'account-456',
1877
+ payee_name: 'Store',
1878
+ date: '2025-10-13',
1879
+ receipt_tax: 0,
1880
+ receipt_total: 30.0,
1881
+ categories: [
1882
+ {
1883
+ category_id: 'category-groceries',
1884
+ category_name: 'Groceries',
1885
+ items: [
1886
+ { name: 'Item1', amount: 10.0 },
1887
+ { name: 'Item2', amount: 10.0 },
1888
+ { name: 'Item3', amount: 10.0 },
1889
+ ],
1890
+ },
1891
+ ],
1892
+ receipt_subtotal: 30.0,
1893
+ dry_run: true,
1894
+ } as const;
1895
+
1896
+ const result = await handleCreateReceiptSplitTransaction(mockYnabAPI, params);
1897
+ const parsed = JSON.parse(result.content[0].text);
1898
+
1899
+ // Should have 3 itemized items, no tax
1900
+ expect(parsed.subtransactions).toHaveLength(3);
1901
+ expect(parsed.subtransactions.every((s: any) => !s.memo.includes('Tax'))).toBe(true);
1902
+ });
1903
+
1904
+ it('should handle single category with exactly 5 items (should collapse)', async () => {
1905
+ const params = {
1906
+ budget_id: 'budget-123',
1907
+ account_id: 'account-456',
1908
+ payee_name: 'Store',
1909
+ date: '2025-10-13',
1910
+ receipt_tax: 4.0,
1911
+ receipt_total: 54.0,
1912
+ categories: [
1913
+ {
1914
+ category_id: 'category-groceries',
1915
+ category_name: 'Groceries',
1916
+ items: [
1917
+ { name: 'Item1', amount: 10.0 },
1918
+ { name: 'Item2', amount: 10.0 },
1919
+ { name: 'Item3', amount: 10.0 },
1920
+ { name: 'Item4', amount: 10.0 },
1921
+ { name: 'Item5', amount: 10.0 },
1922
+ ],
1923
+ },
1924
+ ],
1925
+ receipt_subtotal: 50.0,
1926
+ dry_run: true,
1927
+ } as const;
1928
+
1929
+ const result = await handleCreateReceiptSplitTransaction(mockYnabAPI, params);
1930
+ const parsed = JSON.parse(result.content[0].text);
1931
+
1932
+ // Should have 2 subtransactions: 1 collapsed batch + 1 tax
1933
+ expect(parsed.subtransactions).toHaveLength(2);
1934
+ expect(parsed.subtransactions[0].memo).toContain('Item1');
1935
+ expect(parsed.subtransactions[0].memo).toContain('Item5');
1936
+ expect(parsed.subtransactions[1].memo).toBe('Tax - Groceries');
1937
+ });
1938
+
1939
+ it('should handle single category with exactly 4 items (should NOT collapse)', async () => {
1940
+ const params = {
1941
+ budget_id: 'budget-123',
1942
+ account_id: 'account-456',
1943
+ payee_name: 'Store',
1944
+ date: '2025-10-13',
1945
+ receipt_tax: 4.0,
1946
+ receipt_total: 44.0,
1947
+ categories: [
1948
+ {
1949
+ category_id: 'category-groceries',
1950
+ category_name: 'Groceries',
1951
+ items: [
1952
+ { name: 'Item1', amount: 10.0 },
1953
+ { name: 'Item2', amount: 10.0 },
1954
+ { name: 'Item3', amount: 10.0 },
1955
+ { name: 'Item4', amount: 10.0 },
1956
+ ],
1957
+ },
1958
+ ],
1959
+ receipt_subtotal: 40.0,
1960
+ dry_run: true,
1961
+ } as const;
1962
+
1963
+ const result = await handleCreateReceiptSplitTransaction(mockYnabAPI, params);
1964
+ const parsed = JSON.parse(result.content[0].text);
1965
+
1966
+ // Should have 5 subtransactions: 4 itemized + 1 tax
1967
+ expect(parsed.subtransactions).toHaveLength(5);
1968
+ expect(parsed.subtransactions[0].memo).toBe('Item1');
1969
+ expect(parsed.subtransactions[1].memo).toBe('Item2');
1970
+ expect(parsed.subtransactions[2].memo).toBe('Item3');
1971
+ expect(parsed.subtransactions[3].memo).toBe('Item4');
1972
+ expect(parsed.subtransactions[4].memo).toBe('Tax - Groceries');
1973
+ });
1974
+
1975
+ it('should handle mixed categories with consistency rule (all collapse)', async () => {
1976
+ const params = {
1977
+ budget_id: 'budget-123',
1978
+ account_id: 'account-456',
1979
+ payee_name: 'Store',
1980
+ date: '2025-10-13',
1981
+ receipt_tax: 10.0,
1982
+ receipt_total: 100.0, // 90 subtotal + 10 tax
1983
+ categories: [
1984
+ {
1985
+ category_id: 'category-electronics',
1986
+ category_name: 'Electronics',
1987
+ items: [
1988
+ { name: 'Item1', amount: 20.0 },
1989
+ { name: 'Item2', amount: 20.0 },
1990
+ ],
1991
+ },
1992
+ {
1993
+ category_id: 'category-groceries',
1994
+ category_name: 'Groceries',
1995
+ items: [
1996
+ { name: 'Milk', amount: 10.0 },
1997
+ { name: 'Bread', amount: 10.0 },
1998
+ { name: 'Eggs', amount: 10.0 },
1999
+ { name: 'Cheese', amount: 10.0 },
2000
+ { name: 'Butter', amount: 10.0 },
2001
+ ],
2002
+ },
2003
+ ],
2004
+ receipt_subtotal: 90.0, // 40 + 50
2005
+ dry_run: true,
2006
+ } as const;
2007
+
2008
+ const result = await handleCreateReceiptSplitTransaction(mockYnabAPI, params);
2009
+ const parsed = JSON.parse(result.content[0].text);
2010
+
2011
+ // Total: 7 items -> collapse mode
2012
+ // All categories should collapse (consistency rule)
2013
+ // Should have: 1 collapsed electronics + 1 collapsed groceries + 2 tax
2014
+ expect(parsed.subtransactions).toHaveLength(4);
2015
+
2016
+ // Electronics collapsed even though it only has 2 items
2017
+ expect(parsed.subtransactions[0].memo).toContain('Item1');
2018
+ expect(parsed.subtransactions[0].memo).toContain('Item2');
2019
+ expect(parsed.subtransactions[0].category_id).toBe('category-electronics');
2020
+
2021
+ // Groceries collapsed
2022
+ expect(parsed.subtransactions[1].memo).toContain('Milk');
2023
+ expect(parsed.subtransactions[1].category_id).toBe('category-groceries');
2024
+
2025
+ // Taxes
2026
+ expect(parsed.subtransactions[2].memo).toBe('Tax - Electronics');
2027
+ expect(parsed.subtransactions[3].memo).toBe('Tax - Groceries');
2028
+ });
2029
+
2030
+ it('should truncate long memos at 150 chars with ellipsis', async () => {
2031
+ const params = {
2032
+ budget_id: 'budget-123',
2033
+ account_id: 'account-456',
2034
+ payee_name: 'Store',
2035
+ date: '2025-10-13',
2036
+ receipt_tax: 5.0,
2037
+ receipt_total: 55.0,
2038
+ categories: [
2039
+ {
2040
+ category_id: 'category-groceries',
2041
+ category_name: 'Groceries',
2042
+ items: [
2043
+ {
2044
+ name: 'Very Long Item Name That Will Definitely Cause Truncation',
2045
+ amount: 10.0,
2046
+ },
2047
+ { name: 'Another Very Long Item Name To Exceed Character Limit', amount: 10.0 },
2048
+ { name: 'Third Very Long Item Name Here', amount: 10.0 },
2049
+ { name: 'Fourth Very Long Item Name', amount: 10.0 },
2050
+ { name: 'Fifth Very Long Item Name', amount: 10.0 },
2051
+ ],
2052
+ },
2053
+ ],
2054
+ receipt_subtotal: 50.0,
2055
+ dry_run: true,
2056
+ } as const;
2057
+
2058
+ const result = await handleCreateReceiptSplitTransaction(mockYnabAPI, params);
2059
+ const parsed = JSON.parse(result.content[0].text);
2060
+
2061
+ expect(parsed.subtransactions).toHaveLength(2);
2062
+ const collapsedMemo = parsed.subtransactions[0].memo;
2063
+ expect(collapsedMemo.length).toBeLessThanOrEqual(153); // 150 + "..."
2064
+ if (collapsedMemo.length > 150) {
2065
+ expect(collapsedMemo.endsWith('...')).toBe(true);
2066
+ }
2067
+ });
2068
+
2069
+ it('should detect big ticket by unit price > $50 (not line total)', async () => {
2070
+ const params = {
2071
+ budget_id: 'budget-123',
2072
+ account_id: 'account-456',
2073
+ payee_name: 'Store',
2074
+ date: '2025-10-13',
2075
+ receipt_tax: 10.0,
2076
+ receipt_total: 110.0,
2077
+ categories: [
2078
+ {
2079
+ category_id: 'category-electronics',
2080
+ category_name: 'Electronics',
2081
+ items: [
2082
+ { name: 'Expensive Gadget', amount: 75.0 }, // Unit price $75 > $50 -> big ticket
2083
+ { name: 'Cheap Item', amount: 5.0 },
2084
+ { name: 'Cheap Item2', amount: 5.0 },
2085
+ { name: 'Cheap Item3', amount: 5.0 },
2086
+ { name: 'Cheap Item4', amount: 5.0 },
2087
+ { name: 'Cheap Item5', amount: 5.0 },
2088
+ ],
2089
+ },
2090
+ ],
2091
+ receipt_subtotal: 100.0,
2092
+ dry_run: true,
2093
+ } as const;
2094
+
2095
+ const result = await handleCreateReceiptSplitTransaction(mockYnabAPI, params);
2096
+ const parsed = JSON.parse(result.content[0].text);
2097
+
2098
+ // Big ticket separate + collapsed remaining items + tax
2099
+ expect(parsed.subtransactions.length).toBeGreaterThanOrEqual(3);
2100
+
2101
+ // First should be the big ticket item (not collapsed)
2102
+ expect(parsed.subtransactions[0].memo).toBe('Expensive Gadget');
2103
+ expect(parsed.subtransactions[0].amount).toBe(75); // dry_run returns dollars
2104
+
2105
+ // Remaining 5 items should collapse
2106
+ const collapsedSub = parsed.subtransactions[1];
2107
+ expect(collapsedSub.memo).toContain('Cheap Item');
2108
+ expect(collapsedSub.amount).toBe(25); // dry_run returns dollars
2109
+ });
2110
+ });
2111
+ });
1465
2112
  });
1466
2113
 
1467
2114
  describe('UpdateTransactionSchema', () => {
@@ -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 {