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).
| RPC | params → 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):
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|nilErrors: invalid_amount, invalid_number, self, unknown_number, insufficient_funds, payment_failed, transfer_failed. Amounts are positive integers ≤ Config.City.maxTransfer.
Exports:
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).
| RPC | params → 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 }.
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 tableEvent 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).
| RPC | params → 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 withCreateVehicleServerSetter6 m in front of the player (typeautomobile, plate set; refund on failure). It does not change the garage state in your DB — keep itfalsewhen your garage script handles the event.- Key sharing also calls
qbx_vehiclekeys:GiveKeys/qb-vehiclekeys:GiveKeys(orqb-vehiclekeys:client:AddKeys) when started. impoundReleaseDb = truesetsplayer_vehicles.state = 1on 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:
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.