Files
general-purpose-bot/commands/receipts/receiptCommands.js

250 lines
12 KiB
JavaScript

const { SlashCommandBuilder, EmbedBuilder, AttachmentBuilder } = require('discord.js');
const axios = require('axios').default;
const { HOUSEHOLD_IDS } = require('../../core/constants.js');
const CHART_COLORS = {
[HOUSEHOLD_IDS.MINZ]: '#4e79a7',
[HOUSEHOLD_IDS.MIFFY]: '#f28e2b',
};
const DISPLAY_NAMES = {
[HOUSEHOLD_IDS.MINZ]: 'Minz',
[HOUSEHOLD_IDS.MIFFY]: 'Miffy',
};
module.exports = {
category: 'utility',
global: true,
data: new SlashCommandBuilder()
.setName('budget')
.setDescription('The thing')
.addSubcommand(subcommand =>
subcommand
.setName('set')
.setDescription('Set weekly budget')
.addIntegerOption(option =>
option.setName('amount')
.setRequired(true)
.setDescription('Amount as int'))
.addBooleanOption(option =>
option.setName('override')
.setRequired(false)
.setDescription('Override budget of current period'))
)
.addSubcommand(subcommand =>
subcommand
.setName('view')
.setDescription('View your grocery spendings for the current or previous week')
.addUserOption(option =>
option.setName('user')
.setRequired(false)
.setDescription('User to view budget for'))
.addBooleanOption(option =>
option
.setName('lastweek')
.setDescription('Show spendings from the previous budget cycle instead of the current one')
.setRequired(false)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('stats')
.setDescription('View detailed spending and savings statistics')
.addUserOption(option =>
option.setName('user')
.setRequired(false)
.setDescription('User to view stats for'))
)
.addSubcommand(subcommand =>
subcommand
.setName('chart')
.setDescription('Render a bar chart comparing Minz and Miffy spendings over time')
.addIntegerOption(option =>
option.setName('weeks')
.setRequired(false)
.setDescription('Number of recent weeks to include (default 8)'))
),
async execute(interaction) {
let db = await interaction.client.localDB;
let targetUser = interaction.options.getUser('user') ?? interaction.user;
let discordId = interaction.member.id;
let allowed = [HOUSEHOLD_IDS.MIFFY, HOUSEHOLD_IDS.MINZ].includes(discordId);
if (!allowed) {
await interaction.reply('This command may only be invoked by Miffy!');
return;
}
switch (interaction.options.getSubcommand()) {
case 'set':
let override = interaction.options.getBoolean('override') ?? false;
let budget = interaction.options.get('amount').value;
db.prepare(`INSERT OR REPLACE INTO bot_config (id, weekly_budget) VALUES (1, ?)`).run(budget);
let overrideApplied = true;
if (override) {
const result = db.prepare(`
UPDATE weekly_budgets
SET budget_amount = ?
WHERE id = (
SELECT id FROM weekly_budgets
WHERE date('now', 'localtime') BETWEEN start_date AND end_date
ORDER BY id DESC
LIMIT 1
)`).run(budget);
overrideApplied = result.changes > 0;
}
const overrideNote = override && !overrideApplied ? " (no active budget period to override this week)" : "";
await interaction.reply(`Budget set for ${targetUser.username} to ${Number(budget).toFixed(2)}.${overrideNote}`);
break;
case 'view':
const lastWeek = interaction.options.getBoolean('lastweek') ?? false;
const currentBudget = db.prepare(`
SELECT id, budget_amount, exchange_rate, start_date, end_date
FROM weekly_budgets
WHERE ${lastWeek ? "end_date < date('now', 'localtime')" : "date('now', 'localtime') BETWEEN start_date AND end_date"}
ORDER BY start_date DESC, created_at DESC
LIMIT 1
`).get();
if (!currentBudget) {
await interaction.reply("No budget period found.");
return;
}
const userSpendings = db.prepare(`
SELECT amount, message_raw
FROM grocery_spendings
WHERE budget_id = ? AND discord_id = ?
`).all(currentBudget.id, targetUser.id);
const totalSpentEur = userSpendings.reduce((acc, s) => {
return acc + s.amount;
}, 0);
const remainingEur = currentBudget.budget_amount - totalSpentEur;
const remainingSek = remainingEur * currentBudget.exchange_rate;
const todayTimestamp = Math.floor(Date.now() / 1000);
const endTimestamp = Math.floor(new Date(`${currentBudget.end_date}T23:59:59`).getTime() / 1000);
const budgetEmbed = new EmbedBuilder()
.setColor(remainingEur > 0 ? 0x00ff00 : 0xff0000)
.setTitle(`${targetUser.globalName ?? targetUser.username} Budget as of <t:${todayTimestamp}:F>`)
.setDescription(`Period: \`${currentBudget.start_date}\` to \`${currentBudget.end_date}\`\nReset <t:${endTimestamp}:R>`)
.addFields(
{ name: 'Spent', value: `${totalSpentEur.toFixed(2)}\n${(totalSpentEur * currentBudget.exchange_rate).toFixed(2)} kr`, inline: true },
{ name: 'Remaining', value: `${remainingEur.toFixed(2)}\n${remainingSek.toFixed(2)} kr`, inline: true },
)
.setFooter({ text: `Exchange rate this week: 1 € = ${currentBudget.exchange_rate.toFixed(2)} kr` })
.setTimestamp();
await interaction.reply({ embeds: [budgetEmbed] });
break
case 'stats':
const allBudgets = db.prepare(`
SELECT id, budget_amount, exchange_rate, start_date, end_date
FROM weekly_budgets
ORDER BY start_date DESC
LIMIT 4
`).all().reverse();
let statsDescription = "";
let cumulativeSavingsEur = 0;
for (const budgetPeriod of allBudgets) {
const periodSpendings = db.prepare(`
SELECT amount
FROM grocery_spendings
WHERE budget_id = ? AND discord_id = ?
`).all(budgetPeriod.id, targetUser.id);
const totalSpentInPeriodEur = periodSpendings.reduce((acc, s) => acc + s.amount, 0);
const savedInPeriodEur = Number(budgetPeriod.budget_amount) - Number(totalSpentInPeriodEur);
cumulativeSavingsEur += savedInPeriodEur;
statsDescription += `**Period: ${budgetPeriod.start_date} - ${budgetPeriod.end_date}**\n`;
statsDescription += `Budget: ${budgetPeriod.budget_amount.toFixed(2)}\n`;
statsDescription += `Spent: ${totalSpentInPeriodEur.toFixed(2)}€ (${(totalSpentInPeriodEur * budgetPeriod.exchange_rate).toFixed(2)} kr)\n`;
statsDescription += `Saved: ${savedInPeriodEur.toFixed(2)}€ (${(savedInPeriodEur * budgetPeriod.exchange_rate).toFixed(2)} kr)\n`;
statsDescription += `Cumulative: ${cumulativeSavingsEur.toFixed(2)}\n\n`;
}
if (allBudgets.length === 0) {
statsDescription = "No budget periods on record.";
}
const statsEmbed = new EmbedBuilder()
.setColor(0x0099ff)
.setTitle(`${targetUser.globalName ?? targetUser.username}'s Budget Statistics`)
.setDescription(statsDescription)
.setTimestamp();
await interaction.reply({ embeds: [statsEmbed] });
break;
case 'chart': {
const weeks = Math.min(Math.max(interaction.options.getInteger('weeks') ?? 8, 1), 52);
const chartPeriods = db.prepare(`
SELECT id, start_date, end_date
FROM weekly_budgets
ORDER BY start_date DESC
LIMIT ?
`).all(weeks).reverse();
if (chartPeriods.length === 0) {
await interaction.reply("No budget periods on record.");
return;
}
await interaction.deferReply();
const spentByUserAndPeriod = db.prepare(`
SELECT COALESCE(SUM(amount), 0) as total
FROM grocery_spendings
WHERE budget_id = ? AND discord_id = ?
`);
const labels = chartPeriods.map(period => period.start_date.slice(5));
const datasets = [HOUSEHOLD_IDS.MINZ, HOUSEHOLD_IDS.MIFFY].map(userId => ({
label: DISPLAY_NAMES[userId],
backgroundColor: CHART_COLORS[userId],
data: chartPeriods.map(period => spentByUserAndPeriod.get(period.id, userId).total),
}));
const chartConfig = {
type: 'bar',
data: { labels, datasets },
options: {
title: { display: true, text: 'Weekly Grocery Spending (€)' },
scales: {
yAxes: [{ ticks: { beginAtZero: true }, scaleLabel: { display: true, labelString: '€' } }],
},
},
};
try {
const response = await axios.post('https://quickchart.io/chart', {
chart: chartConfig,
width: 800,
height: 400,
backgroundColor: 'white',
}, { responseType: 'arraybuffer' });
const attachment = new AttachmentBuilder(Buffer.from(response.data), { name: 'budget-chart.png' });
const chartEmbed = new EmbedBuilder()
.setColor(0x0099ff)
.setTitle('Weekly Grocery Spending')
.setDescription(`Last ${chartPeriods.length} week(s): \`${chartPeriods[0].start_date}\` to \`${chartPeriods[chartPeriods.length - 1].end_date}\``)
.setImage('attachment://budget-chart.png')
.setTimestamp();
await interaction.editReply({ embeds: [chartEmbed], files: [attachment] });
} catch (error) {
console.error('[budget chart] Failed to render chart:', error);
await interaction.editReply("Failed to render the chart, sorry!");
}
break;
}
default:
await interaction.reply(`Sub mismatch. Switching on ${interaction.options.getSubcommand()}`);
break;
}
},
};