An economy turns a server into a game: earn coins, claim a daily bonus, spend in a shop, pay your friends. The trap is that money bugs are the worst bugs — a crash mid-payment that duplicates or loses coins destroys trust. This recipe builds the scaffold on better-sqlite3 with transactions, so every coin movement is all-or-nothing.
| At a glance | |
|---|---|
| You need | a working bot from the discord.js guide, and a grasp of storing bot data |
| Plan | free or premium — free runs while your session timer lasts |
| Time | about forty-five minutes |
We use better-sqlite3 — a real SQL database in one file, no separate service. It ships prebuilt binaries and installs cleanly on the Node.js application. Add it from the Packages page (search better-sqlite3, install, restart). Comfortable with the leveling recipe? This is the same toolkit, with transactions added.
The data layer
Put the money logic in economy-db.js. Every function that moves coins is a transaction — better-sqlite3 runs the whole block or none of it, so a crash can never leave coins half-moved:
const Database = require('better-sqlite3');
const db = new Database('economy.db');
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS wallets (
user_id TEXT PRIMARY KEY,
balance INTEGER NOT NULL DEFAULT 0,
last_daily INTEGER NOT NULL DEFAULT 0
)
`);
const DAILY_AMOUNT = 250;
const DAY_MS = 24 * 60 * 60 * 1000;
const ensure = db.prepare('INSERT OR IGNORE INTO wallets (user_id) VALUES (?)');
const getWallet = db.prepare('SELECT balance, last_daily FROM wallets WHERE user_id = ?');
const changeBal = db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?');
const setDaily = db.prepare('UPDATE wallets SET last_daily = ? WHERE user_id = ?');
function getBalance(userId) {
ensure.run(userId);
return getWallet.get(userId).balance;
}
function addCoins(userId, amount) {
ensure.run(userId);
changeBal.run(amount, userId);
return getWallet.get(userId).balance;
}
// Claim once per 24h. Returns {claimed:true, amount, balance} or {claimed:false, retryAfterMs}.
function claimDaily(userId) {
ensure.run(userId);
const { last_daily } = getWallet.get(userId);
const now = Date.now();
if (now - last_daily < DAY_MS) return { claimed: false, retryAfterMs: DAY_MS - (now - last_daily) };
const claim = db.transaction(() => {
changeBal.run(DAILY_AMOUNT, userId);
setDaily.run(now, userId);
});
claim();
return { claimed: true, amount: DAILY_AMOUNT, balance: getWallet.get(userId).balance };
}
// Atomic transfer. Returns true on success, false if the sender is short.
const transfer = db.transaction((fromId, toId, amount) => {
ensure.run(fromId); ensure.run(toId);
if (getWallet.get(fromId).balance < amount) return false;
changeBal.run(-amount, fromId);
changeBal.run(amount, toId);
return true;
});
const SHOP = [
{ id: 'coffee', name: '☕ Coffee', price: 50 },
{ id: 'crown', name: '👑 Crown', price: 1000 },
];
// Atomic purchase. Returns {ok:true, balance, item} or {ok:false, reason}.
function buyItem(userId, itemId) {
const item = SHOP.find(i => i.id === itemId);
if (!item) return { ok: false, reason: 'no such item' };
const purchase = db.transaction(() => {
ensure.run(userId);
if (getWallet.get(userId).balance < item.price) return { ok: false, reason: 'not enough coins' };
changeBal.run(-item.price, userId);
return { ok: true, balance: getWallet.get(userId).balance, item };
});
return purchase();
}
module.exports = { getBalance, addCoins, claimDaily, transfer, buyItem, SHOP };
What earns its place here:
db.transaction(fn)wraps a group of statements into one atomic unit. Intransfer, deducting from one wallet and crediting another either both happen or neither does — the failure that would otherwise vaporise coins can't occur. A transaction thatreturns a value passes it straight back out, which is howtransferandbuyItemreport success.INSERT OR IGNORE(ensure) creates a wallet the first time someone is seen and does nothing thereafter, so every other function can assume the row exists.claimDailyis a real cooldown — it comparesnowagainst the storedlast_daily, and only on success does it credit coins and stamp the new time, together, in a transaction.balanceis an INTEGER of whole coins. Never store money as a floating-point number —0.1 + 0.2isn't0.3in floats, and that rounding drift is exactly the kind of bug an economy can't afford. Count whole coins.WALmode (journal_mode = WAL) lets reads and writes overlap smoothly — a good default for a busy bot.
The commands
The bot file wires each command to a data function. /buy offers the shop items as fixed choices, and /pay takes a user and an amount:
require('dotenv').config();
const { Client, Events, GatewayIntentBits, SlashCommandBuilder } = require('discord.js');
const { getBalance, claimDaily, transfer, buyItem, SHOP } = require('./economy-db.js');
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once(Events.ClientReady, async (c) => {
await c.application.commands.set([
new SlashCommandBuilder().setName('balance').setDescription('Check your coins'),
new SlashCommandBuilder().setName('daily').setDescription('Claim your daily coins'),
new SlashCommandBuilder().setName('shop').setDescription('See what you can buy'),
new SlashCommandBuilder().setName('buy').setDescription('Buy an item')
.addStringOption(o => o.setName('item').setDescription('Item id').setRequired(true)
.addChoices(...SHOP.map(i => ({ name: i.name, value: i.id })))),
new SlashCommandBuilder().setName('pay').setDescription('Send coins to someone')
.addUserOption(o => o.setName('to').setDescription('Who').setRequired(true))
.addIntegerOption(o => o.setName('amount').setDescription('How many').setRequired(true).setMinValue(1)),
]);
console.log(`Listening as ${c.user.tag}`);
});
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return;
const uid = interaction.user.id;
switch (interaction.commandName) {
case 'balance':
return interaction.reply(`💰 You have **${getBalance(uid)}** coins.`);
case 'daily': {
const r = claimDaily(uid);
return interaction.reply(r.claimed
? `🎁 You claimed **${r.amount}** coins. Balance: ${r.balance}.`
: `⏳ Already claimed. Come back in ${Math.ceil(r.retryAfterMs / 3600000)}h.`);
}
case 'shop':
return interaction.reply(SHOP.map(i => `${i.name} — **${i.price}** coins (\`${i.id}\`)`).join('\n'));
case 'buy': {
const r = buyItem(uid, interaction.options.getString('item'));
return interaction.reply(r.ok ? `✅ Bought ${r.item.name}! Balance: ${r.balance}.` : `❌ ${r.reason}.`);
}
case 'pay': {
const to = interaction.options.getUser('to');
const amount = interaction.options.getInteger('amount');
const ok = transfer(uid, to.id, amount);
return interaction.reply(ok ? `✅ Sent ${amount} coins to ${to}.` : "❌ You don't have enough coins.");
}
}
});
client.login(process.env.DISCORD_TOKEN);
A few finishing touches:
setMinValue(1)on/pay's amount stops anyone "paying" a negative number to steal coins — Discord rejects it before your code runs.addChoicesturns the shop into a dropdown on/buy, so users pick a real item id instead of guessing. When your shop grows past a fixed handful, swap those choices for autocomplete.- The
switchoncommandNamekeeps five commands readable in one handler.
🎯 Good to know: This shop charges for items but doesn't track what people own — buying a Crown deducts coins and says "bought". To grant a role, an item in an inventory, or a perk, add an
inventorytable and record the purchase inside the samebuyItemtransaction, so the coins and the item always move together.
Where the data lives
⚠️ Heads up:
economy.dblives in/home/containeron the server. It survives restarts, but a reinstall wipes it — and switching the server's application (or a template that switches it) is a reinstall. For play money that's usually fine; back it up before risky changes and keep it out of Git. If coins must never be lost, use a managed database, which outlives reinstalls.
Verify it works
Start the bot. Run /daily — you get 250 coins; run it again and it's blocked with a countdown. /balance shows your total. /buy item:👑 Crown deducts 1000 (or refuses if you're short). /pay @friend 100 moves coins; try to pay more than you have and it's refused with your balance untouched — that refusal-without-loss is the transaction doing its job. Restart and check /balance: your coins are still there.
Troubleshooting
Cannot find module 'better-sqlite3'— install it from the Packages page and restart; it ships prebuilt binaries, so there's no compiler step.- Coins go missing or double after an error — a coin-moving function isn't wrapped in
db.transaction(...). Every debit-and-credit pair must be one transaction. /dailycan be claimed repeatedly — the cooldown compareslast_dailytonow; make sure the successful branch stampssetDaily(now)inside the transaction.- Someone paid a negative amount — the amount option is missing
setMinValue(1). Add it, and rejectamount <= 0in code as a belt-and-braces check. - Balances reset after a reinstall — expected:
economy.dbis local. Back it up, or move to a managed database.