Lesson 23 · Server-only banking with audit logs · Quasar Academy · FiveM School
TRACK B·BUILD REAL FEATURES·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.
Module B6 · Build real features
Server-authoritative banking with an audit log
The mini bank proved the server owns the balance. This lesson adds the part a real economy cannot run without: a record. Every deposit and withdraw now runs through one server-side handler, triggered by a server-registered command that hands you the real connected player's source. The server reads that player's true balance, applies the change in a single safe UPDATE, and writes an audit row that captures who, what, how much, the balance before, and the balance after. The client sends an intent. It never sends a balance, and now it can never move money without leaving a trace.
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
Client sends an intent, never a balance -> server reads the real balance -> one safe UPDATE -> an INSERT into the audit log on every action -> read the trail back
Prereqs
SQL from zero (oxmysql, parameterized queries) and the mini bank capstone (server-held balance, reject-if-larger) complete.
BEFORE YOU START
Build it
1
Make the resource folder
The server has one folder for this lesson.
Inside your server's resources folder, create this folder:
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.
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.
4
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. Run this SQL once against your database before starting the resource. 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(16) 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.
5
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
-- In a real server the citizenid comes from the player's framework identity
-- (QBCore, Qbox, ESX). For the lesson we derive a stable id from the source so
-- you can test with a real connected player and still get a consistent account.
local function citizenIdFor(source)
return 'player_' .. source
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)
-- 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
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)
6
Start, join, and test it
The expected proof appears in the txAdmin Live Console.
Open server.cfg and add this line:
code
ensure qu_audit_bank
Save, then run:
code
restart qu_audit_bank
Now join your own server so you have a real player source. The deposit and withdraw commands are not restricted, so run them from your in-game chat box (press T). The auditlog command is restricted, so run it from the txAdmin Live Console:
code
/deposit 100
/withdraw 250
/withdraw 40
Then in the txAdmin Live Console:
code
auditlog
Your source number may differ from 1; it is your real server slot. Notice the rejected withdraw of 250 never reaches the audit log, because no money moved. Only the two transactions that actually changed the balance left a row.
Keep reading the full lesson
Sign in to start, then unlock every step of this lesson and the full FiveM School with a membership.