Files
sky_phone-sky-systems/sky_phone/source/server/skyride.lua
T
2026-08-13 17:36:23 +02:00

1869 lines
72 KiB
Lua

Bridge.Database.AfterMigration("sky_phone", function()
local allowed_cancel_reasons = {
changed_mind = true,
driver_delay = true,
emergency = true,
other = true,
wrong_pickup = true,
}
local quotes = {}
local online_drivers = {}
local operation_locks = {}
local services = {}
if Config.SkyRide.DistanceUnit ~= "kilometer" and Config.SkyRide.DistanceUnit ~= "mile" then
error(("[sky_phone] Invalid SkyRide distance unit '%s'."):format(tostring(Config.SkyRide.DistanceUnit)))
end
for index = 1, #Config.SkyRide.Services do
local service = Config.SkyRide.Services[index]
if service.Id ~= "taxi" and service.Id ~= "comfort" and service.Id ~= "xl" and service.Id ~= "premium" then
error(("[sky_phone] Invalid SkyRide service class '%s'."):format(tostring(service.Id)))
end
if services[service.Id] then
error(("[sky_phone] Duplicate SkyRide service class '%s'."):format(service.Id))
end
services[service.Id] = service
end
local ride_select = [[
SELECT r.*,
passenger.`owner_identifier` AS `passenger_identifier`,
COALESCE(NULLIF(passenger.`display_name`, ''), r.`passenger_name`) AS `passenger_profile_name`,
passenger_avatar.`url` AS `passenger_avatar_url`,
driver.`owner_identifier` AS `driver_identifier`,
COALESCE(NULLIF(driver.`display_name`, ''), r.`driver_name`) AS `driver_profile_name`,
driver_avatar.`url` AS `driver_avatar_url`,
UNIX_TIMESTAMP(r.`created_at`) AS `created_at_unix`,
UNIX_TIMESTAMP(r.`updated_at`) AS `updated_at_unix`,
UNIX_TIMESTAMP(r.`accepted_at`) AS `accepted_at_unix`,
UNIX_TIMESTAMP(r.`arrived_at`) AS `arrived_at_unix`,
UNIX_TIMESTAMP(r.`started_at`) AS `started_at_unix`,
UNIX_TIMESTAMP(r.`completed_at`) AS `completed_at_unix`,
UNIX_TIMESTAMP(r.`cancelled_at`) AS `cancelled_at_unix`
FROM `sky_phone_skyride_rides` r
INNER JOIN `sky_phone_skyride_profiles` passenger
ON passenger.`id` = r.`passenger_profile_id`
LEFT JOIN `sky_phone_media` passenger_avatar
ON passenger_avatar.`id` = passenger.`avatar_media_id`
LEFT JOIN `sky_phone_skyride_profiles` driver
ON driver.`id` = r.`driver_profile_id`
LEFT JOIN `sky_phone_media` driver_avatar
ON driver_avatar.`id` = driver.`avatar_media_id`
]]
local function affected_rows(result)
if type(result) == "number" then
return result
end
return type(result) == "table" and tonumber(result.affectedRows) or 0
end
local function player_name(source)
local firstname = Bridge.Framework.GetFirstname(source)
local lastname = Bridge.Framework.GetLastname(source)
local name = ((firstname or "") .. " " .. (lastname or "")):match("^%s*(.-)%s*$")
if name == "" then
name = GetPlayerName(source) or ("Player %s"):format(source)
end
return name:sub(1, 80)
end
local function get_player_coords(source)
local ped = GetPlayerPed(source)
if not ped or ped == 0 or not DoesEntityExist(ped) then
return nil
end
local coords = GetEntityCoords(ped)
return { x = coords.x, y = coords.y, z = coords.z }
end
local function distance_between(left, right)
local x = left.x - right.x
local y = left.y - right.y
local z = left.z - right.z
return math.sqrt((x * x) + (y * y) + (z * z))
end
local function is_near(source, location, radius)
local coords = get_player_coords(source)
return coords and distance_between(coords, location.coords) <= radius
end
local function validate_location(value)
if type(value) ~= "table" or type(value.coords) ~= "table" then
return nil
end
local label = type(value.label) == "string" and value.label:match("^%s*(.-)%s*$") or nil
local label_length = label and utf8.len(label) or nil
local x = tonumber(value.coords.x)
local y = tonumber(value.coords.y)
local z = tonumber(value.coords.z)
local id = value.id
if not label_length
or label_length < 1
or label_length > 80
or not x
or not y
or not z
or x ~= x
or y ~= y
or z ~= z
or math.abs(x) > 10000.0
or math.abs(y) > 10000.0
or z < -1000.0
or z > 3000.0
or (id ~= nil and (type(id) ~= "string" or #id > 40 or not id:match("^[%w_-]+$")))
then
return nil
end
return {
coords = { x = x, y = y, z = z },
id = id,
label = label,
}
end
local function quick_locations()
local result = {}
for index = 1, #Config.SkyRide.QuickLocations do
local location = Config.SkyRide.QuickLocations[index]
result[index] = {
coords = {
x = location.Coords.x,
y = location.Coords.y,
z = location.Coords.z,
},
id = location.Id,
label = location.Label,
}
end
return result
end
local function generate_uuid()
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
local id = rows[1] and rows[1].id
if type(id) ~= "string" then
error("[sky_phone] Database did not generate a SkyRide id.")
end
return id
end
local function discard_quote_group(group)
for quote_id, quote in pairs(quotes) do
if quote.group == group then
quotes[quote_id] = nil
end
end
end
local function require_profile(source)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return nil, error_response
end
local identifier = Bridge.Framework.GetIdentifier(source)
if type(identifier) ~= "string" or identifier == "" or #identifier > 80 then
return nil, { success = false, error = "skyride_unavailable" }
end
Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_skyride_profiles` (`id`, `owner_identifier`)
SELECT UUID(), ?
]], { identifier })
local rows = Bridge.Database.Query([[
SELECT profile.`id`, profile.`owner_identifier`, profile.`display_name`,
profile.`avatar_media_id`, avatar.`url` AS `avatar_url`,
UNIX_TIMESTAMP(profile.`created_at`) AS `created_at_unix`
FROM `sky_phone_skyride_profiles` profile
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = profile.`avatar_media_id`
WHERE profile.`owner_identifier` = ?
LIMIT 1
]], { identifier })
if not rows[1] then
error(("[sky_phone] Failed to load SkyRide profile for source %s."):format(tostring(source)))
end
return rows[1]
end
local function driver_eligible(source)
local job = Bridge.Framework.GetJob(source)
local minimum_grade = Config.SkyRide.DriverJobs[job.name]
return minimum_grade ~= nil and (tonumber(job.grade) or 0) >= minimum_grade
end
local function driver_online(source, profile)
local state = online_drivers[source]
if not state or state.profile_id ~= profile.id or not driver_eligible(source) then
online_drivers[source] = nil
return false
end
return true
end
local function current_vehicle(source)
local ped = GetPlayerPed(source)
if not ped or ped == 0 or not DoesEntityExist(ped) then
return nil
end
local entity = GetVehiclePedIsIn(ped, false)
if not entity or entity == 0 or not DoesEntityExist(entity) then
return nil
end
if GetPedInVehicleSeat(entity, -1) ~= ped then
return nil
end
local vehicle = {
color = "",
model = "",
plate = "",
}
vehicle.model = tostring(GetEntityModel(entity) or "")
vehicle.plate = tostring(GetVehicleNumberPlateText(entity) or ""):match("^%s*(.-)%s*$"):sub(1, 16)
return vehicle
end
local function profile_snapshot(source, profile)
local rows = Bridge.Database.Query([[
SELECT
(SELECT COUNT(*)
FROM `sky_phone_skyride_rides`
WHERE `status` = 'completed'
AND (`passenger_profile_id` = ? OR `driver_profile_id` = ?)) AS `completed_rides`,
(SELECT COUNT(*)
FROM `sky_phone_skyride_rides`
WHERE `status` = 'cancelled'
AND (`passenger_profile_id` = ? OR `driver_profile_id` = ?)) AS `cancelled_rides`,
(SELECT AVG(`passenger_rating`)
FROM `sky_phone_skyride_rides`
WHERE `driver_profile_id` = ? AND `passenger_rating` IS NOT NULL) AS `rating`,
(SELECT COALESCE(SUM(`payout_amount` + `tip_amount`), 0)
FROM `sky_phone_skyride_rides`
WHERE `driver_profile_id` = ?
AND `status` = 'completed'
AND DATE(`completed_at`) = CURRENT_DATE) AS `earnings_today`
]], { profile.id, profile.id, profile.id, profile.id, profile.id, profile.id })
local stats = rows[1] or {}
return {
acceptanceRate = false,
avatarMediaId = profile.avatar_media_id and tonumber(profile.avatar_media_id) or false,
avatarUrl = profile.avatar_url or false,
cancelledRides = tonumber(stats.cancelled_rides) or 0,
completedRides = tonumber(stats.completed_rides) or 0,
currency = Config.SkyRide.Currency,
defaultPaymentMethod = Config.SkyRide.PaymentAccount,
earningsToday = driver_eligible(source) and (tonumber(stats.earnings_today) or 0) or false,
id = profile.id,
memberSince = tonumber(profile.created_at_unix) or os.time(),
name = profile.display_name or player_name(source),
rating = math.floor(((tonumber(stats.rating) or 5.0) * 100) + 0.5) / 100,
}
end
local function select_ride_rows(where_clause, parameters, limit)
local values = {}
for index = 1, #parameters do
values[index] = parameters[index]
end
values[#values + 1] = limit
return Bridge.Database.Query(
ride_select .. " WHERE " .. where_clause .. " ORDER BY r.`updated_at` DESC, r.`id` DESC LIMIT ?",
values
)
end
local function profile_metrics(rows)
local profile_ids = {}
local seen = {}
for index = 1, #rows do
local row = rows[index]
for _, profile_id in ipairs({ row.passenger_profile_id, row.driver_profile_id }) do
if profile_id and not seen[profile_id] then
seen[profile_id] = true
profile_ids[#profile_ids + 1] = profile_id
end
end
end
if #profile_ids == 0 then
return {}
end
local placeholders = {}
for index = 1, #profile_ids do
placeholders[index] = "?"
end
local metrics_rows = Bridge.Database.Query(([[
SELECT profile.`id`,
(SELECT AVG(ride.`passenger_rating`)
FROM `sky_phone_skyride_rides` ride
WHERE ride.`driver_profile_id` = profile.`id`
AND ride.`passenger_rating` IS NOT NULL) AS `rating`,
(SELECT COUNT(*)
FROM `sky_phone_skyride_rides` ride
WHERE ride.`status` = 'completed'
AND (ride.`passenger_profile_id` = profile.`id`
OR ride.`driver_profile_id` = profile.`id`)) AS `trips`
FROM `sky_phone_skyride_profiles` profile
WHERE profile.`id` IN (%s)
]]):format(table.concat(placeholders, ", ")), profile_ids)
local metrics = {}
for index = 1, #metrics_rows do
local row = metrics_rows[index]
metrics[row.id] = {
rating = math.floor(((tonumber(row.rating) or 5.0) * 100) + 0.5) / 100,
trips = tonumber(row.trips) or 0,
}
end
return metrics
end
local function source_for_identifier(identifier)
if not identifier then
return nil
end
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
local source = tonumber(player_source) or player_source
if Bridge.Framework.GetIdentifier(source) == identifier then
return source
end
end
return nil
end
local function phone_number_for_source(source)
local session = source and SkyPhone.RequireSession(source) or nil
if not session then
return nil
end
local device = SkyPhone.LoadDevice(session.imei)
return device and device.phone_number or nil
end
local function online_source_for_identifier(identifier)
if not identifier then
return nil
end
for source, state in pairs(online_drivers) do
if state.identifier == identifier and GetPlayerName(source) then
return source
end
end
return nil
end
local function person_dto(id, name, avatar_url, metrics)
return {
avatarUrl = avatar_url or false,
id = id,
name = name or "",
rating = metrics and metrics.rating or 5.0,
trips = metrics and metrics.trips or 0,
}
end
local function ride_dtos(rows)
local metrics = profile_metrics(rows)
local result = {}
for index = 1, #rows do
local row = rows[index]
local status = row.status == "completing" and "in_progress" or row.status
local passenger = person_dto(
row.passenger_profile_id,
row.passenger_profile_name,
row.passenger_avatar_url,
metrics[row.passenger_profile_id]
)
local driver = false
if row.driver_profile_id then
driver = person_dto(
row.driver_profile_id,
row.driver_profile_name,
row.driver_avatar_url,
metrics[row.driver_profile_id]
)
driver.vehicle = {
color = row.driver_vehicle_color or "",
model = row.driver_vehicle_model or "",
plate = row.driver_vehicle_plate or "",
}
if status == "accepted" or status == "arrived" or status == "in_progress" then
local source = online_source_for_identifier(row.driver_identifier)
local location = source and get_player_coords(source) or nil
if location then
driver.location = location
end
end
end
local ride = {
acceptedAt = row.accepted_at_unix and tonumber(row.accepted_at_unix) or nil,
arrivedAt = row.arrived_at_unix and tonumber(row.arrived_at_unix) or nil,
cancelledAt = row.cancelled_at_unix and tonumber(row.cancelled_at_unix) or nil,
cancelledBy = row.cancelled_by,
completedAt = row.completed_at_unix and tonumber(row.completed_at_unix) or nil,
createdAt = tonumber(row.created_at_unix) or 0,
currency = row.currency,
destination = {
coords = {
x = tonumber(row.destination_x) or 0.0,
y = tonumber(row.destination_y) or 0.0,
z = tonumber(row.destination_z) or 0.0,
},
label = row.destination_label,
},
distanceMeters = tonumber(row.distance_meters) or 0,
driver = driver,
durationSeconds = tonumber(row.duration_seconds) or 0,
id = row.id,
passenger = passenger,
pickup = {
coords = {
x = tonumber(row.pickup_x) or 0.0,
y = tonumber(row.pickup_y) or 0.0,
z = tonumber(row.pickup_z) or 0.0,
},
label = row.pickup_label,
},
price = tonumber(row.price) or 0,
serviceClass = row.service_class,
startedAt = row.started_at_unix and tonumber(row.started_at_unix) or nil,
status = status,
updatedAt = tonumber(row.updated_at_unix) or 0,
}
if status == "completed" then
ride.finalPrice = tonumber(row.price) or 0
end
if row.passenger_rating then
ride.rating = tonumber(row.passenger_rating)
end
result[index] = ride
end
return result
end
local function active_ride(profile_id)
local rows = select_ride_rows([[
(r.`passenger_profile_id` = ? OR r.`driver_profile_id` = ?)
AND r.`status` IN ('searching','accepted','arrived','in_progress','completing')
]], { profile_id, profile_id }, 2)
if #rows > 1 then
Bridge.Debug(
"error",
"[sky_phone] SkyRide profile '%s' has multiple active rides.",
tostring(profile_id)
)
end
local rides = ride_dtos(rows)
if rides[1] and rows[1] then
local passenger_number = phone_number_for_source(source_for_identifier(rows[1].passenger_identifier))
if passenger_number then
rides[1].passenger.phoneNumber = passenger_number
end
if type(rides[1].driver) == "table" then
local driver_number = phone_number_for_source(source_for_identifier(rows[1].driver_identifier))
if driver_number then
rides[1].driver.phoneNumber = driver_number
end
end
end
return rides[1], rows[1]
end
local function available_requests(profile_id)
local rows = select_ride_rows([[
r.`status` = 'searching' AND r.`passenger_profile_id` <> ?
]], { profile_id }, Config.SkyRide.HistoryLimit)
return ride_dtos(rows)
end
local function ride_history(profile_id)
local rows = select_ride_rows([[
(r.`passenger_profile_id` = ? OR r.`driver_profile_id` = ?)
AND r.`status` IN ('completed','cancelled')
]], { profile_id, profile_id }, Config.SkyRide.HistoryLimit)
return ride_dtos(rows)
end
local function pending_rating(profile_id)
local rows = select_ride_rows([[
r.`passenger_profile_id` = ?
AND r.`status` = 'completed'
AND r.`passenger_rating` IS NULL
AND r.`tip_status` = 'none'
]], { profile_id }, 1)
local rides = ride_dtos(rows)
return rides[1]
end
local function ride_by_id(ride_id)
local rows = select_ride_rows("r.`id` = ?", { ride_id }, 1)
local rides = ride_dtos(rows)
return rides[1], rows[1]
end
local function process_pending_refund(row)
if not row or (row.status ~= "payment_pending" and row.status ~= "cancelled") then
return false
end
if row.refund_status == "completed" then
if row.status ~= "payment_pending" then
return true
end
local delete_success, delete_result = pcall(
Bridge.Database.Query,
[[
DELETE FROM `sky_phone_skyride_rides`
WHERE `id` = ? AND `status` = 'payment_pending'
AND `refund_status` = 'completed'
]],
{ row.id }
)
if not delete_success then
Bridge.Debug(
"error",
"[sky_phone] Failed to remove refunded payment-pending SkyRide '%s': %s",
row.id,
tostring(delete_result)
)
return false
end
return true
end
if row.refund_status ~= "pending" then
return false
end
local price = tonumber(row.price) or 0
local passenger_source = source_for_identifier(row.passenger_identifier)
if price > 0 and not passenger_source then
return false
end
local claim_success, claim_result = pcall(
Bridge.Database.Query,
[[
UPDATE `sky_phone_skyride_rides`
SET `refund_status` = 'processing'
WHERE `id` = ? AND `status` IN ('payment_pending','cancelled')
AND `refund_status` = 'pending'
]],
{ row.id }
)
if not claim_success then
Bridge.Debug(
"error",
"[sky_phone] Failed to claim pending refund for SkyRide '%s': %s",
row.id,
tostring(claim_result)
)
return false
end
if affected_rows(claim_result) ~= 1 then
return false
end
local money_success = true
local money_result = true
if price > 0 then
money_success, money_result = pcall(
Bridge.Framework.AddMoney,
passenger_source,
Config.SkyRide.PaymentAccount,
price
)
end
if not money_success then
Bridge.Debug(
"error",
"[sky_phone] Framework refund crashed for SkyRide '%s'; processing state prevents an automatic duplicate refund and requires manual reconciliation: %s",
row.id,
tostring(money_result)
)
return false
end
if not money_result then
local reset_success, reset_result = pcall(
Bridge.Database.Query,
[[
UPDATE `sky_phone_skyride_rides`
SET `refund_status` = 'pending'
WHERE `id` = ? AND `refund_status` = 'processing'
]],
{ row.id }
)
if not reset_success or affected_rows(reset_result) ~= 1 then
Bridge.Debug(
"error",
"[sky_phone] Refund failed and its claim could not be reset for SkyRide '%s'; manual reconciliation is required.",
row.id
)
end
Bridge.Debug(
"error",
"[sky_phone] Framework refund failed for SkyRide '%s': %s",
row.id,
tostring(money_result)
)
return false
end
local finalize_success, finalize_result = pcall(
Bridge.Database.Query,
[[
UPDATE `sky_phone_skyride_rides`
SET `refund_status` = 'completed', `refunded_at` = CURRENT_TIMESTAMP
WHERE `id` = ? AND `refund_status` = 'processing'
]],
{ row.id }
)
if not finalize_success or affected_rows(finalize_result) ~= 1 then
Bridge.Debug(
"error",
"[sky_phone] Refund was paid but could not be finalized for SkyRide '%s'; processing state prevents an automatic duplicate refund.",
row.id
)
return false
end
if passenger_source then
TriggerClientEvent("sky_phone:banking:changed", passenger_source)
end
if row.status == "payment_pending" then
local delete_success, delete_result = pcall(
Bridge.Database.Query,
[[
DELETE FROM `sky_phone_skyride_rides`
WHERE `id` = ? AND `status` = 'payment_pending'
AND `refund_status` = 'completed'
]],
{ row.id }
)
if not delete_success or affected_rows(delete_result) ~= 1 then
Bridge.Debug(
"error",
"[sky_phone] Refunded payment-pending SkyRide '%s' could not be removed; recovery will retry cleanup.",
row.id
)
return false
end
end
return true
end
local function transition_tip_status(ride_id, expected_status, next_status)
local update_success, update_result = pcall(
Bridge.Database.Query,
[[
UPDATE `sky_phone_skyride_rides`
SET `tip_status` = ?
WHERE `id` = ? AND `tip_status` = ?
]],
{ next_status, ride_id, expected_status }
)
if not update_success or affected_rows(update_result) ~= 1 then
Bridge.Debug(
"error",
"[sky_phone] Failed to move SkyRide '%s' tip state from '%s' to '%s'.",
ride_id,
expected_status,
next_status
)
return false
end
return true
end
local function state_snapshot(source, profile, fields)
local result = {}
if fields.activeRide then
result.activeRide = active_ride(profile.id) or false
end
if fields.availableRequests then
result.availableRequests = driver_online(source, profile) and available_requests(profile.id) or {}
end
if fields.driverOnline then
result.driverOnline = driver_online(source, profile)
end
if fields.history then
result.history = ride_history(profile.id)
end
if fields.pendingRating then
result.pendingRating = pending_rating(profile.id) or false
end
if fields.profile then
result.profile = profile_snapshot(source, profile)
end
return result
end
local function push_update(source, fields)
local profile = require_profile(source)
if not profile then
return
end
TriggerClientEvent("sky_phone:skyride:changed", source, state_snapshot(source, profile, fields))
end
local function push_participants(row, fields, excluded_source)
for _, identifier in ipairs({ row.passenger_identifier, row.driver_identifier }) do
local source = source_for_identifier(identifier)
if source and source ~= excluded_source then
push_update(source, fields)
end
end
end
local function push_available_requests(excluded_source)
for source in pairs(online_drivers) do
if source ~= excluded_source then
push_update(source, { availableRequests = true })
end
end
end
local function run_locked(key, callback)
if operation_locks[key] then
return nil, { success = false, error = "rate_limited" }
end
operation_locks[key] = true
local success, result = pcall(callback)
operation_locks[key] = nil
if not success then
error(result)
end
return result
end
local function valid_ride_id(data)
return type(data) == "table"
and type(data.rideId) == "string"
and #data.rideId == 36
and data.rideId:match("^[%x%-]+$")
and data.rideId
or nil
end
local function transition_driver_ride(source, data, expected_statuses, next_status, timestamp_column, radius, location_kind)
if not SkyPhone.AllowOperation(source, "skyride_action", Config.SkyRide.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local profile, error_response = require_profile(source)
if not profile then
return error_response
end
if not driver_online(source, profile) then
return { success = false, error = "driver_offline" }
end
local ride_id = valid_ride_id(data)
if not ride_id then
return { success = false, error = "ride_not_found" }
end
local result, lock_error = run_locked("profile:" .. profile.id, function()
local _, row = ride_by_id(ride_id)
if not row then
return { success = false, error = "ride_not_found" }
end
if row.driver_profile_id ~= profile.id then
return { success = false, error = "not_ride_participant" }
end
if not expected_statuses[row.status] then
return { success = false, error = "invalid_ride_status" }
end
local location = location_kind == "pickup" and {
coords = {
x = tonumber(row.pickup_x),
y = tonumber(row.pickup_y),
z = tonumber(row.pickup_z),
},
} or {
coords = {
x = tonumber(row.destination_x),
y = tonumber(row.destination_y),
z = tonumber(row.destination_z),
},
}
if not is_near(source, location, radius) then
return {
success = false,
error = location_kind == "pickup" and "driver_too_far" or "destination_too_far",
}
end
if next_status == "in_progress" or next_status == "completing" then
local passenger_source = source_for_identifier(row.passenger_identifier)
if not passenger_source or not is_near(passenger_source, location, radius) then
return { success = false, error = "passenger_too_far" }
end
end
local status_placeholders = {}
local parameters = { next_status }
for status in pairs(expected_statuses) do
status_placeholders[#status_placeholders + 1] = "?"
parameters[#parameters + 1] = status
end
parameters[#parameters + 1] = ride_id
parameters[#parameters + 1] = profile.id
local update = Bridge.Database.Query(([[
UPDATE `sky_phone_skyride_rides`
SET `status` = ?, `%s` = CURRENT_TIMESTAMP
WHERE `status` IN (%s) AND `id` = ? AND `driver_profile_id` = ?
]]):format(timestamp_column, table.concat(status_placeholders, ", ")), parameters)
if affected_rows(update) ~= 1 then
return { success = false, error = "invalid_ride_status" }
end
local _, updated_row = ride_by_id(ride_id)
push_participants(updated_row, { activeRide = true }, source)
return {
success = true,
data = state_snapshot(source, profile, { activeRide = true }),
}
end)
return result or lock_error
end
Bridge.Callbacks.Register("sky_phone:skyride:bootstrap", function(source)
if not SkyPhone.AllowOperation(source, "skyride_read", Config.SkyRide.ReadsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local profile, error_response = require_profile(source)
if not profile then
return error_response
end
local online = driver_online(source, profile)
return {
success = true,
data = {
activeRide = active_ride(profile.id) or false,
availableRequests = online and available_requests(profile.id) or {},
driverEligible = driver_eligible(source),
driverOnline = online,
history = ride_history(profile.id),
pendingRating = pending_rating(profile.id) or false,
profile = profile_snapshot(source, profile),
quickLocations = quick_locations(),
},
}
end)
Bridge.Callbacks.Register("sky_phone:skyride:update-profile", function(source, data)
if not SkyPhone.AllowOperation(source, "skyride_action", Config.SkyRide.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local profile, error_response = require_profile(source)
if not profile then
return error_response
end
local name = type(data) == "table" and type(data.name) == "string"
and data.name:match("^%s*(.-)%s*$") or nil
local name_length = name and utf8.len(name) or nil
local avatar_media_id = type(data) == "table" and tonumber(data.avatarMediaId) or nil
if not name_length
or name_length < Config.SkyRide.ProfileNameMinLength
or name_length > Config.SkyRide.ProfileNameMaxLength
then
return { success = false, error = "invalid_profile_name" }
end
if not avatar_media_id or avatar_media_id < 0 or avatar_media_id ~= math.floor(avatar_media_id) then
return { success = false, error = "invalid_profile_image" }
end
if avatar_media_id > 0
and not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(avatar_media_id), "photo")
then
return { success = false, error = "invalid_profile_image" }
end
local result, lock_error = run_locked("profile:" .. profile.id, function()
local update = Bridge.Database.Query([[
UPDATE `sky_phone_skyride_profiles`
SET `display_name` = ?, `avatar_media_id` = NULLIF(?, 0)
WHERE `id` = ? AND `owner_identifier` = ?
]], { name, avatar_media_id, profile.id, profile.owner_identifier })
if affected_rows(update) > 1 then
error(("[sky_phone] SkyRide profile update affected multiple rows for '%s'."):format(profile.id))
end
local updated_profile = require_profile(source)
local ride = active_ride(profile.id)
if ride then
push_participants(ride, { activeRide = true }, source)
end
push_available_requests(source)
return {
success = true,
data = state_snapshot(source, updated_profile, { profile = true }),
}
end)
return result or lock_error
end)
Bridge.Callbacks.Register("sky_phone:skyride:history", function(source)
if not SkyPhone.AllowOperation(source, "skyride_read", Config.SkyRide.ReadsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local profile, error_response = require_profile(source)
if not profile then
return error_response
end
return { success = true, data = { items = ride_history(profile.id) } }
end)
Bridge.Callbacks.Register("sky_phone:skyride:quote", function(source, data)
if not SkyPhone.AllowOperation(source, "skyride_quote", Config.SkyRide.QuotesPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local profile, error_response = require_profile(source)
if not profile then
return error_response
end
local pickup = validate_location(data and data.pickup)
local destination = validate_location(data and data.destination)
if not pickup or not destination then
return { success = false, error = "invalid_location" }
end
if not is_near(source, pickup, Config.SkyRide.PickupSelectionRadius) then
return { success = false, error = "pickup_too_far" }
end
local custom_fare = data and data.customFare or nil
if custom_fare ~= nil then
local custom_service = type(custom_fare) == "table" and custom_fare.serviceClass or nil
local custom_price = type(custom_fare) == "table" and custom_fare.price or nil
if not Config.SkyRide.CustomFare.Enabled
or type(custom_service) ~= "string"
or not services[custom_service]
or type(custom_price) ~= "number"
or custom_price ~= custom_price
or custom_price ~= math.floor(custom_price)
then
return { success = false, error = "invalid_custom_fare" }
end
custom_fare = {
price = custom_price,
service_class = custom_service,
}
end
local direct_distance = distance_between(pickup.coords, destination.coords)
local distance_meters = math.floor((direct_distance * Config.SkyRide.RouteDistanceMultiplier) + 0.5)
if distance_meters < Config.SkyRide.MinimumDistanceMeters
or distance_meters > Config.SkyRide.MaximumDistanceMeters
then
return { success = false, error = "invalid_route" }
end
local duration_seconds = math.max(
60,
math.floor((distance_meters / Config.SkyRide.AverageSpeedMetersPerSecond) + 0.5)
)
local now = os.time()
local expires_at = now + Config.SkyRide.QuoteLifetimeSeconds
local quote_group = generate_uuid()
local options = {}
local distance_unit = Config.SkyRide.DistanceUnit
local distance_value = distance_meters / (distance_unit == "mile" and 1609.344 or 1000)
for index = 1, #Config.SkyRide.Services do
local service = Config.SkyRide.Services[index]
local price_per_distance_unit = distance_unit == "mile"
and service.PricePerMile
or service.PricePerKilometer
local calculated_price = math.floor(math.max(
service.MinimumFare,
service.BaseFare
+ (distance_value * price_per_distance_unit)
+ ((duration_seconds / 60) * service.PricePerMinute)
) + 0.5)
local minimum_custom_price = math.floor(math.max(
Config.SkyRide.CustomFare.MinimumPrice,
service.MinimumFare,
calculated_price * Config.SkyRide.CustomFare.MinimumCalculatedMultiplier
) + 0.5)
local maximum_custom_price = math.floor(math.min(
Config.SkyRide.CustomFare.MaximumPrice,
calculated_price * Config.SkyRide.CustomFare.MaximumCalculatedMultiplier
) + 0.5)
local uses_custom_fare = custom_fare and custom_fare.service_class == service.Id
if uses_custom_fare
and (custom_fare.price < minimum_custom_price or custom_fare.price > maximum_custom_price)
then
discard_quote_group(quote_group)
return { success = false, error = "invalid_custom_fare" }
end
local price = uses_custom_fare and custom_fare.price or calculated_price
local quote_id = quote_group .. ":" .. service.Id
local option = {
available = true,
calculatedPrice = calculated_price,
currency = Config.SkyRide.Currency,
etaMinutes = service.EtaMinutes,
fareMode = uses_custom_fare and "custom" or "calculated",
maximumCustomPrice = maximum_custom_price,
minimumCustomPrice = minimum_custom_price,
price = price,
pricePerDistanceUnit = price_per_distance_unit,
quoteId = quote_id,
seats = service.Seats,
serviceClass = service.Id,
}
quotes[quote_id] = {
destination = destination,
distance_meters = distance_meters,
duration_seconds = duration_seconds,
expires_at = expires_at,
group = quote_group,
option = option,
pickup = pickup,
profile_id = profile.id,
source = source,
}
options[index] = option
end
return {
success = true,
data = {
destination = destination,
distance = distance_value,
distanceMeters = distance_meters,
distanceUnit = distance_unit,
durationSeconds = duration_seconds,
expiresAt = expires_at,
options = options,
pickup = pickup,
},
}
end)
Bridge.Callbacks.Register("sky_phone:skyride:request", function(source, data)
if not SkyPhone.AllowOperation(source, "skyride_action", Config.SkyRide.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local profile, error_response = require_profile(source)
if not profile then
return error_response
end
local quote_id = type(data) == "table" and data.quoteId or nil
if type(quote_id) ~= "string" or #quote_id > 64 then
return { success = false, error = "quote_not_found" }
end
local quote = quotes[quote_id]
if not quote or quote.source ~= source or quote.profile_id ~= profile.id then
return { success = false, error = "quote_not_found" }
end
if quote.expires_at <= os.time() then
quotes[quote_id] = nil
return { success = false, error = "quote_expired" }
end
if not is_near(source, quote.pickup, Config.SkyRide.PickupSelectionRadius) then
return { success = false, error = "pickup_too_far" }
end
local result, lock_error = run_locked("profile:" .. profile.id, function()
local pending_rows = select_ride_rows([[
r.`passenger_profile_id` = ? AND r.`status` = 'payment_pending'
]], { profile.id }, 1)
if active_ride(profile.id) or pending_rows[1] then
return { success = false, error = "active_ride_exists" }
end
local ride_id = generate_uuid()
local price = quote.option.price
local payout = math.floor((price * Config.SkyRide.DriverPayoutPercent / 100) + 0.5)
Bridge.Database.Query([[
INSERT INTO `sky_phone_skyride_rides`
(`id`, `passenger_profile_id`, `passenger_name`, `status`, `service_class`,
`pickup_label`, `pickup_x`, `pickup_y`, `pickup_z`,
`destination_label`, `destination_x`, `destination_y`, `destination_z`,
`distance_meters`, `duration_seconds`, `price`, `payout_amount`, `currency`)
VALUES (?, ?, ?, 'payment_pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
]], {
ride_id,
profile.id,
profile.display_name or player_name(source),
quote.option.serviceClass,
quote.pickup.label,
quote.pickup.coords.x,
quote.pickup.coords.y,
quote.pickup.coords.z,
quote.destination.label,
quote.destination.coords.x,
quote.destination.coords.y,
quote.destination.coords.z,
quote.distance_meters,
quote.duration_seconds,
price,
payout,
quote.option.currency,
})
local charge_success = true
local charge_result = true
if price > 0 then
charge_success, charge_result = pcall(
Bridge.Framework.RemoveMoney,
source,
Config.SkyRide.PaymentAccount,
price
)
end
if not charge_success then
discard_quote_group(quote.group)
Bridge.Debug(
"error",
"[sky_phone] Fare debit crashed for SkyRide '%s'; payment state is ambiguous and requires manual reconciliation: %s",
ride_id,
tostring(charge_result)
)
return { success = false, error = "refund_failed" }
end
if not charge_result then
local delete_success, delete_result = pcall(
Bridge.Database.Query,
"DELETE FROM `sky_phone_skyride_rides` WHERE `id` = ? AND `status` = 'payment_pending'",
{ ride_id }
)
if not delete_success or affected_rows(delete_result) ~= 1 then
Bridge.Debug(
"error",
"[sky_phone] Failed to remove uncharged payment-pending SkyRide '%s'.",
ride_id
)
end
return { success = false, error = "insufficient_funds" }
end
discard_quote_group(quote.group)
if price > 0 then
local capture_success, capture_result = pcall(
Bridge.Database.Query,
[[
UPDATE `sky_phone_skyride_rides`
SET `refund_status` = 'pending'
WHERE `id` = ? AND `status` = 'payment_pending'
AND `refund_status` = 'none'
]],
{ ride_id }
)
if not capture_success then
Bridge.Debug(
"error",
"[sky_phone] Fare was charged but its recovery marker failed for SkyRide '%s': %s. Manual reconciliation is required.",
ride_id,
tostring(capture_result)
)
return { success = false, error = "refund_failed" }
end
if affected_rows(capture_result) ~= 1 then
local refund_success, refund_result = pcall(
Bridge.Framework.AddMoney,
source,
Config.SkyRide.PaymentAccount,
price
)
if refund_success and refund_result then
local delete_success, delete_result = pcall(
Bridge.Database.Query,
"DELETE FROM `sky_phone_skyride_rides` WHERE `id` = ? AND `status` = 'payment_pending'",
{ ride_id }
)
if delete_success and affected_rows(delete_result) == 1 then
TriggerClientEvent("sky_phone:banking:changed", source)
return { success = false, error = "request_failed" }
end
end
Bridge.Debug(
"error",
"[sky_phone] Fare recovery failed before activating SkyRide '%s'. Manual reconciliation is required.",
ride_id
)
return { success = false, error = "refund_failed" }
end
end
local update_success, update = pcall(Bridge.Database.Query, [[
UPDATE `sky_phone_skyride_rides`
SET `status` = 'searching', `refund_status` = 'none'
WHERE `id` = ? AND `status` = 'payment_pending'
AND `refund_status` = ?
]], { ride_id, price > 0 and "pending" or "none" })
if not update_success or affected_rows(update) ~= 1 then
local load_success, _, pending_row = pcall(ride_by_id, ride_id)
if load_success and pending_row and pending_row.status == "searching" then
update_success = true
elseif load_success and pending_row and price > 0 and pending_row.refund_status == "pending" then
local refunded = process_pending_refund(pending_row)
if not refunded then
return { success = false, error = "refund_failed" }
end
return { success = false, error = "request_failed" }
elseif price > 0 then
Bridge.Debug(
"error",
"[sky_phone] Could not verify fare recovery after SkyRide '%s' activation failed; manual reconciliation is required.",
ride_id
)
return { success = false, error = "refund_failed" }
else
local delete_success, delete_result = pcall(
Bridge.Database.Query,
[[
DELETE FROM `sky_phone_skyride_rides`
WHERE `id` = ? AND `status` = 'payment_pending'
AND `refund_status` = 'none'
]],
{ ride_id }
)
if not delete_success or affected_rows(delete_result) ~= 1 then
Bridge.Debug(
"error",
"[sky_phone] Failed to clean payment-pending SkyRide '%s' after request activation failed.",
ride_id
)
end
if not update_success then
Bridge.Debug(
"error",
"[sky_phone] Failed to activate SkyRide '%s': %s",
ride_id,
tostring(update)
)
end
return { success = false, error = "request_failed" }
end
end
TriggerClientEvent("sky_phone:banking:changed", source)
push_available_requests(source)
return {
success = true,
data = state_snapshot(source, profile, {
activeRide = true,
availableRequests = true,
}),
}
end)
return result or lock_error
end)
Bridge.Callbacks.Register("sky_phone:skyride:set-driver-status", function(source, data)
if not SkyPhone.AllowOperation(source, "skyride_action", Config.SkyRide.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local profile, error_response = require_profile(source)
if not profile then
return error_response
end
if type(data) ~= "table" or type(data.online) ~= "boolean" then
return { success = false, error = "invalid_driver_status" }
end
if data.online and not driver_eligible(source) then
return { success = false, error = "driver_not_eligible" }
end
local _, ride_row = active_ride(profile.id)
if not data.online and ride_row and ride_row.driver_profile_id == profile.id then
return { success = false, error = "active_ride_exists" }
end
if data.online and ride_row and ride_row.passenger_profile_id == profile.id and not ride_row.driver_profile_id then
return { success = false, error = "active_ride_exists" }
end
online_drivers[source] = data.online and {
identifier = profile.owner_identifier,
profile_id = profile.id,
} or nil
return {
success = true,
data = state_snapshot(source, profile, {
driverOnline = true,
availableRequests = true,
}),
}
end)
Bridge.Callbacks.Register("sky_phone:skyride:accept", function(source, data)
if not SkyPhone.AllowOperation(source, "skyride_action", Config.SkyRide.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local profile, error_response = require_profile(source)
if not profile then
return error_response
end
if not driver_online(source, profile) then
return { success = false, error = "driver_offline" }
end
local ride_id = valid_ride_id(data)
if not ride_id then
return { success = false, error = "ride_not_found" }
end
local result, lock_error = run_locked("profile:" .. profile.id, function()
if active_ride(profile.id) then
return { success = false, error = "active_ride_exists" }
end
local _, row = ride_by_id(ride_id)
if not row then
return { success = false, error = "ride_not_found" }
end
if row.status ~= "searching" then
return { success = false, error = "invalid_ride_status" }
end
if row.passenger_profile_id == profile.id then
return { success = false, error = "not_ride_participant" }
end
local vehicle = current_vehicle(source)
if not vehicle then
return { success = false, error = "driver_vehicle_required" }
end
local update = Bridge.Database.Query([[
UPDATE `sky_phone_skyride_rides`
SET `driver_profile_id` = ?, `driver_name` = ?, `status` = 'accepted',
`driver_vehicle_model` = ?, `driver_vehicle_color` = ?, `driver_vehicle_plate` = ?,
`accepted_at` = CURRENT_TIMESTAMP
WHERE `id` = ? AND `status` = 'searching' AND `driver_profile_id` IS NULL
]], {
profile.id,
profile.display_name or player_name(source),
vehicle.model,
vehicle.color,
vehicle.plate,
ride_id,
})
if affected_rows(update) ~= 1 then
return { success = false, error = "invalid_ride_status" }
end
local _, updated_row = ride_by_id(ride_id)
push_participants(updated_row, { activeRide = true, availableRequests = true }, source)
push_available_requests(source)
return {
success = true,
data = state_snapshot(source, profile, {
activeRide = true,
availableRequests = true,
}),
}
end)
return result or lock_error
end)
Bridge.Callbacks.Register("sky_phone:skyride:arrive", function(source, data)
return transition_driver_ride(
source,
data,
{ accepted = true },
"arrived",
"arrived_at",
Config.SkyRide.ArrivalRadius,
"pickup"
)
end)
Bridge.Callbacks.Register("sky_phone:skyride:start", function(source, data)
return transition_driver_ride(
source,
data,
{ arrived = true },
"in_progress",
"started_at",
Config.SkyRide.ArrivalRadius,
"pickup"
)
end)
Bridge.Callbacks.Register("sky_phone:skyride:complete", function(source, data)
if not SkyPhone.AllowOperation(source, "skyride_action", Config.SkyRide.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local profile, error_response = require_profile(source)
if not profile then
return error_response
end
if not driver_online(source, profile) then
return { success = false, error = "driver_offline" }
end
local ride_id = valid_ride_id(data)
if not ride_id then
return { success = false, error = "ride_not_found" }
end
local result, lock_error = run_locked("ride:" .. ride_id, function()
local _, row = ride_by_id(ride_id)
if not row then
return { success = false, error = "ride_not_found" }
end
if row.driver_profile_id ~= profile.id then
return { success = false, error = "not_ride_participant" }
end
if row.status ~= "in_progress" then
return { success = false, error = "invalid_ride_status" }
end
local destination = {
coords = {
x = tonumber(row.destination_x),
y = tonumber(row.destination_y),
z = tonumber(row.destination_z),
},
}
if not is_near(source, destination, Config.SkyRide.CompletionRadius) then
return { success = false, error = "destination_too_far" }
end
local passenger_source = source_for_identifier(row.passenger_identifier)
if not passenger_source or not is_near(passenger_source, destination, Config.SkyRide.CompletionRadius) then
return { success = false, error = "passenger_too_far" }
end
local completing = Bridge.Database.Query([[
UPDATE `sky_phone_skyride_rides`
SET `status` = 'completing'
WHERE `id` = ? AND `driver_profile_id` = ? AND `status` = 'in_progress'
]], { ride_id, profile.id })
if affected_rows(completing) ~= 1 then
return { success = false, error = "invalid_ride_status" }
end
local payout = tonumber(row.payout_amount) or 0
local payout_success = true
local payout_result = true
if payout > 0 then
payout_success, payout_result = pcall(
Bridge.Framework.AddMoney,
source,
Config.SkyRide.PaymentAccount,
payout
)
end
if not payout_success then
Bridge.Debug(
"error",
"[sky_phone] Driver payout crashed for SkyRide '%s'; completing state prevents an ambiguous retry and requires manual reconciliation: %s",
ride_id,
tostring(payout_result)
)
return { success = false, error = "payout_failed" }
end
if not payout_result then
local reset_success, reset_result = pcall(Bridge.Database.Query, [[
UPDATE `sky_phone_skyride_rides`
SET `status` = 'in_progress'
WHERE `id` = ? AND `status` = 'completing'
]], { ride_id })
if not reset_success or affected_rows(reset_result) ~= 1 then
Bridge.Debug(
"error",
"[sky_phone] Driver payout failed and SkyRide '%s' could not be restored to in-progress.",
ride_id
)
end
return { success = false, error = "payout_failed" }
end
local completed_success, completed = pcall(Bridge.Database.Query, [[
UPDATE `sky_phone_skyride_rides`
SET `status` = 'completed', `completed_at` = CURRENT_TIMESTAMP,
`paid_out_at` = CURRENT_TIMESTAMP
WHERE `id` = ? AND `status` = 'completing'
]], { ride_id })
if not completed_success then
Bridge.Debug(
"error",
"[sky_phone] SkyRide '%s' completion query crashed after payout; completing state blocks an ambiguous retry and requires manual reconciliation: %s",
ride_id,
tostring(completed)
)
return { success = false, error = "payout_failed" }
end
if affected_rows(completed) ~= 1 then
local compensation_success = true
local compensation_result = true
if payout > 0 then
compensation_success, compensation_result = pcall(
Bridge.Framework.RemoveMoney,
source,
Config.SkyRide.PaymentAccount,
payout
)
end
if compensation_success and compensation_result then
local reset_success, reset_result = pcall(Bridge.Database.Query, [[
UPDATE `sky_phone_skyride_rides`
SET `status` = 'in_progress', `completed_at` = NULL, `paid_out_at` = NULL
WHERE `id` = ? AND `status` = 'completing'
]], { ride_id })
if reset_success and affected_rows(reset_result) == 1 then
if payout > 0 then
TriggerClientEvent("sky_phone:banking:changed", source)
end
Bridge.Debug(
"error",
"[sky_phone] Compensated driver payout after SkyRide '%s' completion could not be finalized.",
ride_id
)
return { success = false, error = "payout_failed" }
end
Bridge.Debug(
"error",
"[sky_phone] Driver payout was compensated but SkyRide '%s' could not be restored; manual reconciliation is required.",
ride_id
)
return { success = false, error = "payout_failed" }
end
Bridge.Debug(
"error",
"[sky_phone] Failed to compensate payout for unfinalized SkyRide '%s'; completing state prevents an automatic duplicate payout: %s",
ride_id,
tostring(compensation_result)
)
return { success = false, error = "payout_failed" }
end
TriggerClientEvent("sky_phone:banking:changed", source)
local _, updated_row = ride_by_id(ride_id)
push_participants(updated_row, {
activeRide = true,
history = true,
pendingRating = true,
profile = true,
}, source)
return {
success = true,
data = state_snapshot(source, profile, {
activeRide = true,
history = true,
pendingRating = true,
profile = true,
}),
}
end)
return result or lock_error
end)
Bridge.Callbacks.Register("sky_phone:skyride:cancel", function(source, data)
if not SkyPhone.AllowOperation(source, "skyride_action", Config.SkyRide.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local profile, error_response = require_profile(source)
if not profile then
return error_response
end
local ride_id = valid_ride_id(data)
if not ride_id then
return { success = false, error = "ride_not_found" }
end
local reason = data.reason
if reason ~= nil and (type(reason) ~= "string" or not allowed_cancel_reasons[reason]) then
return { success = false, error = "invalid_cancel_reason" }
end
local result, lock_error = run_locked("ride:" .. ride_id, function()
local _, row = ride_by_id(ride_id)
if not row then
return { success = false, error = "ride_not_found" }
end
local role
if row.passenger_profile_id == profile.id then
role = "passenger"
elseif row.driver_profile_id == profile.id then
role = "driver"
else
return { success = false, error = "not_ride_participant" }
end
if row.status ~= "searching"
and row.status ~= "accepted"
and row.status ~= "arrived"
and row.status ~= "in_progress"
then
return { success = false, error = "invalid_ride_status" }
end
if row.status == "searching" and role ~= "passenger" then
return { success = false, error = "not_ride_participant" }
end
local update = Bridge.Database.Query([[
UPDATE `sky_phone_skyride_rides`
SET `status` = 'cancelled', `cancelled_by` = ?, `cancel_reason` = ?,
`cancelled_at` = CURRENT_TIMESTAMP, `refund_status` = 'pending'
WHERE `id` = ? AND `status` IN ('searching','accepted','arrived','in_progress')
AND `refund_status` = 'none'
]], { role, reason, ride_id })
if affected_rows(update) ~= 1 then
return { success = false, error = "invalid_ride_status" }
end
local _, updated_row = ride_by_id(ride_id)
if not process_pending_refund(updated_row) then
Bridge.Debug(
"info",
"[sky_phone] Refund for cancelled SkyRide '%s' remains queued for server recovery.",
ride_id
)
end
_, updated_row = ride_by_id(ride_id)
push_participants(updated_row, {
activeRide = true,
availableRequests = true,
history = true,
pendingRating = true,
profile = true,
}, source)
push_available_requests(source)
return {
success = true,
data = state_snapshot(source, profile, {
activeRide = true,
availableRequests = true,
history = true,
pendingRating = true,
profile = true,
}),
}
end)
return result or lock_error
end)
Bridge.Callbacks.Register("sky_phone:skyride:rate", function(source, data)
if not SkyPhone.AllowOperation(source, "skyride_action", Config.SkyRide.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local profile, error_response = require_profile(source)
if not profile then
return error_response
end
local ride_id = valid_ride_id(data)
if not ride_id then
return { success = false, error = "ride_not_found" }
end
local rating = tonumber(data.rating)
local tip = tonumber(data.tip) or 0
local comment = type(data.comment) == "string" and data.comment:match("^%s*(.-)%s*$") or ""
local comment_length = utf8.len(comment)
if not rating or rating ~= math.floor(rating) or rating < 1 or rating > 5 then
return { success = false, error = "invalid_rating" }
end
if not tip or tip ~= math.floor(tip) or tip < 0 or tip > Config.SkyRide.MaximumTip then
return { success = false, error = "invalid_tip" }
end
if not comment_length or comment_length > Config.SkyRide.RatingCommentMaxLength then
return { success = false, error = "invalid_comment" }
end
local result, lock_error = run_locked("ride:" .. ride_id, function()
local _, row = ride_by_id(ride_id)
if not row then
return { success = false, error = "ride_not_found" }
end
if row.passenger_profile_id ~= profile.id then
return { success = false, error = "not_ride_participant" }
end
if row.status ~= "completed" or row.passenger_rating ~= nil or row.tip_status ~= "none" then
return { success = false, error = "invalid_ride_status" }
end
local driver_source = source_for_identifier(row.driver_identifier)
if tip > 0 and not driver_source then
return { success = false, error = "driver_unavailable" }
end
if not transition_tip_status(ride_id, "none", "processing") then
return { success = false, error = "invalid_ride_status" }
end
if tip > 0 then
local debit_success, debit_result = pcall(
Bridge.Framework.RemoveMoney,
source,
Config.SkyRide.PaymentAccount,
tip
)
if not debit_success then
transition_tip_status(ride_id, "processing", "failed")
Bridge.Debug(
"error",
"[sky_phone] Passenger tip debit crashed for SkyRide '%s'; manual reconciliation is required: %s",
ride_id,
tostring(debit_result)
)
return { success = false, error = "tip_failed" }
end
if not debit_result then
transition_tip_status(ride_id, "processing", "none")
return { success = false, error = "insufficient_funds" }
end
local credit_success, credit_result = pcall(
Bridge.Framework.AddMoney,
driver_source,
Config.SkyRide.PaymentAccount,
tip
)
if not credit_success then
transition_tip_status(ride_id, "processing", "failed")
Bridge.Debug(
"error",
"[sky_phone] Driver tip credit crashed for SkyRide '%s'; retry is blocked for manual reconciliation: %s",
ride_id,
tostring(credit_result)
)
return { success = false, error = "tip_failed" }
end
if not credit_result then
local refund_success, refund_result = pcall(
Bridge.Framework.AddMoney,
source,
Config.SkyRide.PaymentAccount,
tip
)
if refund_success and refund_result then
transition_tip_status(ride_id, "processing", "none")
TriggerClientEvent("sky_phone:banking:changed", source)
else
transition_tip_status(ride_id, "processing", "failed")
Bridge.Debug(
"error",
"[sky_phone] Passenger refund after rejected driver tip credit is ambiguous or failed for SkyRide '%s'; retry is blocked for manual reconciliation: %s",
ride_id,
tostring(refund_result)
)
end
return { success = false, error = "tip_failed" }
end
end
local update_success, update = pcall(Bridge.Database.Query, [[
UPDATE `sky_phone_skyride_rides`
SET `passenger_rating` = ?, `rating_comment` = ?, `tip_amount` = ?,
`tip_status` = 'completed'
WHERE `id` = ? AND `passenger_profile_id` = ?
AND `status` = 'completed' AND `passenger_rating` IS NULL
AND `tip_status` = 'processing'
]], { rating, comment, tip, ride_id, profile.id })
if not update_success then
Bridge.Debug(
"error",
"[sky_phone] Rating query crashed after tip transfer for SkyRide '%s'; processing state blocks an ambiguous retry and requires manual reconciliation: %s",
ride_id,
tostring(update)
)
return { success = false, error = "tip_failed" }
end
if affected_rows(update) ~= 1 then
if tip == 0 then
transition_tip_status(ride_id, "processing", "none")
return { success = false, error = "tip_failed" }
end
local driver_debit_success, driver_debit_result = pcall(
Bridge.Framework.RemoveMoney,
driver_source,
Config.SkyRide.PaymentAccount,
tip
)
if not driver_debit_success or not driver_debit_result then
transition_tip_status(ride_id, "processing", "failed")
Bridge.Debug(
"error",
"[sky_phone] Rating persistence failed and driver tip debit was ambiguous or rejected for SkyRide '%s'; passenger refund was not attempted and manual reconciliation is required: %s",
ride_id,
tostring(driver_debit_result)
)
return { success = false, error = "tip_failed" }
end
TriggerClientEvent("sky_phone:banking:changed", driver_source)
local passenger_refund_success, passenger_refund_result = pcall(
Bridge.Framework.AddMoney,
source,
Config.SkyRide.PaymentAccount,
tip
)
if passenger_refund_success and passenger_refund_result then
TriggerClientEvent("sky_phone:banking:changed", source)
transition_tip_status(ride_id, "processing", "none")
Bridge.Debug(
"error",
"[sky_phone] Compensated rating persistence failure for SkyRide '%s'.",
ride_id
)
else
transition_tip_status(ride_id, "processing", "failed")
Bridge.Debug(
"error",
"[sky_phone] Driver tip was removed but passenger refund was ambiguous or rejected for SkyRide '%s'; manual reconciliation is required: %s",
ride_id,
tostring(passenger_refund_result)
)
end
return { success = false, error = "tip_failed" }
end
if tip > 0 then
TriggerClientEvent("sky_phone:banking:changed", source)
TriggerClientEvent("sky_phone:banking:changed", driver_source)
end
local _, updated_row = ride_by_id(ride_id)
push_participants(updated_row, { history = true, pendingRating = true, profile = true }, source)
return {
success = true,
data = state_snapshot(source, profile, {
history = true,
pendingRating = true,
profile = true,
}),
}
end)
return result or lock_error
end)
AddEventHandler("playerDropped", function()
local player_source = source
online_drivers[player_source] = nil
for quote_id, quote in pairs(quotes) do
if quote.source == player_source then
quotes[quote_id] = nil
end
end
end)
CreateThread(function()
while true do
Wait(30000)
local now = os.time()
for quote_id, quote in pairs(quotes) do
if quote.expires_at <= now or not GetPlayerName(quote.source) then
quotes[quote_id] = nil
end
end
for source in pairs(online_drivers) do
if not GetPlayerName(source) then
online_drivers[source] = nil
end
end
end
end)
CreateThread(function()
local reconciliation_rows = Bridge.Database.Query([[
SELECT
SUM(`refund_status` = 'processing') AS `processing_refunds`,
SUM(`tip_status` IN ('processing','failed')) AS `unresolved_tips`,
SUM(`status` = 'completing') AS `unresolved_payouts`,
SUM(`status` = 'payment_pending' AND `refund_status` = 'none') AS `ambiguous_payments`
FROM `sky_phone_skyride_rides`
]], {})
local reconciliation = reconciliation_rows[1] or {}
if (tonumber(reconciliation.processing_refunds) or 0) > 0
or (tonumber(reconciliation.unresolved_tips) or 0) > 0
or (tonumber(reconciliation.unresolved_payouts) or 0) > 0
or (tonumber(reconciliation.ambiguous_payments) or 0) > 0
then
Bridge.Debug(
"error",
"[sky_phone] SkyRide has unresolved cross-system payments (refunds=%s, tips=%s, payouts=%s, ambiguous fares=%s); manual reconciliation is required.",
tostring(reconciliation.processing_refunds or 0),
tostring(reconciliation.unresolved_tips or 0),
tostring(reconciliation.unresolved_payouts or 0),
tostring(reconciliation.ambiguous_payments or 0)
)
end
while true do
local recovery_success, recovery_rows = pcall(
Bridge.Database.Query,
ride_select .. (([[
WHERE r.`refund_status` IN ('pending','completed')
AND (
r.`status` = 'cancelled'
OR (
r.`status` = 'payment_pending'
AND r.`updated_at` <= DATE_SUB(
CURRENT_TIMESTAMP,
INTERVAL %d SECOND
)
)
)
ORDER BY r.`updated_at` ASC
LIMIT 50
]]):format(math.floor(Config.SkyRide.PaymentPendingRecoverySeconds)))
)
if recovery_success then
for index = 1, #recovery_rows do
process_pending_refund(recovery_rows[index])
end
else
Bridge.Debug(
"error",
"[sky_phone] SkyRide refund recovery query failed: %s",
tostring(recovery_rows)
)
end
Wait(math.floor(Config.SkyRide.RecoveryIntervalSeconds * 1000))
end
end)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name ~= GetCurrentResourceName() then
return
end
quotes = {}
online_drivers = {}
operation_locks = {}
end)
end)