@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.
- package/CHANGELOG.md +24 -0
- package/README.md +30 -136
- package/dist/bundle/index.cjs +40 -40
- package/dist/tools/deltaSupport.js +13 -0
- package/dist/tools/transactionTools.js +267 -50
- package/docs/technical/receipt-itemization-spec.md +181 -0
- package/package.json +1 -1
- package/src/tools/__tests__/transactionTools.integration.test.ts +11 -1
- package/src/tools/__tests__/transactionTools.test.ts +730 -0
- package/src/tools/deltaSupport.ts +18 -0
- package/src/tools/transactionTools.ts +404 -57
|
@@ -1462,6 +1462,736 @@ 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
|
+
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
|
+
});
|
|
2193
|
+
});
|
|
2194
|
+
});
|
|
1465
2195
|
});
|
|
1466
2196
|
|
|
1467
2197
|
describe('UpdateTransactionSchema', () => {
|