financialclaw 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/README.es.md +72 -0
- package/README.md +65 -0
- package/bin/financialclaw-setup.mjs +139 -0
- package/bin/postinstall-message.mjs +17 -0
- package/dist/src/db/database.d.ts +3 -0
- package/dist/src/db/database.js +58 -0
- package/dist/src/db/schema.d.ts +16 -0
- package/dist/src/db/schema.js +154 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +112 -0
- package/dist/src/services/daily-sync.d.ts +16 -0
- package/dist/src/services/daily-sync.js +135 -0
- package/dist/src/tools/add-recurring-expense.d.ts +16 -0
- package/dist/src/tools/add-recurring-expense.js +126 -0
- package/dist/src/tools/get-financial-summary.d.ts +8 -0
- package/dist/src/tools/get-financial-summary.js +158 -0
- package/dist/src/tools/helpers/currency-utils.d.ts +17 -0
- package/dist/src/tools/helpers/currency-utils.js +47 -0
- package/dist/src/tools/helpers/date-utils.d.ts +8 -0
- package/dist/src/tools/helpers/date-utils.js +102 -0
- package/dist/src/tools/list-expenses.d.ts +16 -0
- package/dist/src/tools/list-expenses.js +142 -0
- package/dist/src/tools/list-incomes.d.ts +12 -0
- package/dist/src/tools/list-incomes.js +124 -0
- package/dist/src/tools/log-expense-from-receipt.d.ts +13 -0
- package/dist/src/tools/log-expense-from-receipt.js +91 -0
- package/dist/src/tools/log-expense-manual.d.ts +12 -0
- package/dist/src/tools/log-expense-manual.js +60 -0
- package/dist/src/tools/log-income-receipt.d.ts +11 -0
- package/dist/src/tools/log-income-receipt.js +103 -0
- package/dist/src/tools/log-income.d.ts +13 -0
- package/dist/src/tools/log-income.js +103 -0
- package/dist/src/tools/manage-currency.d.ts +10 -0
- package/dist/src/tools/manage-currency.js +104 -0
- package/dist/src/tools/mark-expense-paid.d.ts +8 -0
- package/dist/src/tools/mark-expense-paid.js +44 -0
- package/dist/src/tools/run-daily-sync.d.ts +5 -0
- package/dist/src/tools/run-daily-sync.js +85 -0
- package/dist/src/version.d.ts +2 -0
- package/dist/src/version.js +2 -0
- package/openclaw.plugin.json +24 -0
- package/package.json +52 -0
- package/skills/financialclaw/SKILL.md +257 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import { Value } from "@sinclair/typebox/value";
|
|
3
|
+
import { getDb } from "../db/database.js";
|
|
4
|
+
import { resolvePeriodRange } from "./helpers/date-utils.js";
|
|
5
|
+
import { resolveCurrency, formatAmount } from "./helpers/currency-utils.js";
|
|
6
|
+
export const InputSchema = Type.Object({
|
|
7
|
+
period: Type.Optional(Type.Union([
|
|
8
|
+
Type.Literal("this_month"),
|
|
9
|
+
Type.Literal("last_month"),
|
|
10
|
+
Type.Literal("last_30_days"),
|
|
11
|
+
Type.Literal("this_year"),
|
|
12
|
+
Type.Literal("all"),
|
|
13
|
+
])),
|
|
14
|
+
start_date: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })),
|
|
15
|
+
end_date: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })),
|
|
16
|
+
category: Type.Optional(Type.String()),
|
|
17
|
+
status: Type.Optional(Type.Union([
|
|
18
|
+
Type.Literal("PENDING"),
|
|
19
|
+
Type.Literal("PAID"),
|
|
20
|
+
Type.Literal("OVERDUE"),
|
|
21
|
+
])),
|
|
22
|
+
search: Type.Optional(Type.String()),
|
|
23
|
+
currency: Type.Optional(Type.String()),
|
|
24
|
+
source: Type.Optional(Type.Union([
|
|
25
|
+
Type.Literal("MANUAL"),
|
|
26
|
+
Type.Literal("OCR"),
|
|
27
|
+
])),
|
|
28
|
+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 50 })),
|
|
29
|
+
offset: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
30
|
+
}, { additionalProperties: false });
|
|
31
|
+
function assertValidInput(input) {
|
|
32
|
+
if (!Value.Check(InputSchema, input)) {
|
|
33
|
+
throw new Error("Invalid parameters: check the types of period, status, source, limit, offset, and date format (YYYY-MM-DD).");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function validateDateRange(startDate, endDate) {
|
|
37
|
+
if (startDate !== undefined && endDate === undefined) {
|
|
38
|
+
throw new Error("If you provide start_date, you must also provide end_date.");
|
|
39
|
+
}
|
|
40
|
+
if (startDate === undefined && endDate !== undefined) {
|
|
41
|
+
throw new Error("If you provide end_date, you must also provide start_date.");
|
|
42
|
+
}
|
|
43
|
+
if (startDate !== undefined && endDate !== undefined) {
|
|
44
|
+
if (startDate > endDate) {
|
|
45
|
+
throw new Error("start_date cannot be greater than end_date.");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function executeListExpenses(input, db = getDb()) {
|
|
50
|
+
assertValidInput(input);
|
|
51
|
+
const limit = input.limit ?? 20;
|
|
52
|
+
const offset = input.offset ?? 0;
|
|
53
|
+
validateDateRange(input.start_date, input.end_date);
|
|
54
|
+
let dateRange = null;
|
|
55
|
+
if (input.start_date && input.end_date) {
|
|
56
|
+
dateRange = { start: input.start_date, end: input.end_date };
|
|
57
|
+
}
|
|
58
|
+
else if (input.period) {
|
|
59
|
+
dateRange = resolvePeriodRange(input.period);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
dateRange = resolvePeriodRange("this_month");
|
|
63
|
+
}
|
|
64
|
+
const currencyRow = input.currency
|
|
65
|
+
? resolveCurrency(input.currency, db)
|
|
66
|
+
: null;
|
|
67
|
+
const params = [];
|
|
68
|
+
const conditions = ["is_active = 1"];
|
|
69
|
+
if (dateRange) {
|
|
70
|
+
conditions.push("due_date >= ? AND due_date <= ?");
|
|
71
|
+
params.push(dateRange.start, dateRange.end);
|
|
72
|
+
}
|
|
73
|
+
if (input.category) {
|
|
74
|
+
conditions.push("category = ?");
|
|
75
|
+
params.push(input.category.toUpperCase());
|
|
76
|
+
}
|
|
77
|
+
if (input.status) {
|
|
78
|
+
conditions.push("status = ?");
|
|
79
|
+
params.push(input.status);
|
|
80
|
+
}
|
|
81
|
+
if (input.search) {
|
|
82
|
+
const searchTerm = `%${input.search}%`;
|
|
83
|
+
conditions.push("(description LIKE ? OR merchant LIKE ?)");
|
|
84
|
+
params.push(searchTerm, searchTerm);
|
|
85
|
+
}
|
|
86
|
+
if (currencyRow) {
|
|
87
|
+
conditions.push("currency = ?");
|
|
88
|
+
params.push(currencyRow.code);
|
|
89
|
+
}
|
|
90
|
+
if (input.source) {
|
|
91
|
+
conditions.push("source = ?");
|
|
92
|
+
params.push(input.source);
|
|
93
|
+
}
|
|
94
|
+
const whereClause = conditions.join(" AND ");
|
|
95
|
+
const countSql = `SELECT COUNT(*) as total FROM expenses WHERE ${whereClause}`;
|
|
96
|
+
const countResult = db.prepare(countSql).get(...params);
|
|
97
|
+
const total = countResult.total;
|
|
98
|
+
const orderClause = "ORDER BY due_date DESC";
|
|
99
|
+
const paginatedParams = [...params, limit, offset];
|
|
100
|
+
const sql = `SELECT id, amount, currency, category, merchant, description,
|
|
101
|
+
due_date, payment_date, status, source, created_at
|
|
102
|
+
FROM expenses
|
|
103
|
+
WHERE ${whereClause}
|
|
104
|
+
${orderClause}
|
|
105
|
+
LIMIT ? OFFSET ?`;
|
|
106
|
+
const rows = db.prepare(sql).all(...paginatedParams);
|
|
107
|
+
if (rows.length === 0) {
|
|
108
|
+
const hint = total === 0
|
|
109
|
+
? "No expenses recorded."
|
|
110
|
+
: `No expenses on the requested page (total: ${total}).`;
|
|
111
|
+
return `${hint}\n\nTry different filters or change the offset.`;
|
|
112
|
+
}
|
|
113
|
+
const currencyCache = new Map();
|
|
114
|
+
function getCurrencyRow(code) {
|
|
115
|
+
if (!currencyCache.has(code)) {
|
|
116
|
+
currencyCache.set(code, resolveCurrency(code, db));
|
|
117
|
+
}
|
|
118
|
+
return currencyCache.get(code);
|
|
119
|
+
}
|
|
120
|
+
const lines = [
|
|
121
|
+
`📋 Expenses (${rows.length} of ${total} total)`,
|
|
122
|
+
"",
|
|
123
|
+
];
|
|
124
|
+
for (const row of rows) {
|
|
125
|
+
const curr = getCurrencyRow(row.currency);
|
|
126
|
+
const formattedAmount = formatAmount(row.amount, curr, db);
|
|
127
|
+
const date = row.due_date;
|
|
128
|
+
const category = row.category || "UNCATEGORIZED";
|
|
129
|
+
const merchant = row.merchant ? ` - ${row.merchant}` : "";
|
|
130
|
+
const statusIcon = row.status === "PAID" ? "✓" : row.status === "OVERDUE" ? "⚠" : "○";
|
|
131
|
+
const sourceTag = row.source === "OCR" ? " [OCR]" : "";
|
|
132
|
+
lines.push(`${date} ${statusIcon} ${formattedAmount}${sourceTag}`, ` ${row.description}${merchant}`, ` ${category} • ${row.status}`, ` ID: ${row.id}`, "");
|
|
133
|
+
}
|
|
134
|
+
if (total > limit + offset) {
|
|
135
|
+
const nextOffset = offset + limit;
|
|
136
|
+
lines.push(`→ More results available. Use offset=${nextOffset} to see the next page.`);
|
|
137
|
+
}
|
|
138
|
+
else if (offset > 0) {
|
|
139
|
+
lines.push("← End of results.");
|
|
140
|
+
}
|
|
141
|
+
return lines.join("\n");
|
|
142
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { type Static } from "@sinclair/typebox";
|
|
3
|
+
export declare const InputSchema: import("@sinclair/typebox").TObject<{
|
|
4
|
+
recurring: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
5
|
+
search: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
6
|
+
currency: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
7
|
+
limit: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
|
|
8
|
+
offset: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
|
|
9
|
+
include_receipts: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
10
|
+
}>;
|
|
11
|
+
export type Input = Static<typeof InputSchema>;
|
|
12
|
+
export declare function executeListIncomes(input: Input, db?: DatabaseSync): string;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import { Value } from "@sinclair/typebox/value";
|
|
3
|
+
import { getDb } from "../db/database.js";
|
|
4
|
+
import { resolveCurrency, formatAmount } from "./helpers/currency-utils.js";
|
|
5
|
+
export const InputSchema = Type.Object({
|
|
6
|
+
recurring: Type.Optional(Type.Boolean()),
|
|
7
|
+
search: Type.Optional(Type.String()),
|
|
8
|
+
currency: Type.Optional(Type.String()),
|
|
9
|
+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 50 })),
|
|
10
|
+
offset: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
11
|
+
include_receipts: Type.Optional(Type.Boolean()),
|
|
12
|
+
}, { additionalProperties: false });
|
|
13
|
+
function assertValidInput(input) {
|
|
14
|
+
if (!Value.Check(InputSchema, input)) {
|
|
15
|
+
throw new Error("Invalid parameters: check the types of recurring, search, currency, limit, offset, and include_receipts.");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function executeListIncomes(input, db = getDb()) {
|
|
19
|
+
assertValidInput(input);
|
|
20
|
+
const limit = input.limit ?? 20;
|
|
21
|
+
const offset = input.offset ?? 0;
|
|
22
|
+
const includeReceipts = input.include_receipts ?? false;
|
|
23
|
+
if (input.currency !== undefined) {
|
|
24
|
+
resolveCurrency(input.currency, db);
|
|
25
|
+
}
|
|
26
|
+
const conditions = ["1=1"];
|
|
27
|
+
const params = [];
|
|
28
|
+
if (input.recurring !== undefined) {
|
|
29
|
+
conditions.push(`is_recurring = ?`);
|
|
30
|
+
params.push(input.recurring ? 1 : 0);
|
|
31
|
+
}
|
|
32
|
+
if (input.search !== undefined && input.search.trim().length > 0) {
|
|
33
|
+
conditions.push(`reason LIKE ?`);
|
|
34
|
+
params.push(`%${input.search.trim()}%`);
|
|
35
|
+
}
|
|
36
|
+
if (input.currency !== undefined) {
|
|
37
|
+
conditions.push(`currency = ?`);
|
|
38
|
+
params.push(input.currency.trim().toUpperCase());
|
|
39
|
+
}
|
|
40
|
+
const whereClause = conditions.join(" AND ");
|
|
41
|
+
const countSql = `SELECT COUNT(*) as total FROM incomes WHERE ${whereClause}`;
|
|
42
|
+
const countResult = db.prepare(countSql).get(...params);
|
|
43
|
+
const total = countResult.total;
|
|
44
|
+
const listSql = `
|
|
45
|
+
SELECT id, reason, expected_amount, currency, date, frequency,
|
|
46
|
+
interval_days, is_recurring, next_expected_receipt_date,
|
|
47
|
+
is_active, created_at
|
|
48
|
+
FROM incomes
|
|
49
|
+
WHERE ${whereClause}
|
|
50
|
+
ORDER BY created_at DESC
|
|
51
|
+
LIMIT ? OFFSET ?
|
|
52
|
+
`;
|
|
53
|
+
const incomes = db.prepare(listSql).all(...params, limit, offset);
|
|
54
|
+
if (incomes.length === 0) {
|
|
55
|
+
return `No income entries recorded${total > 0 ? ` (total: ${total})` : ""}.`;
|
|
56
|
+
}
|
|
57
|
+
const currencyCache = new Map();
|
|
58
|
+
const getCurrency = (code) => {
|
|
59
|
+
if (!currencyCache.has(code)) {
|
|
60
|
+
currencyCache.set(code, resolveCurrency(code, db));
|
|
61
|
+
}
|
|
62
|
+
return currencyCache.get(code);
|
|
63
|
+
};
|
|
64
|
+
let receiptsByIncomeId;
|
|
65
|
+
if (includeReceipts) {
|
|
66
|
+
const incomeIds = incomes.map((i) => i.id);
|
|
67
|
+
if (incomeIds.length > 0) {
|
|
68
|
+
const placeholders = incomeIds.map(() => "?").join(",");
|
|
69
|
+
const receiptsSql = `
|
|
70
|
+
SELECT id, income_id, amount, currency, date, notes, created_at FROM (
|
|
71
|
+
SELECT *, ROW_NUMBER() OVER (PARTITION BY income_id ORDER BY date DESC) as rn
|
|
72
|
+
FROM income_receipts
|
|
73
|
+
WHERE income_id IN (${placeholders})
|
|
74
|
+
) WHERE rn <= 5
|
|
75
|
+
ORDER BY income_id, date DESC
|
|
76
|
+
`;
|
|
77
|
+
const allReceipts = db.prepare(receiptsSql).all(...incomeIds);
|
|
78
|
+
receiptsByIncomeId = new Map();
|
|
79
|
+
for (const receipt of allReceipts) {
|
|
80
|
+
const existing = receiptsByIncomeId.get(receipt.income_id) ?? [];
|
|
81
|
+
existing.push(receipt);
|
|
82
|
+
receiptsByIncomeId.set(receipt.income_id, existing);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const lines = [];
|
|
87
|
+
lines.push(`📋 Income (${incomes.length}${total > limit ? ` of ${total}` : ""}):`);
|
|
88
|
+
for (const income of incomes) {
|
|
89
|
+
const currency = getCurrency(income.currency);
|
|
90
|
+
const formattedAmount = formatAmount(income.expected_amount, currency);
|
|
91
|
+
const recurrence = income.is_recurring === 1
|
|
92
|
+
? ` [${income.frequency}${income.interval_days ? ` (every ${income.interval_days} days)` : ""}]`
|
|
93
|
+
: "";
|
|
94
|
+
const nextDate = income.next_expected_receipt_date
|
|
95
|
+
? ` | Next: ${income.next_expected_receipt_date}`
|
|
96
|
+
: "";
|
|
97
|
+
const status = income.is_active === 1 ? "" : " (INACTIVE)";
|
|
98
|
+
lines.push(`- ${income.reason}${recurrence}${status}: ${formattedAmount} (expected: ${income.date})${nextDate}`);
|
|
99
|
+
lines.push(` ID: ${income.id}`);
|
|
100
|
+
if (includeReceipts && receiptsByIncomeId) {
|
|
101
|
+
const receipts = receiptsByIncomeId.get(income.id);
|
|
102
|
+
if (receipts && receipts.length > 0) {
|
|
103
|
+
for (const receipt of receipts) {
|
|
104
|
+
const recCurrency = getCurrency(receipt.currency);
|
|
105
|
+
const recAmount = formatAmount(receipt.amount, recCurrency);
|
|
106
|
+
const note = receipt.notes ? ` - ${receipt.notes}` : "";
|
|
107
|
+
lines.push(` └─ Received: ${recAmount} (${receipt.date})${note}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
lines.push(` └─ No receipts recorded`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (total > limit + offset) {
|
|
116
|
+
lines.push("");
|
|
117
|
+
lines.push(`💡 More results available. Use offset=${offset + limit} to see more.`);
|
|
118
|
+
}
|
|
119
|
+
else if (offset > 0 && incomes.length > 0) {
|
|
120
|
+
lines.push("");
|
|
121
|
+
lines.push("ℹ️ End of results.");
|
|
122
|
+
}
|
|
123
|
+
return lines.join("\n");
|
|
124
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { type Static } from "@sinclair/typebox";
|
|
3
|
+
export declare const InputSchema: import("@sinclair/typebox").TObject<{
|
|
4
|
+
amount: import("@sinclair/typebox").TNumber;
|
|
5
|
+
date: import("@sinclair/typebox").TString;
|
|
6
|
+
merchant: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
7
|
+
category: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
8
|
+
currency: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
9
|
+
raw_text: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
10
|
+
description: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
11
|
+
}>;
|
|
12
|
+
export type LogExpenseFromReceiptInput = Static<typeof InputSchema>;
|
|
13
|
+
export declare function executeLogExpenseFromReceipt(input: LogExpenseFromReceiptInput, db?: DatabaseSync): string;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { Value } from "@sinclair/typebox/value";
|
|
4
|
+
import { getDb } from "../db/database.js";
|
|
5
|
+
import { formatAmount, isPlaceholderCurrency, resolveCurrency, } from "./helpers/currency-utils.js";
|
|
6
|
+
const ISO_DATE_PATTERN = "^\\d{4}-\\d{2}-\\d{2}$";
|
|
7
|
+
export const InputSchema = Type.Object({
|
|
8
|
+
amount: Type.Number({ minimum: 0.01 }),
|
|
9
|
+
date: Type.String({ pattern: ISO_DATE_PATTERN }),
|
|
10
|
+
merchant: Type.Optional(Type.String()),
|
|
11
|
+
category: Type.Optional(Type.String()),
|
|
12
|
+
currency: Type.Optional(Type.String()),
|
|
13
|
+
raw_text: Type.Optional(Type.String()),
|
|
14
|
+
description: Type.Optional(Type.String()),
|
|
15
|
+
}, { additionalProperties: false });
|
|
16
|
+
function assertValidInput(input) {
|
|
17
|
+
if (!Value.Check(InputSchema, input)) {
|
|
18
|
+
throw new Error("Invalid parameters: amount is required and must be greater than 0, date is required in YYYY-MM-DD format.");
|
|
19
|
+
}
|
|
20
|
+
if (input.description !== undefined && input.description.trim().length === 0) {
|
|
21
|
+
throw new Error("description must not be blank.");
|
|
22
|
+
}
|
|
23
|
+
if (input.merchant !== undefined && input.merchant.trim().length === 0) {
|
|
24
|
+
throw new Error("merchant must not be blank.");
|
|
25
|
+
}
|
|
26
|
+
if (input.category !== undefined && input.category.trim().length === 0) {
|
|
27
|
+
throw new Error("category must not be blank.");
|
|
28
|
+
}
|
|
29
|
+
if (input.raw_text !== undefined && input.raw_text.trim().length === 0) {
|
|
30
|
+
throw new Error("raw_text must not be blank.");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function executeLogExpenseFromReceipt(input, db = getDb()) {
|
|
34
|
+
assertValidInput(input);
|
|
35
|
+
const currency = resolveCurrency(input.currency, db);
|
|
36
|
+
const now = new Date().toISOString();
|
|
37
|
+
// Process inputs
|
|
38
|
+
const amount = input.amount;
|
|
39
|
+
const date = input.date;
|
|
40
|
+
const merchant = input.merchant?.trim() ?? null;
|
|
41
|
+
const category = (input.category?.trim() ?? "OTHER").toUpperCase();
|
|
42
|
+
const rawText = input.raw_text?.trim() ?? null;
|
|
43
|
+
const description = input.description?.trim();
|
|
44
|
+
// Determine description if not provided
|
|
45
|
+
const descriptionFinal = description ??
|
|
46
|
+
(merchant ? `Expense at ${merchant}` : "OCR expense");
|
|
47
|
+
// Generate IDs
|
|
48
|
+
const extractionId = randomUUID();
|
|
49
|
+
const expenseId = randomUUID();
|
|
50
|
+
// Use transaction for atomicity
|
|
51
|
+
db.exec("BEGIN");
|
|
52
|
+
try {
|
|
53
|
+
// Insert into ocr_extractions
|
|
54
|
+
db.prepare(`
|
|
55
|
+
INSERT INTO ocr_extractions (
|
|
56
|
+
id, provider, source_path, raw_text, lines_json,
|
|
57
|
+
average_confidence, suggested_amount, suggested_currency,
|
|
58
|
+
suggested_date, suggested_merchant, suggested_category,
|
|
59
|
+
status, failure_code, created_at
|
|
60
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
61
|
+
`).run(extractionId, "openclaw_agent", null, // source_path not applicable for agentic OCR
|
|
62
|
+
rawText, null, // lines_json not applicable for agentic OCR
|
|
63
|
+
null, // average_confidence not applicable for agentic OCR
|
|
64
|
+
amount, currency.code, date, merchant, category, "COMPLETED", null, // failure_code
|
|
65
|
+
now);
|
|
66
|
+
// Insert into expenses
|
|
67
|
+
db.prepare(`
|
|
68
|
+
INSERT INTO expenses (
|
|
69
|
+
id, amount, currency, category, merchant, description,
|
|
70
|
+
due_date, payment_date, status, source,
|
|
71
|
+
ocr_extraction_id, recurring_rule_id, generated_from_rule,
|
|
72
|
+
is_active, created_at, updated_at
|
|
73
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
74
|
+
`).run(expenseId, amount, currency.code, category, merchant, descriptionFinal, date, date, "PAID", "OCR", extractionId, null, 0, 1, now, now);
|
|
75
|
+
db.exec("COMMIT");
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
db.exec("ROLLBACK");
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
// Format response
|
|
82
|
+
const formattedAmount = formatAmount(amount, currency);
|
|
83
|
+
const merchantPart = merchant ? ` — ${merchant}` : "";
|
|
84
|
+
const categoryPart = category !== "OTHER" ? ` [${category}]` : "";
|
|
85
|
+
let message = `OCR expense logged: ${formattedAmount}${categoryPart} · ${descriptionFinal}${merchantPart} · ${date} · Paid (ID: ${expenseId})`;
|
|
86
|
+
if (isPlaceholderCurrency(db)) {
|
|
87
|
+
message +=
|
|
88
|
+
"\n\nHint: you haven't configured a real currency yet. Use manage_currency to add yours and set it as default.";
|
|
89
|
+
}
|
|
90
|
+
return message;
|
|
91
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { type Static } from "@sinclair/typebox";
|
|
3
|
+
export declare const InputSchema: import("@sinclair/typebox").TObject<{
|
|
4
|
+
amount: import("@sinclair/typebox").TNumber;
|
|
5
|
+
description: import("@sinclair/typebox").TString;
|
|
6
|
+
due_date: import("@sinclair/typebox").TString;
|
|
7
|
+
category: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
8
|
+
currency: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
9
|
+
merchant: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
10
|
+
}>;
|
|
11
|
+
export type LogExpenseManualInput = Static<typeof InputSchema>;
|
|
12
|
+
export declare function executeLogExpenseManual(input: LogExpenseManualInput, db?: DatabaseSync): string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { Value } from "@sinclair/typebox/value";
|
|
4
|
+
import { getDb } from "../db/database.js";
|
|
5
|
+
import { todayISO } from "./helpers/date-utils.js";
|
|
6
|
+
import { formatAmount, isPlaceholderCurrency, resolveCurrency, } from "./helpers/currency-utils.js";
|
|
7
|
+
const ISO_DATE_PATTERN = "^\\d{4}-\\d{2}-\\d{2}$";
|
|
8
|
+
export const InputSchema = Type.Object({
|
|
9
|
+
amount: Type.Number({ exclusiveMinimum: 0 }),
|
|
10
|
+
description: Type.String({ minLength: 1 }),
|
|
11
|
+
due_date: Type.String({ pattern: ISO_DATE_PATTERN }),
|
|
12
|
+
category: Type.Optional(Type.String()),
|
|
13
|
+
currency: Type.Optional(Type.String()),
|
|
14
|
+
merchant: Type.Optional(Type.String()),
|
|
15
|
+
}, { additionalProperties: false });
|
|
16
|
+
function assertValidInput(input) {
|
|
17
|
+
if (!Value.Check(InputSchema, input)) {
|
|
18
|
+
throw new Error("Invalid parameters: amount must be greater than 0, description must not be empty, and due_date must be in YYYY-MM-DD format.");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function executeLogExpenseManual(input, db = getDb()) {
|
|
22
|
+
assertValidInput(input);
|
|
23
|
+
const currency = resolveCurrency(input.currency, db);
|
|
24
|
+
const category = input.category?.trim() || "OTHER";
|
|
25
|
+
const today = todayISO();
|
|
26
|
+
const isPaid = input.due_date <= today;
|
|
27
|
+
const status = isPaid ? "PAID" : "PENDING";
|
|
28
|
+
const paymentDate = isPaid ? input.due_date : null;
|
|
29
|
+
const now = new Date().toISOString();
|
|
30
|
+
const id = randomUUID();
|
|
31
|
+
db.prepare(`
|
|
32
|
+
INSERT INTO expenses (
|
|
33
|
+
id,
|
|
34
|
+
amount,
|
|
35
|
+
currency,
|
|
36
|
+
category,
|
|
37
|
+
merchant,
|
|
38
|
+
description,
|
|
39
|
+
due_date,
|
|
40
|
+
payment_date,
|
|
41
|
+
status,
|
|
42
|
+
source,
|
|
43
|
+
ocr_extraction_id,
|
|
44
|
+
recurring_rule_id,
|
|
45
|
+
generated_from_rule,
|
|
46
|
+
is_active,
|
|
47
|
+
created_at,
|
|
48
|
+
updated_at
|
|
49
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
50
|
+
`).run(id, input.amount, currency.code, category, input.merchant ?? null, input.description, input.due_date, paymentDate, status, "MANUAL", null, null, 0, 1, now, now);
|
|
51
|
+
const formattedAmount = formatAmount(input.amount, currency);
|
|
52
|
+
const statusLabel = status === "PAID" ? "paid" : "pending";
|
|
53
|
+
const merchantPart = input.merchant != null ? ` — ${input.merchant}` : "";
|
|
54
|
+
let message = `Expense logged: ${formattedAmount} · ${input.description}${merchantPart} · ${input.due_date} · ${statusLabel} (ID: ${id})`;
|
|
55
|
+
if (isPlaceholderCurrency(db)) {
|
|
56
|
+
message +=
|
|
57
|
+
"\n\nHint: you haven't configured a real currency yet. Use manage_currency to add yours and set it as default.";
|
|
58
|
+
}
|
|
59
|
+
return message;
|
|
60
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { type Static } from "@sinclair/typebox";
|
|
3
|
+
export declare const InputSchema: import("@sinclair/typebox").TObject<{
|
|
4
|
+
income_id: import("@sinclair/typebox").TString;
|
|
5
|
+
received_amount: import("@sinclair/typebox").TNumber;
|
|
6
|
+
currency: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
7
|
+
received_on: import("@sinclair/typebox").TString;
|
|
8
|
+
note: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
9
|
+
}>;
|
|
10
|
+
export type LogIncomeReceiptInput = Static<typeof InputSchema>;
|
|
11
|
+
export declare function executeLogIncomeReceipt(input: LogIncomeReceiptInput, db?: DatabaseSync): string;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { Value } from "@sinclair/typebox/value";
|
|
4
|
+
import { getDb } from "../db/database.js";
|
|
5
|
+
import { computeNextDate } from "./helpers/date-utils.js";
|
|
6
|
+
import { formatAmount, PLACEHOLDER_CURRENCY, resolveCurrency, } from "./helpers/currency-utils.js";
|
|
7
|
+
const ISO_DATE_PATTERN = "^\\d{4}-\\d{2}-\\d{2}$";
|
|
8
|
+
function isValidCalendarDate(dateStr) {
|
|
9
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
|
|
10
|
+
if (match === null)
|
|
11
|
+
return false;
|
|
12
|
+
const year = Number.parseInt(match[1], 10);
|
|
13
|
+
const month = Number.parseInt(match[2], 10);
|
|
14
|
+
const day = Number.parseInt(match[3], 10);
|
|
15
|
+
const d = new Date(Date.UTC(year, month - 1, day));
|
|
16
|
+
return (d.getUTCFullYear() === year &&
|
|
17
|
+
d.getUTCMonth() === month - 1 &&
|
|
18
|
+
d.getUTCDate() === day);
|
|
19
|
+
}
|
|
20
|
+
export const InputSchema = Type.Object({
|
|
21
|
+
income_id: Type.String({ minLength: 1 }),
|
|
22
|
+
received_amount: Type.Number({ minimum: 0 }),
|
|
23
|
+
currency: Type.Optional(Type.String()),
|
|
24
|
+
received_on: Type.String({ pattern: ISO_DATE_PATTERN }),
|
|
25
|
+
note: Type.Optional(Type.String()),
|
|
26
|
+
}, { additionalProperties: false });
|
|
27
|
+
export function executeLogIncomeReceipt(input, db = getDb()) {
|
|
28
|
+
if (!Value.Check(InputSchema, input)) {
|
|
29
|
+
throw new Error("Invalid parameters: income_id must not be empty, received_amount must be >= 0, and received_on must be in YYYY-MM-DD format.");
|
|
30
|
+
}
|
|
31
|
+
if (!isValidCalendarDate(input.received_on)) {
|
|
32
|
+
throw new Error(`The date "${input.received_on}" is not a valid calendar date.`);
|
|
33
|
+
}
|
|
34
|
+
// 1. Look up income by income_id
|
|
35
|
+
const income = db
|
|
36
|
+
.prepare(`SELECT id, reason, expected_amount, currency, date, frequency, interval_days, is_recurring, next_expected_receipt_date
|
|
37
|
+
FROM incomes WHERE id = ?`)
|
|
38
|
+
.get(input.income_id);
|
|
39
|
+
if (income === undefined) {
|
|
40
|
+
throw new Error(`No income found with ID "${input.income_id}".`);
|
|
41
|
+
}
|
|
42
|
+
// 2. Resolve effective currency
|
|
43
|
+
let currency;
|
|
44
|
+
const trimmedCurrency = input.currency?.trim();
|
|
45
|
+
if (trimmedCurrency) {
|
|
46
|
+
// Validate explicitly provided currency (ignore if whitespace-only)
|
|
47
|
+
currency = resolveCurrency(trimmedCurrency, db);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
// Use the currency stored on the income (not the global default)
|
|
51
|
+
const incomeCurrency = db
|
|
52
|
+
.prepare(`SELECT code, name, symbol, is_default FROM currencies WHERE code = ?`)
|
|
53
|
+
.get(income.currency);
|
|
54
|
+
if (incomeCurrency === undefined) {
|
|
55
|
+
throw new Error(`The income currency (${income.currency}) is not registered in the database.`);
|
|
56
|
+
}
|
|
57
|
+
currency = incomeCurrency;
|
|
58
|
+
}
|
|
59
|
+
// 3. Compute next date if income is recurring
|
|
60
|
+
const isRecurring = income.is_recurring === 1;
|
|
61
|
+
let nextDate = null;
|
|
62
|
+
if (isRecurring && income.frequency) {
|
|
63
|
+
nextDate = computeNextDate(input.received_on, income.frequency, income.interval_days ?? 0);
|
|
64
|
+
}
|
|
65
|
+
// 4. Insert receipt and update income (atomic)
|
|
66
|
+
const receiptId = randomUUID();
|
|
67
|
+
const now = new Date().toISOString();
|
|
68
|
+
db.exec("BEGIN");
|
|
69
|
+
try {
|
|
70
|
+
db.prepare(`INSERT INTO income_receipts (id, income_id, amount, currency, date, notes, created_at)
|
|
71
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(receiptId, input.income_id, input.received_amount, currency.code, input.received_on, input.note ?? null, now);
|
|
72
|
+
if (isRecurring && nextDate !== null) {
|
|
73
|
+
db.prepare(`UPDATE incomes SET next_expected_receipt_date = ? WHERE id = ?`).run(nextDate, input.income_id);
|
|
74
|
+
}
|
|
75
|
+
db.exec("COMMIT");
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
db.exec("ROLLBACK");
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
// 5. Format response
|
|
82
|
+
const formattedReceived = formatAmount(input.received_amount, currency);
|
|
83
|
+
let message = `Receipt logged: ${formattedReceived} · ${income.reason} · ${input.received_on} (ID: ${receiptId})`;
|
|
84
|
+
// Difference vs expected_amount
|
|
85
|
+
const diff = input.received_amount - income.expected_amount;
|
|
86
|
+
if (diff !== 0) {
|
|
87
|
+
const formattedDiff = formatAmount(Math.abs(diff), currency);
|
|
88
|
+
if (diff > 0) {
|
|
89
|
+
message += `\nDifference: +${formattedDiff} above expected amount.`;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
message += `\nDifference: -${formattedDiff} below expected amount.`;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (nextDate !== null) {
|
|
96
|
+
message += `\nNext expected receipt: ${nextDate}`;
|
|
97
|
+
}
|
|
98
|
+
if (currency.code === PLACEHOLDER_CURRENCY) {
|
|
99
|
+
message +=
|
|
100
|
+
"\n\nHint: you haven't configured a real currency yet. Use manage_currency to add yours and set it as default.";
|
|
101
|
+
}
|
|
102
|
+
return message;
|
|
103
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { type Static } from "@sinclair/typebox";
|
|
3
|
+
export declare const InputSchema: import("@sinclair/typebox").TObject<{
|
|
4
|
+
reason: import("@sinclair/typebox").TString;
|
|
5
|
+
expected_amount: import("@sinclair/typebox").TNumber;
|
|
6
|
+
currency: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
7
|
+
date: import("@sinclair/typebox").TString;
|
|
8
|
+
recurring: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
9
|
+
frequency: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"WEEKLY">, import("@sinclair/typebox").TLiteral<"BIWEEKLY">, import("@sinclair/typebox").TLiteral<"MONTHLY">, import("@sinclair/typebox").TLiteral<"INTERVAL_DAYS">]>>;
|
|
10
|
+
interval_days: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
|
|
11
|
+
}>;
|
|
12
|
+
export type LogIncomeInput = Static<typeof InputSchema>;
|
|
13
|
+
export declare function executeLogIncome(input: LogIncomeInput, db?: DatabaseSync): string;
|