diff --git a/arma/server/addons/economy/README.md b/arma/server/addons/economy/README.md index 51adab8..f2e567c 100644 --- a/arma/server/addons/economy/README.md +++ b/arma/server/addons/economy/README.md @@ -2,30 +2,80 @@ ## Overview The economy addon contains server-side systems for world economic interactions -that are still implemented in SQF. +that are still implemented in SQF. It owns Arma-world behavior such as active +refueling sessions, medical spawn occupancy, respawn placement, and death +inventory handling. -Current stores cover fuel tracking, medical service behavior, and a placeholder -service economy store. +Current stores cover fuel tracking, medical service behavior, and service +charges such as repairs. ## Dependencies - `forge_server_main` -- `forge_server_common` at runtime for logging, formatting, and player lookup -- `forge_server_bank` at runtime for medical service charges +- `forge_server_common` for logging, formatting, and player lookup +- `forge_server_bank` for player-funded medical billing +- `forge_server_org` for extension-backed organization hot-cache charges - `forge_client_actor` and `forge_client_notifications` for response RPCs ## Main Components -- `fnc_initFEconomyStore.sqf` tracks active refueling sessions and reports fuel - totals. +- `fnc_initFEconomyStore.sqf` tracks active refueling sessions, calculates fuel + totals, charges the player's organization through `OrgStore`, syncs the org + patch, and rolls fuel back to the starting level when organization funds + cannot cover the refuel. - `fnc_initMEconomyStore.sqf` manages medical spawn occupancy, healing charges, - respawn placement, death inventory handling, and body-bag transfer. -- `fnc_initSEconomyStore.sqf` initializes the service economy placeholder. + respawn placement, death inventory handling, and body-bag transfer. Medical + charges use player bank/cash first, then organization funds with repayable + member debt only when the player cannot cover the service. +- `fnc_initSEconomyStore.sqf` handles organization-funded service charges and + repairs. Repairs only apply after the organization charge succeeds. The + shared org-charge helper can also record member debt for medical fallback. ## Event Surface -The addon registers CBA server events for fuel start/tick/stop, player killed, -player respawn, and healing. Medical store initialization runs after post-init -to discover configured medical spawn objects. +The addon registers CBA server events for fuel start/tick/stop, repair service, +player killed, player respawn, and healing. Medical store initialization runs +after post-init to discover configured medical spawn objects. + +Repair service requests use: + +```sqf +[QEGVAR(economy,RepairService), [_target, _unit, _cost]] call CBA_fnc_serverEvent; +``` + +`_cost` is optional. Passing `-1` uses the configured service repair cost. + +## Billing Rules +Economy does not own durable money state. It coordinates Arma-world effects +after the relevant hot-cache charge succeeds. + +Fuel and repair services are organization-funded: + +1. Resolve the player's organization from actor state. +2. Ensure the player is a member of that organization hot record. +3. Call `OrgStore chargeCheckout` with `source = "org_funds"`, + `commit = true`, and member service charging enabled. +4. Send the returned organization patch to online members. +5. If the charge fails, do not complete the service. Refueling rolls the target + back to its starting fuel level; repairs are not applied. + +Medical services are player-funded first: + +1. Load the player's bank hot state. +2. Charge the player's bank balance when it can cover the medical bill. +3. Otherwise charge the player's cash when it can cover the bill. +4. If neither personal balance can cover the bill, charge organization funds + and record the same amount as a debt on the player's organization credit + line. +5. If personal billing is unavailable, or both personal and organization funds + fail, do not complete the heal. + +The organization fallback reduces org funds immediately and adds the medical +cost to the player's credit-line balance due. Repayment uses the normal bank +credit-line repayment flow, which moves player bank funds back into the +organization treasury. + +This keeps money mutation rules in the extension-backed organization service +and bank service while leaving world interactions in SQF. ## Notes -The service economy store is currently a stub. Fuel and medical behavior should -stay server-authoritative because they mutate money, inventory, and respawn -state. +Fuel, medical, and service world behavior should stay server-authoritative +because it mutates inventory, vehicles, and respawn state. Money mutations +should continue to use extension-backed bank and organization hot state. diff --git a/arma/server/addons/economy/XEH_PREP.hpp b/arma/server/addons/economy/XEH_PREP.hpp index 6bbcd10..a377c3a 100644 --- a/arma/server/addons/economy/XEH_PREP.hpp +++ b/arma/server/addons/economy/XEH_PREP.hpp @@ -1,2 +1,3 @@ PREP(initFEconomyStore); PREP(initMEconomyStore); +PREP(initSEconomyStore); diff --git a/arma/server/addons/economy/XEH_postInit.sqf b/arma/server/addons/economy/XEH_postInit.sqf index b912379..96be61d 100644 --- a/arma/server/addons/economy/XEH_postInit.sqf +++ b/arma/server/addons/economy/XEH_postInit.sqf @@ -1,3 +1,5 @@ #include "script_component.hpp" -GVAR(MEconomyStore) call ["init", []]; +if !(isNil QGVAR(MEconomyStore)) then { + GVAR(MEconomyStore) call ["init", []]; +}; diff --git a/arma/server/addons/economy/XEH_preInit.sqf b/arma/server/addons/economy/XEH_preInit.sqf index 8a385c3..7b5bd48 100644 --- a/arma/server/addons/economy/XEH_preInit.sqf +++ b/arma/server/addons/economy/XEH_preInit.sqf @@ -8,7 +8,7 @@ PREP_RECOMPILE_END; if (isNil QGVAR(MEconomyStore)) then { call FUNC(initMEconomyStore); }; if (isNil QGVAR(FEconomyStore)) then { call FUNC(initFEconomyStore); }; -// if (isNil QGVAR(SEconomyStore)) then { call FUNC(initSEconomyStore); }; +if (isNil QGVAR(SEconomyStore)) then { call FUNC(initSEconomyStore); }; [QGVAR(FuelStart), { params ["_source", "_target", "_unit"]; @@ -28,6 +28,11 @@ if (isNil QGVAR(FEconomyStore)) then { call FUNC(initFEconomyStore); }; GVAR(FEconomyStore) call ["stop", [_source, _target]]; }] call CFUNC(addEventHandler); +[QGVAR(RepairService), { + params ["_target", "_unit", ["_cost", -1, [0]]]; + GVAR(SEconomyStore) call ["repair", [_target, _unit, _cost]]; +}] call CFUNC(addEventHandler); + [QGVAR(onKilled), { params ["_unit"]; GVAR(MEconomyStore) call ["onKilled", [_unit]]; diff --git a/arma/server/addons/economy/config.cpp b/arma/server/addons/economy/config.cpp index 05c825e..cbd0a75 100644 --- a/arma/server/addons/economy/config.cpp +++ b/arma/server/addons/economy/config.cpp @@ -8,7 +8,8 @@ class CfgPatches { name = COMPONENT_NAME; requiredVersion = REQUIRED_VERSION; requiredAddons[] = { - "forge_server_main" + "forge_server_main", + "forge_server_common" }; units[] = {}; weapons[] = {}; diff --git a/arma/server/addons/economy/functions/fnc_initFEconomyStore.sqf b/arma/server/addons/economy/functions/fnc_initFEconomyStore.sqf index 613ef18..eb820f9 100644 --- a/arma/server/addons/economy/functions/fnc_initFEconomyStore.sqf +++ b/arma/server/addons/economy/functions/fnc_initFEconomyStore.sqf @@ -8,16 +8,18 @@ * Public: No * * Description: - * No description added yet. + * Initializes the fuel economy store. Active refueling sessions remain + * server-local; payment is routed through the organization extension hot + * cache. * * Parameter(s): * N/A * * Returns: - * Something [BOOL] + * Fuel economy store object [HASHMAP OBJECT] * * Example(s): - * [parameter] call forge_x_component_fnc_myFunction + * call forge_server_economy_fnc_initFEconomyStore */ #pragma hemtt ignore_variables ["_self"] @@ -36,23 +38,66 @@ GVAR(FEconomyStore) = createHashMapObject [[ private _uid = getPlayerUID _unit; private _fuelRegistry = _self getOrDefault ["fuelRegistry", createHashMap]; - _fuelRegistry set [_index, _uid]; + _fuelRegistry set [_index, createHashMapFromArray [ + ["uid", _uid], + ["initialFuel", fuel _target] + ]]; SETVAR(_target,liters,0); }], + ["rollbackFuel", { + params [["_target", objNull, [objNull]], ["_initialFuel", 0, [0]]]; + + if (isNull _target) exitWith { false }; + + _target setFuel (_initialFuel max 0 min 1); + SETVAR(_target,liters,0); + true + }], ["stop", { params ["_source", "_target"]; private _index = netId _target; private _fuelRegistry = _self getOrDefault ["fuelRegistry", createHashMap]; - private _uid = _fuelRegistry get _index; + private _session = _fuelRegistry getOrDefault [_index, createHashMap]; + if (_session isEqualType "") then { + _session = createHashMapFromArray [["uid", _session], ["initialFuel", fuel _target]]; + }; + + private _uid = _session getOrDefault ["uid", ""]; + private _initialFuel = _session getOrDefault ["initialFuel", fuel _target]; private _player = [_uid] call EFUNC(common,getPlayer); private _totalLiters = GETVAR(_target,liters,0); - private _totalCost = _totalLiters * 5; + private _totalCost = _totalLiters * GVAR(FuelCost); private _formattedTotalCost = [_totalCost] call EFUNC(common,formatNumber); private _formattedTotalLiters = _totalLiters toFixed 2; - [CRPC(notifications,recieveNotification), ["info", "Refueling", format ["Refueling complete: %1L
Total Cost: $%2", _formattedTotalLiters, _formattedTotalCost]], _player] call CFUNC(targetEvent); + if (isNull _player || { _uid isEqualTo "" }) exitWith { + ["WARNING", format ["Unable to resolve refueling player for vehicle %1.", _index], nil, nil] call EFUNC(common,log); + _self call ["rollbackFuel", [_target, _initialFuel]]; + _fuelRegistry deleteAt _index; + }; + + if (_totalCost <= 0) exitWith { + [CRPC(notifications,recieveNotification), ["info", "Refueling", format ["Refueling complete: %1L", _formattedTotalLiters]], _player] call CFUNC(targetEvent); + _fuelRegistry deleteAt _index; + }; + + if (isNil QGVAR(SEconomyStore)) exitWith { + ["ERROR", "Service economy store unavailable for refueling charge.", nil, nil] call EFUNC(common,log); + [CRPC(notifications,recieveNotification), ["danger", "Refueling", "Organization billing is unavailable. Refueling was not completed."], _player] call CFUNC(targetEvent); + _self call ["rollbackFuel", [_target, _initialFuel]]; + _fuelRegistry deleteAt _index; + }; + + private _chargeResult = GVAR(SEconomyStore) call ["chargeOrg", [_player, _totalCost, "Refueling"]]; + if !(_chargeResult getOrDefault ["success", false]) exitWith { + [CRPC(notifications,recieveNotification), ["danger", "Refueling", _chargeResult getOrDefault ["message", "Organization funds cannot cover this refuel. Refueling was not completed."]], _player] call CFUNC(targetEvent); + _self call ["rollbackFuel", [_target, _initialFuel]]; + _fuelRegistry deleteAt _index; + }; + + [CRPC(notifications,recieveNotification), ["info", "Refueling", format ["Refueling complete: %1L
Organization charged $%2.", _formattedTotalLiters, _formattedTotalCost]], _player] call CFUNC(targetEvent); _fuelRegistry deleteAt _index; }] ]]; diff --git a/arma/server/addons/economy/functions/fnc_initMEconomyStore.sqf b/arma/server/addons/economy/functions/fnc_initMEconomyStore.sqf index d6edd84..217fe97 100644 --- a/arma/server/addons/economy/functions/fnc_initMEconomyStore.sqf +++ b/arma/server/addons/economy/functions/fnc_initMEconomyStore.sqf @@ -4,20 +4,23 @@ * File: fnc_initMEconomyStore.sqf * Author: IDSolutions * Date: 2025-12-20 - * Last Update: 2026-02-13 + * Last Update: 2026-04-18 * Public: No * * Description: - * No description added yet. + * Initializes the medical economy store. Respawn, body-bag, and spawn + * occupancy behavior remains server-local, while money mutations are + * routed through player bank hot state first, then organization hot state + * with a repayable member debt when personal funds cannot cover the bill. * * Parameter(s): * N/A * * Returns: - * Something [BOOL] + * Medical economy store object [HASHMAP OBJECT] * * Example(s): - * [parameter] call forge_x_component_fnc_myFunction + * call forge_server_economy_fnc_initMEconomyStore */ #pragma hemtt ignore_variables ["_self"] @@ -63,37 +66,105 @@ GVAR(MEconomyStore) = createHashMapObject [[ } forEach _mSpawns; }; }], + ["chargePlayer", { + params [["_uid", "", [""]], ["_amount", 0, [0]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["fallbackEligible", false], + ["source", ""], + ["message", "Unable to charge personal funds."] + ]; + + if (_uid isEqualTo "") exitWith { + _result set ["message", "A valid player UID is required for medical billing."]; + _result + }; + if (_amount <= 0) exitWith { + _result set ["success", true]; + _result + }; + if (isNil QEGVAR(bank,BankStore)) exitWith { + _result set ["message", "Personal billing is unavailable. Medical service cannot complete."]; + _result + }; + + private _account = EGVAR(bank,BankStore) call ["get", [_uid, ""]]; + if (_account isEqualTo createHashMap) exitWith { + _result set ["message", "Personal account could not be loaded for medical billing."]; + _result + }; + + private _source = ""; + if ((_account getOrDefault ["bank", 0]) >= _amount) then { + _source = "bank"; + } else { + if ((_account getOrDefault ["cash", 0]) >= _amount) then { + _source = "cash"; + }; + }; + + if (_source isEqualTo "") exitWith { + _result set ["fallbackEligible", true]; + _result set ["message", "Personal bank and cash balances cannot cover this medical service."]; + _result + }; + + private _charge = EGVAR(bank,BankStore) call ["chargeCheckout", [_uid, _source, _amount, true]]; + if !(_charge getOrDefault ["success", false]) exitWith { + _result set ["message", _charge getOrDefault ["message", "Personal funds could not be charged for medical service."]]; + _result + }; + + private _patch = _charge getOrDefault ["patch", createHashMap]; + if (_patch isNotEqualTo createHashMap && { !(isNil QEGVAR(bank,BankMessenger)) }) then { + EGVAR(bank,BankMessenger) call ["sendAccountSync", [_uid, _patch]]; + }; + + private _savedAccount = EGVAR(bank,BankStore) call ["save", [_uid]]; + if (_savedAccount isEqualTo createHashMap) then { + ["ERROR", format ["Medical charge for %1 succeeded in hot bank state, but durable bank save failed.", _uid]] call EFUNC(common,log); + }; + + _result set ["success", true]; + _result set ["source", _source]; + _result set ["message", ""]; + _result + }], ["onHealed", { params [["_unit", objNull, [objNull]]]; if (isNull _unit) exitWith { ["WARNING", format ["Invalid unit provided: %1", (name _unit)], nil, nil] call EFUNC(common,log); }; private _uid = getPlayerUID _unit; - private _account = EGVAR(bank,BankStore) call ["get", [_uid, ""]]; - if (_account isEqualTo createHashMap) then { - _account = EGVAR(bank,BankStore) call ["init", [_uid]]; - }; - - if (_account isEqualTo createHashMap) exitWith { ["ERROR", format ["No account found for %1. UID: %2", (name _unit), _uid], nil, nil] call EFUNC(common,log); }; - - private _bank = _account get "bank"; - private _cash = _account get "cash"; + if (_uid isEqualTo "") exitWith { ["WARNING", "Unable to charge medical service for unit without UID.", nil, nil] call EFUNC(common,log); }; private _healCost = 100; - private _newBalance = 0; - if (_bank < _healCost && _cash < _healCost) exitWith { - [CRPC(notifications,recieveNotification), ["danger", "Insufficient Funds", format ["Insufficient funds for %1. Bank: $%2, Cash: $%3, Required: $%4", (name _unit), [_bank] call EFUNC(common,formatNumber), [_cash] call EFUNC(common,formatNumber), [_healCost] call EFUNC(common,formatNumber)]], _unit] call CFUNC(targetEvent); + private _personalCharge = _self call ["chargePlayer", [_uid, _healCost]]; + if (_personalCharge getOrDefault ["success", false]) exitWith { + private _sourceLabel = ["cash", "bank"] select ((_personalCharge getOrDefault ["source", "bank"]) isEqualTo "bank"); + [CRPC(notifications,recieveNotification), ["info", "Medical Billing", format ["Medical service charged $%1 from your %2.", [_healCost] call EFUNC(common,formatNumber), _sourceLabel]], _unit] call CFUNC(targetEvent); + [CRPC(actor,onActorHealed), [], _unit] call CFUNC(targetEvent); }; - if (_bank >= _healCost) then { - _newBalance = _bank - _healCost; - _account set ["bank", _newBalance]; - } else { - _newBalance = _cash - _healCost; - _account set ["cash", _newBalance]; + if !(_personalCharge getOrDefault ["fallbackEligible", false]) exitWith { + private _message = _personalCharge getOrDefault ["message", "Personal funds could not be charged for medical service."]; + [CRPC(notifications,recieveNotification), ["danger", "Medical Billing", _message], _unit] call CFUNC(targetEvent); }; + if (isNil QGVAR(SEconomyStore)) exitWith { + ["ERROR", "Service economy store unavailable for medical organization fallback charge.", nil, nil] call EFUNC(common,log); + [CRPC(notifications,recieveNotification), ["danger", "Medical Billing", "Organization billing is unavailable. Medical service cannot complete."], _unit] call CFUNC(targetEvent); + }; + + private _chargeResult = GVAR(SEconomyStore) call ["chargeOrg", [_unit, _healCost, "Medical", true]]; + if !(_chargeResult getOrDefault ["success", false]) exitWith { + private _message = _chargeResult getOrDefault ["message", "Organization funds cannot cover this medical service."]; + [CRPC(notifications,recieveNotification), ["danger", "Medical Billing", _message], _unit] call CFUNC(targetEvent); + }; + + [CRPC(notifications,recieveNotification), ["info", "Medical Billing", format ["Personal funds could not cover medical service. Organization charged $%1; repay it through your organization credit line.", [_healCost] call EFUNC(common,formatNumber)]], _unit] call CFUNC(targetEvent); [CRPC(actor,onActorHealed), [], _unit] call CFUNC(targetEvent); }], ["onRespawn", { diff --git a/arma/server/addons/economy/functions/fnc_initSEconomyStore.sqf b/arma/server/addons/economy/functions/fnc_initSEconomyStore.sqf index 07a94dc..52c6454 100644 --- a/arma/server/addons/economy/functions/fnc_initSEconomyStore.sqf +++ b/arma/server/addons/economy/functions/fnc_initSEconomyStore.sqf @@ -1,31 +1,136 @@ #include "..\script_component.hpp" /* - * File: initSEconomyStore.sqf + * File: fnc_initSEconomyStore.sqf * Author: IDSolutions * Date: 2025-12-20 - * Last Update: 2026-01-03 + * Last Update: 2026-04-18 * Public: No * * Description: - * No description added yet. + * Initializes the service economy store for organization-funded world + * services such as repairs, with optional member debt recording for + * organization-covered medical fallback charges. * * Parameter(s): * N/A * * Returns: - * Something [BOOL] + * Service economy store object [HASHMAP OBJECT] * * Example(s): - * [parameter] call forge_x_component_fnc_myFunction + * call forge_server_economy_fnc_initSEconomyStore */ #pragma hemtt ignore_variables ["_self"] GVAR(SEconomyStore) = createHashMapObject [[ ["#type", "IServiceEconomy"], ["#create", { + GVAR(ServiceRepairCost) = 500; ["INFO", "Service Store Initialized!", nil, nil] call EFUNC(common,log); }], + ["notify", { + params [["_unit", objNull, [objNull]], ["_type", "info", [""]], ["_title", "Service", [""]], ["_message", "", [""]]]; + + if (isNull _unit || { _message isEqualTo "" }) exitWith { false }; + + [CRPC(notifications,recieveNotification), [_type, _title, _message], _unit] call CFUNC(targetEvent); + true + }], + ["syncOrgPatch", { + params [["_result", createHashMap, [createHashMap]]]; + + private _patch = _result getOrDefault ["patch", createHashMap]; + if ((keys _patch) isEqualTo []) exitWith { false }; + + { + private _memberPlayer = [_x] call EFUNC(common,getPlayer); + if (_memberPlayer isNotEqualTo objNull) then { + [CRPC(org,responseSyncOrg), [_patch], _memberPlayer] call CFUNC(targetEvent); + }; + } forEach (_result getOrDefault ["memberUids", []]); + + true + }], + ["chargeOrg", { + params [ + ["_unit", objNull, [objNull]], + ["_amount", 0, [0]], + ["_label", "Service", [""]], + ["_recordDebt", false, [false]] + ]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", "Unable to charge organization funds."], + ["patch", createHashMap], + ["memberUids", []], + ["persisted", false], + ["persistenceMessage", ""] + ]; + + if (isNull _unit) exitWith { + _result set ["message", "A valid player is required for organization billing."]; + _result + }; + + private _uid = getPlayerUID _unit; + if (_uid isEqualTo "") exitWith { + _result set ["message", "A valid player UID is required for organization billing."]; + _result + }; + + if (_amount <= 0) exitWith { + _result set ["success", true]; + _result set ["message", ""]; + _result + }; + + if (isNil QEGVAR(org,OrgStore)) exitWith { + _result set ["message", "Organization service is unavailable."]; + ["ERROR", format ["Org store unavailable for %1 charge.", _label], nil, nil] call EFUNC(common,log); + _result + }; + + private _orgID = EGVAR(org,OrgStore) call ["resolveOrgIdForUid", [_uid]]; + if (_orgID isEqualTo "") then { _orgID = "default"; }; + + private _actor = createHashMap; + if !(isNil QEGVAR(actor,ActorStore)) then { + _actor = EGVAR(actor,ActorStore) call ["load", [_uid]]; + }; + private _memberName = EGVAR(org,OrgStore) call ["resolveActorName", [_uid, _unit, _actor]]; + private _org = EGVAR(org,OrgStore) call ["ensureMember", [_orgID, _uid, _memberName]]; + if (_org isEqualTo createHashMap) exitWith { + _result set ["message", "Organization membership could not be verified."]; + _result + }; + + private _charge = EGVAR(org,OrgStore) call ["chargeCheckout", [_uid, _unit, "org_funds", _amount, true, true, _recordDebt]]; + if !(_charge getOrDefault ["success", false]) exitWith { + _result set ["message", _charge getOrDefault ["message", "Organization funds cannot cover this service."]]; + _result + }; + + _self call ["syncOrgPatch", [_charge]]; + _charge + }], + ["repair", { + params [["_target", objNull, [objNull]], ["_unit", objNull, [objNull]], ["_cost", -1, [0]]]; + + if (isNull _target || { isNull _unit }) exitWith { false }; + + private _repairCost = [_cost, GVAR(ServiceRepairCost)] select (_cost < 0); + private _charge = _self call ["chargeOrg", [_unit, _repairCost, "Repair"]]; + if !(_charge getOrDefault ["success", false]) exitWith { + _self call ["notify", [_unit, "danger", "Repair", _charge getOrDefault ["message", "Organization funds cannot cover this repair."]]]; + false + }; + + _target setDamage 0; + _self call ["notify", [_unit, "info", "Repair", format ["Repair complete. Organization charged $%1.", [_repairCost] call EFUNC(common,formatNumber)]]]; + true + }], ["init", {}] ]]; diff --git a/arma/server/addons/org/functions/fnc_initOrgStore.sqf b/arma/server/addons/org/functions/fnc_initOrgStore.sqf index e1a8dbd..cda2669 100644 --- a/arma/server/addons/org/functions/fnc_initOrgStore.sqf +++ b/arma/server/addons/org/functions/fnc_initOrgStore.sqf @@ -4,12 +4,13 @@ * File: fnc_initOrgStore.sqf * Author: IDSolutions * Date: 2026-02-13 - * Last Update: 2026-04-04 + * Last Update: 2026-04-18 * Public: Yes * * Description: * Initializes the org store for managing player organizations. - * Org hot state is owned by the extension; SQF acts as the bridge. + * Org hot state is owned by the extension; SQF acts as the bridge for + * treasury charges, credit lines, and service debt recording. * * Arguments: * None @@ -800,7 +801,15 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _result }], ["chargeCheckout", compileFinal { - params [["_requesterUid", "", [""]], ["_requesterPlayer", objNull, [objNull]], ["_source", "org_funds", [""]], ["_amount", 0, [0]], ["_commit", false, [false]]]; + params [ + ["_requesterUid", "", [""]], + ["_requesterPlayer", objNull, [objNull]], + ["_source", "org_funds", [""]], + ["_amount", 0, [0]], + ["_commit", false, [false]], + ["_allowMemberCharge", false, [false]], + ["_recordMemberDebt", false, [false]] + ]; private _result = createHashMapFromArray [ ["success", false], @@ -822,6 +831,8 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ ["requesterUid", _requesterUid], ["orgId", _orgID], ["requesterIsDefaultOrgCeo", _requesterIsDefaultOrgCeo], + ["allowMemberCharge", _allowMemberCharge], + ["recordMemberDebt", _recordMemberDebt], ["source", _source], ["amount", _amount], ["commit", _commit] diff --git a/docs/ECONOMY_USAGE_GUIDE.md b/docs/ECONOMY_USAGE_GUIDE.md new file mode 100644 index 0000000..3552e15 --- /dev/null +++ b/docs/ECONOMY_USAGE_GUIDE.md @@ -0,0 +1,69 @@ +# Economy Usage Guide + +The economy server addon owns Arma-world service behavior for fuel, medical, +and repair interactions. It does not own money state. Money mutations go +through extension-backed bank and organization hot state before the world +effect is applied. + +## Dependencies + +- `forge_server_common` for logging, formatting, and player lookup. +- `forge_server_bank` for personal medical billing. +- `forge_server_org` for organization-funded services and medical fallback + debt. +- `forge_client_actor` and `forge_client_notifications` for targeted client + responses. + +## Fuel + +Fuel is organization-funded. + +When refueling stops, `fnc_initFEconomyStore.sqf` calculates the fuel delta and +cost, charges the player's organization through `OrgStore chargeCheckout`, and +syncs the organization patch to online members. If organization funds cannot +cover the refuel, the vehicle is rolled back to the fuel level it had when the +session started. + +## Repair + +Repair is organization-funded. + +Use the repair service event: + +```sqf +[QEGVAR(economy,RepairService), [_target, _unit, _cost]] call CBA_fnc_serverEvent; +``` + +`_cost` is optional. Passing `-1` uses the configured service repair cost. +The target is only repaired after the organization charge succeeds. + +## Medical + +Medical is player-funded first. + +When a heal is requested, `fnc_initMEconomyStore.sqf` uses this billing order: + +1. Charge the player's bank balance when it can cover the medical fee. +2. Otherwise charge the player's cash when it can cover the fee. +3. If neither personal balance can cover the fee, charge organization funds. +4. When organization funds cover the fallback charge, record the same amount as + debt on the player's organization credit line. + +The heal only completes after one of those charges succeeds. If personal +billing is unavailable, the heal does not fall back to organization funds +because the server cannot verify that the player is unable to cover the fee. + +## Medical Debt Repayment + +Medical fallback debt uses the existing organization credit-line repayment +flow. The organization treasury is reduced when the service is rendered, and +the player's credit-line `amount_due` increases by the medical fee. When the +player repays through the bank credit-line repayment action, player bank funds +are moved back into the organization treasury. + +## Hot-Cache Boundary + +The economy addon should stay server-authoritative for world effects such as +vehicle fuel, vehicle repair, healing, respawn placement, and death inventory +movement. Bank and organization balances should continue to mutate through the +extension-backed hot-cache services. diff --git a/docs/MODULE_REFERENCE.md b/docs/MODULE_REFERENCE.md index eb50fc8..9b16dc9 100644 --- a/docs/MODULE_REFERENCE.md +++ b/docs/MODULE_REFERENCE.md @@ -37,6 +37,7 @@ Server and extension guides: [Actor](./ACTOR_USAGE_GUIDE.md), [Bank](./BANK_USAGE_GUIDE.md), [CAD](./CAD_USAGE_GUIDE.md), +[Economy](./ECONOMY_USAGE_GUIDE.md), [Garage](./GARAGE_USAGE_GUIDE.md), [Locker](./LOCKER_USAGE_GUIDE.md), [Organization](./ORG_USAGE_GUIDE.md), @@ -66,7 +67,7 @@ Client guides: | `common` | Shared SQF helpers, base stores, utility functions, and shared UI bridge pieces. | `arma/client/addons/common`, `arma/server/addons/common` | | `extension` | Server SQF bridge around `forge_server` extension calls and chunked transport. | `arma/server/addons/extension` | | `main` | Mod-level configuration, pre-init wiring, and server/client startup glue. | `arma/client/addons/main`, `arma/server/addons/main` | -| `economy` | Server-side economy store initialization and economy-specific state helpers. | `arma/server/addons/economy` | +| `economy` | Server-side fuel, medical, and service economy helpers. Fuel and repair charge organization hot state; medical charges player bank/cash first, then organization funds with repayable member debt when personal funds cannot cover the bill. | `arma/server/addons/economy` | | `notifications` | Client notification UI, sounds, and UI event handling. | `arma/client/addons/notifications` | | `icom` | Rust helper for interprocess communication and event broadcasting. | `bin/icom`, `arma/server/extension/src/icom.rs` | | `terrain` | Extension-side terrain export helper. | `arma/server/extension/src/terrain.rs` | diff --git a/docs/README.md b/docs/README.md index 81c25a7..09210e6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,7 @@ collects framework-level documentation for those pieces. - [Actor Usage Guide](./ACTOR_USAGE_GUIDE.md) - [Bank Usage Guide](./BANK_USAGE_GUIDE.md) - [CAD Usage Guide](./CAD_USAGE_GUIDE.md) +- [Economy Usage Guide](./ECONOMY_USAGE_GUIDE.md) - [Garage Usage Guide](./GARAGE_USAGE_GUIDE.md) - [Locker Usage Guide](./LOCKER_USAGE_GUIDE.md) - [Organization Usage Guide](./ORG_USAGE_GUIDE.md) diff --git a/lib/models/src/org.rs b/lib/models/src/org.rs index 5d05592..266fec3 100644 --- a/lib/models/src/org.rs +++ b/lib/models/src/org.rs @@ -180,6 +180,10 @@ pub struct OrgCheckoutContext { pub requester_uid: String, pub org_id: String, pub requester_is_default_org_ceo: bool, + #[serde(default)] + pub allow_member_charge: bool, + #[serde(default)] + pub record_member_debt: bool, pub source: String, pub amount: f64, pub commit: bool, diff --git a/lib/services/src/org.rs b/lib/services/src/org.rs index 066d72b..f3e5ae0 100644 --- a/lib/services/src/org.rs +++ b/lib/services/src/org.rs @@ -793,24 +793,69 @@ impl OrgHotStateService { match context.source.trim().to_ascii_lowercase().as_str() { "org_funds" => { - if !can_manage_treasury( + let charged_amount = round_currency(context.amount); + let can_charge_org_funds = can_manage_treasury( &org, &context.requester_uid, context.requester_is_default_org_ceo, - ) { + ) || (context.allow_member_charge + && org.members.contains_key(&context.requester_uid)); + + if !can_charge_org_funds { return Err( "Only the organization leader or CEO can charge org funds.".to_string() ); } - if org.funds < context.amount { + if org.funds < charged_amount { return Err("Organization funds cannot cover this checkout.".to_string()); } - org.funds -= context.amount; + org.funds = round_currency(org.funds - charged_amount); + if context.record_member_debt { + let member_name = org + .members + .get(&context.requester_uid) + .map(|member| member.name.clone()) + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| "Unknown".to_string()); + let mut credit_line = org + .credit_lines + .get(&context.requester_uid) + .cloned() + .unwrap_or_else(|| CreditLineSummary { + uid: context.requester_uid.clone(), + name: member_name.clone(), + approved_amount: 0.0, + available_amount: 0.0, + outstanding_principal: 0.0, + interest_rate: DEFAULT_CREDIT_LINE_INTEREST_RATE, + amount_due: 0.0, + amount: 0.0, + }); + credit_line.normalize(); + credit_line.uid = context.requester_uid.clone(); + credit_line.name = member_name; + if credit_line.interest_rate <= 0.0 { + credit_line.interest_rate = DEFAULT_CREDIT_LINE_INTEREST_RATE; + } + credit_line.outstanding_principal = + round_currency(credit_line.outstanding_principal + charged_amount); + credit_line.amount_due = + round_currency(credit_line.amount_due + charged_amount); + credit_line.amount = credit_line.available_amount; + org.credit_lines + .insert(context.requester_uid.clone(), credit_line); + } self.repository.save(&org)?; + let patch_fields = if context.record_member_debt { + vec!["funds", "credit_lines"] + } else { + vec!["funds"] + }; + Ok(OrgMutationResult { - patch: build_org_patch(&org, &["funds"])?, + patch: build_org_patch(&org, &patch_fields)?, member_uids, message: String::new(), org, @@ -1220,3 +1265,158 @@ fn format_currency(amount: f64) -> String { fn round_currency(amount: f64) -> f64 { (amount.max(0.0) * 100.0).round() / 100.0 } + +#[cfg(test)] +mod tests { + use super::*; + use forge_repositories::InMemoryOrgHotRepository; + + #[derive(Clone, Default)] + struct TestOrgRepository; + + impl OrgRepository for TestOrgRepository { + fn create(&self, _org: &Org) -> Result<(), String> { + Ok(()) + } + + fn get_by_id(&self, _id: &str) -> Result, String> { + Ok(None) + } + + fn update(&self, _org: &Org) -> Result<(), String> { + Ok(()) + } + + fn delete(&self, _id: &str) -> Result<(), String> { + Ok(()) + } + + fn exists(&self, _id: &str) -> Result { + Ok(false) + } + + fn add_member(&self, _org_id: &str, _member_uid: &str) -> Result<(), String> { + Ok(()) + } + + fn get_members(&self, _org_id: &str) -> Result, String> { + Ok(Vec::new()) + } + + fn remove_member(&self, _org_id: &str, _member_uid: &str) -> Result<(), String> { + Ok(()) + } + + fn get_assets( + &self, + _org_id: &str, + ) -> Result>, String> { + Ok(HashMap::new()) + } + + fn update_assets( + &self, + _org_id: &str, + _assets: &HashMap>, + ) -> Result<(), String> { + Ok(()) + } + + fn get_fleet(&self, _org_id: &str) -> Result, String> { + Ok(HashMap::new()) + } + + fn update_fleet( + &self, + _org_id: &str, + _fleet: &HashMap, + ) -> Result<(), String> { + Ok(()) + } + } + + fn test_hot_org() -> HotOrgRecord { + let mut members = HashMap::new(); + members.insert( + "member".to_string(), + MemberSummary { + uid: "member".to_string(), + name: "Medic Patient".to_string(), + }, + ); + + HotOrgRecord { + id: "org".to_string(), + owner: "owner".to_string(), + name: "Test Org".to_string(), + funds: 500.0, + reputation: 0, + credit_lines: HashMap::new(), + assets: HashMap::new(), + fleet: HashMap::new(), + members, + pending_invites: HashMap::new(), + } + } + + fn test_service( + hot_repository: InMemoryOrgHotRepository, + ) -> OrgHotStateService { + OrgHotStateService::new(TestOrgRepository, hot_repository) + } + + #[test] + fn org_funds_checkout_without_member_debt_only_reduces_funds() { + let hot_repository = InMemoryOrgHotRepository::new(); + hot_repository.save(&test_hot_org()).unwrap(); + let service = test_service(hot_repository); + + let result = service + .charge_checkout(OrgCheckoutContext { + requester_uid: "member".to_string(), + org_id: "org".to_string(), + requester_is_default_org_ceo: false, + allow_member_charge: true, + record_member_debt: false, + source: "org_funds".to_string(), + amount: 125.0, + commit: true, + }) + .unwrap(); + + assert_eq!(result.org.funds, 375.0); + assert!(result.org.credit_lines.is_empty()); + assert!(result.patch.contains_key("funds")); + assert!(!result.patch.contains_key("credit_lines")); + } + + #[test] + fn org_funds_checkout_can_record_member_debt() { + let hot_repository = InMemoryOrgHotRepository::new(); + hot_repository.save(&test_hot_org()).unwrap(); + let service = test_service(hot_repository); + + let result = service + .charge_checkout(OrgCheckoutContext { + requester_uid: "member".to_string(), + org_id: "org".to_string(), + requester_is_default_org_ceo: false, + allow_member_charge: true, + record_member_debt: true, + source: "org_funds".to_string(), + amount: 100.0, + commit: true, + }) + .unwrap(); + + let credit_line = result.org.credit_lines.get("member").unwrap(); + assert_eq!(result.org.funds, 400.0); + assert_eq!(credit_line.uid, "member"); + assert_eq!(credit_line.name, "Medic Patient"); + assert_eq!(credit_line.outstanding_principal, 100.0); + assert_eq!(credit_line.amount_due, 100.0); + assert_eq!(credit_line.available_amount, 0.0); + assert!(result.patch.contains_key("funds")); + assert!(result.patch.contains_key("credit_lines")); + } +}