@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
|
@@ -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 {
|
|
@@ -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')
|
|
@@ -755,36 +749,264 @@ export async function handleCreateTransaction(ynabAPI, deltaCacheOrParams, knowl
|
|
|
755
749
|
return handleTransactionError(error, 'Failed to create transaction');
|
|
756
750
|
}
|
|
757
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
|
+
}
|
|
758
763
|
function buildItemMemo(item) {
|
|
759
764
|
const quantitySuffix = item.quantity ? ` (x${item.quantity})` : '';
|
|
765
|
+
let result;
|
|
760
766
|
if (item.memo && item.memo.trim().length > 0) {
|
|
761
|
-
|
|
767
|
+
result = `${item.name}${quantitySuffix} - ${item.memo}`;
|
|
768
|
+
}
|
|
769
|
+
else if (quantitySuffix) {
|
|
770
|
+
result = `${item.name}${quantitySuffix}`;
|
|
762
771
|
}
|
|
763
|
-
|
|
764
|
-
|
|
772
|
+
else {
|
|
773
|
+
result = item.name;
|
|
765
774
|
}
|
|
766
|
-
return
|
|
775
|
+
return truncateToLength(result, MAX_MEMO_LENGTH);
|
|
767
776
|
}
|
|
768
|
-
function
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
777
|
+
function applySmartCollapseLogic(categoryCalculations, taxMilliunits) {
|
|
778
|
+
const specialItems = [];
|
|
779
|
+
const remainingItemsByCategory = [];
|
|
780
|
+
for (const category of categoryCalculations) {
|
|
781
|
+
const categorySpecials = [];
|
|
782
|
+
const categoryRemaining = [];
|
|
783
|
+
for (const item of category.items) {
|
|
784
|
+
const isNegative = item.amount_milliunits < 0;
|
|
785
|
+
const unitPrice = item.quantity
|
|
786
|
+
? item.amount_milliunits / item.quantity
|
|
787
|
+
: item.amount_milliunits;
|
|
788
|
+
const isBigTicket = unitPrice > BIG_TICKET_THRESHOLD_MILLIUNITS;
|
|
789
|
+
if (isNegative || isBigTicket) {
|
|
790
|
+
categorySpecials.push(item);
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
categoryRemaining.push(item);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
for (const item of categorySpecials) {
|
|
797
|
+
specialItems.push({
|
|
798
|
+
item,
|
|
799
|
+
category_id: category.category_id,
|
|
800
|
+
category_name: category.category_name,
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
if (categoryRemaining.length > 0) {
|
|
804
|
+
remainingItemsByCategory.push({
|
|
805
|
+
category_id: category.category_id,
|
|
806
|
+
category_name: category.category_name,
|
|
807
|
+
items: categoryRemaining,
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
const totalRemainingItems = remainingItemsByCategory.reduce((sum, cat) => sum + cat.items.length, 0);
|
|
812
|
+
const shouldCollapse = totalRemainingItems >= COLLAPSE_THRESHOLD;
|
|
813
|
+
const subtransactions = [];
|
|
814
|
+
for (const special of specialItems) {
|
|
815
|
+
const memo = buildItemMemo({
|
|
816
|
+
name: special.item.name,
|
|
817
|
+
quantity: special.item.quantity,
|
|
818
|
+
memo: special.item.memo,
|
|
819
|
+
});
|
|
820
|
+
const payload = {
|
|
821
|
+
amount: -special.item.amount_milliunits,
|
|
822
|
+
category_id: special.category_id,
|
|
823
|
+
};
|
|
824
|
+
if (memo)
|
|
825
|
+
payload.memo = memo;
|
|
826
|
+
subtransactions.push(payload);
|
|
773
827
|
}
|
|
774
|
-
if (
|
|
775
|
-
|
|
828
|
+
if (shouldCollapse) {
|
|
829
|
+
for (const categoryGroup of remainingItemsByCategory) {
|
|
830
|
+
const collapsedSubtransactions = collapseItemsByCategory(categoryGroup);
|
|
831
|
+
subtransactions.push(...collapsedSubtransactions);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
else {
|
|
835
|
+
for (const categoryGroup of remainingItemsByCategory) {
|
|
836
|
+
for (const item of categoryGroup.items) {
|
|
837
|
+
const memo = buildItemMemo({
|
|
838
|
+
name: item.name,
|
|
839
|
+
quantity: item.quantity,
|
|
840
|
+
memo: item.memo,
|
|
841
|
+
});
|
|
842
|
+
const payload = {
|
|
843
|
+
amount: -item.amount_milliunits,
|
|
844
|
+
category_id: categoryGroup.category_id,
|
|
845
|
+
};
|
|
846
|
+
if (memo)
|
|
847
|
+
payload.memo = memo;
|
|
848
|
+
subtransactions.push(payload);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
776
851
|
}
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
852
|
+
const taxSubtransactions = allocateTax(categoryCalculations, taxMilliunits);
|
|
853
|
+
subtransactions.push(...taxSubtransactions);
|
|
854
|
+
return subtransactions;
|
|
855
|
+
}
|
|
856
|
+
function collapseItemsByCategory(categoryGroup) {
|
|
857
|
+
const subtransactions = [];
|
|
858
|
+
const items = categoryGroup.items;
|
|
859
|
+
let currentBatch = [];
|
|
860
|
+
let currentBatchTotal = 0;
|
|
861
|
+
for (const item of items) {
|
|
862
|
+
if (currentBatch.length >= MAX_ITEMS_PER_MEMO) {
|
|
863
|
+
const memo = buildCollapsedMemo(currentBatch);
|
|
864
|
+
subtransactions.push({
|
|
865
|
+
amount: -currentBatchTotal,
|
|
866
|
+
category_id: categoryGroup.category_id,
|
|
867
|
+
memo,
|
|
868
|
+
});
|
|
869
|
+
currentBatch = [];
|
|
870
|
+
currentBatchTotal = 0;
|
|
871
|
+
}
|
|
872
|
+
const testBatch = [...currentBatch, item];
|
|
873
|
+
const testMemo = buildCollapsedMemo(testBatch);
|
|
874
|
+
if (testMemo.length <= MAX_MEMO_LENGTH) {
|
|
875
|
+
currentBatch.push(item);
|
|
876
|
+
currentBatchTotal += item.amount_milliunits;
|
|
781
877
|
}
|
|
782
878
|
else {
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
879
|
+
if (currentBatch.length > 0) {
|
|
880
|
+
const memo = buildCollapsedMemo(currentBatch);
|
|
881
|
+
subtransactions.push({
|
|
882
|
+
amount: -currentBatchTotal,
|
|
883
|
+
category_id: categoryGroup.category_id,
|
|
884
|
+
memo,
|
|
885
|
+
});
|
|
886
|
+
currentBatch = [item];
|
|
887
|
+
currentBatchTotal = item.amount_milliunits;
|
|
888
|
+
}
|
|
889
|
+
else {
|
|
890
|
+
currentBatch = [item];
|
|
891
|
+
currentBatchTotal = item.amount_milliunits;
|
|
892
|
+
}
|
|
786
893
|
}
|
|
787
|
-
}
|
|
894
|
+
}
|
|
895
|
+
if (currentBatch.length > 0) {
|
|
896
|
+
const memo = buildCollapsedMemo(currentBatch);
|
|
897
|
+
subtransactions.push({
|
|
898
|
+
amount: -currentBatchTotal,
|
|
899
|
+
category_id: categoryGroup.category_id,
|
|
900
|
+
memo,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
return subtransactions;
|
|
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
|
+
}
|
|
913
|
+
function buildCollapsedMemo(items) {
|
|
914
|
+
const parts = [];
|
|
915
|
+
let currentLength = 0;
|
|
916
|
+
for (let i = 0; i < items.length; i++) {
|
|
917
|
+
const item = items[i];
|
|
918
|
+
if (!item)
|
|
919
|
+
continue;
|
|
920
|
+
const amount = milliunitsToAmount(item.amount_milliunits);
|
|
921
|
+
const amountSuffix = ` $${amount.toFixed(2)}`;
|
|
922
|
+
let itemStr = `${item.name}${amountSuffix}`;
|
|
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
|
+
}
|
|
927
|
+
const testLength = currentLength + separator.length + itemStr.length;
|
|
928
|
+
if (parts.length > 0 && testLength + 4 > MAX_MEMO_LENGTH) {
|
|
929
|
+
break;
|
|
930
|
+
}
|
|
931
|
+
parts.push(itemStr);
|
|
932
|
+
currentLength = testLength;
|
|
933
|
+
}
|
|
934
|
+
let result = parts.join(', ');
|
|
935
|
+
if (parts.length < items.length) {
|
|
936
|
+
result += '...';
|
|
937
|
+
}
|
|
938
|
+
return result;
|
|
939
|
+
}
|
|
940
|
+
function allocateTax(categoryCalculations, taxMilliunits) {
|
|
941
|
+
const subtransactions = [];
|
|
942
|
+
if (taxMilliunits === 0) {
|
|
943
|
+
return subtransactions;
|
|
944
|
+
}
|
|
945
|
+
if (taxMilliunits < 0) {
|
|
946
|
+
let largestReturnCategory = undefined;
|
|
947
|
+
let largestReturnAmount = 0;
|
|
948
|
+
for (const category of categoryCalculations) {
|
|
949
|
+
const categoryReturnAmount = category.items
|
|
950
|
+
.filter((item) => item.amount_milliunits < 0)
|
|
951
|
+
.reduce((sum, item) => sum + Math.abs(item.amount_milliunits), 0);
|
|
952
|
+
if (categoryReturnAmount > largestReturnAmount) {
|
|
953
|
+
largestReturnAmount = categoryReturnAmount;
|
|
954
|
+
largestReturnCategory = category;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
if (!largestReturnCategory) {
|
|
958
|
+
largestReturnCategory = categoryCalculations[0];
|
|
959
|
+
}
|
|
960
|
+
if (largestReturnCategory) {
|
|
961
|
+
subtransactions.push({
|
|
962
|
+
amount: -taxMilliunits,
|
|
963
|
+
category_id: largestReturnCategory.category_id,
|
|
964
|
+
memo: 'Tax refund',
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
return subtransactions;
|
|
968
|
+
}
|
|
969
|
+
const positiveCategorySubtotals = categoryCalculations
|
|
970
|
+
.map((cat) => ({
|
|
971
|
+
category: cat,
|
|
972
|
+
positiveSubtotal: cat.items
|
|
973
|
+
.filter((item) => item.amount_milliunits > 0)
|
|
974
|
+
.reduce((sum, item) => sum + item.amount_milliunits, 0),
|
|
975
|
+
}))
|
|
976
|
+
.filter((x) => x.positiveSubtotal > 0);
|
|
977
|
+
if (positiveCategorySubtotals.length === 0) {
|
|
978
|
+
return subtransactions;
|
|
979
|
+
}
|
|
980
|
+
const totalPositiveSubtotal = positiveCategorySubtotals.reduce((sum, x) => sum + x.positiveSubtotal, 0);
|
|
981
|
+
let allocatedTax = 0;
|
|
982
|
+
const taxAllocations = [];
|
|
983
|
+
for (let i = 0; i < positiveCategorySubtotals.length; i++) {
|
|
984
|
+
const entry = positiveCategorySubtotals[i];
|
|
985
|
+
if (!entry)
|
|
986
|
+
continue;
|
|
987
|
+
const { category, positiveSubtotal } = entry;
|
|
988
|
+
if (i === positiveCategorySubtotals.length - 1) {
|
|
989
|
+
const taxAmount = taxMilliunits - allocatedTax;
|
|
990
|
+
if (taxAmount > 0) {
|
|
991
|
+
taxAllocations.push({ category, taxAmount });
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
else {
|
|
995
|
+
const taxAmount = Math.round((taxMilliunits * positiveSubtotal) / totalPositiveSubtotal);
|
|
996
|
+
if (taxAmount > 0) {
|
|
997
|
+
taxAllocations.push({ category, taxAmount });
|
|
998
|
+
allocatedTax += taxAmount;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
for (const { category, taxAmount } of taxAllocations) {
|
|
1003
|
+
subtransactions.push({
|
|
1004
|
+
amount: -taxAmount,
|
|
1005
|
+
category_id: category.category_id,
|
|
1006
|
+
memo: `Tax - ${category.category_name ?? 'Uncategorized'}`,
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
return subtransactions;
|
|
788
1010
|
}
|
|
789
1011
|
export async function handleCreateReceiptSplitTransaction(ynabAPI, deltaCacheOrParams, knowledgeStoreOrParams, maybeParams) {
|
|
790
1012
|
const { deltaCache, knowledgeStore, params } = resolveDeltaWriteArgs(deltaCacheOrParams, knowledgeStoreOrParams, maybeParams);
|
|
@@ -817,29 +1039,24 @@ export async function handleCreateReceiptSplitTransaction(ynabAPI, deltaCacheOrP
|
|
|
817
1039
|
if (Math.abs(computedTotal - totalMilliunits) > 1) {
|
|
818
1040
|
throw new Error(`Receipt total (${milliunitsToAmount(totalMilliunits)}) does not equal subtotal plus tax (${milliunitsToAmount(computedTotal)})`);
|
|
819
1041
|
}
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
},
|
|
839
|
-
]
|
|
840
|
-
: [];
|
|
841
|
-
return [...itemSubtransactions, ...taxSubtransaction];
|
|
842
|
-
});
|
|
1042
|
+
const subtransactions = applySmartCollapseLogic(categoryCalculations, taxMilliunits);
|
|
1043
|
+
if (taxMilliunits > 0) {
|
|
1044
|
+
const positiveSubtotal = categoryCalculations.reduce((sum, cat) => sum + Math.max(0, cat.subtotal_milliunits), 0);
|
|
1045
|
+
if (positiveSubtotal > 0) {
|
|
1046
|
+
let remainingTax = taxMilliunits;
|
|
1047
|
+
const positiveCats = categoryCalculations.filter((cat) => cat.subtotal_milliunits > 0);
|
|
1048
|
+
positiveCats.forEach((cat, index) => {
|
|
1049
|
+
if (index === positiveCats.length - 1) {
|
|
1050
|
+
cat.tax_milliunits = remainingTax;
|
|
1051
|
+
}
|
|
1052
|
+
else {
|
|
1053
|
+
const share = Math.round((cat.subtotal_milliunits / positiveSubtotal) * taxMilliunits);
|
|
1054
|
+
cat.tax_milliunits = share;
|
|
1055
|
+
remainingTax -= share;
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
843
1060
|
const receiptSummary = {
|
|
844
1061
|
subtotal: milliunitsToAmount(subtotalMilliunits),
|
|
845
1062
|
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
|
@@ -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
|
});
|