Creating A Trader Shop
Buy/sell logic using the inventory bridge, the money bridge, stock checks, and server-side price validation — the pattern behind wc_trader. The menu UI comes from wc_menu; every actual transaction is decided on the server.
The golden rule of trader resources. The client only ever says "I want to buy/sell X." It never gets to say how much that's worth. Prices, stock limits, and item validity all live in a server-side table the client can't touch.
-
Define your catalog server-side
This table is the single source of truth for prices. It never ships to the client as anything other than display labels.
wc_trader/server/catalog.luaCatalog = { pelt_deer = { label = 'Deer Pelt', buyPrice = 4, sellPrice = 2, stock = 50 }, pelt_bear = { label = 'Bear Pelt', buyPrice = 15, sellPrice = 9, stock = 10 }, ammo_rifle = { label = 'Rifle Ammo', buyPrice = 1, sellPrice = 0, stock = 999 }, } -
Open the shop menu client-side
The menu is purely presentational — it shows the catalog and fires a server event per action. See wc_menu for the full item-type reference.
wc_trader/client/main.luaRegisterNetEvent('wc_trader:openShop', function(catalog) local items = { { type = 'label', label = 'Buy' } } for sku, entry in pairs(catalog) do items[#items + 1] = wc.menu.purchase({ id = 'buy_' .. sku, label = entry.label, price = { money = entry.buyPrice }, max = 10, serverEvent = 'wc_trader:buy', args = { sku = sku }, }) end wc.menu.open({ id = 'trader_shop', title = 'General Store', themePreset = 'shop', items = items }) end) -
Validate the purchase server-side
Look the SKU up in
Catalog, compute the real price frombuyPrice * quantity, check the player can afford it, then move money and items together.wc_trader/server/main.luaRegisterNetEvent('wc_trader:buy', function(payload) local src = source local sku = payload.args and payload.args.sku local qty = tonumber(payload.value) or 1 local item = Catalog[sku] if not item or qty < 1 or item.stock < qty then wc:Notify(src, { variant = 'fail', title = "That's out of stock." }) return end local total = item.buyPrice * qty if wc:GetMoney(src) < total then wc:Notify(src, { variant = 'fail', title = "Not enough cash." }) return end if not wc:CanCarryItem(src, sku, qty) then wc:Notify(src, { variant = 'fail', title = "You can't carry that much." }) return end wc:RemoveMoney(src, total) local ok = wc:AddItem(src, sku, qty) if not ok then wc:AddMoney(src, total) -- refund — inventory rejected the add after we already charged return end item.stock = item.stock - qty wc:Notify(src, { variant = 'avanced', title = ("Bought %dx %s"):format(qty, item.label) }) end)Order matters. Check afford → check carry → remove money → add item → refund on failure. Never add the item before confirming the money was actually removed, and always have a refund path if the item add fails after payment. -
Handle selling the same way, in reverse
wc_trader/server/main.luaRegisterNetEvent('wc_trader:sell', function(payload) local src = source local sku = payload.args and payload.args.sku local qty = tonumber(payload.value) or 1 local item = Catalog[sku] if not item then return end if not wc:HasItem(src, sku, qty) then wc:Notify(src, { variant = 'fail', title = "You don't have that many." }) return end local ok = wc:RemoveItem(src, sku, qty) if not ok then return end wc:AddMoney(src, item.sellPrice * qty) wc:Notify(src, { variant = 'avanced', title = ("Sold %dx %s"):format(qty, item.label) }) end)
Common mistakes
- Reading
payload.priceand using it as the actual charge — that field is client-built and untrustworthy. Always recompute from your ownCatalogtable. - Adding the item before confirming the money was actually removed, or not refunding when
AddItemfails after a successful charge. - Sharing one mutable
Catalog.stocktable across concurrent buyers without considering race conditions on a busy server — for high-traffic shops, consider a database-backed stock count instead of an in-memory table.