306 lines
14 KiB
Lua
306 lines
14 KiB
Lua
--[[
|
|
Description:
|
|
Server-side vehicle purchase handling (refactored)
|
|
|
|
Global Namespace:
|
|
None
|
|
|
|
Globals:
|
|
None
|
|
|
|
Exports:
|
|
None
|
|
]]--
|
|
|
|
---Purchase a vehicle
|
|
---@param src number Player source
|
|
---@param purchaseData table Purchase data containing all purchase information
|
|
---@return boolean success
|
|
---@return integer? netId
|
|
---@return integer? vehicleId
|
|
---@return string? plate
|
|
---@return number? amountPaid
|
|
local function purchaseVehicle(src, purchaseData)
|
|
DebugPrint("=== PURCHASE VEHICLE ===", "debug")
|
|
DebugPrint("Player: " .. tostring(src), "debug")
|
|
DebugPrint("Dealership: " .. tostring(purchaseData.dealershipId), "debug")
|
|
DebugPrint("Vehicle: " .. tostring(purchaseData.model), "debug")
|
|
DebugPrint("Payment Method: " .. tostring(purchaseData.paymentMethod), "debug")
|
|
DebugPrint("Finance: " .. tostring(purchaseData.finance), "debug")
|
|
DebugPrint("Coupon: " .. tostring(purchaseData.couponCode or "none"), "debug")
|
|
DebugPrint("========================", "debug")
|
|
|
|
local dealership = Locations.Server.GetById(purchaseData.dealershipId)
|
|
if not dealership then
|
|
DebugPrint("Dealership not found: " .. tostring(purchaseData.dealershipId), "warning")
|
|
return false
|
|
end
|
|
|
|
local pendingSale, sellerPlayer, sellerPlayerName = nil, nil, nil
|
|
|
|
-- If directSaleUuid was provided, fetch info
|
|
if purchaseData.directSaleUuid then
|
|
pendingSale = DirectSales.Server.GetPending(purchaseData.directSaleUuid)
|
|
if not pendingSale then return false end
|
|
|
|
-- Is the intended recipient accepting?
|
|
if src ~= pendingSale.playerId then return false end
|
|
|
|
if pendingSale.dealerPlayerId then
|
|
sellerPlayer = Framework.Server.GetPlayerIdentifier(pendingSale.dealerPlayerId)
|
|
sellerPlayerName = Framework.Server.GetPlayerInfo(pendingSale.dealerPlayerId)
|
|
sellerPlayerName = sellerPlayerName and sellerPlayerName.name or nil
|
|
end
|
|
|
|
purchaseData.finance = pendingSale.finance -- In case this was somehow changed by in transit by manually firing the purchase-vehicle event
|
|
end
|
|
|
|
-- Purchases disabled for this dealership? (only applies to showroom purchases, not direct sales)
|
|
if not dealership.enable_purchase and not purchaseData.directSaleUuid then
|
|
DebugPrint(("Player %s attempted to purchase at dealership %s but purchases are disabled"):format(tostring(src), tostring(purchaseData.dealershipId)), "warning")
|
|
return false
|
|
end
|
|
|
|
-- Financed but the dealership location doesn't allow that?
|
|
if not dealership.enable_finance and purchaseData.finance then return false end
|
|
|
|
-- Check if valid payment method using the new currency system
|
|
local dealershipPaymentMethods = dealership.payment_methods or {"bank", "cash"}
|
|
local isValidPaymentMethod = lib.table.contains(dealershipPaymentMethods, purchaseData.paymentMethod) or purchaseData.paymentMethod == "societyFund"
|
|
|
|
-- Verify the currency actually exists in the system
|
|
local currency = Currencies.Server.Get(purchaseData.paymentMethod)
|
|
if not isValidPaymentMethod or (purchaseData.paymentMethod ~= "societyFund" and not currency) then
|
|
Framework.Server.Notify(src, Locale.invalidPaymentMethod, "error")
|
|
DebugPrint(("%s attempted to purchase a vehicle with an invalid payment method: %s"):format(tostring(src), purchaseData.paymentMethod), "warning")
|
|
return false
|
|
end
|
|
|
|
-- Check if currency allows financing
|
|
if purchaseData.finance and purchaseData.paymentMethod ~= "societyFund" then
|
|
if not Currencies.Server.AllowsFinance(purchaseData.paymentMethod) then
|
|
Framework.Server.Notify(src, Locale.paymentMethodNoFinance, "error")
|
|
DebugPrint(("%s attempted to finance with a payment method that doesn't support financing: %s"):format(tostring(src), purchaseData.paymentMethod), "warning")
|
|
return false
|
|
end
|
|
end
|
|
|
|
local plate = Framework.Server.VehicleGeneratePlate(Config.PlateFormat, true)
|
|
if not plate then
|
|
Framework.Server.Notify(src, Locale.couldNotGeneratePlate, "error")
|
|
return false
|
|
end
|
|
|
|
-- Get vehicle data
|
|
local vehicleData = MySQL.single.await("SELECT * FROM dealership_stock WHERE vehicle = ? AND dealership = ?", {purchaseData.model, purchaseData.dealershipId})
|
|
if not vehicleData then
|
|
DebugPrint("Vehicle not found in dealership(" .. purchaseData.dealershipId .. ") stock: " .. purchaseData.model, "warning")
|
|
return false
|
|
end
|
|
|
|
-- Check stock level
|
|
local vehicleStock = vehicleData.stock
|
|
if dealership.type == "owned" and vehicleStock < 1 then
|
|
Framework.Server.Notify(src, Locale.errorVehicleOutOfStock, "error")
|
|
return false
|
|
end
|
|
|
|
local player = Framework.Server.GetPlayerIdentifier(src)
|
|
local financeData = nil
|
|
local basePriceToPay = Round(vehicleData.price) -- Price in base currency ($)
|
|
local couponDiscount = 0
|
|
local validatedCoupon = nil
|
|
|
|
-- Validate and apply coupon if provided
|
|
if purchaseData.couponCode and purchaseData.couponCode ~= "" and Coupons.Server.ValidateAndApplyCoupon then
|
|
DebugPrint(("Validating coupon: %s"):format(purchaseData.couponCode), "debug")
|
|
local couponResult = Coupons.Server.ValidateAndApplyCoupon(src, purchaseData.dealershipId, purchaseData.couponCode, purchaseData.model, vehicleData.category, purchaseData.finance, vehicleData.price)
|
|
|
|
if couponResult.valid then
|
|
couponDiscount = couponResult.discount
|
|
validatedCoupon = couponResult.coupon
|
|
basePriceToPay = Round(vehicleData.price - couponDiscount)
|
|
DebugPrint(("Coupon applied - Discount: %s, New price: %s"):format(couponDiscount, basePriceToPay), "debug")
|
|
else
|
|
-- Coupon validation failed, notify player and reject purchase
|
|
DebugPrint(("Coupon validation failed: %s"):format(couponResult.message or "Unknown error"), "warning")
|
|
Framework.Server.Notify(src, string.gsub(Locale.invalidCoupon, '%%{value}', couponResult.message or "Unknown error"), "error")
|
|
return false
|
|
end
|
|
elseif purchaseData.couponCode and purchaseData.couponCode ~= "" then
|
|
DebugPrint("Coupon system not available but coupon code provided", "warning")
|
|
end
|
|
|
|
-- Convert base price to the payment currency amount
|
|
local currencyAmountToPay = Currencies.Server.ConvertFromBase(basePriceToPay, purchaseData.paymentMethod)
|
|
currencyAmountToPay = Round(currencyAmountToPay)
|
|
|
|
local accountBalance = Framework.Server.GetPlayerBalance(src, purchaseData.paymentMethod)
|
|
local paymentType, paid, owed = "full", basePriceToPay, 0
|
|
local downPayment, noOfPayments = Config.FinanceDownPayment, Config.FinancePayments
|
|
|
|
if purchaseData.purchaseType == "society" and purchaseData.paymentMethod == "societyFund" then
|
|
accountBalance = Framework.Server.GetSocietyBalance(purchaseData.society, purchaseData.societyType)
|
|
currencyAmountToPay = basePriceToPay -- Society funds use base currency
|
|
end
|
|
|
|
if purchaseData.finance and purchaseData.purchaseType == "personal" then
|
|
local discountedPrice = vehicleData.price - couponDiscount
|
|
local baseDownPayment = Round(discountedPrice * (1 + Config.FinanceInterest) * downPayment) -- down payment in base currency
|
|
|
|
if pendingSale then
|
|
downPayment, noOfPayments = pendingSale.downPayment, pendingSale.noOfPayments
|
|
baseDownPayment = Round(discountedPrice * (1 + Config.FinanceInterest) * downPayment)
|
|
end
|
|
|
|
-- Finance data is stored in the payment currency for recurring payments
|
|
financeData = {
|
|
total = Round(discountedPrice * (1 + Config.FinanceInterest)),
|
|
paid = baseDownPayment,
|
|
recurring_payment = Round((discountedPrice * (1 + Config.FinanceInterest) * (1 - downPayment)) / noOfPayments),
|
|
payments_complete = 0,
|
|
total_payments = noOfPayments,
|
|
payment_interval = Config.FinancePaymentInterval,
|
|
payment_failed = false,
|
|
seconds_to_next_payment = Config.FinancePaymentInterval * 3600,
|
|
seconds_to_repo = 0,
|
|
dealership_id = purchaseData.dealershipId,
|
|
vehicle = purchaseData.model,
|
|
currency = purchaseData.paymentMethod -- Store the currency used for the finance
|
|
}
|
|
|
|
-- Convert down payment to the payment currency
|
|
basePriceToPay = baseDownPayment
|
|
currencyAmountToPay = Currencies.Server.ConvertFromBase(baseDownPayment, purchaseData.paymentMethod)
|
|
currencyAmountToPay = Round(currencyAmountToPay)
|
|
|
|
local vehiclesOnFinance = MySQL.scalar.await("SELECT COUNT(*) as total FROM " .. Framework.VehiclesTable .. " WHERE financed = 1 AND " .. Framework.PlayerId .. " = ?", {player})
|
|
|
|
if vehiclesOnFinance >= (Config.MaxFinancedVehiclesPerPlayer or 999999) then
|
|
Framework.Server.Notify(src, Locale.tooManyFinancedVehicles, "error")
|
|
return false
|
|
end
|
|
|
|
paymentType = "finance"
|
|
paid = financeData.paid
|
|
owed = financeData.total - financeData.paid
|
|
end
|
|
|
|
if currencyAmountToPay > accountBalance then
|
|
DebugPrint(("Insufficient funds - Required: %s, Available: %s (currency: %s)"):format(currencyAmountToPay, accountBalance, purchaseData.paymentMethod), "debug")
|
|
Framework.Server.Notify(src, Locale.errorCannotAffordVehicle, "error")
|
|
return false
|
|
end
|
|
|
|
DebugPrint(("Pre-check - Base Amount: %s, Currency Amount: %s, Payment: %s, Financed: %s"):format(basePriceToPay, currencyAmountToPay, purchaseData.paymentMethod, tostring(purchaseData.finance)), "debug")
|
|
|
|
-- Pre check func in config-sv.lua
|
|
if not PurchaseVehiclePreCheck(src, purchaseData.dealershipId, plate, purchaseData.model, purchaseData.purchaseType, basePriceToPay, purchaseData.paymentMethod, purchaseData.society, purchaseData.societyType, purchaseData.finance, noOfPayments, downPayment, (not not purchaseData.directSaleUuid), pendingSale and pendingSale.dealerPlayerId or nil) then
|
|
DebugPrint(("PurchaseVehiclePreCheck failed for player %s"):format(tostring(src)), "warning")
|
|
return false
|
|
end
|
|
|
|
DebugPrint("Pre-check passed, processing payment", "debug")
|
|
|
|
-- Remove money (use currency amount for custom currencies, base amount for society funds)
|
|
if purchaseData.purchaseType == "society" and purchaseData.paymentMethod == "societyFund" then
|
|
Framework.Server.RemoveFromSocietyFund(purchaseData.society, purchaseData.societyType, basePriceToPay)
|
|
else
|
|
Framework.Server.PlayerRemoveMoney(src, currencyAmountToPay, purchaseData.paymentMethod)
|
|
end
|
|
|
|
if dealership.type == "owned" then
|
|
MySQL.update.await("UPDATE dealership_stock SET stock = stock - 1 WHERE vehicle = ? AND dealership = ?", {purchaseData.model, purchaseData.dealershipId})
|
|
DealershipBalance.Server.Add(purchaseData.dealershipId, basePriceToPay) -- Always add base price to dealership balance
|
|
end
|
|
|
|
MySQL.insert.await("INSERT INTO dealership_sales (dealership, vehicle, plate, player, seller, purchase_type, paid, owed) VALUES(?, ?, ?, ?, ?, ?, ?, ?)", {purchaseData.dealershipId, purchaseData.model, plate, player, sellerPlayer, paymentType, paid, owed})
|
|
|
|
DebugPrint(("Vehicle saved to database - Plate: %s"):format(plate), "debug")
|
|
|
|
-- Save vehicle to garage
|
|
local vehicleId = Framework.Server.SaveVehicleToGarage(src, purchaseData.purchaseType, purchaseData.society, purchaseData.societyType, purchaseData.model, plate, purchaseData.finance, financeData)
|
|
|
|
DebugPrint(("Vehicle saved to garage - ID: %s"):format(tostring(vehicleId)), "debug")
|
|
|
|
-- Spawn vehicle on server (if configured)
|
|
local netId = nil
|
|
|
|
if Config.SpawnVehiclesWithServerSetter then
|
|
-- For direct sales, don't warp the customer into the vehicle (let them walk to it with the seller)
|
|
local isDirectSale = purchaseData.directSaleUuid ~= nil
|
|
local warp = not Config.DoNotSpawnInsideVehicle and not isDirectSale
|
|
local properties = {
|
|
plate = plate,
|
|
colour = purchaseData.colour
|
|
}
|
|
|
|
netId = Spawn.Server.Create(src, vehicleId or 0, purchaseData.model, plate, purchaseData.coords, warp, properties, "purchase")
|
|
if not netId or netId == 0 then
|
|
Framework.Server.Notify(src, Locale.couldNotSpawnVehicle, "error")
|
|
DebugPrint("Could not spawn vehicle with Config.SpawnVehiclesWithServerSetter", "warning")
|
|
return false
|
|
end
|
|
end
|
|
|
|
-- Record coupon usage if a coupon was applied
|
|
if validatedCoupon then
|
|
DebugPrint(("Recording coupon usage - Coupon ID: %s"):format(validatedCoupon.id), "debug")
|
|
---@diagnostic disable-next-line: param-type-mismatch
|
|
Coupons.Server.RecordCouponUsage(validatedCoupon.id, player, purchaseData.model, tostring(purchaseData.purchaseType), purchaseData.finance, couponDiscount)
|
|
DebugPrint("Coupon usage recorded successfully", "debug")
|
|
end
|
|
|
|
-- Send webhook
|
|
local webhookFields = {
|
|
{ key = "Vehicle", value = purchaseData.model },
|
|
{ key = "Plate", value = plate },
|
|
{ key = "Financed", value = purchaseData.finance and "Yes" or "No" },
|
|
{ key = "Amount Paid", value = currencyAmountToPay },
|
|
{ key = "Payment method", value = purchaseData.paymentMethod },
|
|
{ key = "Dealership", value = purchaseData.dealershipId },
|
|
{ key = "Seller Name", value = sellerPlayerName or "-" }
|
|
}
|
|
|
|
if validatedCoupon then
|
|
table.insert(webhookFields, { key = "Coupon Used", value = purchaseData.couponCode })
|
|
table.insert(webhookFields, { key = "Discount Applied", value = couponDiscount })
|
|
end
|
|
|
|
SendWebhook(src, Webhooks.Purchase, "New Vehicle Purchase", "success", webhookFields)
|
|
|
|
-- Update stock level
|
|
Showroom.Server.UpdateVehicleCache(purchaseData.model, purchaseData.dealershipId)
|
|
|
|
Framework.Server.Notify(src, Locale.purchaseSuccess, "success")
|
|
|
|
DebugPrint(("Purchase completed successfully - Player: %s, Vehicle: %s, Plate: %s"):format(tostring(src), purchaseData.model, plate), "debug")
|
|
|
|
return true, netId, vehicleId, plate, currencyAmountToPay
|
|
end
|
|
|
|
lib.callback.register("jg-dealerships:server:purchase-vehicle", function(src, data)
|
|
return purchaseVehicle(src, data)
|
|
end)
|
|
|
|
lib.callback.register("jg-dealerships:server:validate-coupon", function(src, data)
|
|
local dealershipId, code, vehicleModel, finance = data.dealershipId, data.code, data.vehicleModel, data.isFinanced
|
|
|
|
-- Get vehicle data to fetch category and price
|
|
local vehicleData = MySQL.single.await("SELECT * FROM dealership_stock WHERE vehicle = ? AND dealership = ?", {vehicleModel, dealershipId})
|
|
if not vehicleData then
|
|
return { success = false, error = "Vehicle not found" }
|
|
end
|
|
|
|
return Coupons.Server.ValidateAndApplyCoupon(src, dealershipId, code, vehicleModel, vehicleData.category, finance, vehicleData.price)
|
|
end)
|
|
|
|
RegisterNetEvent("jg-dealerships:server:update-purchased-vehicle-props", function(purchaseType, society, plate, props)
|
|
local src = source
|
|
local identifier = purchaseType == "society" and society or Framework.Server.GetPlayerIdentifier(src)
|
|
|
|
MySQL.update.await("UPDATE " .. Framework.VehiclesTable .. " SET " .. Framework.VehProps .. " = ? WHERE plate = ? AND " .. Framework.PlayerId .. " = ?", {
|
|
json.encode(props), plate, identifier
|
|
})
|
|
end)
|