first commit

This commit is contained in:
Colin-Joel Scupin
2026-08-05 01:29:39 +02:00
commit 86d4bcdc37
85 changed files with 7706 additions and 0 deletions
@@ -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)
+73
View File
@@ -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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'
}[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; }
}