-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathexpenses.photon.ts
More file actions
170 lines (151 loc) · 5.29 KB
/
expenses.photon.ts
File metadata and controls
170 lines (151 loc) · 5.29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
/**
* Expenses — Track spending with budgets and summaries
*
* @version 1.0.0
* @author Portel
* @license MIT
* @icon 💰
* @tags expenses, finance, budget
* @stateful true
*/
import { PhotonMCP, Table, Collection } from '@portel/photon-core';
interface Expense {
id: string;
amount: number;
category: string;
description: string;
date: string;
}
interface BudgetConfig {
limit: number;
}
export default class Expenses extends PhotonMCP {
/**
* Add an expense
* @param amount Amount spent {@min 0.01}
* @param category Category {@choice food,transport,utilities,entertainment,other}
* @param description What the expense was for
* @param date Date of expense (YYYY-MM-DD) {@default today}
* @icon ➕
*/
async add(params: { amount: number; category: string; description: string; date?: string }) {
const expenses = await this.memory.get<Expense[]>('expenses') ?? [];
const expense: Expense = {
id: crypto.randomUUID(),
amount: Math.round(params.amount * 100) / 100,
category: params.category.toLowerCase(),
description: params.description,
date: params.date || new Date().toISOString().slice(0, 10),
};
expenses.push(expense);
await this.memory.set('expenses', expenses);
this.emit('added', { id: expense.id, amount: expense.amount, description: expense.description });
// Check budget
const budget = await this.memory.get<BudgetConfig>('budget');
if (budget) {
const month = expense.date.slice(0, 7);
const monthTotal = expenses
.filter(e => e.date.startsWith(month))
.reduce((sum, e) => sum + e.amount, 0);
if (monthTotal > budget.limit) {
this.emit('budget-warning', { spent: monthTotal, limit: budget.limit });
}
}
return { added: expense, total: expenses.length };
}
/**
* List all expenses
* @param category Filter by category {@choice food,transport,utilities,entertainment,other}
* @param month Filter by month (YYYY-MM)
* @format table
* @autorun
* @icon 📋
*/
async list(params?: { category?: string; month?: string }) {
let expenses = await this.memory.get<Expense[]>('expenses') ?? [];
if (params?.category) {
expenses = expenses.filter(e => e.category === params.category?.toLowerCase());
}
if (params?.month) {
expenses = expenses.filter(e => e.date.startsWith(params.month!));
}
const sorted = expenses.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
return new Table(sorted)
.currency('amount', { currency: 'USD' })
.badge('category', {
colors: {
food: 'green', transport: 'blue', utilities: 'orange',
entertainment: 'purple', other: 'gray',
},
})
.date('date');
}
/**
* Category spending summary
* @param month Filter by month (YYYY-MM) {@default current month}
* @format dashboard
* @autorun
* @icon 📊
*/
async summary(params?: { month?: string }) {
const expenses = await this.memory.get<Expense[]>('expenses') ?? [];
const month = params?.month || new Date().toISOString().slice(0, 7);
const monthExpenses = expenses.filter(e => e.date.startsWith(month));
const groups = monthExpenses.reduce((acc, e) => {
if (!acc[e.category]) acc[e.category] = [];
acc[e.category].push(e);
return acc;
}, {} as Record<string, Expense[]>);
const rows = Object.entries(groups).map(([category, items]) => {
const total = items.reduce((sum, e) => sum + e.amount, 0);
return {
category,
count: items.length,
total: Math.round(total * 100) / 100,
average: Math.round((total / items.length) * 100) / 100,
};
}).sort((a, b) => b.total - a.total);
const grandTotal = monthExpenses.reduce((sum, e) => sum + e.amount, 0);
return {
month,
grandTotal: Math.round(grandTotal * 100) / 100,
breakdown: new Table(rows)
.badge('category', {
colors: {
food: 'green', transport: 'blue', utilities: 'orange',
entertainment: 'purple', other: 'gray',
},
})
.currency('total', { currency: 'USD' })
.currency('average', { currency: 'USD' }),
};
}
/**
* Set or check monthly budget
* @param limit Monthly spending limit in dollars (omit to check current)
* @icon 🎯
*/
async budget(params?: { limit?: number }) {
if (params?.limit !== undefined) {
await this.memory.set('budget', { limit: params.limit });
this.emit('budget-set', { limit: params.limit });
return { budget: params.limit, status: 'set' };
}
const budget = await this.memory.get<BudgetConfig>('budget');
if (!budget) {
throw new Error('No budget set — use budget(limit) to set one');
}
const expenses = await this.memory.get<Expense[]>('expenses') ?? [];
const month = new Date().toISOString().slice(0, 7);
const spent = expenses
.filter(e => e.date.startsWith(month))
.reduce((sum, e) => sum + e.amount, 0);
const remaining = budget.limit - spent;
return {
budget: budget.limit,
spent: Math.round(spent * 100) / 100,
remaining: Math.round(remaining * 100) / 100,
status: remaining < 0 ? 'over budget' : remaining < budget.limit * 0.1 ? 'almost at limit' : 'on track',
};
}
}