A Discord economy bot adds a gamification layer to your server that keeps members engaged: they earn a virtual currency, claim daily rewards, and spend what they save in a shop to buy roles or items. In this guide we'll build a working economy system from scratch with discord.js, covering balance, daily reward, and shop commands. We'll use modern slash commands and store the data in a persistent database.
Requirements and project setup
To get started you need Node.js 18 or newer, a code editor, and a bot application created in the Discord Developer Portal. The Message Content intent is not required here because we use slash commands, but when inviting the bot to your server you must select the applications.commands and bot scopes.
Create a new folder and install the dependencies:
npm init -y
npm install discord.js better-sqlite3 dotenv
Instead of hard-coding the token and application ID, we keep them in a .env file:
DISCORD_TOKEN=your_bot_token
CLIENT_ID=application_id
GUILD_ID=test_guild_id
Database schema
The economy data must be persistent; balances should not reset every time the bot restarts. We use better-sqlite3 because it's trivial to set up. A single table is enough:
const Database = require('better-sqlite3');
const db = new Database('economy.db');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY,
balance INTEGER NOT NULL DEFAULT 0,
last_daily INTEGER
);
`);
function getUser(id) {
let row = db.prepare('SELECT * FROM users WHERE user_id = ?').get(id);
if (!row) {
db.prepare('INSERT INTO users (user_id) VALUES (?)').run(id);
row = { user_id: id, balance: 0, last_daily: null };
}
return row;
}
function addBalance(id, amount) {
getUser(id);
db.prepare('UPDATE users SET balance = balance + ? WHERE user_id = ?').run(amount, id);
}
The last_daily field stores, as a Unix timestamp, when the daily reward was last claimed. Routing every currency change through a single addBalance function makes it far easier later if you want to add logging or transaction safety.
Defining and registering commands
In recent versions of discord.js, slash commands must be registered with the Discord API separately. During development, registering to a single guild updates instantly; global registration can take up to an hour to propagate. We write a separate deploy-commands.js script:
const { REST, Routes, SlashCommandBuilder } = require('discord.js');
require('dotenv').config();
const commands = [
new SlashCommandBuilder().setName('balance').setDescription('Show your balance'),
new SlashCommandBuilder().setName('daily').setDescription('Claim your daily reward'),
new SlashCommandBuilder().setName('shop').setDescription('List the shop'),
new SlashCommandBuilder()
.setName('buy')
.setDescription('Buy an item from the shop')
.addStringOption(o =>
o.setName('item').setDescription('Item key').setRequired(true)),
].map(c => c.toJSON());
const rest = new REST().setToken(process.env.DISCORD_TOKEN);
rest.put(
Routes.applicationGuildCommands(process.env.CLIENT_ID, process.env.GUILD_ID),
{ body: commands }
).then(() => console.log('Commands registered.'));
Balance and daily reward logic
Now we handle interactions in the main bot file. The critical part of the daily reward is the cooldown check that limits a user to one claim every 24 hours. We do this by comparing against last_daily:
const { Client, GatewayIntentBits } = require('discord.js');
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
const DAILY_AMOUNT = 250;
const DAILY_COOLDOWN = 24 * 60 * 60 * 1000; // 24 hours (ms)
client.on('interactionCreate', async (i) => {
if (!i.isChatInputCommand()) return;
const user = getUser(i.user.id);
if (i.commandName === 'balance') {
await i.reply(`💰 Your balance: **${user.balance}** coins.`);
}
if (i.commandName === 'daily') {
const now = Date.now();
if (user.last_daily && now - user.last_daily < DAILY_COOLDOWN) {
const remaining = user.last_daily + DAILY_COOLDOWN - now;
const hours = Math.ceil(remaining / (60 * 60 * 1000));
return i.reply(`⏳ Wait ~${hours}h for your next reward.`);
}
addBalance(i.user.id, DAILY_AMOUNT);
db.prepare('UPDATE users SET last_daily = ? WHERE user_id = ?').run(now, i.user.id);
await i.reply(`🎁 You earned ${DAILY_AMOUNT} coins!`);
}
});
client.login(process.env.DISCORD_TOKEN);
Storing and comparing timestamps in milliseconds eliminates timezone issues entirely. A "24 hours since last claim" rule is both fairer and more robust to implement than a "resets at midnight" system.
Shop and purchase flow
For the shop, defining items as an object in code is enough; a separate table isn't required on small servers. Each item has a key, a name, a price, and optionally the ID of a Discord role it grants:
const SHOP = {
vip: { name: 'VIP Role', price: 1000, roleId: 'ROLE_ID' },
color: { name: 'Colored Name', price: 500, roleId: 'ROLE_ID_2' },
};
In the buy command we first check the balance, deduct the cost if it's sufficient, and grant the role. Doing the balance check and the deduction in one go prevents a negative balance in race conditions such as double-clicks:
if (i.commandName === 'shop') {
const list = Object.entries(SHOP)
.map(([k, v]) => `• \`${k}\` — ${v.name}: ${v.price} coins`)
.join('\n');
return i.reply(`🛒 **Shop**\n${list}`);
}
if (i.commandName === 'buy') {
const key = i.options.getString('item');
const item = SHOP[key];
if (!item) return i.reply('❌ No such item.');
if (user.balance < item.price) return i.reply('❌ Not enough coins.');
addBalance(i.user.id, -item.price);
if (item.roleId) {
const member = await i.guild.members.fetch(i.user.id);
await member.roles.add(item.roleId).catch(() => null);
}
await i.reply(`✅ Purchased **${item.name}**!`);
}
For role granting to work, the bot's own role must be higher in the list than the role it grants, and the bot needs the "Manage Roles" permission. Otherwise Discord returns a permission error; handling it gracefully with .catch() so the user is informed without crashing the bot is a good habit.
Security and improvement tips
The core system is ready, but a few things deserve attention before going to production. If you add a system that rewards currency per message, put a short per-user cooldown in place to prevent spam. Always validate balances on the server side and never trust a value coming from the client. To keep the bot up 24/7, the most reliable approach is running it on a small VPS with a process manager like pm2.
Frequently Asked Questions
Can I use MySQL as the database?
Yes. SQLite is ideal for ease of setup, but for a large bot running across multiple servers, MySQL or PostgreSQL is a better fit. The query logic stays the same; you only swap the database driver and connection.
Why is the daily reward checked with a timestamp?
Because "resets every midnight" approaches are affected by timezone differences and are open to abuse. Storing the last claim time and checking whether 24 hours have passed is both fair and timezone-independent.
Does a purchased role disappear when the bot goes offline?
No. The role is assigned to the member on Discord's side and is permanent; it stays even if the bot goes down. Only the coin balance lives in the database, so be careful not to lose it.
Want a professional, scalable economy bot for your server? I can build a custom bot tailored to your needs with custom commands, seasons, and panel integration. To discuss your project, get in touch with me.