mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 07:01:39 +00:00
1802 lines
68 KiB
Lua
1802 lines
68 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`,
|
|
driver.`owner_identifier` AS `driver_identifier`,
|
|
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_skyride_profiles` driver
|
|
ON driver.`id` = r.`driver_profile_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 `id`, `owner_identifier`, UNIX_TIMESTAMP(`created_at`) AS `created_at_unix`
|
|
FROM `sky_phone_skyride_profiles`
|
|
WHERE `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,
|
|
avatarUrl = 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 = 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, metrics)
|
|
return {
|
|
avatarUrl = 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_name,
|
|
metrics[row.passenger_profile_id]
|
|
)
|
|
local driver = false
|
|
if row.driver_profile_id then
|
|
driver = person_dto(row.driver_profile_id, row.driver_name, 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: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,
|
|
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,
|
|
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)
|