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.

Server-authoritative banking with an audit log

The mini bank proved the server owns the practice balance. This lesson adds an audit record and resolves the connected player to the framework's active character id. It is still a teaching ledger, not a drop-in replacement for framework cash: a deposit is restricted to administrators, and production code must make the balance write and audit insert one atomic transaction.

You'll ship
qu_audit_bank: two MariaDB tables and one server file where the real player triggers a deposit or withdraw, and every transaction writes an audit row.
Time
~50 minutes
You'll learn
Resolve source to the active character -> validate an intent -> update a practice balance -> append an audit row -> understand the transaction work still required
Prereqs
SQL from zero (oxmysql, parameterized queries) and the mini bank capstone (server-held balance, reject-if-larger) complete.
Quasar School: the banking system, comprehensive.
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_audit_bank

Create the files

Every file named in the manifest exists.

Create this exact file layout:

code
resources/qu_audit_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. You will drive it with commands that you run from your own in-game chat, because a command typed in chat by a connected player carries a real player source, and the source is the heart of this lesson.

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'
}

There is no lua54 'yes' line. As of June 2025 that directive is deprecated and ignored, since Lua 5.4 is the only Lua runtime. The dependencies { 'oxmysql' } block is the canonical plural form and tells the server to start oxmysql before qu_audit_bank, so the @oxmysql/lib/MySQL.lua import resolves.

Create the two tables

The database has one balance per player and one append-only audit log.

A bank with a record needs two tables, not one. You run this SQL once in a database client connected to the same MariaDB database your mysql_connection_string in server.cfg points at. Use whatever client you have: HeidiSQL or DBeaver on Windows, or the database/SQL tab some panels expose. Open a new query window, paste both statements, and execute. oxmysql speaks to MariaDB, the recommended engine for FiveM in 2026:

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

CREATE TABLE IF NOT EXISTS qu_audit_log (
id INT AUTO_INCREMENT PRIMARY KEY,
citizenid VARCHAR(64) NOT NULL,
source INT NOT NULL,
action VARCHAR(32) NOT NULL,
amount INT NOT NULL,
balance_before INT NOT NULL,
balance_after INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

The balances table keys on citizenid: one row per player, and the primary key forbids a duplicate. The audit log is the opposite shape on purpose. It keys on an AUTO_INCREMENT id and is append-only, so every transaction adds a new row and nothing is ever overwritten. The created_at column fills itself in with the server time on every insert, so you never pass a timestamp by hand.

Write the lesson code

A real player's intent flows through a server event into a balance change plus an audit row.

Open server.lua and paste this:

code
-- Resolve the active character id. A server source is only a temporary slot;
-- never use it as the durable account key.
local function citizenIdFor(source)
if GetResourceState('qbx_core') == 'started' then
    local player = exports.qbx_core:GetPlayer(source)
    return player and player.PlayerData.citizenid
elseif GetResourceState('qb-core') == 'started' then
    local core = exports['qb-core']:GetCoreObject()
    local player = core.Functions.GetPlayer(source)
    return player and player.PlayerData.citizenid
elseif GetResourceState('es_extended') == 'started' then
    local esx = exports.es_extended:getSharedObject()
    local player = esx.GetPlayerFromId(source)
    return player and player.identifier
end
end

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

-- The single trusted path. Every money move on the server goes through here.
-- This is a same-runtime handler fired by AddEventHandler below, so the player
-- is passed in explicitly as `src` (the magic `source` is only populated for a
-- real client -> server net event, which this is not).
AddEventHandler('qu_audit_bank:request', function(src, action, amount)
local citizenid = citizenIdFor(src)
if not citizenid then
    print('[qu_audit_bank] REJECTED: no loaded framework character for source ' .. src)
    return
end

-- Validate the intent. The client may only ask to deposit or withdraw a
-- positive whole number. It never sends a balance.
amount = tonumber(amount)
if action ~= 'deposit' and action ~= 'withdraw' then return end
if not amount or amount <= 0 or amount ~= math.floor(amount) then
    print('[qu_audit_bank] REJECTED bad amount from source ' .. src)
    return
end

-- This demo has no cash account to debit, so only an administrator may
-- create ledger value with /deposit. A real ATM would remove framework
-- cash here and continue only if that server-side removal succeeded.
if action == 'deposit' and not IsPlayerAceAllowed(src, 'qu_audit_bank.deposit') then
    print('[qu_audit_bank] REJECTED unauthorized deposit from source ' .. src)
    return
end

local balanceBefore = getBalance(citizenid)
local balanceAfter

if action == 'deposit' then
    balanceAfter = balanceBefore + amount
else -- withdraw
    if amount > balanceBefore then
        print('[qu_audit_bank] REJECTED withdraw of ' .. amount .. ', balance is only ' .. balanceBefore)
        return
    end
    balanceAfter = balanceBefore - amount
end

-- One safe UPDATE applies the change the server computed.
MySQL.update.await(
    'UPDATE qu_audit_balances SET balance = ? WHERE citizenid = ?',
    { balanceAfter, citizenid }
)

-- One INSERT records the transaction. This runs for every action.
MySQL.insert.await(
    'INSERT INTO qu_audit_log (citizenid, source, action, amount, balance_before, balance_after) VALUES (?, ?, ?, ?, ?, ?)',
    { citizenid, src, action, amount, balanceBefore, balanceAfter }
)

print('[qu_audit_bank] ' .. action .. ' ' .. amount ..
    ' for ' .. citizenid .. ': ' .. balanceBefore .. ' -> ' .. balanceAfter)
end)

-- Thin commands so you can fire the request as a real player from chat.
-- These commands are registered in a server_script, so their handlers run on
-- the server, where RegisterCommand hands you the player's server id as the
-- first argument. We pass that trusted source into the same-runtime event with
-- TriggerEvent (the correct same-runtime call; TriggerServerEvent is client-only).
RegisterCommand('deposit', function(source, args)
TriggerEvent('qu_audit_bank:request', source, 'deposit', args[1])
end, false)

RegisterCommand('withdraw', function(source, args)
TriggerEvent('qu_audit_bank:request', source, 'withdraw', args[1])
end, false)

-- Print the last few audit rows so you can read the trail.
RegisterCommand('auditlog', function()
local rows = MySQL.query.await(
    'SELECT id, citizenid, source, action, amount, balance_before, balance_after FROM qu_audit_log ORDER BY id DESC LIMIT 5',
    {}
)
for i = #rows, 1, -1 do
    local r = rows[i]
    print('[qu_audit_bank] #' .. r.id .. ' src=' .. r.source ..
        ' ' .. r.action .. ' ' .. r.amount ..
        ' (' .. r.balance_before .. ' -> ' .. r.balance_after .. ')')
end
end, true)

Start, join, and test it

The expected proof appears in the txAdmin Live Console.

Open server.cfg and add these lines. The ACE lets txAdmin administrators use the demo deposit; ordinary players cannot mint ledger value:

code
ensure qu_audit_bank
add_ace group.admin qu_audit_bank.deposit allow

Save, then run:

code
restart qu_audit_bank

Join with a loaded framework character and a txAdmin administrator account. Run deposit and withdraw from your in-game chat box (press T). The handler performs the explicit deposit ACE check. auditlog is registered as a restricted command (the true you passed to RegisterCommand), which means a normal player needs the command.auditlog ACE to run it. We never grant that ACE to anyone, so type auditlog in the txAdmin Live Console instead: the server console runs as the system principal that is allowed every ACE, so it bypasses the restriction. A player who types /auditlog in chat is silently denied, which is exactly what you want for an admin-only command:

code
/deposit 100
/withdraw 250
/withdraw 40

Then in the txAdmin Live Console:

code
auditlog

Your source number and character id will differ from the sample. QBCore/Qbox print the active citizenid; ESX prints the active character identifier. The rejected withdraw never reaches the transaction log because no balance moved.

CLIENT
the player's game
network
SERVER
validate here
✓ trust boundary
Every money move is a server-side transaction the client can only request, never perform.

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
  • Common mistakes
  • What you can do now
  • Try it yourself

The remainder of Server-only banking and audit is available to FiveM School members.