first commit
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
local open = false
|
||||
local bankLocations = {
|
||||
vector3(149.77, -1040.72, 29.37),
|
||||
vector3(-1212.98, -330.84, 37.78),
|
||||
vector3(-2962.58, 482.63, 15.70),
|
||||
vector3(314.19, -278.62, 54.17)
|
||||
}
|
||||
|
||||
local function setOpen(state)
|
||||
open = state
|
||||
SetNuiFocus(state, state)
|
||||
SendNUIMessage({ action = state and 'open' or 'close' })
|
||||
if state then
|
||||
DevXTriggerCallback('banking:overview', {}, function(result)
|
||||
SendNUIMessage({
|
||||
action = result.ok and 'overview' or 'error',
|
||||
overview = result.overview,
|
||||
message = result.error
|
||||
})
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
RegisterCommand('bank', function()
|
||||
if exports.devx_core:IsPlayerLoaded() then setOpen(not open) end
|
||||
end, false)
|
||||
|
||||
RegisterNUICallback('close', function(_, cb)
|
||||
setOpen(false)
|
||||
cb({ ok = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback('refresh', function(_, cb)
|
||||
DevXTriggerCallback('banking:overview', {}, cb)
|
||||
end)
|
||||
|
||||
RegisterNUICallback('transfer', function(data, cb)
|
||||
DevXTriggerCallback('banking:transfer', data, cb)
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
for _, location in ipairs(bankLocations) do
|
||||
local blip = AddBlipForCoord(location.x, location.y, location.z)
|
||||
SetBlipSprite(blip, 108)
|
||||
SetBlipScale(blip, 0.65)
|
||||
SetBlipColour(blip, 2)
|
||||
SetBlipAsShortRange(blip, true)
|
||||
BeginTextCommandSetBlipName('STRING')
|
||||
AddTextComponentString('Bank')
|
||||
EndTextCommandSetBlipName(blip)
|
||||
end
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
local sleep = 1000
|
||||
if exports.devx_core:IsPlayerLoaded() then
|
||||
local coords = GetEntityCoords(PlayerPedId())
|
||||
for _, location in ipairs(bankLocations) do
|
||||
local distance = #(coords - location)
|
||||
if distance < 15.0 then
|
||||
sleep = 0
|
||||
DrawMarker(
|
||||
1, location.x, location.y, location.z - 1.0,
|
||||
0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
|
||||
1.1, 1.1, 0.35, 70, 120, 255, 120,
|
||||
false, false, 2, false, nil, nil, false
|
||||
)
|
||||
if distance < 1.5 then
|
||||
BeginTextCommandDisplayHelp('STRING')
|
||||
AddTextComponentSubstringPlayerName('Drücke ~INPUT_CONTEXT~, um die Bank zu öffnen.')
|
||||
EndTextCommandDisplayHelp(0, false, true, -1)
|
||||
if IsControlJustReleased(0, 38) then setOpen(true) end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Wait(sleep)
|
||||
end
|
||||
end)
|
||||
@@ -0,0 +1,31 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
|
||||
author 'Colin-Joel / DevX Studios'
|
||||
description 'DevX RP banking'
|
||||
version '0.4.3'
|
||||
|
||||
lua54 'yes'
|
||||
|
||||
shared_script '@devx_core/shared/config.lua'
|
||||
|
||||
ui_page 'web/index.html'
|
||||
|
||||
files {
|
||||
'web/index.html',
|
||||
'web/style.css',
|
||||
'web/app.js'
|
||||
}
|
||||
|
||||
client_scripts {
|
||||
'@devx_core/client/callbacks.lua',
|
||||
'client/main.lua'
|
||||
}
|
||||
|
||||
server_scripts {
|
||||
'@oxmysql/lib/MySQL.lua',
|
||||
'@devx_core/server/callbacks.lua',
|
||||
'server/main.lua'
|
||||
}
|
||||
|
||||
dependency 'devx_core'
|
||||
@@ -0,0 +1,218 @@
|
||||
local accountLocks = {}
|
||||
|
||||
local function money(value, allowZero)
|
||||
local amount = tonumber(value)
|
||||
if not amount then return nil end
|
||||
amount = math.floor(amount)
|
||||
if amount < (allowZero and 0 or 1) or amount > DevXConfig.MaxMoneyOperation then return nil end
|
||||
return amount
|
||||
end
|
||||
|
||||
local function withLocks(accountIds, callback)
|
||||
local unique = {}
|
||||
for _, id in ipairs(accountIds) do
|
||||
id = tonumber(id)
|
||||
if id and not unique[id] then unique[id] = true end
|
||||
end
|
||||
|
||||
for id in pairs(unique) do
|
||||
if accountLocks[id] then return false, 'Ein beteiligtes Konto verarbeitet gerade eine andere Buchung.' end
|
||||
end
|
||||
for id in pairs(unique) do accountLocks[id] = true end
|
||||
|
||||
local ok, resultA, resultB = pcall(callback)
|
||||
for id in pairs(unique) do accountLocks[id] = nil end
|
||||
|
||||
if not ok then
|
||||
print(('[devx_banking] Buchungsfehler: %s'):format(tostring(resultA)))
|
||||
return false, 'Interner Buchungsfehler.'
|
||||
end
|
||||
return resultA, resultB
|
||||
end
|
||||
|
||||
local function getOverview(characterId)
|
||||
local account = MySQL.single.await([[
|
||||
SELECT id, account_number AS accountNumber, account_name AS accountName,
|
||||
account_type AS accountType, balance
|
||||
FROM devx_bank_accounts
|
||||
WHERE character_id = ? AND account_type = 'personal'
|
||||
LIMIT 1
|
||||
]], { characterId })
|
||||
if not account then return nil end
|
||||
|
||||
account.id = tonumber(account.id)
|
||||
account.balance = tonumber(account.balance) or 0
|
||||
|
||||
local transactions = MySQL.query.await([[
|
||||
SELECT id, direction, amount, balance_after AS balanceAfter,
|
||||
counterparty, reference, created_at AS createdAt
|
||||
FROM devx_bank_transactions
|
||||
WHERE account_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 25
|
||||
]], { account.id }) or {}
|
||||
|
||||
for _, transaction in ipairs(transactions) do
|
||||
transaction.id = tonumber(transaction.id)
|
||||
transaction.amount = tonumber(transaction.amount) or 0
|
||||
transaction.balanceAfter = tonumber(transaction.balanceAfter) or 0
|
||||
end
|
||||
|
||||
return { account = account, transactions = transactions }
|
||||
end
|
||||
|
||||
local function addTransaction(accountId, direction, amount, balanceAfter, counterparty, reference)
|
||||
return MySQL.insert.await([[
|
||||
INSERT INTO devx_bank_transactions
|
||||
(account_id, direction, amount, balance_after, counterparty, reference)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
]], { accountId, direction, amount, balanceAfter, counterparty, reference })
|
||||
end
|
||||
|
||||
local function chargeCharacter(characterId, amount, reference, counterparty)
|
||||
amount = money(amount)
|
||||
if not amount then return false, 'Ungültiger Betrag.' end
|
||||
|
||||
local account = MySQL.single.await([[
|
||||
SELECT id, balance FROM devx_bank_accounts
|
||||
WHERE character_id = ? AND account_type = 'personal' LIMIT 1
|
||||
]], { characterId })
|
||||
if not account then return false, 'Kein Bankkonto gefunden.' end
|
||||
|
||||
return withLocks({ account.id }, function()
|
||||
account = MySQL.single.await('SELECT id, balance FROM devx_bank_accounts WHERE id = ? LIMIT 1', { account.id })
|
||||
if not account or tonumber(account.balance) < amount then return false, 'Das Kontoguthaben reicht nicht aus.' end
|
||||
|
||||
local newBalance = tonumber(account.balance) - amount
|
||||
local changed = MySQL.update.await('UPDATE devx_bank_accounts SET balance = ? WHERE id = ?', { newBalance, account.id })
|
||||
if not changed or changed < 1 then return false, 'Belastung fehlgeschlagen.' end
|
||||
|
||||
addTransaction(account.id, 'debit', amount, newBalance, counterparty or 'Unbekannt', reference or 'Zahlung')
|
||||
return true, newBalance
|
||||
end)
|
||||
end
|
||||
|
||||
local function creditCharacter(characterId, amount, reference, counterparty)
|
||||
amount = money(amount)
|
||||
if not amount then return false, 'Ungültiger Betrag.' end
|
||||
|
||||
local account = MySQL.single.await([[
|
||||
SELECT id, balance FROM devx_bank_accounts
|
||||
WHERE character_id = ? AND account_type = 'personal' LIMIT 1
|
||||
]], { characterId })
|
||||
if not account then return false, 'Kein Bankkonto gefunden.' end
|
||||
|
||||
return withLocks({ account.id }, function()
|
||||
account = MySQL.single.await('SELECT id, balance FROM devx_bank_accounts WHERE id = ? LIMIT 1', { account.id })
|
||||
if not account then return false, 'Kein Bankkonto gefunden.' end
|
||||
|
||||
local newBalance = tonumber(account.balance) + amount
|
||||
local changed = MySQL.update.await('UPDATE devx_bank_accounts SET balance = ? WHERE id = ?', { newBalance, account.id })
|
||||
if not changed or changed < 1 then return false, 'Gutschrift fehlgeschlagen.' end
|
||||
|
||||
addTransaction(account.id, 'credit', amount, newBalance, counterparty or 'Unbekannt', reference or 'Gutschrift')
|
||||
return true, newBalance
|
||||
end)
|
||||
end
|
||||
|
||||
local function setPlayerBalance(source, amount, reason)
|
||||
local player = exports.devx_core:GetPlayer(source)
|
||||
amount = money(amount, true)
|
||||
if not player or not player.accountId or amount == nil then return false, 'Ungültige Anfrage.' end
|
||||
|
||||
return withLocks({ player.accountId }, function()
|
||||
local changed = MySQL.update.await('UPDATE devx_bank_accounts SET balance = ? WHERE id = ?', { amount, player.accountId })
|
||||
if not changed or changed < 1 then return false, 'Kontostand konnte nicht geändert werden.' end
|
||||
addTransaction(player.accountId, 'credit', 0, amount, 'Administration', reason or 'Kontostand gesetzt')
|
||||
exports.devx_core:SetBankCache(source, amount)
|
||||
return true, amount
|
||||
end)
|
||||
end
|
||||
|
||||
local function transferForSource(source, payload)
|
||||
local player = exports.devx_core:GetPlayer(source)
|
||||
local amount = money(payload.amount)
|
||||
local destinationNumber = type(payload.destination) == 'string' and payload.destination:gsub('%s+', ''):upper() or nil
|
||||
local reference = type(payload.reference) == 'string' and payload.reference:sub(1, 140) or 'Überweisung'
|
||||
|
||||
if not player or not player.accountId then return false, 'Kein Charakter aktiv.' end
|
||||
if not amount or not destinationNumber or #destinationNumber < 4 then return false, 'Betrag oder Empfängerkonto ist ungültig.' end
|
||||
if destinationNumber == player.accountNumber then return false, 'Du kannst nicht auf dasselbe Konto überweisen.' end
|
||||
|
||||
local destination = MySQL.single.await([[
|
||||
SELECT id, account_number, account_name, balance
|
||||
FROM devx_bank_accounts WHERE account_number = ? LIMIT 1
|
||||
]], { destinationNumber })
|
||||
if not destination then return false, 'Empfängerkonto wurde nicht gefunden.' end
|
||||
|
||||
return withLocks({ player.accountId, destination.id }, function()
|
||||
local sourceAccount = MySQL.single.await([[
|
||||
SELECT id, account_number, account_name, balance
|
||||
FROM devx_bank_accounts WHERE id = ? AND character_id = ? LIMIT 1
|
||||
]], { player.accountId, player.characterId })
|
||||
destination = MySQL.single.await([[
|
||||
SELECT id, account_number, account_name, balance
|
||||
FROM devx_bank_accounts WHERE id = ? LIMIT 1
|
||||
]], { destination.id })
|
||||
|
||||
if not sourceAccount or not destination then return false, 'Ein Konto wurde nicht gefunden.' end
|
||||
if tonumber(sourceAccount.balance) < amount then return false, 'Das Kontoguthaben reicht nicht aus.' end
|
||||
|
||||
local sourceBalance = tonumber(sourceAccount.balance) - amount
|
||||
local destinationBalance = tonumber(destination.balance) + amount
|
||||
local transactionOk = MySQL.transaction.await({
|
||||
{
|
||||
query = 'UPDATE devx_bank_accounts SET balance = ? WHERE id = ?',
|
||||
values = { sourceBalance, sourceAccount.id }
|
||||
},
|
||||
{
|
||||
query = 'UPDATE devx_bank_accounts SET balance = ? WHERE id = ?',
|
||||
values = { destinationBalance, destination.id }
|
||||
},
|
||||
{
|
||||
query = [[INSERT INTO devx_bank_transactions
|
||||
(account_id, direction, amount, balance_after, counterparty, reference)
|
||||
VALUES (?, 'debit', ?, ?, ?, ?)]],
|
||||
values = { sourceAccount.id, amount, sourceBalance, destination.account_name, reference }
|
||||
},
|
||||
{
|
||||
query = [[INSERT INTO devx_bank_transactions
|
||||
(account_id, direction, amount, balance_after, counterparty, reference)
|
||||
VALUES (?, 'credit', ?, ?, ?, ?)]],
|
||||
values = { destination.id, amount, destinationBalance, sourceAccount.account_name, reference }
|
||||
}
|
||||
})
|
||||
|
||||
if not transactionOk then return false, 'Überweisung fehlgeschlagen.' end
|
||||
exports.devx_core:SetBankCache(source, sourceBalance)
|
||||
return true, sourceBalance
|
||||
end)
|
||||
end
|
||||
|
||||
exports('GetOverview', getOverview)
|
||||
exports('ChargeCharacter', chargeCharacter)
|
||||
exports('CreditCharacter', creditCharacter)
|
||||
exports('SetPlayerBalance', setPlayerBalance)
|
||||
exports('TransferForSource', transferForSource)
|
||||
|
||||
DevXRegisterCallback('banking:overview', function(source)
|
||||
local player = exports.devx_core:GetPlayer(source)
|
||||
if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end
|
||||
local overview = getOverview(player.characterId)
|
||||
if not overview then return { ok = false, error = 'Kein Bankkonto gefunden.' } end
|
||||
exports.devx_core:SetBankCache(source, overview.account.balance)
|
||||
return { ok = true, overview = overview }
|
||||
end)
|
||||
|
||||
DevXRegisterCallback('banking:transfer', function(source, payload)
|
||||
local success, result = transferForSource(source, payload)
|
||||
return success and { ok = true, balance = result } or { ok = false, error = result }
|
||||
end)
|
||||
|
||||
RegisterCommand('setbank', function(source, args)
|
||||
if source ~= 0 and not IsPlayerAceAllowed(source, 'devx.admin') then return end
|
||||
local target = tonumber(args[1])
|
||||
local amount = money(args[2], true)
|
||||
if not target or amount == nil then return end
|
||||
setPlayerBalance(target, amount, ('Admin %s'):format(source))
|
||||
end, false)
|
||||
@@ -0,0 +1,73 @@
|
||||
const resource = typeof GetParentResourceName === 'function'
|
||||
? GetParentResourceName()
|
||||
: 'devx_banking';
|
||||
const app = document.querySelector('#app');
|
||||
const form = document.querySelector('#transferForm');
|
||||
const transactions = document.querySelector('#transactions');
|
||||
const errorEl = document.querySelector('#error');
|
||||
const format = new Intl.NumberFormat('de-DE');
|
||||
|
||||
async function post(event, data = {}) {
|
||||
const response = await fetch(`https://${resource}/${event}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function showError(text = '') {
|
||||
errorEl.textContent = text;
|
||||
errorEl.classList.toggle('hidden', !text);
|
||||
}
|
||||
|
||||
function render(overview) {
|
||||
const account = overview.account;
|
||||
document.querySelector('#accountName').textContent = account.accountName;
|
||||
document.querySelector('#accountNumber').textContent = account.accountNumber;
|
||||
document.querySelector('#balance').textContent = `${format.format(account.balance)} $`;
|
||||
|
||||
transactions.innerHTML = '';
|
||||
for (const entry of overview.transactions || []) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'transaction';
|
||||
const sign = entry.direction === 'credit' ? '+' : '−';
|
||||
row.innerHTML = `
|
||||
<div><strong>${escapeHtml(entry.reference)}</strong><br>
|
||||
<small>${escapeHtml(entry.counterparty || '')} · ${escapeHtml(String(entry.createdAt))}</small></div>
|
||||
<strong class="${entry.direction}">${sign}${format.format(entry.amount)} $</strong>`;
|
||||
transactions.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, char => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[char]));
|
||||
}
|
||||
|
||||
document.querySelector('#close').addEventListener('click', () => post('close'));
|
||||
document.addEventListener('keyup', event => {
|
||||
if (event.key === 'Escape') post('close');
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
showError('');
|
||||
const result = await post('transfer', Object.fromEntries(new FormData(form)));
|
||||
if (!result.ok) {
|
||||
showError(result.error || 'Überweisung fehlgeschlagen.');
|
||||
return;
|
||||
}
|
||||
form.reset();
|
||||
const refreshed = await post('refresh');
|
||||
if (refreshed.ok) render(refreshed.overview);
|
||||
});
|
||||
|
||||
window.addEventListener('message', event => {
|
||||
const data = event.data;
|
||||
if (data.action === 'open') app.classList.remove('hidden');
|
||||
if (data.action === 'close') app.classList.add('hidden');
|
||||
if (data.action === 'overview') render(data.overview);
|
||||
if (data.action === 'error') showError(data.message);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main id="app" class="hidden">
|
||||
<section class="bank">
|
||||
<header>
|
||||
<div>
|
||||
<p>LOS SANTOS FINANCIAL</p>
|
||||
<h1>Banking</h1>
|
||||
</div>
|
||||
<button id="close" aria-label="Schließen">×</button>
|
||||
</header>
|
||||
|
||||
<div id="error" class="error hidden"></div>
|
||||
|
||||
<section class="account">
|
||||
<div><span>KONTO</span><strong id="accountName">–</strong></div>
|
||||
<div><span>KONTONUMMER</span><strong id="accountNumber">–</strong></div>
|
||||
<div class="balance"><span>VERFÜGBAR</span><strong id="balance">0 $</strong></div>
|
||||
</section>
|
||||
|
||||
<section class="columns">
|
||||
<form id="transferForm">
|
||||
<h2>Überweisung</h2>
|
||||
<label>Empfängerkonto<input name="destination" placeholder="LS0000000000" required></label>
|
||||
<label>Betrag<input name="amount" type="number" min="1" step="1" required></label>
|
||||
<label>Verwendungszweck<input name="reference" maxlength="140" value="Überweisung"></label>
|
||||
<button type="submit">Überweisen</button>
|
||||
</form>
|
||||
|
||||
<div>
|
||||
<h2>Letzte Buchungen</h2>
|
||||
<div id="transactions" class="transactions"></div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
:root { font-family: Inter, Arial, sans-serif; color: #f7f8fb; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; overflow: hidden; background: transparent; }
|
||||
.hidden { display: none !important; }
|
||||
#app {
|
||||
min-height: 100vh; display: grid; place-items: center;
|
||||
background: rgba(2,5,9,.55); backdrop-filter: blur(5px);
|
||||
}
|
||||
.bank {
|
||||
width: min(980px, 92vw); max-height: 86vh; overflow: auto;
|
||||
background: #10151e; border: 1px solid rgba(255,255,255,.1);
|
||||
border-radius: 22px; padding: 28px; box-shadow: 0 30px 100px rgba(0,0,0,.55);
|
||||
}
|
||||
header { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
header p, .account span { color: #8f9aae; font-size: 11px; letter-spacing: .15em; }
|
||||
h1 { margin: 4px 0 22px; font-size: 38px; }
|
||||
h2 { font-size: 18px; }
|
||||
#close { background: transparent; color: white; border: 0; font-size: 32px; cursor: pointer; }
|
||||
.account {
|
||||
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px;
|
||||
background: linear-gradient(135deg, #2857dd, #1e3e98);
|
||||
border-radius: 16px; padding: 20px;
|
||||
}
|
||||
.account div { display: grid; gap: 7px; }
|
||||
.account .balance strong { font-size: 28px; }
|
||||
.columns { display: grid; grid-template-columns: .8fr 1.2fr; gap: 24px; margin-top: 22px; }
|
||||
form { display: grid; gap: 12px; align-content: start; }
|
||||
label { display: grid; gap: 6px; color: #aeb7c7; font-size: 13px; }
|
||||
input {
|
||||
border: 1px solid rgba(255,255,255,.11); background: #0a0e15;
|
||||
color: white; border-radius: 10px; padding: 12px;
|
||||
}
|
||||
button[type=submit] {
|
||||
border: 0; background: #416cf5; color: white; font-weight: 800;
|
||||
border-radius: 10px; padding: 12px; cursor: pointer;
|
||||
}
|
||||
.transaction {
|
||||
display: grid; grid-template-columns: 1fr auto; gap: 6px;
|
||||
padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,.08);
|
||||
}
|
||||
.transaction small { color: #8f9aae; }
|
||||
.credit { color: #6ee7a5; }
|
||||
.debit { color: #ff8592; }
|
||||
.error { background: rgba(180,45,65,.28); padding: 12px; border-radius: 10px; margin-bottom: 14px; }
|
||||
@media (max-width: 760px) {
|
||||
.account, .columns { grid-template-columns: 1fr; }
|
||||
}
|
||||
Reference in New Issue
Block a user