actual-mcp-server 0.5.0
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/LICENSE +21 -0
- package/README.md +663 -0
- package/bin/actual-mcp-server.js +3 -0
- package/dist/generated/actual-client/types.js +5 -0
- package/dist/package.json +88 -0
- package/dist/src/actualConnection.js +157 -0
- package/dist/src/actualToolsManager.js +211 -0
- package/dist/src/auth/budget-acl.js +143 -0
- package/dist/src/auth/setup.js +58 -0
- package/dist/src/config.js +41 -0
- package/dist/src/index.js +313 -0
- package/dist/src/lib/ActualConnectionPool.js +343 -0
- package/dist/src/lib/ActualMCPConnection.js +125 -0
- package/dist/src/lib/actual-adapter.js +1228 -0
- package/dist/src/lib/actual-schema.js +222 -0
- package/dist/src/lib/budget-registry.js +64 -0
- package/dist/src/lib/constants.js +121 -0
- package/dist/src/lib/errors.js +19 -0
- package/dist/src/lib/loggerFactory.js +72 -0
- package/dist/src/lib/node-polyfills.js +20 -0
- package/dist/src/lib/query-validator.js +221 -0
- package/dist/src/lib/retry.js +26 -0
- package/dist/src/lib/schemas/common.js +203 -0
- package/dist/src/lib/toolFactory.js +109 -0
- package/dist/src/logger.js +127 -0
- package/dist/src/observability.js +58 -0
- package/dist/src/prompts/showLargeTransactions.js +6 -0
- package/dist/src/resources/accountsSummary.js +13 -0
- package/dist/src/server/httpServer.js +540 -0
- package/dist/src/server/httpServer_testing.js +401 -0
- package/dist/src/server/stdioServer.js +52 -0
- package/dist/src/server/streamable-http.js +148 -0
- package/dist/src/tests/actualToolsTests.js +70 -0
- package/dist/src/tests/observability.smoke.test.js +18 -0
- package/dist/src/tests/testMcpClient.js +170 -0
- package/dist/src/tests_adapter_runner.js +86 -0
- package/dist/src/tools/accounts_close.js +16 -0
- package/dist/src/tools/accounts_create.js +27 -0
- package/dist/src/tools/accounts_delete.js +16 -0
- package/dist/src/tools/accounts_get_balance.js +40 -0
- package/dist/src/tools/accounts_list.js +16 -0
- package/dist/src/tools/accounts_reopen.js +16 -0
- package/dist/src/tools/accounts_update.js +52 -0
- package/dist/src/tools/bank_sync.js +22 -0
- package/dist/src/tools/budget_updates_batch.js +77 -0
- package/dist/src/tools/budgets_getMonth.js +14 -0
- package/dist/src/tools/budgets_getMonths.js +14 -0
- package/dist/src/tools/budgets_get_all.js +13 -0
- package/dist/src/tools/budgets_holdForNextMonth.js +19 -0
- package/dist/src/tools/budgets_list_available.js +20 -0
- package/dist/src/tools/budgets_resetHold.js +16 -0
- package/dist/src/tools/budgets_setAmount.js +26 -0
- package/dist/src/tools/budgets_setCarryover.js +18 -0
- package/dist/src/tools/budgets_switch.js +27 -0
- package/dist/src/tools/budgets_transfer.js +64 -0
- package/dist/src/tools/categories_create.js +65 -0
- package/dist/src/tools/categories_delete.js +16 -0
- package/dist/src/tools/categories_get.js +14 -0
- package/dist/src/tools/categories_update.js +22 -0
- package/dist/src/tools/category_groups_create.js +18 -0
- package/dist/src/tools/category_groups_delete.js +26 -0
- package/dist/src/tools/category_groups_get.js +13 -0
- package/dist/src/tools/category_groups_update.js +21 -0
- package/dist/src/tools/get_id_by_name.js +36 -0
- package/dist/src/tools/index.js +63 -0
- package/dist/src/tools/payee_rules_get.js +27 -0
- package/dist/src/tools/payees_create.js +25 -0
- package/dist/src/tools/payees_delete.js +16 -0
- package/dist/src/tools/payees_get.js +14 -0
- package/dist/src/tools/payees_merge.js +17 -0
- package/dist/src/tools/payees_update.js +59 -0
- package/dist/src/tools/query_run.js +78 -0
- package/dist/src/tools/rules_create.js +129 -0
- package/dist/src/tools/rules_create_or_update.js +191 -0
- package/dist/src/tools/rules_delete.js +26 -0
- package/dist/src/tools/rules_get.js +13 -0
- package/dist/src/tools/rules_update.js +120 -0
- package/dist/src/tools/schedules_create.js +54 -0
- package/dist/src/tools/schedules_delete.js +41 -0
- package/dist/src/tools/schedules_get.js +13 -0
- package/dist/src/tools/schedules_update.js +40 -0
- package/dist/src/tools/server_get_version.js +22 -0
- package/dist/src/tools/server_info.js +86 -0
- package/dist/src/tools/session_close.js +100 -0
- package/dist/src/tools/session_list.js +24 -0
- package/dist/src/tools/transactions_create.js +50 -0
- package/dist/src/tools/transactions_delete.js +20 -0
- package/dist/src/tools/transactions_filter.js +73 -0
- package/dist/src/tools/transactions_get.js +23 -0
- package/dist/src/tools/transactions_import.js +21 -0
- package/dist/src/tools/transactions_search_by_amount.js +126 -0
- package/dist/src/tools/transactions_search_by_category.js +137 -0
- package/dist/src/tools/transactions_search_by_month.js +142 -0
- package/dist/src/tools/transactions_search_by_payee.js +142 -0
- package/dist/src/tools/transactions_summary_by_category.js +80 -0
- package/dist/src/tools/transactions_summary_by_payee.js +72 -0
- package/dist/src/tools/transactions_uncategorized.js +66 -0
- package/dist/src/tools/transactions_update.js +34 -0
- package/dist/src/tools/transactions_update_batch.js +60 -0
- package/dist/src/utils.js +63 -0
- package/package.json +88 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
const InputSchema = z.object({
|
|
4
|
+
query: z.string().min(1).describe('ActualQL query string to execute'),
|
|
5
|
+
});
|
|
6
|
+
const tool = {
|
|
7
|
+
name: 'actual_query_run',
|
|
8
|
+
description: `Execute SQL queries for advanced financial data analysis.
|
|
9
|
+
|
|
10
|
+
**RECOMMENDED: Use SQL syntax** - Most reliable and well-tested format.
|
|
11
|
+
|
|
12
|
+
SQL SYNTAX (Preferred):
|
|
13
|
+
SELECT [fields] FROM [table] WHERE [conditions] ORDER BY [field] DESC LIMIT [n]
|
|
14
|
+
|
|
15
|
+
Examples:
|
|
16
|
+
• "SELECT * FROM transactions ORDER BY date DESC LIMIT 5"
|
|
17
|
+
• "SELECT id, date, amount, payee.name FROM transactions WHERE amount < 0 LIMIT 10"
|
|
18
|
+
• "SELECT id, date, amount, category.name FROM transactions WHERE date >= '2025-01-01'"
|
|
19
|
+
|
|
20
|
+
IMPORTANT - Field Names:
|
|
21
|
+
• Use payee.name (NOT payee_name) for payee names
|
|
22
|
+
• Use category.name (NOT category_name) for category names
|
|
23
|
+
• Use account.name (NOT account_name) for account names
|
|
24
|
+
• Amounts are in cents: $100.00 = 10000
|
|
25
|
+
|
|
26
|
+
Available Tables:
|
|
27
|
+
• transactions: id, date, amount, notes, cleared, account, payee, category
|
|
28
|
+
- Join with: payee.name, category.name, account.name
|
|
29
|
+
• accounts: id, name, type, closed, offbudget
|
|
30
|
+
• categories: id, name, group, is_income
|
|
31
|
+
• payees: id, name
|
|
32
|
+
• category_groups: id, name, is_income
|
|
33
|
+
|
|
34
|
+
Common Queries:
|
|
35
|
+
• Last 5 transactions: "SELECT * FROM transactions ORDER BY date DESC LIMIT 5"
|
|
36
|
+
• By category: "SELECT id, date, amount, payee.name FROM transactions WHERE category.name = 'Food' LIMIT 10"
|
|
37
|
+
• Expenses: "SELECT id, date, amount, payee.name FROM transactions WHERE amount < 0 ORDER BY date DESC LIMIT 10"
|
|
38
|
+
• Date range: "SELECT * FROM transactions WHERE date >= '2025-01-01' AND date <= '2025-12-31'"
|
|
39
|
+
|
|
40
|
+
Alternative Formats:
|
|
41
|
+
• Simple table name: "transactions" (returns all records)
|
|
42
|
+
• ActualQL objects: Not recommended, use SQL instead
|
|
43
|
+
|
|
44
|
+
For details: https://actualbudget.org/docs/api/actual-ql/`,
|
|
45
|
+
inputSchema: InputSchema,
|
|
46
|
+
call: async (args, _meta) => {
|
|
47
|
+
try {
|
|
48
|
+
const input = InputSchema.parse(args || {});
|
|
49
|
+
// Detect and reject GraphQL-like syntax with nested objects
|
|
50
|
+
if (input.query.trim().startsWith('query ') && input.query.includes('{') && input.query.includes('}')) {
|
|
51
|
+
throw new Error(`GraphQL syntax is not fully supported. Please use SQL instead.\n\nExample: SELECT id, date, amount, payee.name, category.name FROM transactions ORDER BY date DESC LIMIT 5\n\nYour query attempted: ${input.query.substring(0, 100)}...`);
|
|
52
|
+
}
|
|
53
|
+
const result = await adapter.runQuery(input.query);
|
|
54
|
+
return { result };
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
// Provide helpful error messages
|
|
58
|
+
const errorMessage = error?.message || String(error);
|
|
59
|
+
// Check if error is about payee_name, category_name, account_name
|
|
60
|
+
if (errorMessage.includes('payee_name') || errorMessage.includes('category_name') || errorMessage.includes('account_name')) {
|
|
61
|
+
throw new Error(`Field name error: Use dot notation for joins.\n• Use payee.name (NOT payee_name)\n• Use category.name (NOT category_name)\n• Use account.name (NOT account_name)\n\nExample: SELECT id, date, amount, payee.name FROM transactions LIMIT 5\n\nOriginal error: ${errorMessage}`);
|
|
62
|
+
}
|
|
63
|
+
if (errorMessage.includes('does not exist in the schema')) {
|
|
64
|
+
throw new Error(`Invalid table or field name. Available tables: transactions, accounts, categories, payees, category_groups, schedules, rules. Use dot notation for joins (e.g., category.name). Original error: ${errorMessage}`);
|
|
65
|
+
}
|
|
66
|
+
else if (errorMessage.includes('ActualQL query builder not available')) {
|
|
67
|
+
throw new Error('ActualQL query builder is not available. The Actual Budget API may not be properly initialized.');
|
|
68
|
+
}
|
|
69
|
+
else if (errorMessage.includes('parse') || errorMessage.includes('syntax')) {
|
|
70
|
+
throw new Error(`Query syntax error: ${errorMessage}\n\nRecommended: Use SQL syntax\nExample: SELECT * FROM transactions ORDER BY date DESC LIMIT 5\n\nSee tool description for more examples.`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
throw new Error(`Query execution failed: ${errorMessage}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
export default tool;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
// Define the schema for rule conditions and actions
|
|
4
|
+
const ConditionSchema = z.object({
|
|
5
|
+
field: z.string().describe('Field to match (e.g., "payee", "notes", "amount", "category")'),
|
|
6
|
+
op: z.string().describe('Operation (e.g., "is", "contains", "isapprox", "gte", "lte")'),
|
|
7
|
+
value: z.union([z.string(), z.number()]).describe('Value to match against'),
|
|
8
|
+
type: z.string().optional().describe('Type of condition (e.g., "string", "number", "id")'),
|
|
9
|
+
});
|
|
10
|
+
const ActionSchema = z.object({
|
|
11
|
+
op: z.string()
|
|
12
|
+
.default('set')
|
|
13
|
+
.describe('Operation to perform. Options: "set" (default, assign value to field), "set-split-amount" (split transaction), "link-schedule" (link to scheduled transaction), "prepend-notes" (add text before notes), "append-notes" (add text after notes)'),
|
|
14
|
+
field: z.string()
|
|
15
|
+
.optional()
|
|
16
|
+
.describe('Field to modify - required for "set" operation. Options: "category" (transaction category), "payee" (transaction payee), "notes" (transaction notes), "cleared" (cleared status), "account" (move to different account)'),
|
|
17
|
+
value: z.union([z.string(), z.number(), z.boolean(), z.object({}).passthrough()])
|
|
18
|
+
.describe('Value to assign. Use category/payee/account UUID for "id" types, text for "string" types, number for amounts'),
|
|
19
|
+
type: z.string()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe('Value type hint. Options: "id" (UUID for category/payee/account), "string" (text value), "number" (numeric value), "boolean" (true/false)'),
|
|
22
|
+
options: z.object({}).passthrough().optional().describe('Additional options for the action'),
|
|
23
|
+
});
|
|
24
|
+
// Operator validation map
|
|
25
|
+
const FIELD_OPERATORS = {
|
|
26
|
+
'imported_payee': { type: 'string', operators: ['contains', 'matches', 'doesNotContain', 'is', 'isNot'] },
|
|
27
|
+
'payee': { type: 'id', operators: ['is', 'isNot', 'oneOf', 'notOneOf'] },
|
|
28
|
+
'account': { type: 'id', operators: ['is', 'isNot', 'oneOf', 'notOneOf'] },
|
|
29
|
+
'category': { type: 'id', operators: ['is', 'isNot', 'oneOf', 'notOneOf'] },
|
|
30
|
+
'notes': { type: 'string', operators: ['contains', 'matches', 'doesNotContain', 'is', 'isNot'] },
|
|
31
|
+
'description': { type: 'string', operators: ['contains', 'matches', 'doesNotContain', 'is', 'isNot'] },
|
|
32
|
+
'amount': { type: 'number', operators: ['is', 'gte', 'lte', 'gt', 'lt', 'isapprox'] },
|
|
33
|
+
'date': { type: 'date', operators: ['is', 'gte', 'lte', 'gt', 'lt'] },
|
|
34
|
+
};
|
|
35
|
+
const InputSchema = z.object({
|
|
36
|
+
stage: z.enum(['pre', 'post']).optional().default('pre').describe('When to apply the rule - "pre" (before transactions sync) or "post" (after transactions sync)'),
|
|
37
|
+
conditionsOp: z.enum(['and', 'or']).optional().default('and').describe('How to combine multiple conditions'),
|
|
38
|
+
conditions: z.array(ConditionSchema).describe('Array of conditions that must be met for the rule to apply'),
|
|
39
|
+
actions: z.array(ActionSchema).describe('Array of actions to perform when conditions are met'),
|
|
40
|
+
});
|
|
41
|
+
const tool = {
|
|
42
|
+
name: 'actual_rules_create',
|
|
43
|
+
description: `Create a new budget rule with conditions and actions.
|
|
44
|
+
|
|
45
|
+
IMPORTANT Field Types:
|
|
46
|
+
- "imported_payee" (string) - for text matching payee names. Supports: contains, matches, doesNotContain, is, isNot
|
|
47
|
+
- "payee" (ID) - for exact payee ID matching. Supports: is, isNot, oneOf, notOneOf
|
|
48
|
+
- "account", "category" (ID) - for account/category IDs. Supports: is, isNot, oneOf, notOneOf
|
|
49
|
+
- "notes", "description" (string) - for text matching. Supports: contains, matches, doesNotContain, is, isNot
|
|
50
|
+
- "amount", "date" (number/date) - supports: is, gte, lte, gt, lt
|
|
51
|
+
|
|
52
|
+
Stage options: 'pre' or 'post'.
|
|
53
|
+
Action operators: 'set', 'set-split-amount', 'link-schedule', 'append-notes'.
|
|
54
|
+
|
|
55
|
+
Example: {stage: "post", conditionsOp: "and", conditions: [{field: "imported_payee", op: "contains", value: "Amazon"}], actions: [{op: "set", field: "category", value: "category-uuid"}]}`,
|
|
56
|
+
inputSchema: InputSchema,
|
|
57
|
+
call: async (args, _meta) => {
|
|
58
|
+
try {
|
|
59
|
+
const input = InputSchema.parse(args || {});
|
|
60
|
+
// Validate that actions with op="set" have a field
|
|
61
|
+
for (const action of input.actions) {
|
|
62
|
+
if (action.op === 'set' && !action.field) {
|
|
63
|
+
throw new Error('Action with op="set" requires a "field" property (e.g., "category", "payee", "notes", "cleared")');
|
|
64
|
+
}
|
|
65
|
+
// Validate action field values for ID-type fields
|
|
66
|
+
if (action.op === 'set' && action.field) {
|
|
67
|
+
// Check if using category field with text value instead of ID
|
|
68
|
+
if (action.field === 'category' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
69
|
+
throw new Error(`Action field "category" expects a category ID (UUID), but got text value "${action.value}". ` +
|
|
70
|
+
`Use the category UUID from your budget data. You can list categories to find the correct UUID.`);
|
|
71
|
+
}
|
|
72
|
+
// Check if using payee field with text value instead of ID
|
|
73
|
+
if (action.field === 'payee' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
74
|
+
throw new Error(`Action field "payee" expects a payee ID (UUID), but got text value "${action.value}". ` +
|
|
75
|
+
`Use the payee UUID from your budget data. You can list payees to find the correct UUID.`);
|
|
76
|
+
}
|
|
77
|
+
// Check if using account field with text value instead of ID
|
|
78
|
+
if (action.field === 'account' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
79
|
+
throw new Error(`Action field "account" expects an account ID (UUID), but got text value "${action.value}". ` +
|
|
80
|
+
`Use the account UUID from your budget data. You can list accounts to find the correct UUID.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Validate append-notes and prepend-notes have string values
|
|
84
|
+
if ((action.op === 'append-notes' || action.op === 'prepend-notes') && typeof action.value !== 'string') {
|
|
85
|
+
throw new Error(`Action "${action.op}" requires a string value, but got ${typeof action.value}. ` +
|
|
86
|
+
`Example: {op: "${action.op}", value: "text to ${action.op === 'append-notes' ? 'append' : 'prepend'}"}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Validate field usage to guide users toward correct field selection
|
|
90
|
+
for (const condition of input.conditions) {
|
|
91
|
+
const fieldInfo = FIELD_OPERATORS[condition.field];
|
|
92
|
+
// Validate operator is compatible with field type
|
|
93
|
+
if (fieldInfo && !fieldInfo.operators.includes(condition.op)) {
|
|
94
|
+
throw new Error(`Invalid operator "${condition.op}" for field "${condition.field}". ` +
|
|
95
|
+
`Field "${condition.field}" is a ${fieldInfo.type} field and only supports: ${fieldInfo.operators.join(', ')}. ` +
|
|
96
|
+
`Please use one of these operators instead.`);
|
|
97
|
+
}
|
|
98
|
+
// Check if using payee field with text value instead of ID
|
|
99
|
+
if (condition.field === 'payee' && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
100
|
+
throw new Error(`Field "payee" expects a payee ID (UUID), but got text value "${condition.value}". ` +
|
|
101
|
+
`To match payee names with text, use "imported_payee" field instead. ` +
|
|
102
|
+
`Example: {field: "imported_payee", op: "contains", value: "${condition.value}"}`);
|
|
103
|
+
}
|
|
104
|
+
// Similar validation for account and category
|
|
105
|
+
if (['account', 'category'].includes(condition.field) && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
106
|
+
throw new Error(`Field "${condition.field}" expects an ID (UUID), but got text value "${condition.value}". ` +
|
|
107
|
+
`Use the ${condition.field} UUID from your budget data. List ${condition.field === 'account' ? 'accounts' : 'categories'} to find the correct UUID.`);
|
|
108
|
+
}
|
|
109
|
+
// Validate oneOf/notOneOf operators expect array values
|
|
110
|
+
if (['oneOf', 'notOneOf'].includes(condition.op) && !Array.isArray(condition.value)) {
|
|
111
|
+
throw new Error(`Operator "${condition.op}" expects an array of values, but got ${typeof condition.value}. ` +
|
|
112
|
+
`Example: {field: "${condition.field}", op: "${condition.op}", value: ["uuid-1", "uuid-2"]}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// No automatic translation - require explicit field names
|
|
116
|
+
const ruleData = JSON.parse(JSON.stringify(input)); // deep clone
|
|
117
|
+
const ruleId = await adapter.createRule(ruleData);
|
|
118
|
+
return { id: ruleId, success: true };
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error instanceof z.ZodError) {
|
|
122
|
+
const fieldErrors = error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ');
|
|
123
|
+
throw new Error(`Invalid rule data: ${fieldErrors}`);
|
|
124
|
+
}
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
export default tool;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* actual_rules_create_or_update
|
|
3
|
+
*
|
|
4
|
+
* Idempotent rule upsert: create a rule if none with matching conditions exists,
|
|
5
|
+
* or update the existing one in place. Prevents duplicate rules when an AI client
|
|
6
|
+
* retries or regenerates the same rule creation request.
|
|
7
|
+
*
|
|
8
|
+
* Matching logic: a rule is considered a "match" when it has the same set of
|
|
9
|
+
* (field, op, value) triples AND the same conditionsOp ("and"/"or"). Order of
|
|
10
|
+
* conditions in the array is irrelevant — the comparison is set-based.
|
|
11
|
+
*
|
|
12
|
+
* Concept and implementation adapted from the ZanzyTHEbar fork:
|
|
13
|
+
* https://github.com/ZanzyTHEbar/actual-mcp-server/blob/main/src/tools/rules_create_or_update.ts
|
|
14
|
+
* Credit: ZanzyTHEbar (https://github.com/ZanzyTHEbar)
|
|
15
|
+
*
|
|
16
|
+
* Adapted for this project's conventions:
|
|
17
|
+
* - No wrapToolCall — uses direct call() pattern
|
|
18
|
+
* - Reuses exact same ConditionSchema / ActionSchema / FIELD_OPERATORS as rules_create.ts
|
|
19
|
+
*/
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
import adapter from '../lib/actual-adapter.js';
|
|
22
|
+
// Mirrors the same schemas used in rules_create.ts
|
|
23
|
+
const ConditionSchema = z.object({
|
|
24
|
+
field: z.string().describe('Field to match (e.g., "payee", "notes", "amount", "category", "imported_payee")'),
|
|
25
|
+
op: z.string().describe('Operation (e.g., "is", "contains", "isapprox", "gte", "lte")'),
|
|
26
|
+
value: z.union([z.string(), z.number()]).describe('Value to match against'),
|
|
27
|
+
type: z.string().optional().describe('Type of condition (e.g., "string", "number", "id")'),
|
|
28
|
+
});
|
|
29
|
+
const ActionSchema = z.object({
|
|
30
|
+
op: z.string()
|
|
31
|
+
.default('set')
|
|
32
|
+
.describe('Operation to perform. Options: "set" (default), "set-split-amount", "link-schedule", "prepend-notes", "append-notes"'),
|
|
33
|
+
field: z.string()
|
|
34
|
+
.optional()
|
|
35
|
+
.describe('Field to modify — required for "set" op. Options: "category", "payee", "notes", "cleared", "account"'),
|
|
36
|
+
value: z.union([z.string(), z.number(), z.boolean(), z.object({}).passthrough()])
|
|
37
|
+
.describe('Value to assign. Use UUIDs for id-type fields, text for strings, numbers for amounts'),
|
|
38
|
+
type: z.string()
|
|
39
|
+
.optional()
|
|
40
|
+
.describe('Value type hint: "id", "string", "number", "boolean"'),
|
|
41
|
+
options: z.object({}).passthrough().optional().describe('Additional options for the action'),
|
|
42
|
+
});
|
|
43
|
+
// Same operator validation map as rules_create.ts
|
|
44
|
+
const FIELD_OPERATORS = {
|
|
45
|
+
'imported_payee': { type: 'string', operators: ['contains', 'matches', 'doesNotContain', 'is', 'isNot'] },
|
|
46
|
+
'payee': { type: 'id', operators: ['is', 'isNot', 'oneOf', 'notOneOf'] },
|
|
47
|
+
'account': { type: 'id', operators: ['is', 'isNot', 'oneOf', 'notOneOf'] },
|
|
48
|
+
'category': { type: 'id', operators: ['is', 'isNot', 'oneOf', 'notOneOf'] },
|
|
49
|
+
'notes': { type: 'string', operators: ['contains', 'matches', 'doesNotContain', 'is', 'isNot'] },
|
|
50
|
+
'description': { type: 'string', operators: ['contains', 'matches', 'doesNotContain', 'is', 'isNot'] },
|
|
51
|
+
'amount': { type: 'number', operators: ['is', 'gte', 'lte', 'gt', 'lt', 'isapprox'] },
|
|
52
|
+
'date': { type: 'date', operators: ['is', 'gte', 'lte', 'gt', 'lt'] },
|
|
53
|
+
};
|
|
54
|
+
const InputSchema = z.object({
|
|
55
|
+
stage: z.enum(['pre', 'post']).optional().default('pre').describe('When to apply the rule — "pre" or "post"'),
|
|
56
|
+
conditionsOp: z.enum(['and', 'or']).optional().default('and').describe('How to combine multiple conditions'),
|
|
57
|
+
conditions: z.array(ConditionSchema).describe('Array of conditions that must be met'),
|
|
58
|
+
actions: z.array(ActionSchema).describe('Array of actions to perform when conditions match'),
|
|
59
|
+
});
|
|
60
|
+
/**
|
|
61
|
+
* Normalize a single object for stable JSON comparison by sorting its keys
|
|
62
|
+
* and stripping undefined values.
|
|
63
|
+
*/
|
|
64
|
+
function canonicalize(obj) {
|
|
65
|
+
const sorted = {};
|
|
66
|
+
for (const key of Object.keys(obj).sort()) {
|
|
67
|
+
if (obj[key] !== undefined)
|
|
68
|
+
sorted[key] = obj[key];
|
|
69
|
+
}
|
|
70
|
+
return JSON.stringify(sorted);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Return true when two rules have semantically equivalent conditions.
|
|
74
|
+
* Matching is set-based on (field, op, value) triples — order is irrelevant.
|
|
75
|
+
*/
|
|
76
|
+
function conditionsMatch(existingConditions, existingConditionsOp, newConditions, newConditionsOp) {
|
|
77
|
+
if ((existingConditionsOp || 'and') !== newConditionsOp)
|
|
78
|
+
return false;
|
|
79
|
+
if (!Array.isArray(existingConditions))
|
|
80
|
+
return false;
|
|
81
|
+
if (existingConditions.length !== newConditions.length)
|
|
82
|
+
return false;
|
|
83
|
+
const existingSet = new Set(existingConditions.map((c) => {
|
|
84
|
+
const cond = c;
|
|
85
|
+
return canonicalize({ field: cond.field, op: cond.op, value: cond.value });
|
|
86
|
+
}));
|
|
87
|
+
const newSet = new Set(newConditions.map((c) => canonicalize({ field: c.field, op: c.op, value: c.value })));
|
|
88
|
+
if (existingSet.size !== newSet.size)
|
|
89
|
+
return false;
|
|
90
|
+
for (const item of newSet) {
|
|
91
|
+
if (!existingSet.has(item))
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
const tool = {
|
|
97
|
+
name: 'actual_rules_create_or_update',
|
|
98
|
+
description: `Create a rule if no matching rule exists, or update the existing rule if one with the same conditions already exists. Prevents duplicate rules.
|
|
99
|
+
|
|
100
|
+
Matching logic: a rule is considered a "match" when it has the same set of conditions (field + op + value triples) and the same conditionsOp ("and"/"or"). Condition order is irrelevant.
|
|
101
|
+
|
|
102
|
+
When a match is found: the rule's actions (and stage) are REPLACED with the new values.
|
|
103
|
+
When no match exists: a new rule is created.
|
|
104
|
+
|
|
105
|
+
IMPORTANT Field Types:
|
|
106
|
+
- "imported_payee" (string) — text matching. Supports: contains, matches, doesNotContain, is, isNot
|
|
107
|
+
- "payee" (ID) — exact payee UUID. Supports: is, isNot, oneOf, notOneOf
|
|
108
|
+
- "account", "category" (ID) — UUID matching. Supports: is, isNot, oneOf, notOneOf
|
|
109
|
+
- "notes", "description" (string) — text matching. Supports: contains, matches, doesNotContain, is, isNot
|
|
110
|
+
- "amount", "date" (number/date) — supports: is, gte, lte, gt, lt
|
|
111
|
+
|
|
112
|
+
Returns: { id, created: boolean } — created=true if new rule was created, false if existing rule was updated.`,
|
|
113
|
+
inputSchema: InputSchema,
|
|
114
|
+
call: async (args, _meta) => {
|
|
115
|
+
try {
|
|
116
|
+
const input = InputSchema.parse(args || {});
|
|
117
|
+
// ── Validate conditions ──
|
|
118
|
+
for (const condition of input.conditions) {
|
|
119
|
+
const fieldInfo = FIELD_OPERATORS[condition.field];
|
|
120
|
+
if (fieldInfo && !fieldInfo.operators.includes(condition.op)) {
|
|
121
|
+
throw new Error(`Invalid operator "${condition.op}" for field "${condition.field}". ` +
|
|
122
|
+
`Field "${condition.field}" is a ${fieldInfo.type} field and only supports: ${fieldInfo.operators.join(', ')}.`);
|
|
123
|
+
}
|
|
124
|
+
if (condition.field === 'payee' && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
125
|
+
throw new Error(`Field "payee" expects a UUID, but got "${condition.value}". ` +
|
|
126
|
+
`Use "imported_payee" for text matching instead.`);
|
|
127
|
+
}
|
|
128
|
+
if (['account', 'category'].includes(condition.field) && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
129
|
+
throw new Error(`Field "${condition.field}" expects a UUID, but got text "${condition.value}".`);
|
|
130
|
+
}
|
|
131
|
+
if (['oneOf', 'notOneOf'].includes(condition.op) && !Array.isArray(condition.value)) {
|
|
132
|
+
throw new Error(`Operator "${condition.op}" expects an array of values.`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// ── Validate actions ──
|
|
136
|
+
for (const action of input.actions) {
|
|
137
|
+
if (action.op === 'set' && !action.field) {
|
|
138
|
+
throw new Error('Action with op="set" requires a "field" property.');
|
|
139
|
+
}
|
|
140
|
+
if (action.op === 'set' && action.field) {
|
|
141
|
+
for (const idField of ['category', 'payee', 'account']) {
|
|
142
|
+
if (action.field === idField && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
143
|
+
throw new Error(`Action field "${idField}" expects a UUID, but got "${action.value}".`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (['append-notes', 'prepend-notes'].includes(action.op) && typeof action.value !== 'string') {
|
|
148
|
+
throw new Error(`Action "${action.op}" requires a string value.`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// ── Fetch existing rules and look for a match ──
|
|
152
|
+
const existingRules = await adapter.getRules();
|
|
153
|
+
let matchedRule = null;
|
|
154
|
+
for (const rule of existingRules) {
|
|
155
|
+
const r = rule;
|
|
156
|
+
if (!r.id || typeof r.id !== 'string')
|
|
157
|
+
continue;
|
|
158
|
+
const existingConditions = Array.isArray(r.conditions) ? r.conditions : [];
|
|
159
|
+
const existingConditionsOp = r.conditionsOp || 'and';
|
|
160
|
+
if (conditionsMatch(existingConditions, existingConditionsOp, input.conditions, input.conditionsOp)) {
|
|
161
|
+
matchedRule = r;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const ruleData = JSON.parse(JSON.stringify(input)); // deep clone for API call
|
|
166
|
+
if (matchedRule) {
|
|
167
|
+
// ── UPDATE existing rule ──
|
|
168
|
+
await adapter.updateRule(matchedRule.id, {
|
|
169
|
+
stage: ruleData.stage,
|
|
170
|
+
conditionsOp: ruleData.conditionsOp,
|
|
171
|
+
conditions: ruleData.conditions,
|
|
172
|
+
actions: ruleData.actions,
|
|
173
|
+
});
|
|
174
|
+
return { id: matchedRule.id, created: false };
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
// ── CREATE new rule ──
|
|
178
|
+
const ruleId = await adapter.createRule(ruleData);
|
|
179
|
+
return { id: ruleId, created: true };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (error instanceof z.ZodError) {
|
|
184
|
+
const fieldErrors = error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ');
|
|
185
|
+
throw new Error(`Invalid rule data: ${fieldErrors}`);
|
|
186
|
+
}
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
export default tool;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import { notFoundMsg } from '../lib/errors.js';
|
|
4
|
+
const InputSchema = z.object({
|
|
5
|
+
id: z.string().describe('Rule ID to delete'),
|
|
6
|
+
});
|
|
7
|
+
const tool = {
|
|
8
|
+
name: 'actual_rules_delete',
|
|
9
|
+
description: `Delete a budget rule from Actual Budget. The rule will no longer be applied to new or existing transactions. This operation cannot be undone.`,
|
|
10
|
+
inputSchema: InputSchema,
|
|
11
|
+
call: async (args, _meta) => {
|
|
12
|
+
const input = InputSchema.parse(args || {});
|
|
13
|
+
// Pre-flight: verify rule exists (BUG-9)
|
|
14
|
+
const allRules = await adapter.getRules();
|
|
15
|
+
const ruleExists = allRules.some((r) => r.id === input.id);
|
|
16
|
+
if (!ruleExists) {
|
|
17
|
+
return {
|
|
18
|
+
error: notFoundMsg('Rule', input.id, 'actual_rules_get'),
|
|
19
|
+
success: false,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
await adapter.deleteRule(input.id);
|
|
23
|
+
return { success: true };
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
export default tool;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
const InputSchema = z.object({});
|
|
4
|
+
const tool = {
|
|
5
|
+
name: 'actual_rules_get',
|
|
6
|
+
description: `List all budget rules in Actual Budget. Rules automate transaction categorization and other budget operations based on conditions. Each rule has conditions (e.g., payee matches "Amazon") and actions (e.g., set category to "Shopping").`,
|
|
7
|
+
inputSchema: InputSchema,
|
|
8
|
+
call: async (args, _meta) => {
|
|
9
|
+
const rules = await adapter.getRules();
|
|
10
|
+
return { rules };
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
export default tool;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
// Define the schema for rule conditions and actions (same as create)
|
|
4
|
+
const ConditionSchema = z.object({
|
|
5
|
+
field: z.string().describe('Field to match (e.g., "payee", "notes", "amount", "category")'),
|
|
6
|
+
op: z.string().describe('Operation (e.g., "is", "contains", "isapprox", "gte", "lte")'),
|
|
7
|
+
value: z.union([z.string(), z.number()]).describe('Value to match against'),
|
|
8
|
+
type: z.string().optional().describe('Type of condition (e.g., "string", "number", "id")'),
|
|
9
|
+
});
|
|
10
|
+
const ActionSchema = z.object({
|
|
11
|
+
op: z.string().describe('Operation to perform (e.g., "set", "set-split-amount", "link-schedule", "prepend-notes", "append-notes")'),
|
|
12
|
+
field: z.string().optional().describe('Field to set (e.g., "category", "payee", "notes", "cleared") - required for "set" operation'),
|
|
13
|
+
value: z.union([z.string(), z.number(), z.boolean(), z.object({}).passthrough()]).describe('Value to set or use in operation'),
|
|
14
|
+
type: z.string().optional().describe('Type of action (e.g., "id", "string", "number", "boolean")'),
|
|
15
|
+
options: z.object({}).passthrough().optional().describe('Additional options for the action'),
|
|
16
|
+
});
|
|
17
|
+
// Operator validation map
|
|
18
|
+
const FIELD_OPERATORS = {
|
|
19
|
+
'imported_payee': { type: 'string', operators: ['contains', 'matches', 'doesNotContain', 'is', 'isNot'] },
|
|
20
|
+
'payee': { type: 'id', operators: ['is', 'isNot', 'oneOf', 'notOneOf'] },
|
|
21
|
+
'account': { type: 'id', operators: ['is', 'isNot', 'oneOf', 'notOneOf'] },
|
|
22
|
+
'category': { type: 'id', operators: ['is', 'isNot', 'oneOf', 'notOneOf'] },
|
|
23
|
+
'notes': { type: 'string', operators: ['contains', 'matches', 'doesNotContain', 'is', 'isNot'] },
|
|
24
|
+
'description': { type: 'string', operators: ['contains', 'matches', 'doesNotContain', 'is', 'isNot'] },
|
|
25
|
+
'amount': { type: 'number', operators: ['is', 'gte', 'lte', 'gt', 'lt', 'isapprox'] },
|
|
26
|
+
'date': { type: 'date', operators: ['is', 'gte', 'lte', 'gt', 'lt'] },
|
|
27
|
+
};
|
|
28
|
+
const InputSchema = z.object({
|
|
29
|
+
id: z.string().describe('Rule ID to update'),
|
|
30
|
+
fields: z.object({
|
|
31
|
+
stage: z.enum(['pre', 'post']).optional().describe('When to apply the rule - "pre" (before transactions sync) or "post" (after transactions sync)'),
|
|
32
|
+
conditionsOp: z.enum(['and', 'or']).optional().describe('How to combine multiple conditions'),
|
|
33
|
+
conditions: z.array(ConditionSchema).optional().describe('New array of conditions'),
|
|
34
|
+
actions: z.array(ActionSchema).optional().describe('New array of actions'),
|
|
35
|
+
}).describe('Fields to update'),
|
|
36
|
+
});
|
|
37
|
+
const tool = {
|
|
38
|
+
name: 'actual_rules_update',
|
|
39
|
+
description: `Update an existing budget rule by ID. Only provide the fields you want to change.
|
|
40
|
+
|
|
41
|
+
IMPORTANT Field Types:
|
|
42
|
+
- "imported_payee" (string) - for text matching payee names. Supports: contains, matches, doesNotContain, is, isNot
|
|
43
|
+
- "payee" (ID) - for exact payee ID matching. Supports: is, isNot, oneOf, notOneOf
|
|
44
|
+
- "account", "category" (ID) - for account/category IDs. Supports: is, isNot, oneOf, notOneOf
|
|
45
|
+
- "notes", "description" (string) - for text matching. Supports: contains, matches, doesNotContain, is, isNot
|
|
46
|
+
- "amount", "date" (number/date) - supports: is, gte, lte, gt, lt
|
|
47
|
+
|
|
48
|
+
Stage options: 'pre' or 'post'.
|
|
49
|
+
Action operators: 'set', 'set-split-amount', 'link-schedule', 'append-notes'.
|
|
50
|
+
|
|
51
|
+
Do not include the rule ID in the fields object - it is provided separately.`,
|
|
52
|
+
inputSchema: InputSchema,
|
|
53
|
+
call: async (args, _meta) => {
|
|
54
|
+
const input = InputSchema.parse(args || {});
|
|
55
|
+
// Validate action field values
|
|
56
|
+
if (input.fields.actions) {
|
|
57
|
+
for (const action of input.fields.actions) {
|
|
58
|
+
if (action.op === 'set' && !action.field) {
|
|
59
|
+
throw new Error('Action with op="set" requires a "field" property (e.g., "category", "payee", "notes", "cleared")');
|
|
60
|
+
}
|
|
61
|
+
// Validate action field values for ID-type fields
|
|
62
|
+
if (action.op === 'set' && action.field) {
|
|
63
|
+
// Check if using category field with text value instead of ID
|
|
64
|
+
if (action.field === 'category' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
65
|
+
throw new Error(`Action field "category" expects a category ID (UUID), but got text value "${action.value}". ` +
|
|
66
|
+
`Use the category UUID from your budget data. You can list categories to find the correct UUID.`);
|
|
67
|
+
}
|
|
68
|
+
// Check if using payee field with text value instead of ID
|
|
69
|
+
if (action.field === 'payee' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
70
|
+
throw new Error(`Action field "payee" expects a payee ID (UUID), but got text value "${action.value}". ` +
|
|
71
|
+
`Use the payee UUID from your budget data. You can list payees to find the correct UUID.`);
|
|
72
|
+
}
|
|
73
|
+
// Check if using account field with text value instead of ID
|
|
74
|
+
if (action.field === 'account' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
75
|
+
throw new Error(`Action field "account" expects an account ID (UUID), but got text value "${action.value}". ` +
|
|
76
|
+
`Use the account UUID from your budget data. You can list accounts to find the correct UUID.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Validate append-notes and prepend-notes have string values
|
|
80
|
+
if ((action.op === 'append-notes' || action.op === 'prepend-notes') && typeof action.value !== 'string') {
|
|
81
|
+
throw new Error(`Action "${action.op}" requires a string value, but got ${typeof action.value}. ` +
|
|
82
|
+
`Example: {op: "${action.op}", value: "text to ${action.op === 'append-notes' ? 'append' : 'prepend'}"}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Validate field usage to guide users toward correct field selection
|
|
87
|
+
if (input.fields.conditions) {
|
|
88
|
+
for (const condition of input.fields.conditions) {
|
|
89
|
+
const fieldInfo = FIELD_OPERATORS[condition.field];
|
|
90
|
+
// Validate operator is compatible with field type
|
|
91
|
+
if (fieldInfo && !fieldInfo.operators.includes(condition.op)) {
|
|
92
|
+
throw new Error(`Invalid operator "${condition.op}" for field "${condition.field}". ` +
|
|
93
|
+
`Field "${condition.field}" is a ${fieldInfo.type} field and only supports: ${fieldInfo.operators.join(', ')}. ` +
|
|
94
|
+
`Please use one of these operators instead.`);
|
|
95
|
+
}
|
|
96
|
+
// Check if using payee field with text value instead of ID
|
|
97
|
+
if (condition.field === 'payee' && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
98
|
+
throw new Error(`Field "payee" expects a payee ID (UUID), but got text value "${condition.value}". ` +
|
|
99
|
+
`To match payee names with text, use "imported_payee" field instead. ` +
|
|
100
|
+
`Example: {field: "imported_payee", op: "contains", value: "${condition.value}"}`);
|
|
101
|
+
}
|
|
102
|
+
// Similar validation for account and category
|
|
103
|
+
if (['account', 'category'].includes(condition.field) && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
104
|
+
throw new Error(`Field "${condition.field}" expects an ID (UUID), but got text value "${condition.value}". ` +
|
|
105
|
+
`Use the ${condition.field} UUID from your budget data. List ${condition.field === 'account' ? 'accounts' : 'categories'} to find the correct UUID.`);
|
|
106
|
+
}
|
|
107
|
+
// Validate oneOf/notOneOf operators expect array values
|
|
108
|
+
if (['oneOf', 'notOneOf'].includes(condition.op) && !Array.isArray(condition.value)) {
|
|
109
|
+
throw new Error(`Operator "${condition.op}" expects an array of values, but got ${typeof condition.value}. ` +
|
|
110
|
+
`Example: {field: "${condition.field}", op: "${condition.op}", value: ["uuid-1", "uuid-2"]}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// No automatic translation - require explicit field names
|
|
115
|
+
const fields = JSON.parse(JSON.stringify(input.fields)); // deep clone
|
|
116
|
+
await adapter.updateRule(input.id, fields);
|
|
117
|
+
return { success: true };
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
export default tool;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import { UUID_PATTERN } from '../lib/constants.js';
|
|
4
|
+
const RecurConfigSchema = z.object({
|
|
5
|
+
frequency: z.enum(['daily', 'weekly', 'monthly', 'yearly'])
|
|
6
|
+
.describe('How often the schedule repeats'),
|
|
7
|
+
start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
8
|
+
.describe('Start date in YYYY-MM-DD format'),
|
|
9
|
+
endMode: z.enum(['never', 'after_n_occurrences', 'on_date'])
|
|
10
|
+
.describe('When the schedule stops: never, after N occurrences, or on a specific date'),
|
|
11
|
+
interval: z.number().int().positive().optional()
|
|
12
|
+
.describe('Every N periods. Default: 1 (every period)'),
|
|
13
|
+
skipWeekend: z.boolean().optional()
|
|
14
|
+
.describe('If true, occurrence is moved when it falls on a weekend'),
|
|
15
|
+
weekendSolveMode: z.enum(['before', 'after']).optional()
|
|
16
|
+
.describe('Move to Friday before or Monday after the weekend. Requires skipWeekend: true'),
|
|
17
|
+
endOccurrences: z.number().int().positive().optional()
|
|
18
|
+
.describe('Number of occurrences before stopping. Required when endMode is after_n_occurrences'),
|
|
19
|
+
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
|
|
20
|
+
.describe('Date (YYYY-MM-DD) after which the schedule stops. Required when endMode is on_date'),
|
|
21
|
+
});
|
|
22
|
+
const InputSchema = z.object({
|
|
23
|
+
name: z.string().optional()
|
|
24
|
+
.describe('Unique display name for the schedule'),
|
|
25
|
+
payee: z.string().regex(UUID_PATTERN, 'Invalid UUID format').optional()
|
|
26
|
+
.describe('Payee UUID (from actual_payees_get)'),
|
|
27
|
+
account: z.string().regex(UUID_PATTERN, 'Invalid UUID format').optional()
|
|
28
|
+
.describe('Account UUID (from actual_accounts_list). If omitted the schedule is not tied to an account'),
|
|
29
|
+
amount: z.number().int().optional()
|
|
30
|
+
.describe('Amount in cents. Negative = expense (e.g. -5000 = -$50.00), positive = income'),
|
|
31
|
+
amountOp: z.enum(['is', 'isapprox', 'isbetween']).optional().default('is')
|
|
32
|
+
.describe('How to match the amount. "isbetween" requires amount to be an object { num1, num2 }'),
|
|
33
|
+
date: z.union([
|
|
34
|
+
z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
35
|
+
.describe('YYYY-MM-DD — one-off schedule on this specific date'),
|
|
36
|
+
RecurConfigSchema
|
|
37
|
+
.describe('RecurConfig object — recurring schedule'),
|
|
38
|
+
]).describe('Date string for one-off or RecurConfig object for recurring. Required.'),
|
|
39
|
+
posts_transaction: z.boolean().optional().default(false)
|
|
40
|
+
.describe('When true, Actual automatically posts a transaction on each occurrence'),
|
|
41
|
+
});
|
|
42
|
+
const tool = {
|
|
43
|
+
name: 'actual_schedules_create',
|
|
44
|
+
description: `Create a new schedule in Actual Budget. Schedules can be one-off (supply a YYYY-MM-DD date string) or recurring (supply a RecurConfig object with frequency, start, and endMode). Amounts are in cents — negative for expenses, positive for income.`,
|
|
45
|
+
inputSchema: InputSchema,
|
|
46
|
+
call: async (args, _meta) => {
|
|
47
|
+
const input = InputSchema.parse(args || {});
|
|
48
|
+
const { date, ...rest } = input;
|
|
49
|
+
const schedule = { ...rest, date };
|
|
50
|
+
const id = await adapter.createSchedule(schedule);
|
|
51
|
+
return { id };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
export default tool;
|