Discord economy bot: coins, /daily, shop and leaderboard (Python and JavaScript)
An economy bot gives your Discord community coins: they earn them with /daily and /work, pass them around with /pay and spend them in a role shop. Here it is in full in discord.py and in discord.js, saved in a SQLite file, with cooldowns that survive a restart, a leaderboard and the thing almost every homemade bot gets wrong: making sure nobody can spend the same coins twice.
How it works
Every member of your server has a balance of the server's own coins: they earn them with /daily (once a day) and /work (once an hour), pass them to others with /pay and spend them with /buy on what /shop lists, where each item is a role. /balance shows how many each person has, /leaderboard shows the richest and /give lets admins give or take coins. You set the prices, the rewards and what's for sale.
Each Discord server has its own economy (the key is server and user) and everything is saved in a SQLite file, economy.db, that survives restarts without installing any database. Why SQLite and not a JSON file, in the database guide for bots. It only uses slash commands, so it doesn't need any privileged intent (neither “Message Content” nor “Server Members”).
If it's your first bot, start with your first bot in Python or in JavaScript: that covers creating it in the developer portal, getting the token and inviting it. Invite it with the bot and applications.commands scopes and, for the shop, the “Manage Roles” permission (the link, ready made, in the bot permissions calculator).
Rather not set it up yourself? Discord bot hosting from €1.50 a month, no contract and a full refund within 72 hours. Or create a free Discord bot, no card needed.
The bot in Python (discord.py)
Three files in one folder. The token goes in .env, without quotes. You need Python 3.10 or newer; sqlite3 comes with Python, nothing else to install. Swap the IDs in SHOP for your own roles: with Discord's “Developer Mode” on (“User Settings” → “Advanced”), right-click the role and “Copy Role ID”.
Every command opens the database with with db:: whatever happens inside is saved together on the way out and, if something fails halfway, nothing is saved. charge only subtracts if the balance is enough (the section on spending twice explains why).
discord.py
python-dotenvDISCORD_TOKEN=paste-your-token-hereimport os
import random
import sqlite3
import time
from pathlib import Path
import discord
from discord import app_commands
from dotenv import load_dotenv
load_dotenv() # reads the token from the .env file
COIN = "🪙"
DAILY = 200 # what /daily gives, once every 24 hours
WORK = (50, 150) # what /work gives: between these two amounts
WORK_COOLDOWN = 3600 # seconds between one /work and the next
# What /buy sells: each item gives a role (put your own role IDs)
SHOP = {
"vip": {"name": "VIP role", "price": 5000, "role": 123456789012345678},
"color": {"name": "Colored name", "price": 1500, "role": 234567890123456789},
}
# The file sits next to app.py: so it doesn't matter which folder it's started from
db = sqlite3.connect(Path(__file__).with_name("economy.db"))
db.execute(
"CREATE TABLE IF NOT EXISTS accounts (guild_id INTEGER, user INTEGER,"
" balance INTEGER NOT NULL DEFAULT 0, daily REAL NOT NULL DEFAULT 0,"
" work REAL NOT NULL DEFAULT 0, PRIMARY KEY (guild_id, user))"
)
db.commit()
client = discord.Client(intents=discord.Intents.default()) # no privileged intents
tree = app_commands.CommandTree(client)
def account(guild_id, user):
"""That person's row in that server; if it doesn't exist, it's created at zero."""
db.execute("INSERT OR IGNORE INTO accounts (guild_id, user) VALUES (?, ?)", (guild_id, user))
return db.execute(
"SELECT balance, daily, work FROM accounts WHERE guild_id = ? AND user = ?",
(guild_id, user),
).fetchone()
def charge(guild_id, user, amount):
"""Subtracts only if the balance is enough, in a single statement: two commands at once can't spend the same coins."""
cursor = db.execute(
"UPDATE accounts SET balance = balance - ? WHERE guild_id = ? AND user = ? AND balance >= ?",
(amount, guild_id, user, amount),
)
return cursor.rowcount == 1
def deposit(guild_id, user, amount):
db.execute(
"UPDATE accounts SET balance = balance + ? WHERE guild_id = ? AND user = ?",
(amount, guild_id, user),
)
@tree.command(name="balance", description="Your coins (or someone else's)")
@app_commands.guild_only()
async def balance(interaction: discord.Interaction, user: discord.Member | None = None):
user = user or interaction.user
with db: # saves when leaving the block; if something fails, nothing is saved halfway
coins = account(interaction.guild_id, user.id)[0]
await interaction.response.send_message(f"{user.display_name} has {coins} {COIN}")
@tree.command(name="daily", description="Collect your daily coins")
@app_commands.guild_only()
async def daily(interaction: discord.Interaction):
now = time.time()
with db:
last = account(interaction.guild_id, interaction.user.id)[1]
# adds and records the time only if 24 hours have passed: the wait survives restarts
claimed = db.execute(
"UPDATE accounts SET balance = balance + ?, daily = ? WHERE guild_id = ? AND user = ? AND daily <= ?",
(DAILY, now, interaction.guild_id, interaction.user.id, now - 86400),
).rowcount
if not claimed:
# Discord shows <t:…:R> as “in 3 hours”, in each person's language
await interaction.response.send_message(f"Already collected. Come back <t:{int(last + 86400)}:R>", ephemeral=True)
return
await interaction.response.send_message(f"+{DAILY} {COIN} for today")
@tree.command(name="work", description="Earn some coins (once an hour)")
@app_commands.guild_only()
async def work(interaction: discord.Interaction):
now = time.time()
earned = random.randint(*WORK)
with db:
last = account(interaction.guild_id, interaction.user.id)[2]
claimed = db.execute(
"UPDATE accounts SET balance = balance + ?, work = ? WHERE guild_id = ? AND user = ? AND work <= ?",
(earned, now, interaction.guild_id, interaction.user.id, now - WORK_COOLDOWN),
).rowcount
if not claimed:
await interaction.response.send_message(f"You're resting. Come back <t:{int(last + WORK_COOLDOWN)}:R>", ephemeral=True)
return
await interaction.response.send_message(f"You worked and earned {earned} {COIN}")
@tree.command(name="pay", description="Give coins to someone else")
@app_commands.guild_only()
async def pay(interaction: discord.Interaction, user: discord.Member, amount: app_commands.Range[int, 1, 1_000_000]):
if user.bot or user.id == interaction.user.id:
await interaction.response.send_message("Not to yourself or to a bot.", ephemeral=True)
return
with db: # both things or neither: if something fails, nobody loses coins
account(interaction.guild_id, user.id)
had = account(interaction.guild_id, interaction.user.id)[0]
paid = charge(interaction.guild_id, interaction.user.id, amount)
if paid:
deposit(interaction.guild_id, user.id, amount)
if not paid:
await interaction.response.send_message(f"Not enough: you have {had} {COIN}", ephemeral=True)
return
await interaction.response.send_message(f"{interaction.user.mention} gives {amount} {COIN} to {user.mention}")
@tree.command(name="shop", description="What you can buy")
async def shop(interaction: discord.Interaction):
lines = [f"**{a['name']}** · {a['price']} {COIN}" for a in SHOP.values()]
card = discord.Embed(title="🛒 Shop", description="\n".join(lines) + "\n\nBuy with /buy", color=0x5B4AE8)
await interaction.response.send_message(embed=card)
@tree.command(name="buy", description="Buy something from the shop")
@app_commands.guild_only()
@app_commands.choices(item=[app_commands.Choice(name=a["name"], value=key) for key, a in SHOP.items()])
async def buy(interaction: discord.Interaction, item: app_commands.Choice[str]):
a = SHOP[item.value]
role = interaction.guild.get_role(a["role"])
if role is None:
await interaction.response.send_message("That role doesn't exist: check SHOP in the code.", ephemeral=True)
return
if role in interaction.user.roles:
await interaction.response.send_message("You already have it.", ephemeral=True)
return
with db:
account(interaction.guild_id, interaction.user.id)
paid = charge(interaction.guild_id, interaction.user.id, a["price"])
if not paid:
await interaction.response.send_message(f"Not enough: it costs {a['price']} {COIN}", ephemeral=True)
return
try:
await interaction.user.add_roles(role, reason="Bought in the shop")
except discord.HTTPException:
with db: # the role couldn't be given: the coins are refunded
deposit(interaction.guild_id, interaction.user.id, a["price"])
await interaction.response.send_message(
"I couldn't give you the role (do I have “Manage Roles” and is my role above it?). Your coins are refunded.",
ephemeral=True,
)
return
await interaction.response.send_message(f"Bought: {a['name']} ✅")
@tree.command(name="leaderboard", description="The 10 richest in the server")
@app_commands.guild_only()
async def leaderboard(interaction: discord.Interaction):
rows = db.execute(
"SELECT user, balance FROM accounts WHERE guild_id = ? AND balance > 0 ORDER BY balance DESC LIMIT 10",
(interaction.guild_id,),
).fetchall()
if not rows:
await interaction.response.send_message("Nobody has coins yet: try /daily")
return
# a mention inside an embed shows the name and doesn't ping anyone
lines = [f"**{n}.** <@{user}> · {coins} {COIN}" for n, (user, coins) in enumerate(rows, 1)]
card = discord.Embed(title="🏆 Richest", description="\n".join(lines), color=0x5B4AE8)
await interaction.response.send_message(embed=card)
@tree.command(name="give", description="(Admins) Give or take coins")
@app_commands.guild_only()
@app_commands.default_permissions(manage_guild=True) # by default, only people who can manage the server
async def give(interaction: discord.Interaction, user: discord.Member, amount: int):
with db:
account(interaction.guild_id, user.id)
# negative takes away, never below zero
db.execute(
"UPDATE accounts SET balance = MAX(0, balance + ?) WHERE guild_id = ? AND user = ?",
(amount, interaction.guild_id, user.id),
)
await interaction.response.send_message(f"Done: {amount:+} {COIN} to {user.display_name}", ephemeral=True)
@client.event
async def on_ready():
await tree.sync() # publishes the commands
print(f"Logged in as {client.user}")
client.run(os.environ["DISCORD_TOKEN"])The same bot in JavaScript (discord.js)
package.json, .env and index.js. The database is node:sqlite, which comes with Node.js 22.13 or newer: nothing to install for it (on startup, Node may warn that it's experimental; that's normal). Server and user IDs are stored as text, because Discord's don't fit in a JavaScript number.
The SQL statements are prepared once at the top (db.prepare) and run returns in changes how many rows it changed: if it's 0, there wasn't enough balance or the cooldown wasn't over yet.
{
"name": "economy-bot",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"discord.js": "^14.16.0",
"dotenv": "^16.4.0"
}
}DISCORD_TOKEN=paste-your-token-hererequire("dotenv").config(); // reads the token from the .env file
const path = require("node:path");
const { DatabaseSync } = require("node:sqlite"); // comes with Node.js 22.13 or newer
const { Client, EmbedBuilder, Events, GatewayIntentBits, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } = require("discord.js");
const COIN = "🪙";
const DAILY = 200; // what /daily gives, once every 24 hours
const WORK = [50, 150]; // what /work gives: between these two amounts
const WORK_COOLDOWN = 3600; // seconds between one /work and the next
// What /buy sells: each item gives a role (put your own role IDs)
const SHOP = {
vip: { name: "VIP role", price: 5000, role: "123456789012345678" },
color: { name: "Colored name", price: 1500, role: "234567890123456789" },
};
// The file sits next to index.js: so it doesn't matter which folder it's started from
const db = new DatabaseSync(path.join(__dirname, "economy.db"));
db.exec(
"CREATE TABLE IF NOT EXISTS accounts (guild_id TEXT, user TEXT," +
" balance INTEGER NOT NULL DEFAULT 0, daily INTEGER NOT NULL DEFAULT 0," +
" work INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (guild_id, user))",
);
const create = db.prepare("INSERT OR IGNORE INTO accounts (guild_id, user) VALUES (?, ?)");
const read = db.prepare("SELECT balance, daily, work FROM accounts WHERE guild_id = ? AND user = ?");
// Subtracts only if the balance is enough, in a single statement: two commands at once can't spend the same coins
const charge = db.prepare("UPDATE accounts SET balance = balance - ? WHERE guild_id = ? AND user = ? AND balance >= ?");
const deposit = db.prepare("UPDATE accounts SET balance = balance + ? WHERE guild_id = ? AND user = ?");
// They add and record the time only if the wait is over: it survives restarts
const claimDaily = db.prepare("UPDATE accounts SET balance = balance + ?, daily = ? WHERE guild_id = ? AND user = ? AND daily <= ?");
const claimWork = db.prepare("UPDATE accounts SET balance = balance + ?, work = ? WHERE guild_id = ? AND user = ? AND work <= ?");
const adjust = db.prepare("UPDATE accounts SET balance = MAX(0, balance + ?) WHERE guild_id = ? AND user = ?");
const top = db.prepare("SELECT user, balance FROM accounts WHERE guild_id = ? AND balance > 0 ORDER BY balance DESC LIMIT 10");
// That person's row in that server; if it doesn't exist, it's created at zero.
// IDs are stored as text: Discord's don't fit in a JavaScript number.
function account(guild_id, user) {
create.run(guild_id, user);
return read.get(guild_id, user);
}
const client = new Client({ intents: [GatewayIntentBits.Guilds] }); // no privileged intents
const command = (name, text) => new SlashCommandBuilder().setName(name).setDescription(text).setContexts(InteractionContextType.Guild);
const commands = [
command("balance", "Your coins (or someone else's)").addUserOption((o) => o.setName("user").setDescription("Whose")),
command("daily", "Collect your daily coins"),
command("work", "Earn some coins (once an hour)"),
command("pay", "Give coins to someone else")
.addUserOption((o) => o.setName("user").setDescription("Who to").setRequired(true))
.addIntegerOption((o) => o.setName("amount").setDescription("How many").setRequired(true).setMinValue(1).setMaxValue(1_000_000)),
command("shop", "What you can buy"),
command("buy", "Buy something from the shop").addStringOption((o) =>
o
.setName("item")
.setDescription("What")
.setRequired(true)
.addChoices(...Object.entries(SHOP).map(([value, a]) => ({ name: a.name, value }))),
),
command("leaderboard", "The 10 richest in the server"),
command("give", "(Admins) Give or take coins")
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) // by default, only people who can manage the server
.addUserOption((o) => o.setName("user").setDescription("Who to").setRequired(true))
.addIntegerOption((o) => o.setName("amount").setDescription("Negative takes away").setRequired(true)),
];
const justYou = (content) => ({ content, flags: MessageFlags.Ephemeral });
client.on(Events.InteractionCreate, async (i) => {
if (!i.isChatInputCommand() || !i.inCachedGuild()) return;
const g = i.guildId;
const now = Math.floor(Date.now() / 1000); // in seconds, like <t:…:R>
if (i.commandName === "balance") {
const user = i.options.getUser("user") ?? i.user;
await i.reply(`${user.displayName} has ${account(g, user.id).balance} ${COIN}`);
} else if (i.commandName === "daily") {
const { daily } = account(g, i.user.id);
if (claimDaily.run(DAILY, now, g, i.user.id, now - 86400).changes === 0) {
// Discord shows <t:…:R> as “in 3 hours”, in each person's language
return i.reply(justYou(`Already collected. Come back <t:${daily + 86400}:R>`));
}
await i.reply(`+${DAILY} ${COIN} for today`);
} else if (i.commandName === "work") {
const { work } = account(g, i.user.id);
const earned = WORK[0] + Math.floor(Math.random() * (WORK[1] - WORK[0] + 1));
if (claimWork.run(earned, now, g, i.user.id, now - WORK_COOLDOWN).changes === 0) {
return i.reply(justYou(`You're resting. Come back <t:${work + WORK_COOLDOWN}:R>`));
}
await i.reply(`You worked and earned ${earned} ${COIN}`);
} else if (i.commandName === "pay") {
const target = i.options.getUser("user", true);
const amount = i.options.getInteger("amount", true);
if (target.bot || target.id === i.user.id) return i.reply(justYou("Not to yourself or to a bot."));
account(g, target.id);
const { balance } = account(g, i.user.id);
// no await between charge and deposit: nothing can sneak in between
if (charge.run(amount, g, i.user.id, amount).changes === 0) return i.reply(justYou(`Not enough: you have ${balance} ${COIN}`));
deposit.run(amount, g, target.id);
await i.reply(`${i.user} gives ${amount} ${COIN} to ${target}`);
} else if (i.commandName === "shop") {
const lines = Object.values(SHOP).map((a) => `**${a.name}** · ${a.price} ${COIN}`);
const card = new EmbedBuilder().setColor(0x5b4ae8).setTitle("🛒 Shop").setDescription(`${lines.join("\n")}\n\nBuy with /buy`);
await i.reply({ embeds: [card] });
} else if (i.commandName === "buy") {
const a = SHOP[i.options.getString("item", true)];
const role = i.guild.roles.cache.get(a.role);
if (!role) return i.reply(justYou("That role doesn't exist: check SHOP in the code."));
if (i.member.roles.cache.has(role.id)) return i.reply(justYou("You already have it."));
account(g, i.user.id);
if (charge.run(a.price, g, i.user.id, a.price).changes === 0) return i.reply(justYou(`Not enough: it costs ${a.price} ${COIN}`));
try {
await i.member.roles.add(role, "Bought in the shop");
} catch {
deposit.run(a.price, g, i.user.id); // the role couldn't be given: the coins are refunded
return i.reply(justYou("I couldn't give you the role (do I have “Manage Roles” and is my role above it?). Your coins are refunded."));
}
await i.reply(`Bought: ${a.name} ✅`);
} else if (i.commandName === "leaderboard") {
const rows = top.all(g);
if (!rows.length) return i.reply("Nobody has coins yet: try /daily");
// a mention inside an embed shows the name and doesn't ping anyone
const lines = rows.map((f, n) => `**${n + 1}.** <@${f.user}> · ${f.balance} ${COIN}`);
await i.reply({ embeds: [new EmbedBuilder().setColor(0x5b4ae8).setTitle("🏆 Richest").setDescription(lines.join("\n"))] });
} else if (i.commandName === "give") {
const target = i.options.getUser("user", true);
const amount = i.options.getInteger("amount", true);
account(g, target.id);
adjust.run(amount, g, target.id); // negative takes away, never below zero
await i.reply(justYou(`Done: ${amount > 0 ? "+" : ""}${amount} ${COIN} to ${target.displayName}`));
}
});
client.once(Events.ClientReady, async (c) => {
await c.application.commands.set(commands.map((x) => x.toJSON())); // publishes the commands
console.log(`Logged in as ${c.user.tag}`);
});
client.login(process.env.DISCORD_TOKEN);Nobody spends the same coins twice
The bug in almost every homemade economy bot: read the balance, check it's enough and subtract afterwards, in three steps with an await in between. If someone runs /pay twice in a row very fast (or /pay and /buy at once), both commands read the same balance before the other subtracts, and what was there once gets spent twice. That's where negative balances and coins out of nowhere come from.
The fix is to check and subtract in a single SQL statement: UPDATE accounts SET balance = balance - ? WHERE guild_id = ? AND user = ? AND balance >= ?. If it isn't enough, no row changes, and the bot knows from rowcount in Python or changes in node:sqlite. That's what charge does in both examples. Same with /daily and /work: the cooldown goes in the WHERE, so running it twice at once only pays once.
And when you have to call Discord between charging and delivering (giving a role can fail), charge first and refund if it fails, like /buy. The other way round, giving the role and charging afterwards, someone could keep the role without paying.
Cooldowns that survive a restart
Many bots keep the /daily cooldown in memory (a dictionary, a Map or the library's cooldown): when the bot restarts, everyone can claim again. Here the time of the last claim goes in the database (the daily and work columns, in seconds), so it doesn't matter how often you restart it.
To say when someone can come back, the bot writes the time like this: <t:1793426844:R>. Discord shows it as “in 5 hours”, in each person's language and time zone, and keeps it updated by itself. With :F instead of :R you get the full date and time.
The shop: selling roles
Each item in SHOP gives a role. The bot needs the “Manage Roles” permission and its own role has to be above the ones it sells, in “Server Settings” → “Roles”: Discord doesn't let you give a role higher than your own. If either is missing, /buy refunds the coins and says so.
The options of /buy come from SHOP as a list to pick from, and Discord allows up to 25. When you change the shop, restart the bot so it publishes the commands again; if the change doesn't show up in Discord, see why slash commands don't show up.
A bought role can be just for show (a color, a name) or unlock a private channel: give that role permission to see the channel and take it away from @everyone. For roles people add and remove for free with buttons, the roles bot is a better fit.
Keeping the economy from breaking
If coins only come in and nothing takes them out, within a month everyone is rich and the shop stops mattering. Do the maths: with a /daily of 200 and a /work of 50 to 150 every hour, a very active member makes more than 1000 a day. Set prices at that scale and give them things to spend on: expensive roles, new items now and then, giveaways with an entry fee.
/give is shown by default only to people with “Manage Server” (that's the command's default_member_permissions), but admins can change who sees it in “Server Settings” → “Integrations” → your bot. Use it for event prizes, not to fix balances by hand every day.
To earn coins by chatting, combine this bot with the leveling bot: add coins where it adds XP. And keep the coins as game money only: don't sell them for real money or run bets with anything that's bought.
Keeping it online all the time
An economy bot that's offline is a /daily that fails: slash commands only answer while the bot is running, and on your computer it shuts down with you. Free and paid options, compared, in where to host your Discord bot.
NapNodes has a free 256 MB bot that isn't shut down for being idle; you renew it every 7 days with “Start” (its catches, in this guide). Create it, pick Node.js or Python in “Settings” → “Language” and upload your files in “Files” (or put your repository in “Settings” → “GitHub repository”, with economy.db in .gitignore); the libraries in requirements.txt or package.json install themselves on startup. With Node.js, keep version 24 or 22 in “Version & startup” (node:sqlite isn't in 20). economy.db stays in the bot's folder between restarts, and you can download it from “Files” to keep your own copy.
Frequently asked questions
How do you make a daily command in a Discord bot?
Store the time of the last claim in the database and add coins only if 24 hours have passed, in a single statement (… WHERE daily <= now - 86400). That way the cooldown survives a restart and running it twice at once only pays once.
Why does my economy bot end up with negative balances?
Because it reads the balance, checks and subtracts in separate steps: two commands at once read the same balance and spend twice. Check and subtract in one statement, UPDATE … SET balance = balance - ? WHERE … AND balance >= ?, and see whether any row changed.
Does an economy bot need privileged intents?
No, if it only uses slash commands, like this one. You'd only need them to give coins for chatting (Message Content, if it reads the text) or when someone joins (Server Members).
Where are the bot's coins stored?
In economy.db, a SQLite file next to the code. It survives restarts; to avoid losing it, download it now and then and don't upload it to GitHub.
How many items fit in the shop?
With the pick list on /buy, up to 25, which is what Discord allows for one option. For more, change the option to free text or split the shop into categories.
Shall we set it up for you?
Discord bot hosting from €1.50 a month, never oversold: your RAM is yours alone. And if you'd rather start without paying, there's a free Discord bot, no card needed.
Free tool: Discord bot permissions calculator. Tick your bot's permissions and get the number and the invite link; or paste a number and see which permissions it grants.
Keep reading
- Discord Leveling Bot: XP for Messages, Leaderboard and Roles (Python and JavaScript)
Make your own Discord leveling bot in Python or JavaScript: spam-proof XP for messages, /level, /leaderboard and roles on level-up, stored in SQLite.
- Discord Welcome Bot: Join Message and Auto Role (Python and JavaScript)
Build a Discord bot that greets new members and gives them a role automatically, in Python or JavaScript: the missing intent, role order and rules screening.
- How to Make a Discord Bot in Python (discord.py), Step by Step
Make your first Discord bot in Python with discord.py: create the application, get the token, add slash commands (/hello) and keep it online for free.