Add checkout mutation and defer hot state saves

- Replace bank payment flow with a checkout mutation using explicit source context
- Return backend errors to players instead of silently falling back to local state
- Queue hot state persistence for actors, garages, lockers, orgs, and owned assets
This commit is contained in:
Jacob Schmidt 2026-04-02 09:54:32 -05:00
parent 53bc8db7d0
commit ffbfc70be8
15 changed files with 235 additions and 134 deletions

View File

@ -35,12 +35,6 @@ PREP_RECOMPILE_END;
GVAR(BankStore) call ["deposit", [_uid, _amount]];
}] call CFUNC(addEventHandler);
[QGVAR(requestPayment), {
params [["_uid", "", [""]], ["_amount", 0, [0]]];
GVAR(BankStore) call ["payment", [_uid, _amount]];
}] call CFUNC(addEventHandler);
[QGVAR(requestSubmitPin), {
params [["_uid", "", [""]], ["_pin", "", [""]]];

View File

@ -37,6 +37,14 @@ GVAR(BankPayloadBuilder) = createHashMapObject [[
_context set ["fromField", _from];
_context
}],
["buildCheckoutContext", compileFinal {
params [["_source", "bank", [""]], ["_commit", false, [false]]];
createHashMapFromArray [
["commit", _commit],
["sourceField", toLowerANSI _source]
]
}],
["resolveOrgState", compileFinal {
params [["_uid", "", [""]]];

View File

@ -145,35 +145,23 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [
params [["_uid", "", [""]], ["_source", "cash", [""]], ["_amount", 0, [0]], ["_commit", false, [false]]];
private _result = createHashMapFromArray [["success", false], ["message", "Unable to process bank payment."], ["patch", createHashMap]];
private _field = switch (toLowerANSI _source) do {
case "cash": { "cash" };
case "bank": { "bank" };
default { "" };
};
if (_uid isEqualTo "") exitWith { _result };
if (_field isEqualTo "") exitWith {
_result set ["message", "Selected bank payment source is unsupported."];
private _checkoutContext = GVAR(BankPayloadBuilder) call ["buildCheckoutContext", [_source, _commit]];
private _envelope = _self call [
"callHotBankEnvelope",
[
"bank:hot:charge_checkout",
[_uid, str _amount, toJSON _checkoutContext]
]
];
private _mutationResult = _envelope getOrDefault ["data", createHashMap];
private _patch = _self call ["finalizeMutation", [_uid, _mutationResult, false]];
if (_patch isEqualTo createHashMap) exitWith {
_result set ["message", _envelope getOrDefault ["error", "Bank checkout payment failed."]];
_result
};
private _account = _self call ["get", [_uid, ""]];
if (_account isEqualTo createHashMap) exitWith {
_result set ["message", "Bank account data is unavailable for checkout."];
_result
};
private _balance = _account getOrDefault [_field, 0];
if (_balance < _amount) exitWith {
_result set ["message", ["Bank balance cannot cover this checkout.", "Cash on hand cannot cover this checkout."] select (_field isEqualTo "cash")];
_result
};
private _patch = createHashMapFromArray [[_field, (_balance - _amount)]];
if (_commit) then {
private _result = _self call ["callHotBank", ["bank:hot:patch", [_uid, toJSON _patch]]];
_patch = _self call ["finalizeMutation", [_uid, _result, false]];
};
_result set ["success", true];
_result set ["message", ""];
_result set ["patch", _patch];
@ -214,17 +202,19 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [
private _playerName = if (isNull _player) then { "Unknown" } else { name _player };
["bank:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"];
if !(_isSuccess) exitWith {
["ERROR", format ["Failed to check if bank account %1 exists! Using fallback account.", _uid]] call EFUNC(common,log);
private _fallbackAccount = GVAR(BankModel) call ["fromPlayer", [_player]];
_fallbackAccount set ["uid", _uid];
if ((_fallbackAccount getOrDefault ["name", ""]) isEqualTo "") then {
_fallbackAccount set ["name", _playerName];
["ERROR", format ["Failed to check if bank account %1 exists in backend.", _uid]] call EFUNC(common,log);
GVAR(BankMessenger) call ["sendAlert", [_uid, "error", "Bank backend is unavailable right now."]];
createHashMap
};
_fallbackAccount = _self call ["normalizeAccount", [_uid, _fallbackAccount, _playerName]];
GVAR(BankMessenger) call ["sendAccountSync", [_uid, _fallbackAccount, CRPC(bank,responseInitBank)]];
_fallbackAccount
if !(_result isEqualType "") exitWith {
["ERROR", format ["Bank exists check for %1 returned an invalid response.", _uid]] call EFUNC(common,log);
GVAR(BankMessenger) call ["sendAlert", [_uid, "error", "Bank backend returned an invalid response."]];
createHashMap
};
if ((_result find "Error:") == 0) exitWith {
["ERROR", format ["Bank exists check for %1 failed: %2", _uid, _result]] call EFUNC(common,log);
GVAR(BankMessenger) call ["sendAlert", [_uid, "error", _result select [7]]];
createHashMap
};
private _finalAccount = createHashMap;
@ -241,23 +231,29 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [
private _json = _self call ["toJSON", [_finalAccount]];
["bank:create", [_uid, _json]] call EFUNC(extension,extCall) params ["_createResult", "_createSuccess"];
if (!_createSuccess) exitWith {
["ERROR", format ["Failed to create bank account %1! Using fallback account.", _uid]] call EFUNC(common,log);
_finalAccount = _self call ["normalizeAccount", [_uid, _finalAccount, _playerName]];
GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalAccount, CRPC(bank,responseInitBank)]];
_finalAccount
["ERROR", format ["Failed to create bank account %1 in backend.", _uid]] call EFUNC(common,log);
GVAR(BankMessenger) call ["sendAlert", [_uid, "error", "Failed to create bank account in backend."]];
createHashMap
};
if !(_createResult isEqualType "") exitWith {
["ERROR", format ["Bank create for %1 returned an invalid response.", _uid]] call EFUNC(common,log);
GVAR(BankMessenger) call ["sendAlert", [_uid, "error", "Bank backend returned an invalid create response."]];
createHashMap
};
if ((_createResult find "Error:") == 0) exitWith {
["ERROR", format ["Bank create for %1 failed: %2", _uid, _createResult]] call EFUNC(common,log);
GVAR(BankMessenger) call ["sendAlert", [_uid, "error", _createResult select [7]]];
createHashMap
};
_finalAccount = _self call ["loadHotBank", [_uid, true, _playerName]];
["INFO", format ["Created new bank account for %1", _uid]] call EFUNC(common,log);
};
if (_finalAccount isEqualTo createHashMap) then {
_finalAccount = GVAR(BankModel) call ["fromPlayer", [_player]];
_finalAccount set ["uid", _uid];
if ((_finalAccount getOrDefault ["name", ""]) isEqualTo "") then {
_finalAccount set ["name", _playerName];
};
if (_finalAccount isEqualTo createHashMap) exitWith {
["ERROR", format ["Failed to initialize bank hot state for %1.", _uid]] call EFUNC(common,log);
GVAR(BankMessenger) call ["sendAlert", [_uid, "error", "Bank account hot state could not be initialized."]];
createHashMap
};
_finalAccount = _self call ["normalizeAccount", [_uid, _finalAccount, _playerName]];
@ -294,25 +290,17 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [
params [["_uid", "", [""]]];
if (_uid isEqualTo "") exitWith { createHashMap };
private _account = _self call ["callHotBank", ["bank:hot:save", [_uid]]];
if (_account isEqualTo createHashMap) exitWith { _account };
private _envelope = _self call ["callHotBankEnvelope", ["bank:hot:save", [_uid]]];
private _account = _envelope getOrDefault ["data", createHashMap];
if (_account isEqualTo createHashMap) exitWith {
private _message = _envelope getOrDefault ["error", "Bank save failed."];
["ERROR", format ["Failed to save bank account %1: %2", _uid, _message]] call EFUNC(common,log);
GVAR(BankMessenger) call ["sendAlert", [_uid, "error", _message]];
createHashMap
};
_self call ["normalizeAccount", [_uid, _account, ""]]
}],
["payment", compileFinal {
params [["_uid", "", [""]], ["_amount", 0, [0]]];
_self call [
"runMutation",
[
_uid,
"bank:hot:payment",
[_uid, str _amount],
false,
format ["Paid $%1", [_amount] call EFUNC(common,formatNumber)]
]
]
}],
["transfer", compileFinal {
params [["_uid", "", [""]], ["_target", "", [""]], ["_amount", 0, [0]], ["_context", createHashMap, [createHashMap]]];
@ -325,7 +313,13 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [
]
];
private _result = _envelope getOrDefault ["data", createHashMap];
if (_result isEqualTo createHashMap) exitWith { false };
if (_result isEqualTo createHashMap) exitWith {
private _message = _envelope getOrDefault ["error", "Bank transfer failed."];
if (_message isNotEqualTo "") then {
GVAR(BankMessenger) call ["sendAlert", [_uid, "error", _message]];
};
false
};
private _sourceAccount = _result getOrDefault ["sourceAccount", createHashMap];
private _targetAccount = _result getOrDefault ["targetAccount", createHashMap];

View File

@ -9,6 +9,7 @@ use forge_services::{ActorHotStateService, ActorService};
use std::sync::LazyLock;
use crate::adapters::ExtensionRedisClient;
use crate::enqueue_persistence_task;
use crate::helpers::resolve_uid;
use crate::log::log;
@ -104,8 +105,13 @@ pub(crate) fn save_hot_actor(call_context: CallContext, key: String) -> String {
None => return format!("Error: Failed to resolve UID for key: {}", key),
};
match HOT_ACTOR_SERVICE.save_actor(resolved_uid) {
Ok(saved_actor) => serialize_hot_actor(saved_actor),
match HOT_ACTOR_SERVICE.get_actor(resolved_uid.clone()) {
Ok(actor) => {
enqueue_persistence_task("actor", move || {
HOT_ACTOR_SERVICE.save_actor(resolved_uid).map(|_| ())
});
serialize_hot_actor(actor)
}
Err(error) => format!("Error: {}", error),
}
}

View File

@ -5,14 +5,15 @@
use arma_rs::{CallContext, Group};
use forge_models::{
BankMutationResult, BankOperationContext, BankPinContext, BankTransferContext,
BankTransferResult,
BankCheckoutContext, BankMutationResult, BankOperationContext, BankPinContext,
BankTransferContext, BankTransferResult,
};
use forge_repositories::{InMemoryBankHotRepository, RedisBankRepository};
use forge_services::{BankHotStateService, BankService};
use std::sync::LazyLock;
use crate::adapters::ExtensionRedisClient;
use crate::enqueue_persistence_task;
use crate::helpers::resolve_uid;
use crate::log::log;
@ -51,9 +52,9 @@ pub fn group() -> Group {
.command("get", get_hot_bank)
.command("override", override_hot_bank)
.command("patch", patch_hot_bank)
.command("charge_checkout", charge_checkout_hot_bank)
.command("deposit", deposit_hot_bank)
.command("withdraw", withdraw_hot_bank)
.command("payment", payment_hot_bank)
.command("deposit_earnings", deposit_earnings_hot_bank)
.command("transfer", transfer_hot_bank)
.command("validate_pin", validate_pin_hot_bank)
@ -99,6 +100,11 @@ fn parse_transfer_context(json_context: String) -> Result<BankTransferContext, S
.map_err(|error| format!("Invalid bank transfer context: {}", error))
}
fn parse_checkout_context(json_context: String) -> Result<BankCheckoutContext, String> {
serde_json::from_str(&json_context)
.map_err(|error| format!("Invalid bank checkout context: {}", error))
}
fn parse_pin_context(json_context: String) -> Result<BankPinContext, String> {
serde_json::from_str(&json_context)
.map_err(|error| format!("Invalid bank PIN context: {}", error))
@ -156,6 +162,32 @@ pub(crate) fn patch_hot_bank(call_context: CallContext, key: String, json_patch:
}
}
pub(crate) fn charge_checkout_hot_bank(
call_context: CallContext,
key: String,
amount: String,
json_context: String,
) -> String {
let resolved_uid = match resolve_uid(&key, &call_context) {
Some(uid) => uid,
None => return format!("Error: Failed to resolve UID for key: {}", key),
};
let amount = match parse_amount(amount, "checkout") {
Ok(value) => value,
Err(error) => return format!("Error: {}", error),
};
let context = match parse_checkout_context(json_context) {
Ok(value) => value,
Err(error) => return format!("Error: {}", error),
};
match HOT_BANK_SERVICE.charge_checkout(resolved_uid, amount, context) {
Ok(result) => serialize_hot_bank_mutation(result),
Err(error) => format!("Error: {}", error),
}
}
pub(crate) fn deposit_hot_bank(
call_context: CallContext,
key: String,
@ -208,23 +240,6 @@ pub(crate) fn withdraw_hot_bank(
}
}
pub(crate) fn payment_hot_bank(call_context: CallContext, key: String, amount: String) -> String {
let resolved_uid = match resolve_uid(&key, &call_context) {
Some(uid) => uid,
None => return format!("Error: Failed to resolve UID for key: {}", key),
};
let amount = match parse_amount(amount, "payment") {
Ok(value) => value,
Err(error) => return format!("Error: {}", error),
};
match HOT_BANK_SERVICE.payment(resolved_uid, amount) {
Ok(result) => serialize_hot_bank_mutation(result),
Err(error) => format!("Error: {}", error),
}
}
pub(crate) fn deposit_earnings_hot_bank(
call_context: CallContext,
key: String,
@ -308,8 +323,13 @@ pub(crate) fn save_hot_bank(call_context: CallContext, key: String) -> String {
None => return format!("Error: Failed to resolve UID for key: {}", key),
};
match HOT_BANK_SERVICE.save_bank(resolved_uid) {
Ok(saved_bank) => serialize_hot_bank(saved_bank),
match HOT_BANK_SERVICE.get_bank(resolved_uid.clone()) {
Ok(bank) => {
enqueue_persistence_task("bank", move || {
HOT_BANK_SERVICE.save_bank(resolved_uid).map(|_| ())
});
serialize_hot_bank(bank)
}
Err(error) => format!("Error: {}", error),
}
}

View File

@ -10,6 +10,7 @@ use std::collections::HashMap;
use std::sync::LazyLock;
use crate::adapters::ExtensionRedisClient;
use crate::enqueue_persistence_task;
use crate::helpers::resolve_uid;
use crate::log::log;
@ -105,8 +106,13 @@ pub(crate) fn save_hot_garage(call_context: CallContext, key: String) -> String
None => return format!("Error: Failed to resolve UID for key: {}", key),
};
match HOT_GARAGE_SERVICE.save_garage(resolved_uid) {
Ok(saved_garage) => serialize_hot_vehicles(saved_garage),
match HOT_GARAGE_SERVICE.get_garage(resolved_uid.clone()) {
Ok(garage) => {
enqueue_persistence_task("garage", move || {
HOT_GARAGE_SERVICE.save_garage(resolved_uid).map(|_| ())
});
serialize_hot_vehicles(garage)
}
Err(error) => format!("Error: {}", error),
}
}

View File

@ -37,7 +37,7 @@ static CONTEXT: LazyLock<TokioRwLock<Option<Context>>> = LazyLock::new(|| TokioR
static REDIS_POOL: OnceLock<redis::client::RedisClient> = OnceLock::new();
/// Global multi-threaded Tokio runtime used to execute async operations from
/// command handlers and startup tasks.
static RUNTIME: LazyLock<Runtime> = LazyLock::new(|| {
pub(crate) static RUNTIME: LazyLock<Runtime> = LazyLock::new(|| {
Builder::new_multi_thread()
.enable_all()
.build()
@ -54,6 +54,21 @@ enum ConnectionState {
static CONNECTION_STATE: LazyLock<StdRwLock<ConnectionState>> =
LazyLock::new(|| StdRwLock::new(ConnectionState::Initializing));
pub(crate) fn enqueue_persistence_task<F>(module: &'static str, job: F)
where
F: FnOnce() -> Result<(), String> + Send + 'static,
{
RUNTIME.spawn_blocking(move || {
if let Err(error) = job() {
crate::log::log(
module,
"ERROR",
&format!("Async persistence failed: {}", error),
);
}
});
}
#[arma]
/// Initializes the extension, registers commands/groups, and asynchronously
/// creates the Redis connection pool on the global runtime.

View File

@ -6,6 +6,7 @@ use std::collections::HashMap;
use std::sync::LazyLock;
use crate::adapters::ExtensionRedisClient;
use crate::enqueue_persistence_task;
use crate::helpers::resolve_uid;
use crate::log::log;
@ -98,8 +99,13 @@ pub(crate) fn save_hot_locker(call_context: CallContext, key: String) -> String
None => return format!("Error: Failed to resolve UID for key: {}", key),
};
match HOT_LOCKER_SERVICE.save_locker(resolved_uid) {
Ok(saved_locker) => serialize_hot_items(saved_locker),
match HOT_LOCKER_SERVICE.get_locker(resolved_uid.clone()) {
Ok(locker) => {
enqueue_persistence_task("locker", move || {
HOT_LOCKER_SERVICE.save_locker(resolved_uid).map(|_| ())
});
serialize_hot_items(locker)
}
Err(error) => format!("Error: {}", error),
}
}

View File

@ -10,6 +10,7 @@ use forge_services::{OrgHotStateService, OrgService};
use std::sync::LazyLock;
use crate::adapters::ExtensionRedisClient;
use crate::enqueue_persistence_task;
use crate::log::log;
/// Global organization service instance.
@ -104,8 +105,11 @@ pub(crate) fn override_hot_org(org_id: String, json_data: String) -> String {
}
pub(crate) fn save_hot_org(org_id: String) -> String {
match HOT_ORG_SERVICE.save_org(org_id) {
Ok(org) => serialize_hot_org(org),
match HOT_ORG_SERVICE.get_org(org_id.clone()) {
Ok(org) => {
enqueue_persistence_task("org", move || HOT_ORG_SERVICE.save_org(org_id).map(|_| ()));
serialize_hot_org(org)
}
Err(error) => format!("Error: {}", error),
}
}

View File

@ -253,6 +253,15 @@ fn route_command(
arguments[1].clone(),
))
}
"bank:hot:charge_checkout" => {
expect_arg_count(function_name, &arguments, 3)?;
Ok(bank::charge_checkout_hot_bank(
call_context,
arguments[0].clone(),
arguments[1].clone(),
arguments[2].clone(),
))
}
"bank:hot:deposit" => {
expect_arg_count(function_name, &arguments, 3)?;
Ok(bank::deposit_hot_bank(
@ -271,14 +280,6 @@ fn route_command(
arguments[2].clone(),
))
}
"bank:hot:payment" => {
expect_arg_count(function_name, &arguments, 2)?;
Ok(bank::payment_hot_bank(
call_context,
arguments[0].clone(),
arguments[1].clone(),
))
}
"bank:hot:deposit_earnings" => {
expect_arg_count(function_name, &arguments, 3)?;
Ok(bank::deposit_earnings_hot_bank(

View File

@ -5,6 +5,7 @@ use forge_services::{VGarageHotStateService, VGarageService};
use std::sync::LazyLock;
use crate::adapters::ExtensionRedisClient;
use crate::enqueue_persistence_task;
use crate::helpers::resolve_uid;
use crate::log::log;
@ -121,8 +122,13 @@ pub(crate) fn save_hot_vgarage(call_context: CallContext, key: String) -> String
None => return format!("Error: Failed to resolve UID for key: {}", key),
};
match HOT_VGARAGE_SERVICE.save_garage(&resolved_uid) {
Ok(garage) => serialize_hot_vgarage(garage),
match HOT_VGARAGE_SERVICE.fetch_garage(&resolved_uid) {
Ok(garage) => {
enqueue_persistence_task("owned_garage", move || {
HOT_VGARAGE_SERVICE.save_garage(&resolved_uid).map(|_| ())
});
serialize_hot_vgarage(garage)
}
Err(error) => format!("Error: {}", error),
}
}

View File

@ -5,6 +5,7 @@ use forge_services::{VLockerHotStateService, VLockerService};
use std::sync::LazyLock;
use crate::adapters::ExtensionRedisClient;
use crate::enqueue_persistence_task;
use crate::helpers::resolve_uid;
use crate::log::log;
@ -120,8 +121,13 @@ pub(crate) fn save_hot_vlocker(call_context: CallContext, key: String) -> String
None => return format!("Error: Failed to resolve UID for key: {}", key),
};
match HOT_VLOCKER_SERVICE.save_locker(&resolved_uid) {
Ok(locker) => serialize_hot_vlocker(locker),
match HOT_VLOCKER_SERVICE.fetch_locker(&resolved_uid) {
Ok(locker) => {
enqueue_persistence_task("owned_locker", move || {
HOT_VLOCKER_SERVICE.save_locker(&resolved_uid).map(|_| ())
});
serialize_hot_vlocker(locker)
}
Err(error) => format!("Error: {}", error),
}
}

View File

@ -45,6 +45,13 @@ pub struct BankTransferContext {
pub from_field: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BankCheckoutContext {
pub source_field: String,
pub commit: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BankPinContext {

View File

@ -9,8 +9,8 @@ pub mod v_locker;
pub use actor::Actor;
pub use bank::{
Bank, BankMutationResult, BankOperationContext, BankPinContext, BankTransferContext,
BankTransferResult,
Bank, BankCheckoutContext, BankMutationResult, BankOperationContext, BankPinContext,
BankTransferContext, BankTransferResult,
};
pub use cad::{
CadActivityEntry, CadAssignmentMutationResult, CadDispatchOrderContextSeed,

View File

@ -6,8 +6,8 @@
//! For full documentation, architecture, and examples, see the [crate README](../README.md).
use forge_models::{
Bank, BankMutationResult, BankOperationContext, BankPinContext, BankTransferContext,
BankTransferResult,
Bank, BankCheckoutContext, BankMutationResult, BankOperationContext, BankPinContext,
BankTransferContext, BankTransferResult,
};
use forge_repositories::{BankHotRepository, BankRepository};
use serde_json::{Value, json};
@ -96,6 +96,51 @@ impl<R: BankRepository, H: BankHotRepository> BankHotStateService<R, H> {
})
}
pub fn charge_checkout(
&self,
key: String,
amount: f64,
context: BankCheckoutContext,
) -> Result<BankMutationResult, String> {
if amount <= 0.0 {
return Err("Checkout amount must be greater than zero".to_string());
}
let mut bank = self.get_bank(key)?;
let source_field = match context.source_field.trim().to_ascii_lowercase().as_str() {
"cash" => "cash",
"bank" => "bank",
_ => return Err("Selected bank payment source is unsupported.".to_string()),
};
let source_balance = match source_field {
"cash" => bank.cash,
_ => bank.bank,
};
if source_balance < amount {
return Err(match source_field {
"cash" => "Cash on hand cannot cover this checkout.".to_string(),
_ => "Bank balance cannot cover this checkout.".to_string(),
});
}
match source_field {
"cash" => bank.cash -= amount,
_ => bank.bank -= amount,
}
bank.validate()
.map_err(|e| format!("Validation failed: {}", e))?;
if context.commit {
self.repository.save(&bank)?;
}
Ok(BankMutationResult {
account: bank.clone(),
patch: build_patch(&bank, &[source_field])?,
})
}
pub fn deposit(
&self,
key: String,
@ -152,23 +197,6 @@ impl<R: BankRepository, H: BankHotRepository> BankHotStateService<R, H> {
})
}
pub fn payment(&self, key: String, amount: f64) -> Result<BankMutationResult, String> {
if amount <= 0.0 {
return Err("Payment amount must be greater than zero".to_string());
}
let mut bank = self.get_bank(key)?;
bank.bank += amount;
bank.validate()
.map_err(|e| format!("Validation failed: {}", e))?;
self.repository.save(&bank)?;
Ok(BankMutationResult {
account: bank.clone(),
patch: build_patch(&bank, &["bank"])?,
})
}
pub fn deposit_earnings(
&self,
key: String,