Skip to content

City apps — Wallet, Mail, Garage, Groups, Jobs, Info ​

Files: server/apps/{wallet,mail,garage,groups,jobs,info}.lua, client/apps/garage.lua, shared/config/city.lua (Config.City), sql/city.sql, web/src/apps/{wallet,mail,garage,groups,jobs,info}/.

Wallet ​

Card with bank balance (getMoney 'bank'), cash, quick actions Send / Request / Invoices / History / IDs, orange banner for invoices due within overdueWarnDays, recent transactions, paginated history (infinite scroll).

RPCparams → result
wallet.summary→ { bank, balance, cash, today } (home / lock widget; today = sum of today's transactions)
wallet.overview→ { balance, cash, today, card, recent, unpaid, alert }
wallet.transactions{ before? } → { items, hasMore } (page size transactionsPageSize)
wallet.lookup{ number } → { number, name?, exists } (name via Phone.Contacts.getName; number normalised like calls, 5550142 → 555-0142)
wallet.send{ number, amount, note? } → { balance }
wallet.request{ number, amount, reason? } → { id } (invoice of kind request for the receiver)
wallet.invoices→ { unpaid, history, sent, allowDecline } (sent[].sender = the recipient: your contact name for them, else their number)
wallet.payInvoice / wallet.declineInvoice / wallet.cancelRequest{ id }
wallet.ids→ { firstname, lastname, dob, gender, nationality, stateId, licenses[] }
wallet.showId→ { shown } — nearest player within idShowRadius gets a notification (+ push wallet:idShown)

Push: wallet:update, wallet:invoice, wallet:idShown. Notification data = { view = 'invoices' | 'history' | 'shownId', card? } deep-links (money received / request paid → history, invoices and declined requests → invoices). wallet.summary is registered { public = true }: the lock-screen widget asks for it before the passcode was entered (Config.EnforcePasscode). The home / lock widgets reload after unlocking and on wallet:update.

Server API (other areas):

lua
Phone.Wallet.transfer(src, targetNumber, amount, note) -> ok, err  -- bank→bank, offline receivers via addMoneyOffline
Phone.Wallet.charge(src, amount, reason, payee)        -> ok, err  -- bank charge + transaction
Phone.Wallet.log(identifier, signedAmount, title, { subtitle, icon, tint, number })
Phone.Wallet.createInvoice(srcOrIdentifier, data)      -> id
Phone.Wallet.nearestPlayer(src, radius)                -> src|nil

Errors: invalid_amount, invalid_number, self, unknown_number, insufficient_funds, payment_failed, transfer_failed. Amounts are positive integers ≤ Config.City.maxTransfer.

Exports:

lua
exports.np_phone:createInvoice(targetSrcOrIdentifier, { sender = 'LSPD', senderJob = 'police', amount = 350,
  reason = 'Citation #4412 · Speeding', dueAt = os.time() + 3 * 86400, senderIdentifier = nil })
exports.np_phone:logTransaction(srcOrIdentifier, -850, 'LS Customs', { subtitle = 'Invoice #0182', icon = 'fa-solid fa-wrench', tint = 'orange' })

Paying a business invoice calls Config.City.invoicePayout(invoice) (pcall) and fires the server event np_phone:invoicePaid (invoice) — use either to credit the society account. Payment requests credit the requester.

Mail ​

Inbox with folder chips (Primary / Transactions / Gov / Sent), unread dots + app badge, search, detail with action buttons, delete / mark unread, reply, compose to player addresses (first.last@<mailDomain>, created on first use).

RPCparams → result
mail.list{ folder, before?, search? } → { address, items, hasMore, unread }
mail.get{ id } → mail with body, actions (marks read)
mail.setRead{ id, read } · mail.delete { id } · mail.address → { address }
mail.send{ to, subject, body } → { id }
mail.action{ id, index } → { waypoint = {x,y} } | { done } — action is read from the stored mail (error busy while an action of the same mail runs)

Push: mail:new, phone:badge { app = 'mail', count }.

lua
Phone.Mail.send(identifier, { sender, subject, body, senderAddress?, folder? = 'primary'|'transactions'|'gov',
  actions = { { label = 'Set waypoint', type = 'waypoint', data = { x, y } },
              { label = 'Accept', type = 'event', data = { event = 'myjob:accept', args = {...}, client = false, once = true } } } })
exports.np_phone:sendMail(srcOrIdentifier, mail) -- same table

Event actions: server event TriggerEvent(event, src, args, mailId) (default) or client event TriggerClientEvent(event, src, args, mailId) with client = true. once = true disables the button after use. Mail from Config.City.govSenders lands in Gov when no folder is given.

Garage ​

Vehicles from Phone.fw('getVehicles', identifier) + vehicles whose keys were shared with you. Filter chips All / Out / Impound / Shared, fuel / engine / body bars, display names via the client natives (garage.describe client RPC).

RPCparams → result
garage.list→ { vehicles, fees = { valet, impound } } (out vehicles get coords, distance from GetAllVehicles)
garage.track{ plate } → { x, y, label } (out: live entity; garaged: Config.City.garages[garage]; impounded: impound)
garage.valet{ plate } → { charged } (with valetSpawn: error already_out while a vehicle with that plate exists; the event fires after a successful spawn)
garage.shareKeys{ plate, target? } → { name } (target server id, nearest player when omitted, max 15 m)
garage.keys / garage.revokeKeys{ plate } / { plate, target }
garage.release{ plate } → { charged }
garage.describe (client){ models, points } → { labels, zones }

Server events: np_phone:garage:valet (src, plate, vehicle), np_phone:garage:shareKeys (src, targetSrc, plate), np_phone:garage:revokeKeys (src, targetIdentifier, plate), np_phone:garage:impoundRelease (src, plate, vehicle). Fees are charged with Phone.Wallet.charge before the event fires.

  • valetSpawn = true: built-in spawn with CreateVehicleServerSetter 6 m in front of the player (type automobile, plate set; refund on failure). It does not change the garage state in your DB — keep it false when your garage script handles the event.
  • Key sharing also calls qbx_vehiclekeys:GiveKeys / qb-vehiclekeys:GiveKeys (or qb-vehiclekeys:client:AddKeys) when started.
  • impoundReleaseDb = true sets player_vehicles.state = 1 on qb/qbx.

Groups ​

In-memory groups: create, invite by number / nearby, join requests, ready check, kick, transfer leadership, rename. Available jobs from Config.City.groupJobs (waypoint + leader "Sign in" → server event np_phone:groups:signIn (groupId, jobId, leaderSrc)).

RPC: groups.get, groups.create {name}, groups.invite {number}|{nearby=true}, groups.respondInvite {id, accept}, groups.requestJoin {id}, groups.respondRequest {src, accept}, groups.leave, groups.kick {src}, groups.transfer {src}, groups.rename {name}, groups.readyCheck, groups.setReady {ready}, groups.signIn {jobId}. Push: groups:update { group | false } (snapshot from the receiver's point of view), groups:invite.

Exports for job scripts:

lua
local gid, info = exports.np_phone:groupGetByPlayer(src)          -- id, { id, name, leader, members, job }
exports.np_phone:groupGetMembers(gid)                             -- { src, ... }
exports.np_phone:groupStartJob(gid, { id = 'sanitation', label = 'Sanitation', icon = 'fa-solid fa-trash-can',
  stages = { { label = 'Pick up truck at depot' }, { label = 'Collect bags', total = 8 }, { label = 'Return truck' } },
  payout = '$1.4k', next = 'Depot · Alta St' })
exports.np_phone:groupSetStage(gid, 2, { next = 'Vinewood Blvd' })
exports.np_phone:groupSetProgress(gid, 5, 8)
exports.np_phone:groupUpdateJob(gid, { next = 'Alta St', payout = '$1.6k' })
exports.np_phone:groupFinishJob(gid, { payoutEach = 350 })      -- bank + transaction log per member
exports.np_phone:groupNotify(gid, { title = 'Sanitation', body = 'Truck is full!' })

Server events: np_phone:groups:created|disbanded|memberJoined|memberLeft. Live Activity: web/src/apps/groups/live.ts is the groups app's background service (manifest service, started by the shell after init, app open or not), so the Dynamic Island follows groups:update even while the app is closed: compact = job icon + 5/8, tap to expand "Sanitation · 5/8 bags / Next: Vinewood Blvd", tap again to open Groups. Invites and join requests are listed at the top of the app, so the invite / join-request notifications open it without params. Demo: set Config.City.demoJob.enabled = true, create a group, /np_phone_demojob.

Jobs ​

Current job + grade, duty toggle, job center list (Config.City.jobCenter) with Set waypoint and Apply (sends a Mail with a waypoint action, applyCooldown). RPC: jobs.get → { job, center }, jobs.setDuty {onduty} → { job }, jobs.apply {id} → { mailId }. Duty: Phone.fw('setDuty', src, bool) (bridge: qb/qbx SetJobDuty, ESX >= 1.10 setJob with onDuty); when it returns true the reply carries the framework's updated job, otherwise the requested state. The server event np_phone:jobs:setDuty (src, onDuty) always fires, so duty scripts can react on frameworks without duty support. np_phone:jobs:applied (src, jobId) on apply.

Info ​

info.get → { name, firstname, lastname, dob, gender, nationality, stateId, number, email, job, bank, cash, licenses, serverId }. Copy-number button; respects streamer mode.

np_* FiveM resources