Skip to main content
TRACK B·BUILD REAL PRODUCTS·Verified June 2026 · Lua 5.4 · ox_lib 3.x
Learning with an AI assistant?
Copies this lesson plus 2026 ground rules (no lua54 'yes', Cfx.re Portal, correct callback signatures) as a ready-to-paste mentor prompt.

Capstone: a mini bank, step by step

By the end of this lesson you will run a server-console /bank command that changes a practice ledger in MariaDB. This is not yet a framework bank: it does not remove pocket cash on deposit or grant pocket cash on withdrawal. It isolates the database rule so you can prove validation before connecting it to real money.

You'll build
qu_bank: one balance table in MariaDB and one server file that runs /bank deposit, withdraw, and balance.
Time
About 60 minutes if you have never used oxmysql before.
Prereqs
Lessons 14 (events), 15 (callbacks), and 16 (ox_lib menus).
Outcome
A server-only ledger demo that rejects invalid amounts and a withdraw larger than the stored balance.
Cruze builds an ATM robbery with ox_lib, the same server-authority pattern.
BEFORE YOU START

Build it

Make the resource folder

The server has one folder for this lesson.

Inside your server's resources folder, create this folder:

code
resources/qu_bank

Create the files

Every file named in the manifest exists.

Create this exact file layout:

code
resources/qu_bank/
fxmanifest.lua
server.lua

This is a server-only resource. There is no client file and no NUI, so there is no F8 output to read. Every line you see will land in the txAdmin Live Console.

Write fxmanifest.lua

FiveM knows which files to load.

Open fxmanifest.lua and paste this:

code
fx_version 'cerulean'
game 'gta5'

server_script '@oxmysql/lib/MySQL.lua'
server_script 'server.lua'

dependencies {
'oxmysql'
}

Notice there is no lua54 'yes' line. As of June 2025 that directive is deprecated and ignored, since Lua 5.4 is now the only Lua runtime. The dependencies { 'oxmysql' } block is the canonical plural form: it tells the server to start oxmysql before qu_bank, so the @oxmysql/lib/MySQL.lua import resolves. You may see the singular dependency 'oxmysql' in older code; it is tolerated, but the plural block is the form to learn.

Create the balance table

The database has one row per player, keyed so it cannot duplicate.

Run this SQL in your database before starting the resource. oxmysql works with both MySQL and MariaDB; MariaDB is the most common choice for FiveM servers and benchmarks fastest in oxmysql's own tests:

code
CREATE TABLE IF NOT EXISTS qu_bank_accounts (
citizenid VARCHAR(64) NOT NULL,
balance INT NOT NULL DEFAULT 0,
PRIMARY KEY (citizenid)
);

The table keys on a character id, not an auto-increment row id. QBCore and Qbox call that value citizenid; ESX uses its active character identifier instead. The primary key makes one ledger row per active character id.

Write the lesson code

The /bank command runs deposit, withdraw, and balance on the server.

Open server.lua and paste this:

code
-- For the lesson we use one fixed account so you can test from the console.
-- In a real server this comes from the player's framework identity.
local TEST_CITIZEN = 'ABC12345'

-- Read the balance, creating the account at 0 on first touch.
local function getBalance(citizenid)
local row = MySQL.single.await(
    'SELECT balance FROM qu_bank_accounts WHERE citizenid = ?',
    { citizenid }
)
if not row then
    MySQL.insert.await(
        'INSERT INTO qu_bank_accounts (citizenid, balance) VALUES (?, ?)',
        { citizenid, 0 }
    )
    return 0
end
return row.balance
end

RegisterCommand('bank', function(source, args)
local action = args[1]
local amount = tonumber(args[2])
local citizenid = TEST_CITIZEN

if action ~= 'balance' and (not amount or amount <= 0 or amount ~= math.floor(amount)) then
    print('[qu_bank] amount must be a positive whole number')
    return
end

local balance = getBalance(citizenid)

if action == 'balance' then
    print('[qu_bank] balance for ' .. citizenid .. ' is ' .. balance)

elseif action == 'deposit' then
    local newBalance = balance + amount
    MySQL.update.await(
        'UPDATE qu_bank_accounts SET balance = ? WHERE citizenid = ?',
        { newBalance, citizenid }
    )
    print('[qu_bank] deposited ' .. amount .. ', balance now ' .. newBalance)

elseif action == 'withdraw' then
    if amount > balance then
        print('[qu_bank] REJECTED withdraw of ' .. amount .. ', balance is only ' .. balance)
        return
    end
    local newBalance = balance - amount
    MySQL.update.await(
        'UPDATE qu_bank_accounts SET balance = ? WHERE citizenid = ?',
        { newBalance, citizenid }
    )
    print('[qu_bank] withdrew ' .. amount .. ', balance now ' .. newBalance)

else
    print('[qu_bank] usage: bank deposit|withdraw|balance <amount>')
end
end, true)

Start and test it

The expected proof appears in the txAdmin console.

Open server.cfg and add this line:

code
ensure qu_bank

Save. If this is the first time you are starting the resource this session, type ensure qu_bank in the txAdmin Live Console to start it. After any later edit to the files, use restart qu_bank to reload it:

code
ensure qu_bank

Now run these four commands in order, from the txAdmin Live Console:

code
bank balance
bank deposit 100
bank withdraw 250
bank withdraw 40

The third line is the whole point. You asked to withdraw 250 from a balance of 100, and the server said no. The balance never went negative, and the fourth line proves the account still works afterward.

CLIENT
the player's game
network
SERVER
validate here
✓ trust boundary
The client asks to deposit; the server checks the amount and owns the balance. Never trust a client-sent number.

Keep reading the full lesson

Sign in to start, then unlock every step of this lesson and the full FiveM School with a membership.

Still ahead in this lesson
  • How it works
  • If something went wrong
  • What you can do now
  • Try it yourself
  • Connecting it to real money (ESX and QBCore)

The remainder of Mini banking capstone is available to FiveM School members.