From d58346d8a06dd484545d11086ab68711918fd164 Mon Sep 17 00:00:00 2001 From: Kenshin13 <63159154+Kenshiin13@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:06:57 +0200 Subject: [PATCH 1/8] fix(accounts): wrap balance update and ledger insert in a transaction --- server/accounts/db.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/server/accounts/db.ts b/server/accounts/db.ts index c1397e1a..dc70bfaf 100644 --- a/server/accounts/db.ts +++ b/server/accounts/db.ts @@ -53,14 +53,21 @@ export async function UpdateBalance( }; const addAction = action === 'add'; + + await conn.beginTransaction(); + const success = addAction ? await conn.update(addBalance, [amount, accountId]) : await conn.update(overdraw ? removeBalance : safeRemoveBalance, [amount, accountId, amount]); - if (!success) + + if (!success) { + await conn.rollback(); + return { success: false, message: 'insufficient_balance', }; + } !message && (message = locales(action === 'add' ? 'deposit' : 'withdraw')); @@ -76,11 +83,16 @@ export async function UpdateBalance( addAction ? balance + amount : null, ])) === 1; - if (!didUpdate) + if (!didUpdate) { + await conn.rollback(); + return { success: false, message: 'something_went_wrong', }; + } + + await conn.commit(); emit('ox:updatedBalance', { accountId, amount, action }); From b0809a614fa1e55c8be9c6c09cfcf7de8aeb6a5c Mon Sep 17 00:00:00 2001 From: Kenshin13 <63159154+Kenshiin13@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:07:09 +0200 Subject: [PATCH 2/8] fix(accounts): commit transfer explicitly instead of on disposal --- server/accounts/db.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/accounts/db.ts b/server/accounts/db.ts index dc70bfaf..50e9160a 100644 --- a/server/accounts/db.ts +++ b/server/accounts/db.ts @@ -144,6 +144,8 @@ export async function PerformTransaction( toBalance + amount, ]); + await conn.commit(); + emit('ox:transferredMoney', { fromId, toId, amount }); return { success: true }; From 91f44c33ce91e231127ff11165fa2855e3bdbfab Mon Sep 17 00:00:00 2001 From: Kenshin13 <63159154+Kenshiin13@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:07:30 +0200 Subject: [PATCH 3/8] fix(accounts): await rollback in money operations --- server/accounts/db.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/accounts/db.ts b/server/accounts/db.ts index 50e9160a..ea6f67c2 100644 --- a/server/accounts/db.ts +++ b/server/accounts/db.ts @@ -155,7 +155,7 @@ export async function PerformTransaction( console.log(e); } - conn.rollback(); + await conn.rollback(); return { success: false, message: 'something_went_wrong' }; } @@ -249,7 +249,7 @@ export async function DepositMoney( const affectedRows = await conn.update(addBalance, [amount, accountId]); if (!affectedRows || !exports.ox_inventory.RemoveItem(playerId, 'money', amount)) { - conn.rollback(); + await conn.rollback(); return { success: false, message: 'something_went_wrong', @@ -305,7 +305,7 @@ export async function WithdrawMoney( const affectedRows = await conn.update(safeRemoveBalance, [amount, accountId, amount]); if (!affectedRows || !exports.ox_inventory.AddItem(playerId, 'money', amount)) { - conn.rollback(); + await conn.rollback(); return { success: false, message: 'something_went_wrong', From 0383b64086595dee644c37f067b05f86507a2c55 Mon Sep 17 00:00:00 2001 From: Kenshin13 <63159154+Kenshiin13@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:07:41 +0200 Subject: [PATCH 4/8] fix(accounts): make invoice payment atomic --- server/accounts/db.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/server/accounts/db.ts b/server/accounts/db.ts index ea6f67c2..ec68c354 100644 --- a/server/accounts/db.ts +++ b/server/accounts/db.ts @@ -373,29 +373,17 @@ export async function UpdateInvoice( if (!hasPermission) return { success: false, message: 'no_permission' }; - const updateReceiver = await UpdateBalance( + const transfer = await PerformTransaction( invoice.toAccount, - invoice.amount, - 'remove', - false, - locales('invoice_payment'), - undefined, - charId, - ); - - if (!updateReceiver.success) return { success: false, message: 'no_balance' }; - - const updateSender = await UpdateBalance( invoice.fromAccount, invoice.amount, - 'add', false, locales('invoice_payment'), undefined, charId, ); - if (!updateSender.success) return { success: false, message: 'no_balance' }; + if (!transfer.success) return { success: false, message: transfer.message ?? 'no_balance' }; const invoiceUpdated = await db.update('UPDATE `accounts_invoices` SET `payerId` = ?, `paidAt` = ? WHERE `id` = ?', [ player.charId, From 6779277eea43ebee40066338ff7a971b8874fcf9 Mon Sep 17 00:00:00 2001 From: Kenshin13 <63159154+Kenshiin13@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:16:58 +0200 Subject: [PATCH 5/8] fix(accounts): commit deposits and withdrawals explicitly --- server/accounts/db.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/accounts/db.ts b/server/accounts/db.ts index ec68c354..c7e5553e 100644 --- a/server/accounts/db.ts +++ b/server/accounts/db.ts @@ -267,6 +267,8 @@ export async function DepositMoney( balance + amount, ]); + await conn.commit(); + emit('ox:depositedMoney', { playerId, accountId, amount }); return { @@ -323,6 +325,8 @@ export async function WithdrawMoney( null, ]); + await conn.commit(); + emit('ox:withdrewMoney', { playerId, accountId, amount }); return { success: true }; From eefd899f4513634168597c7967ca5472fdff4472 Mon Sep 17 00:00:00 2001 From: Kenshin13 <63159154+Kenshiin13@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:17:13 +0200 Subject: [PATCH 6/8] fix(groups): commit group insert explicitly --- server/groups/db.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/groups/db.ts b/server/groups/db.ts index 20d491f3..2903d1c4 100644 --- a/server/groups/db.ts +++ b/server/groups/db.ts @@ -35,6 +35,8 @@ export async function InsertGroup({ name, label, type, colour, hasAccount, grade grades.map((gradeLabel, index) => [name, index + 1, gradeLabel, accountRoles[index + 1]]), )) as UpsertResult[]; + await conn.commit(); + return insertedGrades.reduce((acc, curr) => acc + curr.affectedRows, 0) > 0; } From b3432f31d925b9a15607d051b4d9861f70b50ad5 Mon Sep 17 00:00:00 2001 From: Kenshin13 <63159154+Kenshiin13@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:19:04 +0200 Subject: [PATCH 7/8] fix(db): roll back uncommitted transactions on disposal --- server/accounts/db.ts | 8 ++++---- server/db/index.ts | 8 +++++++- server/groups/db.ts | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/server/accounts/db.ts b/server/accounts/db.ts index c7e5553e..5672c85d 100644 --- a/server/accounts/db.ts +++ b/server/accounts/db.ts @@ -43,7 +43,7 @@ export async function UpdateBalance( if (amount <= 0) return { success: false, message: 'invalid_amount' }; - using conn = await GetConnection(); + await using conn = await GetConnection(); const balance = await conn.scalar(getBalance, [accountId]); if (balance === null) @@ -114,7 +114,7 @@ export async function PerformTransaction( if (amount <= 0) return { success: false, message: 'invalid_amount' }; - using conn = await GetConnection(); + await using conn = await GetConnection(); const fromBalance = await conn.scalar(getBalance, [fromId]); const toBalance = await conn.scalar(getBalance, [toId]); @@ -235,7 +235,7 @@ export async function DepositMoney( if (amount > money) return { success: false, message: 'insufficient_funds' }; - using conn = await GetConnection(); + await using conn = await GetConnection(); const balance = await conn.scalar(getBalance, [accountId]); if (balance === null) return { success: false, message: 'no_balance' }; @@ -293,7 +293,7 @@ export async function WithdrawMoney( if (!player?.charId) return { success: false, message: 'no_charId' }; - using conn = await GetConnection(); + await using conn = await GetConnection(); const role = await conn.scalar(selectAccountRole, [accountId, player.charId]); if (!(await CanPerformAction(player, accountId, role, 'withdraw'))) return { success: false, message: 'no_access' }; diff --git a/server/db/index.ts b/server/db/index.ts index b8c80c0c..2adb39d5 100644 --- a/server/db/index.ts +++ b/server/db/index.ts @@ -4,6 +4,7 @@ import type { Dict } from 'types'; import type { PoolConnection, QueryOptions } from 'mariadb'; (Symbol as any).dispose ??= Symbol('Symbol.dispose'); +(Symbol as any).asyncDispose ??= Symbol('Symbol.asyncDispose'); export interface MySqlRow | undefined> { [column: string]: T; @@ -72,8 +73,13 @@ export class Connection { return this.connection.commit(); } + async [Symbol.asyncDispose]() { + if (this.transaction) await this.rollback(); + await this.connection.release(); + } + [Symbol.dispose]() { - if (this.transaction) this.commit(); + if (this.transaction) this.rollback(); this.connection.release(); } } diff --git a/server/groups/db.ts b/server/groups/db.ts index 2903d1c4..2d87c32f 100644 --- a/server/groups/db.ts +++ b/server/groups/db.ts @@ -20,7 +20,7 @@ export function SelectGroups() { } export async function InsertGroup({ name, label, type, colour, hasAccount, grades, accountRoles }: DbGroup) { - using conn = await GetConnection(); + await using conn = await GetConnection(); await conn.beginTransaction(); const insertedGroup = await conn.update( From f222798d9b313e46fda0c948786c44fe0bb36884 Mon Sep 17 00:00:00 2001 From: Kenshin13 <63159154+Kenshiin13@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:20:30 +0200 Subject: [PATCH 8/8] fix(accounts): mark invoice paid within the transfer transaction --- server/accounts/db.ts | 126 ++++++++++++++++++++++++++++-------------- 1 file changed, 83 insertions(+), 43 deletions(-) diff --git a/server/accounts/db.ts b/server/accounts/db.ts index 5672c85d..f78cc215 100644 --- a/server/accounts/db.ts +++ b/server/accounts/db.ts @@ -99,6 +99,43 @@ export async function UpdateBalance( return { success: true }; } +/** Moves funds between two accounts on an existing transaction, without committing. */ +async function ApplyTransfer( + conn: Connection, + fromId: number, + toId: number, + amount: number, + overdraw: boolean, + fromBalance: number, + toBalance: number, + message?: string, + note?: string, + actorId?: number, +) { + const query = overdraw ? removeBalance : safeRemoveBalance; + const values = [amount, fromId]; + + if (!overdraw) values.push(amount); + + const removedBalance = await conn.update(query, values); + const addedBalance = removedBalance && (await conn.update(addBalance, [amount, toId])); + + if (!addedBalance) return false; + + await conn.execute(addTransaction, [ + actorId, + fromId, + toId, + amount, + message ?? locales('transfer'), + note, + fromBalance - amount, + toBalance + amount, + ]); + + return true; +} + export async function PerformTransaction( fromId: number, toId: number, @@ -124,26 +161,7 @@ export async function PerformTransaction( await conn.beginTransaction(); try { - const query = overdraw ? removeBalance : safeRemoveBalance; - const values = [amount, fromId]; - - if (!overdraw) values.push(amount); - - const removedBalance = await conn.update(query, values); - const addedBalance = removedBalance && (await conn.update(addBalance, [amount, toId])); - - if (addedBalance) { - await conn.execute(addTransaction, [ - actorId, - fromId, - toId, - amount, - message ?? locales('transfer'), - note, - fromBalance - amount, - toBalance + amount, - ]); - + if (await ApplyTransfer(conn, fromId, toId, amount, overdraw, fromBalance, toBalance, message, note, actorId)) { await conn.commit(); emit('ox:transferredMoney', { fromId, toId, amount }); @@ -377,37 +395,59 @@ export async function UpdateInvoice( if (!hasPermission) return { success: false, message: 'no_permission' }; - const transfer = await PerformTransaction( - invoice.toAccount, - invoice.fromAccount, - invoice.amount, - false, - locales('invoice_payment'), - undefined, - charId, - ); + await using conn = await GetConnection(); - if (!transfer.success) return { success: false, message: transfer.message ?? 'no_balance' }; + const fromBalance = await conn.scalar(getBalance, [invoice.toAccount]); + const toBalance = await conn.scalar(getBalance, [invoice.fromAccount]); - const invoiceUpdated = await db.update('UPDATE `accounts_invoices` SET `payerId` = ?, `paidAt` = ? WHERE `id` = ?', [ - player.charId, - new Date(), - invoiceId, - ]); + if (fromBalance === null || toBalance === null) return { success: false, message: 'no_balance' }; - if (!invoiceUpdated) - return { - success: false, - message: 'invoice_not_updated', - }; + await conn.beginTransaction(); + + try { + const transferred = await ApplyTransfer( + conn, + invoice.toAccount, + invoice.fromAccount, + invoice.amount, + false, + fromBalance, + toBalance, + locales('invoice_payment'), + undefined, + charId, + ); + + if (!transferred) { + await conn.rollback(); + return { success: false, message: 'no_balance' }; + } + + const invoiceUpdated = await conn.update( + 'UPDATE `accounts_invoices` SET `payerId` = ?, `paidAt` = ? WHERE `id` = ?', + [player.charId, new Date(), invoiceId], + ); + + if (!invoiceUpdated) { + await conn.rollback(); + return { success: false, message: 'invoice_not_updated' }; + } + + await conn.commit(); + } catch (e) { + console.error(`Failed to pay invoice ${invoiceId}`); + console.log(e); + + await conn.rollback(); + return { success: false, message: 'something_went_wrong' }; + } invoice.payerId = charId; + emit('ox:transferredMoney', { fromId: invoice.toAccount, toId: invoice.fromAccount, amount: invoice.amount }); emit('ox:invoicePaid', invoice); - return { - success: true, - }; + return { success: true }; } export async function CreateInvoice({