Files
red-valley/resources/[framework]/[addons]/jg-dealerships/server/sv-purchase.lua
2026-03-29 21:41:17 +03:00

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)