qb-vehiclesales
qb-vehiclesales is the player-to-player used car lot. An owner parks a vehicle they own, lists it
at a price, and another player buys it. Ownership moves between player_vehicles rows and the
seller receives the sale price minus a commission.
It is distinct from qb-vehicleshop, which sells new vehicles from a dealership catalogue, and from qb-garages, which only stores vehicles a player already owns.
Note the internal namespace: the resource folder is qb-vehiclesales, but almost every event is
qb-occasions:. Only one callback uses the qb-vehiclesales: prefix.
Before deploying it, read The listing price comes from the client.
The manifest at the commit linked above declares version 1.5.0.
Dependencies and start order
From the manifest:
- Shared:
config.lua,@qb-core/shared/locale.lua, locales - Client:
@PolyZone/client.lua,BoxZone,EntityZone,CircleZone,ComboZone,client.lua - Server:
@oxmysql/lib/MySQL.lua,server.lua ui_page 'html/ui.html', with a bundledvue.min.js
Start order: oxmysql, qb-core, PolyZone, then qb-vehiclesales. Your garage resource should
also be started, because ownership rows written here are what garages read.
Database
The repository ships qb-vehiclesales.sql:
CREATE TABLE IF NOT EXISTS `occasion_vehicles` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`seller` varchar(50) DEFAULT NULL,
`price` int(11) DEFAULT NULL,
`description` longtext DEFAULT NULL,
`plate` varchar(50) DEFAULT NULL,
`model` varchar(50) DEFAULT NULL,
`mods` text DEFAULT NULL,
`occasionid` varchar(50) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `occasionId` (`occasionid`)
) ENGINE=InnoDB AUTO_INCREMENT=325 DEFAULT CHARSET=utf8mb4;seller holds a citizenid. occasionid is a generated listing ID used together with the plate to
address a listing.
The resource also reads and writes two tables it does not own:
player_vehicles, for the actual ownership rows. Listing deletes the owner’s row, buying inserts a new one for the buyer, delisting reinserts it for the seller.players, to pay an offline seller by rewriting theirmoneyJSON column directly.
Back up both before first deployment. A failure between the delete and the insert loses a vehicle.
Configuration
config.lua is short at this commit.
| Key | Default | Meaning |
|---|---|---|
Config.UseTarget | GetConvar('UseTarget', 'false') == 'true' | Set setr UseTarget true in server.cfg |
Config.Zones | table | Lot zones, display spots, and interaction points |
There is no configurable commission, price ceiling, or listing limit. Those are hardcoded, see below.
Server surface
| Handler | Kind | Arguments |
|---|---|---|
qb-occasions:server:getVehicles | callback | none, returns all listings |
qb-occasions:server:checkVehicleOwner | callback | plate |
qb-occasions:server:getSellerInformation | callback | citizenid |
qb-vehiclesales:server:CheckModelName | callback | plate |
qb-occasions:server:sellVehicle | net event | vehiclePrice, vehicleData |
qb-occasions:server:buyVehicle | net event | vehicleData |
qb-occasions:server:ReturnVehicle | net event | vehicleData |
qb-occasions:server:sellVehicleBack | net event | vehData |
Ownership checks
Listing and selling back both prove ownership through the database rather than through a client claim, which is the right shape:
local ownsVehicle = MySQL.query.await(
'DELETE FROM player_vehicles WHERE plate = ? AND citizenid = ? and vehicle = ?',
{ vehicleData.plate, Player.PlayerData.citizenid, vehicleData.model }
)
if ownsVehicle.affectedRows > 0 then
-- the row existed and belonged to this citizenid, proceed
endReturnVehicle similarly checks that result[1].seller equals the caller’s citizenid before
handing the vehicle back.
The listing price comes from the client
qb-occasions:server:sellVehicle takes vehiclePrice as its first argument and writes it straight
into occasion_vehicles.price. At this commit there is no minimum, no maximum, no type check, and no
comparison against the vehicle’s shared price.
That is fine as long as the seller is the one who sets it, which is the intended flow. It becomes a problem when combined with the buy path, because a listing price is also what the buyer is charged and what the seller is paid. A modified client can list at an arbitrary value.
If you accept player-set prices at all, clamp them server side before this reaches the database:
-- in your own wrapper, or patched into server.lua
local MIN_PRICE, MAX_PRICE = 1, 5000000
local price = math.floor(tonumber(vehiclePrice) or 0)
if price < MIN_PRICE or price > MAX_PRICE then return endTreat every client argument the same way. See Server functions for the core helpers available on the server side, and the server-authority checklist for the review questions.
Buying and commission
buyVehicle requires the buyer’s bank balance to cover result[1].price, removes that amount,
inserts a player_vehicles row for the buyer, and pays the seller
math.ceil((price / 100) * 77). The 23 percent difference is a hardcoded commission. It is not
configurable at this commit.
If the seller is online the payment goes through SellerData.Functions.AddMoney('bank', ...). If
they are offline, the resource reads the players row, decodes the money JSON, adjusts it, and
writes it back. That path bypasses the normal money API, so it will not appear in any transaction
log your core keeps.
Selling back to the lot
sellVehicleBack pays 50 percent of the vehicle’s shared price, looked up from the shared vehicle
list by hash, and deletes the player_vehicles row. A vehicle whose model is not in your shared
vehicles list resolves to a price of 0 and the player receives nothing while still losing the row.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| ”Not your vehicle” for a vehicle the player owns | The player_vehicles row’s vehicle or hash column does not match what the client sent. sellVehicle matches on vehicle, sellVehicleBack matches on hash |
| Vehicle vanished after listing | The delete succeeded and the insert into occasion_vehicles failed. Check the oxmysql log |
| Seller never paid | The seller was offline and the players row rewrite failed, or the seller’s citizenid no longer exists |
| Selling back pays nothing | The model is missing from your shared vehicles list, so the lookup returns 0 |
| Listings do not appear | occasion_vehicles was not created, or the callback errored. Check the server console |
| Absurd listing prices | The price is accepted from the client unvalidated. Add a clamp |
Proposed manual smoke test
Not yet executed. On staging, with a database backup:
- Confirm
occasion_vehiclesexists before starting the resource. - List a vehicle, confirm the
player_vehiclesrow is gone and theoccasion_vehiclesrow exists. - Buy it as a second character and confirm both balances and the new ownership row.
- Confirm the seller receives 77 percent, and repeat with the seller offline.
- Delist a vehicle and confirm ownership returns.
- Sell one back to the lot and confirm the 50 percent payout.