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.
Files changed (43) hide show
  1. package/README.es.md +72 -0
  2. package/README.md +65 -0
  3. package/bin/financialclaw-setup.mjs +139 -0
  4. package/bin/postinstall-message.mjs +17 -0
  5. package/dist/src/db/database.d.ts +3 -0
  6. package/dist/src/db/database.js +58 -0
  7. package/dist/src/db/schema.d.ts +16 -0
  8. package/dist/src/db/schema.js +154 -0
  9. package/dist/src/index.d.ts +8 -0
  10. package/dist/src/index.js +112 -0
  11. package/dist/src/services/daily-sync.d.ts +16 -0
  12. package/dist/src/services/daily-sync.js +135 -0
  13. package/dist/src/tools/add-recurring-expense.d.ts +16 -0
  14. package/dist/src/tools/add-recurring-expense.js +126 -0
  15. package/dist/src/tools/get-financial-summary.d.ts +8 -0
  16. package/dist/src/tools/get-financial-summary.js +158 -0
  17. package/dist/src/tools/helpers/currency-utils.d.ts +17 -0
  18. package/dist/src/tools/helpers/currency-utils.js +47 -0
  19. package/dist/src/tools/helpers/date-utils.d.ts +8 -0
  20. package/dist/src/tools/helpers/date-utils.js +102 -0
  21. package/dist/src/tools/list-expenses.d.ts +16 -0
  22. package/dist/src/tools/list-expenses.js +142 -0
  23. package/dist/src/tools/list-incomes.d.ts +12 -0
  24. package/dist/src/tools/list-incomes.js +124 -0
  25. package/dist/src/tools/log-expense-from-receipt.d.ts +13 -0
  26. package/dist/src/tools/log-expense-from-receipt.js +91 -0
  27. package/dist/src/tools/log-expense-manual.d.ts +12 -0
  28. package/dist/src/tools/log-expense-manual.js +60 -0
  29. package/dist/src/tools/log-income-receipt.d.ts +11 -0
  30. package/dist/src/tools/log-income-receipt.js +103 -0
  31. package/dist/src/tools/log-income.d.ts +13 -0
  32. package/dist/src/tools/log-income.js +103 -0
  33. package/dist/src/tools/manage-currency.d.ts +10 -0
  34. package/dist/src/tools/manage-currency.js +104 -0
  35. package/dist/src/tools/mark-expense-paid.d.ts +8 -0
  36. package/dist/src/tools/mark-expense-paid.js +44 -0
  37. package/dist/src/tools/run-daily-sync.d.ts +5 -0
  38. package/dist/src/tools/run-daily-sync.js +85 -0
  39. package/dist/src/version.d.ts +2 -0
  40. package/dist/src/version.js +2 -0
  41. package/openclaw.plugin.json +24 -0
  42. package/package.json +52 -0
  43. package/skills/financialclaw/SKILL.md +257 -0
@@ -0,0 +1,16 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ export interface DailySyncReminder {
3
+ expense_id: string;
4
+ reminder_id: string;
5
+ description: string;
6
+ amount: number;
7
+ currency: string;
8
+ due_date: string;
9
+ days_before: number;
10
+ }
11
+ export interface DailySyncResult {
12
+ expensesGenerated: number;
13
+ expensesMarkedOverdue: number;
14
+ remindersDue: DailySyncReminder[];
15
+ }
16
+ export declare function dailySync(db?: DatabaseSync, today?: string): DailySyncResult;
@@ -0,0 +1,135 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getDb } from "../db/database.js";
3
+ import { computeNextDate, todayISO } from "../tools/helpers/date-utils.js";
4
+ function subtractDaysFromIso(dateStr, days) {
5
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
6
+ if (match === null) {
7
+ throw new Error(`La fecha "${dateStr}" no tiene el formato YYYY-MM-DD.`);
8
+ }
9
+ const [, yearPart, monthPart, dayPart] = match;
10
+ const year = Number.parseInt(yearPart, 10);
11
+ const month = Number.parseInt(monthPart, 10);
12
+ const day = Number.parseInt(dayPart, 10);
13
+ const date = new Date(Date.UTC(year, month - 1, day));
14
+ date.setUTCDate(date.getUTCDate() - days);
15
+ return [
16
+ date.getUTCFullYear(),
17
+ String(date.getUTCMonth() + 1).padStart(2, "0"),
18
+ String(date.getUTCDate()).padStart(2, "0"),
19
+ ].join("-");
20
+ }
21
+ function nextDueDate(rule, lastGeneratedDate) {
22
+ if (lastGeneratedDate === null) {
23
+ return rule.starts_on;
24
+ }
25
+ return computeNextDate(lastGeneratedDate, rule.frequency, rule.interval_days ?? 0);
26
+ }
27
+ export function dailySync(db = getDb(), today = todayISO()) {
28
+ const selectRulesStmt = db.prepare(`
29
+ SELECT
30
+ id,
31
+ name,
32
+ amount,
33
+ category,
34
+ currency,
35
+ frequency,
36
+ interval_days,
37
+ starts_on,
38
+ ends_on,
39
+ reminder_days_before
40
+ FROM recurring_expense_rules
41
+ WHERE is_active = 1
42
+ ORDER BY starts_on ASC, created_at ASC
43
+ `);
44
+ const selectLastDateStmt = db.prepare(`
45
+ SELECT MAX(due_date) AS last_date
46
+ FROM expenses
47
+ WHERE recurring_rule_id = ? AND generated_from_rule = 1
48
+ `);
49
+ const insertExpenseStmt = db.prepare(`
50
+ INSERT INTO expenses (
51
+ id,
52
+ amount,
53
+ currency,
54
+ category,
55
+ merchant,
56
+ description,
57
+ due_date,
58
+ payment_date,
59
+ status,
60
+ source,
61
+ ocr_extraction_id,
62
+ recurring_rule_id,
63
+ generated_from_rule,
64
+ is_active,
65
+ created_at,
66
+ updated_at
67
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
68
+ `);
69
+ const insertReminderStmt = db.prepare(`
70
+ INSERT INTO reminders (
71
+ id,
72
+ expense_id,
73
+ scheduled_date,
74
+ days_before,
75
+ sent,
76
+ created_at
77
+ ) VALUES (?, ?, ?, ?, ?, ?)
78
+ `);
79
+ const markOverdueStmt = db.prepare(`
80
+ UPDATE expenses
81
+ SET status = 'OVERDUE', updated_at = ?
82
+ WHERE status = 'PENDING' AND due_date < ? AND is_active = 1
83
+ `);
84
+ const selectRemindersDueStmt = db.prepare(`
85
+ SELECT
86
+ r.id AS reminder_id,
87
+ r.expense_id,
88
+ e.description,
89
+ e.amount,
90
+ e.currency,
91
+ e.due_date,
92
+ r.days_before
93
+ FROM reminders r
94
+ JOIN expenses e ON e.id = r.expense_id
95
+ WHERE r.scheduled_date <= ? AND r.sent = 0
96
+ ORDER BY e.due_date ASC, r.scheduled_date ASC, r.id ASC
97
+ `);
98
+ db.exec("BEGIN");
99
+ let expensesGenerated = 0;
100
+ let expensesMarkedOverdue = 0;
101
+ try {
102
+ const recurringRules = selectRulesStmt.all();
103
+ const now = new Date().toISOString();
104
+ for (const rule of recurringRules) {
105
+ const lastExpense = selectLastDateStmt.get(rule.id);
106
+ let dueDate = nextDueDate(rule, lastExpense?.last_date ?? null);
107
+ while (dueDate <= today) {
108
+ if (rule.ends_on !== null && dueDate > rule.ends_on) {
109
+ break;
110
+ }
111
+ const expenseId = randomUUID();
112
+ insertExpenseStmt.run(expenseId, rule.amount, rule.currency, rule.category, null, rule.name, dueDate, null, "PENDING", "MANUAL", null, rule.id, 1, 1, now, now);
113
+ expensesGenerated += 1;
114
+ if (rule.reminder_days_before > 0) {
115
+ insertReminderStmt.run(randomUUID(), expenseId, subtractDaysFromIso(dueDate, rule.reminder_days_before), rule.reminder_days_before, 0, now);
116
+ }
117
+ dueDate = computeNextDate(dueDate, rule.frequency, rule.interval_days ?? 0);
118
+ }
119
+ }
120
+ const overdueResult = markOverdueStmt.run(now, today);
121
+ expensesMarkedOverdue = overdueResult.changes;
122
+ db.exec("COMMIT");
123
+ }
124
+ catch (err) {
125
+ db.exec("ROLLBACK");
126
+ throw err;
127
+ }
128
+ const mutationResult = { expensesGenerated, expensesMarkedOverdue };
129
+ const remindersDue = selectRemindersDueStmt.all(today);
130
+ return {
131
+ expensesGenerated: mutationResult.expensesGenerated,
132
+ expensesMarkedOverdue: mutationResult.expensesMarkedOverdue,
133
+ remindersDue,
134
+ };
135
+ }
@@ -0,0 +1,16 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { type Static } from "@sinclair/typebox";
3
+ export declare const InputSchema: import("@sinclair/typebox").TObject<{
4
+ description: import("@sinclair/typebox").TString;
5
+ amount: import("@sinclair/typebox").TNumber;
6
+ currency: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
7
+ category: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
8
+ merchant: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
9
+ frequency: 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
+ starts_on: import("@sinclair/typebox").TString;
12
+ ends_on: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
13
+ reminder_days_before: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
14
+ }>;
15
+ export type AddRecurringExpenseInput = Static<typeof InputSchema>;
16
+ export declare function executeAddRecurringExpense(input: AddRecurringExpenseInput, db?: DatabaseSync): string;
@@ -0,0 +1,126 @@
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
+ function subtractDaysFromIso(dateStr, days) {
21
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
22
+ const year = Number.parseInt(match[1], 10);
23
+ const month = Number.parseInt(match[2], 10);
24
+ const day = Number.parseInt(match[3], 10);
25
+ const d = new Date(Date.UTC(year, month - 1, day));
26
+ d.setUTCDate(d.getUTCDate() - days);
27
+ return [
28
+ d.getUTCFullYear(),
29
+ String(d.getUTCMonth() + 1).padStart(2, "0"),
30
+ String(d.getUTCDate()).padStart(2, "0"),
31
+ ].join("-");
32
+ }
33
+ export const InputSchema = Type.Object({
34
+ description: Type.String({ minLength: 1 }),
35
+ amount: Type.Number({ minimum: 0 }),
36
+ currency: Type.Optional(Type.String()),
37
+ category: Type.Optional(Type.String()),
38
+ merchant: Type.Optional(Type.String()),
39
+ frequency: Type.Union([
40
+ Type.Literal("WEEKLY"),
41
+ Type.Literal("BIWEEKLY"),
42
+ Type.Literal("MONTHLY"),
43
+ Type.Literal("INTERVAL_DAYS"),
44
+ ]),
45
+ interval_days: Type.Optional(Type.Integer({ minimum: 1 })),
46
+ starts_on: Type.String({ pattern: ISO_DATE_PATTERN }),
47
+ ends_on: Type.Optional(Type.String({ pattern: ISO_DATE_PATTERN })),
48
+ reminder_days_before: Type.Optional(Type.Integer({ minimum: 1 })),
49
+ }, { additionalProperties: false });
50
+ export function executeAddRecurringExpense(input, db = getDb()) {
51
+ if (!Value.Check(InputSchema, input)) {
52
+ throw new Error("Invalid parameters: check description, amount, frequency, and starts_on.");
53
+ }
54
+ const trimmedDescription = input.description.trim();
55
+ if (trimmedDescription.length === 0) {
56
+ throw new Error("The description field must not be empty or blank.");
57
+ }
58
+ if (!isValidCalendarDate(input.starts_on)) {
59
+ throw new Error(`The starts_on date "${input.starts_on}" is not a valid calendar date.`);
60
+ }
61
+ if (input.ends_on !== undefined && !isValidCalendarDate(input.ends_on)) {
62
+ throw new Error(`The ends_on date "${input.ends_on}" is not a valid calendar date.`);
63
+ }
64
+ if (input.ends_on !== undefined && input.starts_on > input.ends_on) {
65
+ throw new Error(`The starts_on date "${input.starts_on}" cannot be later than ends_on "${input.ends_on}".`);
66
+ }
67
+ if (input.frequency === "INTERVAL_DAYS" && !input.interval_days) {
68
+ throw new Error("The interval_days field is required when frequency is INTERVAL_DAYS.");
69
+ }
70
+ const currency = resolveCurrency(input.currency?.trim(), db);
71
+ const category = input.category?.trim() || "OTHER";
72
+ // Pre-compute reminder date if applicable (used in transaction and response)
73
+ const scheduledDate = input.reminder_days_before !== undefined
74
+ ? subtractDaysFromIso(input.starts_on, input.reminder_days_before)
75
+ : null;
76
+ const nextDate = computeNextDate(input.starts_on, input.frequency, input.interval_days ?? 0);
77
+ const ruleId = randomUUID();
78
+ const expenseId = randomUUID();
79
+ const now = new Date().toISOString();
80
+ db.exec("BEGIN");
81
+ try {
82
+ // 1. Insert recurring rule
83
+ // day_of_month stays NULL (MONTHLY anchors to the day of starts_on)
84
+ db.prepare(`INSERT INTO recurring_expense_rules (
85
+ id, name, amount, category, currency, frequency,
86
+ interval_days, day_of_month, starts_on, ends_on,
87
+ reminder_days_before, is_active, created_at
88
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(ruleId, trimmedDescription, input.amount, category, currency.code, input.frequency, input.interval_days ?? null, null, input.starts_on, input.ends_on ?? null, input.reminder_days_before ?? 0, 1, now);
89
+ // 2. Insert first expense generated by the rule
90
+ db.prepare(`INSERT INTO expenses (
91
+ id, amount, currency, category, merchant, description,
92
+ due_date, payment_date, status, source,
93
+ ocr_extraction_id, recurring_rule_id, generated_from_rule,
94
+ is_active, created_at, updated_at
95
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(expenseId, input.amount, currency.code, category, input.merchant ?? null, trimmedDescription, input.starts_on, null, "PENDING", "MANUAL", null, ruleId, 1, 1, now, now);
96
+ // 3. Insert first reminder if applicable
97
+ if (scheduledDate !== null && input.reminder_days_before !== undefined) {
98
+ db.prepare(`INSERT INTO reminders (id, expense_id, scheduled_date, days_before, sent, created_at)
99
+ VALUES (?, ?, ?, ?, ?, ?)`).run(randomUUID(), expenseId, scheduledDate, input.reminder_days_before, 0, now);
100
+ }
101
+ db.exec("COMMIT");
102
+ }
103
+ catch (err) {
104
+ db.exec("ROLLBACK");
105
+ throw err;
106
+ }
107
+ // Format response
108
+ const formattedAmount = formatAmount(input.amount, currency);
109
+ let message = `Rule created: ${formattedAmount} · ${trimmedDescription} · ${input.frequency} from ${input.starts_on} (rule: ${ruleId})`;
110
+ message += `\nFirst expense generated due on ${input.starts_on} (expense: ${expenseId})`;
111
+ const nextDateWithinWindow = input.ends_on === undefined || nextDate <= input.ends_on;
112
+ if (nextDateWithinWindow) {
113
+ message += `\nNext date: ${nextDate}`;
114
+ }
115
+ else {
116
+ message += `\nNo next occurrence within the validity window (ends_on: ${input.ends_on}).`;
117
+ }
118
+ if (scheduledDate !== null) {
119
+ message += `\nReminder scheduled for ${scheduledDate} (${input.reminder_days_before} days before)`;
120
+ }
121
+ if (currency.code === PLACEHOLDER_CURRENCY) {
122
+ message +=
123
+ "\n\nHint: you haven't configured a real currency yet. Use manage_currency to add yours and set it as default.";
124
+ }
125
+ return message;
126
+ }
@@ -0,0 +1,8 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { type Static } from "@sinclair/typebox";
3
+ export declare const InputSchema: import("@sinclair/typebox").TObject<{
4
+ period: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"this_month">, import("@sinclair/typebox").TLiteral<"last_month">, import("@sinclair/typebox").TLiteral<"last_30_days">, import("@sinclair/typebox").TLiteral<"this_year">]>>;
5
+ currency: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
6
+ }>;
7
+ export type GetFinancialSummaryInput = Static<typeof InputSchema>;
8
+ export declare function executeGetFinancialSummary(input: GetFinancialSummaryInput, db?: DatabaseSync): string;
@@ -0,0 +1,158 @@
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 { formatAmount, resolveCurrency, } 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
+ ])),
13
+ currency: Type.Optional(Type.String()),
14
+ }, { additionalProperties: false });
15
+ const PERIOD_LABELS = {
16
+ this_month: "this month",
17
+ last_month: "last month",
18
+ last_30_days: "last 30 days",
19
+ this_year: "this year",
20
+ all: "all",
21
+ };
22
+ function monthlyEquivalent(amount, frequency, intervalDays) {
23
+ switch (frequency) {
24
+ case "MONTHLY":
25
+ return amount;
26
+ case "WEEKLY":
27
+ return Math.round(amount * 4.33);
28
+ case "BIWEEKLY":
29
+ return Math.round(amount * 2.17);
30
+ case "INTERVAL_DAYS":
31
+ return intervalDays ? Math.round((amount * 30) / intervalDays) : amount;
32
+ default:
33
+ return amount;
34
+ }
35
+ }
36
+ export function executeGetFinancialSummary(input, db = getDb()) {
37
+ if (!Value.Check(InputSchema, input)) {
38
+ throw new Error("Invalid parameters: period must be one of: this_month, last_month, last_30_days, this_year.");
39
+ }
40
+ const period = (input.period ?? "this_month");
41
+ const range = resolvePeriodRange(period);
42
+ if (!range) {
43
+ throw new Error("The 'all' period is not supported by get_financial_summary.");
44
+ }
45
+ // Validate currency filter if provided — throws descriptively if not registered
46
+ const filterCode = input.currency?.trim() || undefined;
47
+ if (filterCode) {
48
+ resolveCurrency(filterCode, db);
49
+ }
50
+ const filter = filterCode ?? null;
51
+ // ── Queries ────────────────────────────────────────────────────────────────
52
+ // Total expenses by currency and category within the period
53
+ const expenseByCat = db
54
+ .prepare(`SELECT currency, category, SUM(amount) AS total
55
+ FROM expenses
56
+ WHERE due_date BETWEEN ? AND ? AND is_active = 1
57
+ AND (? IS NULL OR currency = ?)
58
+ GROUP BY currency, category`)
59
+ .all(range.start, range.end, filter, filter);
60
+ // Pending expenses by currency within the period
61
+ const pendingByCurrency = db
62
+ .prepare(`SELECT currency, SUM(amount) AS total
63
+ FROM expenses
64
+ WHERE status = 'PENDING' AND due_date BETWEEN ? AND ? AND is_active = 1
65
+ AND (? IS NULL OR currency = ?)
66
+ GROUP BY currency`)
67
+ .all(range.start, range.end, filter, filter);
68
+ // Income received by currency within the period
69
+ // (income_receipts.amount / income_receipts.date — real columns, not doc aliases)
70
+ const incomeByCurrency = db
71
+ .prepare(`SELECT currency, SUM(amount) AS total
72
+ FROM income_receipts
73
+ WHERE date BETWEEN ? AND ?
74
+ AND (? IS NULL OR currency = ?)
75
+ GROUP BY currency`)
76
+ .all(range.start, range.end, filter, filter);
77
+ // Active recurring rules — independent of the period
78
+ // recurring_expense_rules.name = what the user called "description" in the input
79
+ const rules = db
80
+ .prepare(`SELECT name, amount, currency, frequency, interval_days
81
+ FROM recurring_expense_rules
82
+ WHERE is_active = 1
83
+ AND (? IS NULL OR currency = ?)`)
84
+ .all(filter, filter);
85
+ // ── Collect all relevant currencies ───────────────────────────────────────
86
+ const allCurrencies = new Set();
87
+ for (const r of expenseByCat)
88
+ allCurrencies.add(r.currency);
89
+ for (const r of pendingByCurrency)
90
+ allCurrencies.add(r.currency);
91
+ for (const r of incomeByCurrency)
92
+ allCurrencies.add(r.currency);
93
+ for (const r of rules)
94
+ allCurrencies.add(r.currency);
95
+ // If a currency filter was applied, always show that section even if empty
96
+ if (filterCode)
97
+ allCurrencies.add(filterCode);
98
+ // ── Format output ──────────────────────────────────────────────────────────
99
+ const periodLabel = PERIOD_LABELS[period];
100
+ const lines = [
101
+ `Period: ${periodLabel} (${range.start} – ${range.end})`,
102
+ "",
103
+ ];
104
+ if (allCurrencies.size === 0) {
105
+ lines.push("No transactions recorded in this period.");
106
+ lines.push("");
107
+ lines.push("Active recurring commitments: 0");
108
+ return lines.join("\n");
109
+ }
110
+ // Fetch currency rows for amount formatting
111
+ const currencyCache = new Map();
112
+ for (const code of allCurrencies) {
113
+ const row = db
114
+ .prepare(`SELECT code, name, symbol, is_default FROM currencies WHERE code = ?`)
115
+ .get(code);
116
+ if (row)
117
+ currencyCache.set(code, row);
118
+ }
119
+ // One section per currency, sorted alphabetically
120
+ for (const code of [...allCurrencies].sort()) {
121
+ const currencyRow = currencyCache.get(code);
122
+ if (!currencyRow)
123
+ continue;
124
+ const fmt = (amount) => formatAmount(amount, currencyRow);
125
+ const catRows = expenseByCat.filter((r) => r.currency === code);
126
+ const totalExpenses = catRows.reduce((sum, r) => sum + r.total, 0);
127
+ const totalPending = pendingByCurrency.find((r) => r.currency === code)?.total ?? 0;
128
+ const totalIncome = incomeByCurrency.find((r) => r.currency === code)?.total ?? 0;
129
+ const balance = totalIncome - totalExpenses;
130
+ lines.push(code);
131
+ lines.push(`Income received: ${fmt(totalIncome)}`);
132
+ lines.push(`Total expenses: ${fmt(totalExpenses)}`);
133
+ lines.push(`Pending expenses: ${fmt(totalPending)}`);
134
+ lines.push(`Received balance: ${fmt(balance)}`);
135
+ if (catRows.length > 0) {
136
+ lines.push("");
137
+ lines.push("By category:");
138
+ for (const cat of [...catRows].sort((a, b) => b.total - a.total)) {
139
+ lines.push(` ${cat.category ?? "UNCATEGORIZED"} ${fmt(cat.total)}`);
140
+ }
141
+ }
142
+ const currencyRules = rules.filter((r) => r.currency === code);
143
+ if (currencyRules.length > 0) {
144
+ const totalMonthly = currencyRules.reduce((sum, r) => sum + monthlyEquivalent(r.amount, r.frequency, r.interval_days), 0);
145
+ lines.push("");
146
+ lines.push(`Active recurring commitments: ${currencyRules.length} (${fmt(totalMonthly)}/mo)`);
147
+ for (const rule of currencyRules) {
148
+ lines.push(` ${rule.name} ${fmt(rule.amount)} (${rule.frequency})`);
149
+ }
150
+ }
151
+ lines.push("");
152
+ }
153
+ // If no active rules exist at all, append global zero count
154
+ if (rules.length === 0) {
155
+ lines.push("Active recurring commitments: 0");
156
+ }
157
+ return lines.join("\n").trimEnd();
158
+ }
@@ -0,0 +1,17 @@
1
+ export declare const PLACEHOLDER_CURRENCY = "XXX";
2
+ interface StatementLike {
3
+ get(...params: unknown[]): unknown;
4
+ }
5
+ interface DbLike {
6
+ prepare(sql: string): StatementLike;
7
+ }
8
+ export interface CurrencyRow {
9
+ code: string;
10
+ name: string;
11
+ symbol: string;
12
+ is_default?: number;
13
+ }
14
+ export declare function resolveCurrency(inputCode?: string, db?: DbLike): CurrencyRow;
15
+ export declare function isPlaceholderCurrency(db?: DbLike): boolean;
16
+ export declare function formatAmount(amount: number, currency: CurrencyRow, _db?: DbLike): string;
17
+ export {};
@@ -0,0 +1,47 @@
1
+ import { getDb } from "../../db/database.js";
2
+ export const PLACEHOLDER_CURRENCY = "XXX";
3
+ function getDefaultCurrency(db) {
4
+ const currency = db
5
+ .prepare(`
6
+ SELECT code, name, symbol, is_default
7
+ FROM currencies
8
+ WHERE is_default = 1
9
+ ORDER BY created_at ASC
10
+ LIMIT 1
11
+ `)
12
+ .get();
13
+ if (currency === undefined) {
14
+ throw new Error("No default currency configured.");
15
+ }
16
+ return currency;
17
+ }
18
+ export function resolveCurrency(inputCode, db = getDb()) {
19
+ const normalizedCode = inputCode?.trim().toUpperCase();
20
+ if (normalizedCode === undefined || normalizedCode === "") {
21
+ return getDefaultCurrency(db);
22
+ }
23
+ const currency = db
24
+ .prepare(`
25
+ SELECT code, name, symbol, is_default
26
+ FROM currencies
27
+ WHERE code = ?
28
+ LIMIT 1
29
+ `)
30
+ .get(normalizedCode);
31
+ if (currency === undefined) {
32
+ throw new Error(`Currency ${normalizedCode} is not registered. Use manage_currency to add it first.`);
33
+ }
34
+ return currency;
35
+ }
36
+ export function isPlaceholderCurrency(db = getDb()) {
37
+ return resolveCurrency(undefined, db).code === PLACEHOLDER_CURRENCY;
38
+ }
39
+ export function formatAmount(amount, currency, _db) {
40
+ const sign = amount < 0 ? "-" : "";
41
+ const absoluteAmount = Math.abs(amount);
42
+ const formattedNumber = absoluteAmount.toLocaleString("es-CO", {
43
+ minimumFractionDigits: 0,
44
+ maximumFractionDigits: Number.isInteger(absoluteAmount) ? 0 : 2,
45
+ });
46
+ return `${sign}${currency.symbol}${formattedNumber} ${currency.code}`;
47
+ }
@@ -0,0 +1,8 @@
1
+ export type SupportedPeriod = "this_month" | "last_month" | "last_30_days" | "this_year" | "all";
2
+ export interface DateRange {
3
+ start: string;
4
+ end: string;
5
+ }
6
+ export declare function todayISO(): string;
7
+ export declare function computeNextDate(date: string, frequency: string, intervalDays?: number): string;
8
+ export declare function resolvePeriodRange(period: SupportedPeriod, referenceDate?: string): DateRange | null;
@@ -0,0 +1,102 @@
1
+ function pad(value) {
2
+ return String(value).padStart(2, "0");
3
+ }
4
+ function formatDateUtc(date) {
5
+ return [
6
+ date.getUTCFullYear(),
7
+ pad(date.getUTCMonth() + 1),
8
+ pad(date.getUTCDate()),
9
+ ].join("-");
10
+ }
11
+ function createUtcDate(year, month, day) {
12
+ return new Date(Date.UTC(year, month, day));
13
+ }
14
+ function parseIsoDate(date) {
15
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
16
+ if (match === null) {
17
+ throw new Error(`The date "${date}" does not match the YYYY-MM-DD format.`);
18
+ }
19
+ const [, yearPart, monthPart, dayPart] = match;
20
+ const year = Number.parseInt(yearPart, 10);
21
+ const month = Number.parseInt(monthPart, 10);
22
+ const day = Number.parseInt(dayPart, 10);
23
+ const parsed = createUtcDate(year, month - 1, day);
24
+ if (parsed.getUTCFullYear() !== year ||
25
+ parsed.getUTCMonth() !== month - 1 ||
26
+ parsed.getUTCDate() !== day) {
27
+ throw new Error(`The date "${date}" is not valid.`);
28
+ }
29
+ return parsed;
30
+ }
31
+ function addDays(date, days) {
32
+ const next = new Date(date.getTime());
33
+ next.setUTCDate(next.getUTCDate() + days);
34
+ return next;
35
+ }
36
+ function addMonthsClamped(date, months) {
37
+ const year = date.getUTCFullYear();
38
+ const month = date.getUTCMonth();
39
+ const day = date.getUTCDate();
40
+ const targetMonthIndex = month + months;
41
+ const targetYear = year + Math.floor(targetMonthIndex / 12);
42
+ const normalizedMonth = ((targetMonthIndex % 12) + 12) % 12;
43
+ const lastDayOfTargetMonth = createUtcDate(targetYear, normalizedMonth + 1, 0).getUTCDate();
44
+ return createUtcDate(targetYear, normalizedMonth, Math.min(day, lastDayOfTargetMonth));
45
+ }
46
+ export function todayISO() {
47
+ const now = new Date();
48
+ return [
49
+ now.getFullYear(),
50
+ pad(now.getMonth() + 1),
51
+ pad(now.getDate()),
52
+ ].join("-");
53
+ }
54
+ export function computeNextDate(date, frequency, intervalDays = 0) {
55
+ const baseDate = parseIsoDate(date);
56
+ switch (frequency) {
57
+ case "WEEKLY":
58
+ return formatDateUtc(addDays(baseDate, 7));
59
+ case "BIWEEKLY":
60
+ return formatDateUtc(addDays(baseDate, 14));
61
+ case "MONTHLY":
62
+ return formatDateUtc(addMonthsClamped(baseDate, 1));
63
+ case "INTERVAL_DAYS":
64
+ return formatDateUtc(addDays(baseDate, intervalDays));
65
+ default:
66
+ throw new Error(`The frequency "${frequency}" is not valid.`);
67
+ }
68
+ }
69
+ export function resolvePeriodRange(period, referenceDate = todayISO()) {
70
+ if (period === "all") {
71
+ return null;
72
+ }
73
+ const reference = parseIsoDate(referenceDate);
74
+ switch (period) {
75
+ case "this_month":
76
+ return {
77
+ start: formatDateUtc(createUtcDate(reference.getUTCFullYear(), reference.getUTCMonth(), 1)),
78
+ end: formatDateUtc(reference),
79
+ };
80
+ case "last_month": {
81
+ const lastMonthDate = addMonthsClamped(reference, -1);
82
+ const year = lastMonthDate.getUTCFullYear();
83
+ const month = lastMonthDate.getUTCMonth();
84
+ return {
85
+ start: formatDateUtc(createUtcDate(year, month, 1)),
86
+ end: formatDateUtc(createUtcDate(year, month + 1, 0)),
87
+ };
88
+ }
89
+ case "last_30_days":
90
+ return {
91
+ start: formatDateUtc(addDays(reference, -29)),
92
+ end: formatDateUtc(reference),
93
+ };
94
+ case "this_year":
95
+ return {
96
+ start: formatDateUtc(createUtcDate(reference.getUTCFullYear(), 0, 1)),
97
+ end: formatDateUtc(reference),
98
+ };
99
+ default:
100
+ throw new Error(`The period "${period}" is not valid.`);
101
+ }
102
+ }
@@ -0,0 +1,16 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { type Static } from "@sinclair/typebox";
3
+ export declare const InputSchema: import("@sinclair/typebox").TObject<{
4
+ period: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"this_month">, import("@sinclair/typebox").TLiteral<"last_month">, import("@sinclair/typebox").TLiteral<"last_30_days">, import("@sinclair/typebox").TLiteral<"this_year">, import("@sinclair/typebox").TLiteral<"all">]>>;
5
+ start_date: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
6
+ end_date: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
7
+ category: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
8
+ status: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"PENDING">, import("@sinclair/typebox").TLiteral<"PAID">, import("@sinclair/typebox").TLiteral<"OVERDUE">]>>;
9
+ search: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
10
+ currency: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
11
+ source: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"MANUAL">, import("@sinclair/typebox").TLiteral<"OCR">]>>;
12
+ limit: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
13
+ offset: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
14
+ }>;
15
+ export type Input = Static<typeof InputSchema>;
16
+ export declare function executeListExpenses(input: Input, db?: DatabaseSync): string;