@bbookmind/mcp 1.0.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/.claude-plugin/marketplace.json +27 -0
- package/.claude-plugin/plugin.json +18 -0
- package/.env.example +15 -0
- package/.github/workflows/validate.yml +55 -0
- package/CEO_QUICKSTART.md +161 -0
- package/CLAUDE.md +30 -0
- package/CNAME +1 -0
- package/LAUNCH_CONTENT.md +210 -0
- package/LICENSE +21 -0
- package/README.md +196 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +283 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/db.d.ts +6 -0
- package/dist/lib/db.d.ts.map +1 -0
- package/dist/lib/db.js +209 -0
- package/dist/lib/db.js.map +1 -0
- package/dist/lib/llm.d.ts +14 -0
- package/dist/lib/llm.d.ts.map +1 -0
- package/dist/lib/llm.js +110 -0
- package/dist/lib/llm.js.map +1 -0
- package/dist/lib/qbo.d.ts +35 -0
- package/dist/lib/qbo.d.ts.map +1 -0
- package/dist/lib/qbo.js +157 -0
- package/dist/lib/qbo.js.map +1 -0
- package/dist/tools/anomalies.d.ts +3 -0
- package/dist/tools/anomalies.d.ts.map +1 -0
- package/dist/tools/anomalies.js +142 -0
- package/dist/tools/anomalies.js.map +1 -0
- package/dist/tools/approveCategories.d.ts +3 -0
- package/dist/tools/approveCategories.d.ts.map +1 -0
- package/dist/tools/approveCategories.js +41 -0
- package/dist/tools/approveCategories.js.map +1 -0
- package/dist/tools/categorize.d.ts +3 -0
- package/dist/tools/categorize.d.ts.map +1 -0
- package/dist/tools/categorize.js +160 -0
- package/dist/tools/categorize.js.map +1 -0
- package/dist/tools/chaseEmail.d.ts +3 -0
- package/dist/tools/chaseEmail.d.ts.map +1 -0
- package/dist/tools/chaseEmail.js +29 -0
- package/dist/tools/chaseEmail.js.map +1 -0
- package/dist/tools/fetchTransactions.d.ts +3 -0
- package/dist/tools/fetchTransactions.d.ts.map +1 -0
- package/dist/tools/fetchTransactions.js +72 -0
- package/dist/tools/fetchTransactions.js.map +1 -0
- package/dist/tools/learnRules.d.ts +3 -0
- package/dist/tools/learnRules.d.ts.map +1 -0
- package/dist/tools/learnRules.js +72 -0
- package/dist/tools/learnRules.js.map +1 -0
- package/dist/tools/listCompanies.d.ts +3 -0
- package/dist/tools/listCompanies.d.ts.map +1 -0
- package/dist/tools/listCompanies.js +26 -0
- package/dist/tools/listCompanies.js.map +1 -0
- package/dist/tools/monthlyClose.d.ts +3 -0
- package/dist/tools/monthlyClose.d.ts.map +1 -0
- package/dist/tools/monthlyClose.js +92 -0
- package/dist/tools/monthlyClose.js.map +1 -0
- package/dist/tools/reconcile.d.ts +3 -0
- package/dist/tools/reconcile.d.ts.map +1 -0
- package/dist/tools/reconcile.js +86 -0
- package/dist/tools/reconcile.js.map +1 -0
- package/dist/tools/rejectCategories.d.ts +3 -0
- package/dist/tools/rejectCategories.d.ts.map +1 -0
- package/dist/tools/rejectCategories.js +36 -0
- package/dist/tools/rejectCategories.js.map +1 -0
- package/dist/tools/selectCompany.d.ts +3 -0
- package/dist/tools/selectCompany.d.ts.map +1 -0
- package/dist/tools/selectCompany.js +28 -0
- package/dist/tools/selectCompany.js.map +1 -0
- package/dist/tools/syncToQbo.d.ts +3 -0
- package/dist/tools/syncToQbo.d.ts.map +1 -0
- package/dist/tools/syncToQbo.js +53 -0
- package/dist/tools/syncToQbo.js.map +1 -0
- package/dist/tools/undoCategories.d.ts +3 -0
- package/dist/tools/undoCategories.d.ts.map +1 -0
- package/dist/tools/undoCategories.js +50 -0
- package/dist/tools/undoCategories.js.map +1 -0
- package/dist/tools/vendorSearch.d.ts +3 -0
- package/dist/tools/vendorSearch.d.ts.map +1 -0
- package/dist/tools/vendorSearch.js +39 -0
- package/dist/tools/vendorSearch.js.map +1 -0
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/index.html +403 -0
- package/install/claude-desktop.json +14 -0
- package/install/cline.json +16 -0
- package/install/continue.json +14 -0
- package/install/cursor.json +14 -0
- package/install/goose.yaml +10 -0
- package/install/lm-studio.json +14 -0
- package/install/vscode-copilot.json +14 -0
- package/install/windsurf.json +14 -0
- package/mcp-server.toml +30 -0
- package/package.json +49 -0
- package/server.json +136 -0
- package/src/index.ts +313 -0
- package/src/lib/db.ts +212 -0
- package/src/lib/llm.ts +140 -0
- package/src/lib/qbo.ts +187 -0
- package/src/tools/anomalies.ts +144 -0
- package/src/tools/approveCategories.ts +46 -0
- package/src/tools/categorize.ts +166 -0
- package/src/tools/chaseEmail.ts +30 -0
- package/src/tools/fetchTransactions.ts +79 -0
- package/src/tools/learnRules.ts +71 -0
- package/src/tools/listCompanies.ts +30 -0
- package/src/tools/monthlyClose.ts +95 -0
- package/src/tools/reconcile.ts +86 -0
- package/src/tools/rejectCategories.ts +43 -0
- package/src/tools/selectCompany.ts +33 -0
- package/src/tools/syncToQbo.ts +56 -0
- package/src/tools/undoCategories.ts +50 -0
- package/src/tools/vendorSearch.ts +32 -0
- package/src/types.ts +11 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ToolDefinition } from "../types.js";
|
|
2
|
+
import { Database } from "sql.js";
|
|
3
|
+
import { getCurrentCompanyId, auditChange } from "../lib/db.js";
|
|
4
|
+
|
|
5
|
+
export const approveCategoriesTool: ToolDefinition = {
|
|
6
|
+
name: "bookmind.approve_categorizations",
|
|
7
|
+
description:
|
|
8
|
+
"Approve pending categorization suggestions. Accepts transaction IDs or 'all' to approve everything pending. Approved transactions get status='categorized' and approved=1.",
|
|
9
|
+
schema: {
|
|
10
|
+
type: "object",
|
|
11
|
+
properties: {
|
|
12
|
+
transaction_ids: {
|
|
13
|
+
type: "array",
|
|
14
|
+
items: { type: "string" },
|
|
15
|
+
description: "List of transaction IDs to approve, or ['all'] to approve all pending",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
required: ["transaction_ids"],
|
|
19
|
+
},
|
|
20
|
+
handler: async (args, db: Database) => {
|
|
21
|
+
const coId = getCurrentCompanyId();
|
|
22
|
+
const { transaction_ids } = args as { transaction_ids: string[] };
|
|
23
|
+
|
|
24
|
+
const useAll = transaction_ids.length === 1 && transaction_ids[0] === "all";
|
|
25
|
+
let rows: any[] = [];
|
|
26
|
+
|
|
27
|
+
if (useAll) {
|
|
28
|
+
db.exec(`UPDATE transactions SET status='categorized', approved=1, updated_at=datetime('now') WHERE company_id='${coId}' AND status='pending'`);
|
|
29
|
+
const r: any = db.exec(`SELECT id, description, vendor, amount, category FROM transactions WHERE company_id='${coId}' AND status='categorized' AND approved=1`);
|
|
30
|
+
rows = r[0]?.values || [];
|
|
31
|
+
} else {
|
|
32
|
+
for (const id of transaction_ids) {
|
|
33
|
+
const old: any = db.exec(`SELECT status FROM transactions WHERE id=? AND company_id=?`);
|
|
34
|
+
if (old[0]?.values?.[0]?.[0] !== "categorized") {
|
|
35
|
+
auditChange(db, "transaction", id, "status", "pending", "categorized", "approval");
|
|
36
|
+
}
|
|
37
|
+
db.exec(`UPDATE transactions SET status='categorized', approved=1, updated_at=datetime('now') WHERE id='${id.replace(/'/g, "''")}' AND company_id='${coId}'`);
|
|
38
|
+
}
|
|
39
|
+
const r: any = db.exec(`SELECT id, description, vendor, amount, category FROM transactions WHERE company_id='${coId}' AND status='categorized' AND approved=1`);
|
|
40
|
+
rows = r[0]?.values || [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const result = rows.map((r: any) => r.values).flat();
|
|
44
|
+
return `Approved ${result.length} categorizations.\n\n${JSON.stringify(result.map((r: any) => ({ id: r[0], description: r[1], vendor: r[2], amount: r[3], category: r[4] })), null, 2)}`;
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { ToolDefinition } from "../types.js";
|
|
2
|
+
import { Database } from "sql.js";
|
|
3
|
+
import { auditChange, getCurrentCompanyId } from "../lib/db.js";
|
|
4
|
+
import { getProvider } from "../lib/llm.js";
|
|
5
|
+
|
|
6
|
+
export const categorizeTool: ToolDefinition = {
|
|
7
|
+
name: "bookmind.categorize",
|
|
8
|
+
description:
|
|
9
|
+
"Auto-categorize Uncategorized QuickBooks transactions using learned rules (regex) + keyword dict + LLM (when connected). Returns confidence-scored suggestions. Use bookmind.approve_categorizations to apply. New categorization requests stage in 'pending' status and follow approval workflow.",
|
|
10
|
+
schema: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: {
|
|
13
|
+
transactions: {
|
|
14
|
+
type: "array",
|
|
15
|
+
items: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
id: { type: "string" },
|
|
19
|
+
qbo_id: { type: "string" },
|
|
20
|
+
date: { type: "string" },
|
|
21
|
+
amount: { type: "number" },
|
|
22
|
+
description: { type: "string" },
|
|
23
|
+
vendor: { type: "string" },
|
|
24
|
+
account_id: { type: "string" },
|
|
25
|
+
},
|
|
26
|
+
required: ["id", "date", "amount", "description"],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
confidence_threshold: { type: "number", default: 0.75 },
|
|
30
|
+
auto_apply: { type: "boolean", default: false },
|
|
31
|
+
},
|
|
32
|
+
required: ["transactions"],
|
|
33
|
+
},
|
|
34
|
+
handler: async (args, db: Database) => {
|
|
35
|
+
const coId = getCurrentCompanyId();
|
|
36
|
+
const { transactions, confidence_threshold = 0.75, auto_apply = false } = args as {
|
|
37
|
+
transactions: Array<{ id: string; qbo_id?: string; date: string; amount: number; description: string; vendor?: string; account_id?: string }>;
|
|
38
|
+
confidence_threshold?: number;
|
|
39
|
+
auto_apply?: boolean;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const ruleRows = db.exec(`SELECT vendor_pattern, description_pattern, category, account_id, confidence, priority FROM categorization_rules WHERE (company_id='${coId}' OR company_id='default') AND (vendor_pattern IS NOT NULL OR description_pattern IS NOT NULL) ORDER BY priority DESC`);
|
|
43
|
+
const rules = ruleRows[0]?.values || [];
|
|
44
|
+
|
|
45
|
+
const provider = getProvider();
|
|
46
|
+
const results = [];
|
|
47
|
+
|
|
48
|
+
for (const txn of transactions) {
|
|
49
|
+
let bestMatch: { category: string; account_id?: string; source: string } | null = null;
|
|
50
|
+
let bestScore = 0;
|
|
51
|
+
|
|
52
|
+
for (const [vendorPattern, descPattern, cat, accId, conf, prio] of rules as any[]) {
|
|
53
|
+
let match = false;
|
|
54
|
+
if (vendorPattern) {
|
|
55
|
+
try { match = txn.vendor ? new RegExp(vendorPattern, "i").test(txn.vendor) : false; } catch { match = txn.vendor?.toLowerCase().includes(String(vendorPattern).toLowerCase()) || false; }
|
|
56
|
+
}
|
|
57
|
+
if (!match && descPattern) {
|
|
58
|
+
try { match = txn.description ? new RegExp(descPattern, "i").test(txn.description) : false; } catch { match = txn.description?.toLowerCase().includes(String(descPattern).toLowerCase()) || false; }
|
|
59
|
+
}
|
|
60
|
+
if (match) {
|
|
61
|
+
const score = (parseFloat(String(conf)) || 0.9) + (parseFloat(String(prio)) || 0) * 0.001;
|
|
62
|
+
if (score > bestScore) {
|
|
63
|
+
bestScore = score;
|
|
64
|
+
bestMatch = { category: String(cat), account_id: accId ? String(accId) : undefined, source: "rule" };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!bestMatch && confidence_threshold <= 0.9) {
|
|
70
|
+
const keywords: Record<string, { category: string; account: string }> = {
|
|
71
|
+
"aws": { category: "Software & Subscriptions", account: "6000" },
|
|
72
|
+
"github": { category: "Software & Subscriptions", account: "6000" },
|
|
73
|
+
"stripe": { category: "Payment Processing Fees", account: "6100" },
|
|
74
|
+
"slack": { category: "Software & Subscriptions", account: "6000" },
|
|
75
|
+
"notion": { category: "Software & Subscriptions", account: "6000" },
|
|
76
|
+
"linear": { category: "Software & Subscriptions", account: "6000" },
|
|
77
|
+
"openai": { category: "AI & ML Services", account: "6000" },
|
|
78
|
+
"anthropic": { category: "AI & ML Services", account: "6000" },
|
|
79
|
+
"digitalocean": { category: "Hosting & Infrastructure", account: "6000" },
|
|
80
|
+
"cloudflare": { category: "Hosting & Infrastructure", account: "6000" },
|
|
81
|
+
"vercel": { category: "Hosting & Infrastructure", account: "6000" },
|
|
82
|
+
"sendgrid": { category: "Email & Marketing", account: "6200" },
|
|
83
|
+
"twilio": { category: "Communication Services", account: "6200" },
|
|
84
|
+
"google": { category: "Advertising & Marketing", account: "6200" },
|
|
85
|
+
"meta": { category: "Advertising & Marketing", account: "6200" },
|
|
86
|
+
"linkedin": { category: "Advertising & Marketing", account: "6200" },
|
|
87
|
+
"uber": { category: "Travel & Transportation", account: "6300" },
|
|
88
|
+
"lyft": { category: "Travel & Transportation", account: "6300" },
|
|
89
|
+
"airbnb": { category: "Travel & Transportation", account: "6300" },
|
|
90
|
+
"restaurant": { category: "Meals & Entertainment", account: "6400" },
|
|
91
|
+
"coffee": { category: "Meals & Entertainment", account: "6400" },
|
|
92
|
+
"lunch": { category: "Meals & Entertainment", account: "6400" },
|
|
93
|
+
"office": { category: "Office Supplies", account: "6500" },
|
|
94
|
+
"staples": { category: "Office Supplies", account: "6500" },
|
|
95
|
+
"amazon": { category: "Office Supplies", account: "6500" },
|
|
96
|
+
"insurance": { category: "Insurance", account: "6600" },
|
|
97
|
+
"legal": { category: "Legal & Professional", account: "6700" },
|
|
98
|
+
"accounting": { category: "Legal & Professional", account: "6700" },
|
|
99
|
+
"lawyer": { category: "Legal & Professional", account: "6700" },
|
|
100
|
+
"contractor": { category: "Contractors & Freelancers", account: "6800" },
|
|
101
|
+
"freelancer": { category: "Contractors & Freelancers", account: "6800" },
|
|
102
|
+
"upwork": { category: "Contractors & Freelancers", account: "6800" },
|
|
103
|
+
"fiverr": { category: "Contractors & Freelancers", account: "6800" },
|
|
104
|
+
};
|
|
105
|
+
const dl = txn.description.toLowerCase();
|
|
106
|
+
const vl = txn.vendor?.toLowerCase() || "";
|
|
107
|
+
for (const [kw, cat] of Object.entries(keywords)) {
|
|
108
|
+
if (dl.includes(kw) || vl.includes(kw)) {
|
|
109
|
+
bestMatch = { category: cat.category, account_id: cat.account, source: "keyword" };
|
|
110
|
+
bestScore = 0.8; break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!bestMatch && provider) {
|
|
116
|
+
try {
|
|
117
|
+
const known = (rules as any[]).map((r: any) => r[2]);
|
|
118
|
+
const unique = [...new Set<string>([...known])];
|
|
119
|
+
const llm = await import("../lib/llm.js");
|
|
120
|
+
const res = await llm.categorizeTransaction(txn.description, txn.vendor || "", txn.amount, unique);
|
|
121
|
+
if (res.category !== "Uncategorized Expenses") {
|
|
122
|
+
bestMatch = { category: res.category, source: "llm" };
|
|
123
|
+
bestScore = res.confidence;
|
|
124
|
+
}
|
|
125
|
+
} catch {}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!bestMatch) {
|
|
129
|
+
bestMatch = { category: "Uncategorized Expenses", account_id: "6900", source: "default" };
|
|
130
|
+
bestScore = 0.3;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const apply = bestScore >= confidence_threshold;
|
|
134
|
+
if (apply) {
|
|
135
|
+
const existing = db.exec(`SELECT status, category FROM transactions WHERE id=? AND company_id=?`);
|
|
136
|
+
const oldCat = existing[0]?.values?.[0]?.[1] || null;
|
|
137
|
+
if (oldCat !== bestMatch.category) {
|
|
138
|
+
auditChange(db, "transaction", txn.id, "category", oldCat ? String(oldCat) : null, String(bestMatch.category), bestMatch.source);
|
|
139
|
+
}
|
|
140
|
+
const status = auto_apply ? "categorized" : "pending";
|
|
141
|
+
db.run(`INSERT INTO transactions (id, company_id, qbo_id, date, amount, description, vendor, account_id, category, confidence, status, approved, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,datetime('now')) ON CONFLICT(id) DO UPDATE SET category=?, account_id=?, confidence=?, status=?, approved=?, updated_at=datetime('now')`,
|
|
142
|
+
[txn.id, coId, txn.qbo_id || null, txn.date, txn.amount, txn.description, txn.vendor || null, bestMatch.account_id || null, bestMatch.category, bestScore, status, auto_apply ? 1 : 0,
|
|
143
|
+
bestMatch.category, bestMatch.account_id || null, bestScore, status, auto_apply ? 1 : 0] as any);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
results.push({
|
|
147
|
+
transaction_id: txn.id,
|
|
148
|
+
qbo_id: txn.qbo_id,
|
|
149
|
+
description: txn.description,
|
|
150
|
+
vendor: txn.vendor,
|
|
151
|
+
amount: txn.amount,
|
|
152
|
+
date: txn.date,
|
|
153
|
+
category: bestMatch.category,
|
|
154
|
+
account_id: bestMatch.account_id,
|
|
155
|
+
confidence: bestScore,
|
|
156
|
+
source: bestMatch.source,
|
|
157
|
+
applied: apply,
|
|
158
|
+
approved: apply && auto_apply,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const categorized = results.filter((r) => r.applied).length;
|
|
163
|
+
const pending = results.filter((r) => r.applied && !r.approved).length;
|
|
164
|
+
return `Categorized ${categorized}/${transactions.length}${pending > 0 ? ` (${pending} pending approval — use bookmind.approve_categorizations)` : ""}.\n\n${JSON.stringify(results, null, 2)}`;
|
|
165
|
+
},
|
|
166
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ToolDefinition } from "../types.js";
|
|
2
|
+
|
|
3
|
+
export const chaseEmailTool: ToolDefinition = {
|
|
4
|
+
name: "bookmind.chase_email",
|
|
5
|
+
description: "Generate overdue invoice chase emails in friendly, firm, or escalation tone. Returns ready-to-send drafts.",
|
|
6
|
+
schema: {
|
|
7
|
+
type: "object",
|
|
8
|
+
properties: {
|
|
9
|
+
invoices: { type: "array", items: { type: "object", properties: { id: { type: "string" }, customer_name: { type: "string" }, customer_email: { type: "string" }, amount: { type: "number" }, due_date: { type: "string" }, days_overdue: { type: "number" }, invoice_number: { type: "string" } }, required: ["id", "customer_name", "amount", "due_date", "invoice_number", "days_overdue"] } },
|
|
10
|
+
tone: { type: "string", enum: ["friendly", "firm", "escalation"], default: "friendly" },
|
|
11
|
+
custom_message: { type: "string" },
|
|
12
|
+
},
|
|
13
|
+
required: ["invoices"],
|
|
14
|
+
},
|
|
15
|
+
handler: async (args) => {
|
|
16
|
+
const { invoices, tone = "friendly", custom_message } = args as any;
|
|
17
|
+
const templates: Record<string, (inv: any) => string> = {
|
|
18
|
+
friendly: (inv: any) => `Hi ${inv.customer_name},\n\nJust a friendly follow-up on invoice **${inv.invoice_number}** for **$${inv.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}**, due **${inv.due_date}** (${inv.days_overdue} days ago).\n\nWe know things get busy — let us know when to expect payment. If already sent, thank you!\n\nBest regards,\nFinance Team`,
|
|
19
|
+
firm: (inv: any) => `Dear ${inv.customer_name},\n\nFormal reminder: invoice **${inv.invoice_number}** for **$${inv.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}** is **${inv.days_overdue} days overdue** (due: ${inv.due_date}).\n\nPlease remit payment immediately or contact us to discuss a payment plan.\n\nRegards,\nAccounts Receivable`,
|
|
20
|
+
escalation: (inv: any) => `URGENT: Invoice ${inv.invoice_number} — ${inv.days_overdue} Days Overdue\n\n${inv.customer_name},\n\nDespite previous reminders, invoice **${inv.invoice_number}** for **$${inv.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}** remains unpaid since **${inv.due_date}**.\n\nThis account may be referred to collections. Remit within **5 business days** or contact us.\n\nFinal Notice,\nCollections Department`,
|
|
21
|
+
};
|
|
22
|
+
const emails = invoices.map((inv: any) => ({
|
|
23
|
+
to: inv.customer_email || "customer@example.com",
|
|
24
|
+
subject: tone === "escalation" ? `URGENT: Invoice ${inv.invoice_number} — ${inv.days_overdue} Days Overdue` : `Follow-up: Invoice ${inv.invoice_number} (${inv.days_overdue} days overdue)`,
|
|
25
|
+
body: (custom_message ? custom_message + "\n\n" : "") + templates[tone](inv),
|
|
26
|
+
invoice_id: inv.id, tone,
|
|
27
|
+
}));
|
|
28
|
+
return `Generated ${emails.length} ${tone} email(s):\n\n${emails.map((e: any) => `--- TO: ${e.to} ---\nSUBJECT: ${e.subject}\n\n${e.body}\n`).join("\n")}`;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ToolDefinition } from "../types.js";
|
|
2
|
+
import { getCurrentCompanyId } from "../lib/db.js";
|
|
3
|
+
import { Database } from "sql.js";
|
|
4
|
+
import { qboClient } from "../lib/qbo.js";
|
|
5
|
+
|
|
6
|
+
export const fetchTransactionsTool: ToolDefinition = {
|
|
7
|
+
name: "bookmind.fetch_transactions",
|
|
8
|
+
description:
|
|
9
|
+
"Fetch transactions from QuickBooks Online for a date range. Returns normalized JSON array compatible with categorize, reconcile, anomalies, and monthly_close tools. Caches results in local SQLite.",
|
|
10
|
+
schema: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: {
|
|
13
|
+
start_date: { type: "string", description: "Start date YYYY-MM-DD" },
|
|
14
|
+
end_date: { type: "string", description: "End date YYYY-MM-DD" },
|
|
15
|
+
fetch: { type: "boolean", default: true, description: "If false, returns cached data only" },
|
|
16
|
+
invoices: { type: "boolean", default: true, description: "Also fetch invoices for this period" },
|
|
17
|
+
bills: { type: "boolean", default: true, description: "Also fetch bills for this period" },
|
|
18
|
+
max_results: { type: "number", default: 500 },
|
|
19
|
+
},
|
|
20
|
+
required: ["start_date", "end_date"],
|
|
21
|
+
},
|
|
22
|
+
handler: async (args, db: Database) => {
|
|
23
|
+
const coId = getCurrentCompanyId();
|
|
24
|
+
const { start_date, end_date, fetch = true, invoices = true, bills = true, max_results = 500 } = args as any;
|
|
25
|
+
|
|
26
|
+
if (!qboClient.isAuthenticated()) return "QBO not connected. Connect using your OAuth URL first. No cached data found.";
|
|
27
|
+
|
|
28
|
+
let transactions: any[] = [];
|
|
29
|
+
|
|
30
|
+
if (fetch) {
|
|
31
|
+
try {
|
|
32
|
+
const qboTxns: any = await qboClient.getTransactions(start_date, end_date, max_results);
|
|
33
|
+
const items = qboTxns.QueryResponse?.Transaction || [];
|
|
34
|
+
|
|
35
|
+
for (const t of items) {
|
|
36
|
+
const id = t.Id || crypto.randomUUID();
|
|
37
|
+
const qboId = t.Id || "";
|
|
38
|
+
const date = t.TxnDate || start_date;
|
|
39
|
+
const total = parseFloat(t.TotalAmt) || 0;
|
|
40
|
+
const vendor = t.VendorRef?.name || t.EntityRef?.name || "";
|
|
41
|
+
const desc = t.Line?.[0]?.Description || t.PrivateNote || "";
|
|
42
|
+
|
|
43
|
+
const existing = db.exec(`SELECT 1 FROM transactions WHERE company_id='${coId}' AND qbo_id='${qboId.replace(/'/g, "''")}'`);
|
|
44
|
+
if (!existing[0]?.values?.length) {
|
|
45
|
+
db.exec(`INSERT INTO transactions (id, company_id, qbo_id, date, amount, description, vendor, status) VALUES ('${id.replace(/'/g, "''")}', '${coId}', '${qboId.replace(/'/g, "''")}', '${date.replace(/'/g, "''")}', ${total}, '${desc.replace(/'/g, "''")}', '${vendor.replace(/'/g, "''")}', 'uncategorized')`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
transactions.push({ id, qbo_id: qboId, date, amount: total, description: desc, vendor });
|
|
49
|
+
}
|
|
50
|
+
} catch (e: any) {
|
|
51
|
+
if (e.message === "QBO_NOT_AUTHENTICATED") return "QBO not authenticated. Run OAuth flow first.";
|
|
52
|
+
return `QBO error: ${e.message || "Unknown error"}`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (invoices) {
|
|
57
|
+
try {
|
|
58
|
+
const qboInvs: any = await qboClient.getInvoices();
|
|
59
|
+
const invs = qboInvs.QueryResponse?.Invoice || [];
|
|
60
|
+
for (const inv of invs) {
|
|
61
|
+
const invId = inv.Id || crypto.randomUUID();
|
|
62
|
+
db.exec(`INSERT INTO invoices (id, company_id, qbo_id, customer_id, customer_name, customer_email, amount, due_date, status, invoice_number, balance) VALUES ('${invId.replace(/'/g, "''")}', '${coId}', '${(inv.Id || "").replace(/'/g, "''")}', ${inv.CustomerRef?.value ? `'${inv.CustomerRef.value.replace(/'/g, "''")}'` : "NULL"}, ${inv.CustomerRef?.name ? `'${inv.CustomerRef.name.replace(/'/g, "''")}'` : "NULL"}, NULL, ${parseFloat(inv.TotalAmt) || 0}, '${(inv.DueDate || end_date).replace(/'/g, "''")}', '${(inv.DocStatus || "Open").replace(/'/g, "''")}', '${(inv.DocNumber || "").replace(/'/g, "''")}', ${parseFloat(inv.Balance) || 0}) ON CONFLICT(qbo_id) DO UPDATE SET status='${(inv.DocStatus || "Open").replace(/'/g, "''")}', balance=${parseFloat(inv.Balance) || 0}`);
|
|
63
|
+
}
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (bills) {
|
|
68
|
+
try {
|
|
69
|
+
const qboBills: any = await qboClient.getBills();
|
|
70
|
+
const bls = qboBills.QueryResponse?.Bill || [];
|
|
71
|
+
for (const bill of bls) {
|
|
72
|
+
db.exec(`INSERT INTO bills (id, company_id, qbo_id, vendor_id, vendor_name, amount, due_date, status) VALUES ('${(bill.Id || crypto.randomUUID()).replace(/'/g, "''")}', '${coId}', '${(bill.Id || "").replace(/'/g, "''")}', ${bill.VendorRef?.value ? `'${bill.VendorRef.value.replace(/'/g, "''")}'` : "NULL"}, ${bill.VendorRef?.name ? `'${bill.VendorRef.name.replace(/'/g, "''")}'` : "NULL"}, ${parseFloat(bill.TotalAmt) || 0}, '${(bill.DueDate || end_date).replace(/'/g, "''")}', '${(bill.DocStatus || "Open").replace(/'/g, "''")}') ON CONFLICT(qbo_id) DO UPDATE SET status='${(bill.DocStatus || "Open").replace(/'/g, "''")}'`);
|
|
73
|
+
}
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return `Fetched ${transactions.length} transactions from QBO${invoices ? " (invoices synced)" : ""}${bills ? " (bills synced)" : ""}.\n\n${JSON.stringify(transactions.slice(0, 50), null, 2)}${transactions.length > 50 ? `\n... and ${transactions.length - 50} more` : ""}`;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ToolDefinition } from "../types.js";
|
|
2
|
+
import { Database } from "sql.js";
|
|
3
|
+
import { getCurrentCompanyId, auditChange } from "../lib/db.js";
|
|
4
|
+
|
|
5
|
+
export const learnRulesTool: ToolDefinition = {
|
|
6
|
+
name: "bookmind.learn_rules",
|
|
7
|
+
description:
|
|
8
|
+
"Learn categorization rules from user corrections. Stores regex patterns. Rules apply to current company by default; set global=true to share across all companies under one account.",
|
|
9
|
+
schema: {
|
|
10
|
+
type: "object",
|
|
11
|
+
properties: {
|
|
12
|
+
rule: {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: {
|
|
15
|
+
vendor_pattern: { type: "string" },
|
|
16
|
+
description_pattern: { type: "string" },
|
|
17
|
+
category: { type: "string" },
|
|
18
|
+
account_id: { type: "string" },
|
|
19
|
+
confidence: { type: "number", minimum: 0, maximum: 1, default: 0.9 },
|
|
20
|
+
priority: { type: "number", default: 0 },
|
|
21
|
+
global: { type: "boolean", default: false },
|
|
22
|
+
},
|
|
23
|
+
required: ["category"],
|
|
24
|
+
},
|
|
25
|
+
transactions_to_apply: {
|
|
26
|
+
type: "array",
|
|
27
|
+
items: { type: "object", properties: { id: { type: "string" }, vendor: { type: "string" }, description: { type: "string" } }, required: ["id"] },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
required: ["rule"],
|
|
31
|
+
},
|
|
32
|
+
handler: async (args, db: Database) => {
|
|
33
|
+
const coId = getCurrentCompanyId();
|
|
34
|
+
const { rule, transactions_to_apply = [] } = args as any;
|
|
35
|
+
const { vendor_pattern, description_pattern, category, account_id, confidence = 0.9, priority = 0, global = false } = rule;
|
|
36
|
+
const ruleCompanyId = global ? "default" : coId;
|
|
37
|
+
|
|
38
|
+
const ruleId = crypto.randomUUID();
|
|
39
|
+
db.run(
|
|
40
|
+
`INSERT INTO categorization_rules (id, company_id, vendor_pattern, description_pattern, category, account_id, confidence, priority, created_at, uses) VALUES (?,?,?,?,?,?,?,?,?,0)`,
|
|
41
|
+
[ruleId, ruleCompanyId, vendor_pattern || null, description_pattern || null, category, account_id || null, confidence, priority, new Date().toISOString()] as any
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
let applied = 0;
|
|
45
|
+
if (transactions_to_apply.length > 0) {
|
|
46
|
+
for (const t of transactions_to_apply) {
|
|
47
|
+
let match = false;
|
|
48
|
+
if (vendor_pattern) {
|
|
49
|
+
try { match = t.vendor && new RegExp(vendor_pattern, "i").test(t.vendor); } catch { match = t.vendor?.toLowerCase().includes(vendor_pattern.toLowerCase()) || false; }
|
|
50
|
+
}
|
|
51
|
+
if (!match && description_pattern) {
|
|
52
|
+
try { match = t.description && new RegExp(description_pattern, "i").test(t.description); } catch { match = t.description?.toLowerCase().includes(description_pattern.toLowerCase()) || false; }
|
|
53
|
+
}
|
|
54
|
+
if (match) {
|
|
55
|
+
const rows = db.exec(`SELECT category FROM transactions WHERE id=? AND company_id=?`);
|
|
56
|
+
const oldCat = rows[0]?.values?.[0]?.[0] || null;
|
|
57
|
+
if (oldCat !== category) {
|
|
58
|
+
auditChange(db, "transaction", t.id, "category", oldCat ? String(oldCat) : null, category, "learn_rule");
|
|
59
|
+
}
|
|
60
|
+
db.exec(`UPDATE transactions SET category='${category.replace(/'/g, "''")}', confidence=${confidence}, status='categorized', approved=1, updated_at=datetime('now') WHERE id='${t.id.replace(/'/g, "''")}' AND company_id='${coId}'`);
|
|
61
|
+
applied++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (applied > 0) {
|
|
65
|
+
db.run(`UPDATE categorization_rules SET uses = uses + ?, last_applied = datetime('now') WHERE id = ?`, [applied, ruleId] as any);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return `✅ Rule learned: "${vendor_pattern || description_pattern}" → ${category} (priority: ${priority}, confidence: ${confidence})\nApplied to ${applied} existing transaction(s).`;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ToolDefinition } from "../types.js";
|
|
2
|
+
import { Database } from "sql.js";
|
|
3
|
+
|
|
4
|
+
export const listCompaniesTool: ToolDefinition = {
|
|
5
|
+
name: "bookmind.list_companies",
|
|
6
|
+
description:
|
|
7
|
+
"List all companies configured in BookMind, showing company ID, name, and association status. Allows OAuth connection for unconnected companies.",
|
|
8
|
+
schema: {
|
|
9
|
+
type: "object",
|
|
10
|
+
properties: {
|
|
11
|
+
oauth_url: { type: "boolean", default: false, description: "If true, returns OAuth auth URL to connect a new QBO company" },
|
|
12
|
+
company_id: { type: "string", description: "If oauth_url is true, the company ID to connect" },
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
handler: async (args, db: Database) => {
|
|
16
|
+
const { oauth_url, company_id } = args as any;
|
|
17
|
+
|
|
18
|
+
if (oauth_url) {
|
|
19
|
+
const cid = company_id || "new";
|
|
20
|
+
const { qboClient } = await import("../lib/qbo.js");
|
|
21
|
+
const url = qboClient.getAuthUrl();
|
|
22
|
+
return `OAuth URL for company '${cid}':\n${url}\n\nVisit this URL, authorize access, then paste the callback code.\nRedirect URI: ${process.env.QBO_REDIRECT_URI || "http://localhost:3000/callback"}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const rows: any = db.exec("SELECT id, name, realm_id FROM companies WHERE realm_id IS NOT NULL ORDER BY created_at DESC");
|
|
26
|
+
const companies: any = rows[0]?.values || [];
|
|
27
|
+
if (companies.length === 0) return "No companies configured yet. Use bookmind.list_companies with oauth_url=true to connect one.";
|
|
28
|
+
return `Connected companies:\n\n${companies.map(([id, name, realmId]: any) => `- ${name} (ID: ${id}${realmId ? `, Realm: ${realmId}` : ""})`).join("\n")}\n\nUse bookmind.select_company to switch.`;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { ToolDefinition } from "../types.js";
|
|
2
|
+
import { getCurrentCompanyId } from "../lib/db.js";
|
|
3
|
+
|
|
4
|
+
export const monthlyCloseTool: ToolDefinition = {
|
|
5
|
+
name: "bookmind.monthly_close",
|
|
6
|
+
description:
|
|
7
|
+
"Generate complete monthly close package: P&L, Balance Sheet snapshot, Cash Flow, top vendors, expense breakdown, overdue AR, anomaly summary, and action items. Returns formatted markdown report.",
|
|
8
|
+
schema: {
|
|
9
|
+
type: "object",
|
|
10
|
+
properties: {
|
|
11
|
+
transactions: {
|
|
12
|
+
type: "array",
|
|
13
|
+
items: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: { id: { type: "string" }, date: { type: "string" }, amount: { type: "number" }, description: { type: "string" }, vendor: { type: "string" }, category: { type: "string" }, account_id: { type: "string" } },
|
|
16
|
+
required: ["id", "date", "amount", "category"],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
invoices: { type: "array", items: { type: "object", properties: { id: { type: "string" }, amount: { type: "number" }, status: { type: "string" }, due_date: { type: "string" } }, required: ["id", "amount", "status"] } },
|
|
20
|
+
month: { type: "string", pattern: "^\\d{4}-\\d{2}$" },
|
|
21
|
+
anomalies: { type: "array", items: { type: "object" } },
|
|
22
|
+
},
|
|
23
|
+
required: ["transactions", "month"],
|
|
24
|
+
},
|
|
25
|
+
handler: async (args) => {
|
|
26
|
+
const { transactions, invoices = [], month, anomalies = [] } = args as any;
|
|
27
|
+
const [year, monthNum] = month.split("-").map(Number);
|
|
28
|
+
const monthStart = new Date(year, monthNum - 1, 1);
|
|
29
|
+
const monthEnd = new Date(year, monthNum, 0, 23, 59, 59);
|
|
30
|
+
|
|
31
|
+
const monthTxns = (transactions as any[]).filter((t: any) => new Date(t.date) >= monthStart && new Date(t.date) <= monthEnd);
|
|
32
|
+
const revenue = monthTxns.filter((t: any) => t.amount > 0).reduce((s: number, t: any) => s + t.amount || 0, 0);
|
|
33
|
+
const expenses = monthTxns.filter((t: any) => t.amount < 0).reduce((s: number, t: any) => s + Math.abs(t.amount || 0), 0);
|
|
34
|
+
const netIncome = revenue - expenses;
|
|
35
|
+
|
|
36
|
+
const byCategory: Record<string, { revenue: number; expenses: number }> = {};
|
|
37
|
+
for (const t of monthTxns) {
|
|
38
|
+
const cat = t.category || "Uncategorized";
|
|
39
|
+
if (!byCategory[cat]) byCategory[cat] = { revenue: 0, expenses: 0 };
|
|
40
|
+
if (t.amount > 0) byCategory[cat].revenue += t.amount; else byCategory[cat].expenses += Math.abs(t.amount);
|
|
41
|
+
}
|
|
42
|
+
const sortedCats = Object.entries(byCategory).sort((a, b) => (b[1].revenue - b[1].expenses) - (a[1].revenue - a[1].expenses));
|
|
43
|
+
|
|
44
|
+
const vendorTotals: Record<string, { amount: number; count: number }> = {};
|
|
45
|
+
for (const t of monthTxns.filter((t: any) => t.amount < 0)) {
|
|
46
|
+
const v = t.vendor || "Unknown";
|
|
47
|
+
if (!vendorTotals[v]) vendorTotals[v] = { amount: 0, count: 0 };
|
|
48
|
+
vendorTotals[v].amount += Math.abs(t.amount);
|
|
49
|
+
vendorTotals[v].count++;
|
|
50
|
+
}
|
|
51
|
+
const topVendors = Object.entries(vendorTotals).sort((a, b) => b[1].amount - a[1].amount).slice(0, 20);
|
|
52
|
+
|
|
53
|
+
const unreconciled = monthTxns.filter((t: any) => t.amount < 0 && (!t.category || t.category === "Uncategorized Expenses")).length;
|
|
54
|
+
const overdueInvoices = (invoices as any[]).filter((inv: any) => inv.status !== "Paid" && new Date(inv.due_date) < new Date());
|
|
55
|
+
const arTotal = overdueInvoices.reduce((s: number, inv: any) => s + (inv.amount || 0), 0);
|
|
56
|
+
|
|
57
|
+
const report = `# Monthly Close Package — ${month}
|
|
58
|
+
|
|
59
|
+
## Executive Summary
|
|
60
|
+
- **Revenue**: $${revenue.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
|
61
|
+
- **Expenses**: $${expenses.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
|
62
|
+
- **Net Income**: $${netIncome.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
|
63
|
+
- **Transactions Processed**: ${monthTxns.length} (${unreconciled} uncategorized)
|
|
64
|
+
- **Anomalies Flagged**: ${anomalies.length}
|
|
65
|
+
- **Overdue AR**: $${arTotal.toLocaleString(undefined, { minimumFractionDigits: 2 })} (${overdueInvoices.length} invoices)
|
|
66
|
+
|
|
67
|
+
## Profit & Loss by Category
|
|
68
|
+
| Category | Revenue | Expenses | Net |
|
|
69
|
+
|----------|---------|----------|-----|
|
|
70
|
+
${sortedCats.map(([cat, vals]) => `| ${cat} | $${vals.revenue.toLocaleString(undefined, { minimumFractionDigits: 2 })} | $${vals.expenses.toLocaleString(undefined, { minimumFractionDigits: 2 })} | $${(vals.revenue - vals.expenses).toLocaleString(undefined, { minimumFractionDigits: 2 })} |`).join("\n")}
|
|
71
|
+
|
|
72
|
+
## Top 10 Vendors by Spend
|
|
73
|
+
| Vendor | Amount | % |
|
|
74
|
+
|--------|--------|---|
|
|
75
|
+
${topVendors.slice(0, 10).map(([v, d]) => `| ${v} | $${d.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })} | ${((d.amount / (expenses || 1)) * 100).toFixed(1)}% |`).join("\n")}
|
|
76
|
+
|
|
77
|
+
## Cash Flow
|
|
78
|
+
- **Inflow**: $${revenue.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
|
79
|
+
- **Outflow**: $${expenses.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
|
80
|
+
- **Net**: $${(revenue - expenses).toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
|
81
|
+
|
|
82
|
+
## Anomalies
|
|
83
|
+
${anomalies.length > 0 ? (anomalies as any[]).map((a: any) => `- **${a.severity.toUpperCase()}**: ${a.description}`).join("\n") : "None"}
|
|
84
|
+
|
|
85
|
+
## Action Items
|
|
86
|
+
${unreconciled > 0 ? `- 🔴 Categorize ${unreconciled} unreconciled transactions` : "- ✅ All categorized"}
|
|
87
|
+
${overdueInvoices.length > 0 ? `- 🟡 Follow up on ${overdueInvoices.length} overdue invoices ($${arTotal.toLocaleString()})` : "- ✅ No overdue"}
|
|
88
|
+
${anomalies.filter((a: any) => a.severity === "high").length > 0 ? `- 🔴 Review ${anomalies.filter((a: any) => a.severity === "high").length} high-severity anomalies` : ""}
|
|
89
|
+
${anomalies.filter((a: any) => a.severity === "medium").length > 0 ? `- 🟡 Review ${anomalies.filter((a: any) => a.severity === "medium").length} medium-severity anomalies` : ""}
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
*Generated by BookMind on ${new Date().toISOString().split("T")[0]}*`;
|
|
93
|
+
return report;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { ToolDefinition } from "../types.js";
|
|
2
|
+
import { getCurrentCompanyId } from "../lib/db.js";
|
|
3
|
+
|
|
4
|
+
export const reconcileTool: ToolDefinition = {
|
|
5
|
+
name: "bookmind.reconcile",
|
|
6
|
+
description:
|
|
7
|
+
"Match bank feed transactions to invoices/bills by amount, date tolerance, and vendor similarity. Returns matched pairs and unmatched items. Score uses Levenshtein vendor similarity and weighted date/amount distance.",
|
|
8
|
+
schema: {
|
|
9
|
+
type: "object",
|
|
10
|
+
properties: {
|
|
11
|
+
bank_transactions: {
|
|
12
|
+
type: "array",
|
|
13
|
+
items: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: { id: { type: "string" }, date: { type: "string" }, amount: { type: "number" }, description: { type: "string" }, vendor: { type: "string" } },
|
|
16
|
+
required: ["id", "date", "amount"],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
invoices: {
|
|
20
|
+
type: "array",
|
|
21
|
+
items: {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: { id: { type: "string" }, qbo_id: { type: "string" }, customer_name: { type: "string" }, amount: { type: "number" }, due_date: { type: "string" }, status: { type: "string" } },
|
|
24
|
+
required: ["id", "amount", "due_date"],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
bills: {
|
|
28
|
+
type: "array",
|
|
29
|
+
items: {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: { id: { type: "string" }, qbo_id: { type: "string" }, vendor_name: { type: "string" }, amount: { type: "number" }, due_date: { type: "string" }, status: { type: "string" } },
|
|
32
|
+
required: ["id", "amount", "due_date"],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
date_tolerance_days: { type: "number", default: 3 },
|
|
36
|
+
amount_tolerance: { type: "number", default: 0.01 },
|
|
37
|
+
fee_tolerance: { type: "number", default: 0.05 },
|
|
38
|
+
},
|
|
39
|
+
required: ["bank_transactions"],
|
|
40
|
+
},
|
|
41
|
+
handler: async (args, db) => {
|
|
42
|
+
const coId = getCurrentCompanyId();
|
|
43
|
+
const { bank_transactions, invoices = [], bills = [], date_tolerance_days = 3, amount_tolerance = 0.01, fee_tolerance = 0.05 } = args as any;
|
|
44
|
+
const matched = [];
|
|
45
|
+
const unmatched = [];
|
|
46
|
+
|
|
47
|
+
for (const txn of bank_transactions) {
|
|
48
|
+
let bestMatch: any = null;
|
|
49
|
+
let bestScore = 0;
|
|
50
|
+
|
|
51
|
+
if (txn.amount > 0) {
|
|
52
|
+
for (const inv of invoices) {
|
|
53
|
+
if (inv.status === "Paid") continue;
|
|
54
|
+
const amountDiff = Math.abs(txn.amount - inv.amount) / Math.max(Math.abs(inv.amount), 1);
|
|
55
|
+
const dateDiff = Math.abs(new Date(txn.date).getTime() - new Date(inv.due_date).getTime()) / (1000 * 60 * 60 * 24);
|
|
56
|
+
if (amountDiff <= fee_tolerance && dateDiff <= date_tolerance_days) {
|
|
57
|
+
const score = 1 - amountDiff * 0.4 - Math.min(dateDiff / date_tolerance_days, 1) * 0.6;
|
|
58
|
+
if (score > bestScore) {
|
|
59
|
+
bestScore = score;
|
|
60
|
+
bestMatch = { type: "invoice", qbo_id: inv.qbo_id, invoice_id: inv.id, score: bestScore };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
for (const bill of bills) {
|
|
66
|
+
if (bill.status === "Paid") continue;
|
|
67
|
+
const amountDiff = Math.abs(Math.abs(txn.amount) - bill.amount) / Math.max(bill.amount, 1);
|
|
68
|
+
const dateDiff = Math.abs(new Date(txn.date).getTime() - new Date(bill.due_date).getTime()) / (1000 * 60 * 60 * 24);
|
|
69
|
+
if (amountDiff <= fee_tolerance && dateDiff <= date_tolerance_days) {
|
|
70
|
+
const score = 1 - amountDiff * 0.4 - Math.min(dateDiff / date_tolerance_days, 1) * 0.6;
|
|
71
|
+
if (score > bestScore) {
|
|
72
|
+
bestScore = score;
|
|
73
|
+
bestMatch = { type: "bill", qbo_id: bill.qbo_id, bill_id: bill.id, score: bestScore };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (bestMatch && bestScore > 0.5) {
|
|
79
|
+
matched.push({ transaction: txn, match: bestMatch });
|
|
80
|
+
} else {
|
|
81
|
+
unmatched.push(txn);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return `Reconciled: ${matched.length} matched, ${unmatched.length} unmatched.\n\nMATCHED:\n${JSON.stringify(matched, null, 2)}\n\nUNMATCHED (need review):\n${JSON.stringify(unmatched, null, 2)}`;
|
|
85
|
+
},
|
|
86
|
+
};
|