feat(economy): Enhance economy system with service charges and medical billing

- Expanded README.md to detail economy addon functionalities including refueling, medical services, and service charges.
- Updated XEH_PREP.hpp to include initSEconomyStore preparation.
- Modified XEH_postInit.sqf to ensure MEconomyStore initializes only if not nil.
- Adjusted XEH_preInit.sqf to initialize SEconomyStore correctly.
- Updated config.cpp to include forge_server_common as a required addon.
- Enhanced fnc_initFEconomyStore.sqf to manage fuel refueling sessions and organization charges.
- Improved fnc_initMEconomyStore.sqf to handle medical billing and fallback to organization funds.
- Created fnc_initSEconomyStore.sqf for organization-funded service charges and repairs.
- Updated org.rs and org.rs service layer to support member debt recording and organization fund charging.
- Added ECONOMY_USAGE_GUIDE.md for comprehensive documentation on economy functionalities.
- Updated MODULE_REFERENCE.md and README.md to include links to the new economy guide.
This commit is contained in:
Jacob Schmidt 2026-04-18 13:37:09 -05:00
parent 89169f1e84
commit 8117e6ffa6
14 changed files with 627 additions and 61 deletions

View File

@ -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.

View File

@ -1,2 +1,3 @@
PREP(initFEconomyStore);
PREP(initMEconomyStore);
PREP(initSEconomyStore);

View File

@ -1,3 +1,5 @@
#include "script_component.hpp"
GVAR(MEconomyStore) call ["init", []];
if !(isNil QGVAR(MEconomyStore)) then {
GVAR(MEconomyStore) call ["init", []];
};

View File

@ -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]];

View File

@ -8,7 +8,8 @@ class CfgPatches {
name = COMPONENT_NAME;
requiredVersion = REQUIRED_VERSION;
requiredAddons[] = {
"forge_server_main"
"forge_server_main",
"forge_server_common"
};
units[] = {};
weapons[] = {};

View File

@ -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<br />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<br />Organization charged $%2.", _formattedTotalLiters, _formattedTotalCost]], _player] call CFUNC(targetEvent);
_fuelRegistry deleteAt _index;
}]
]];

View File

@ -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", {

View File

@ -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", {}]
]];

View File

@ -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]

View File

@ -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.

View File

@ -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` |

View File

@ -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)

View File

@ -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,

View File

@ -793,24 +793,69 @@ impl<R: OrgRepository, H: OrgHotRepository> OrgHotStateService<R, H> {
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<Option<Org>, 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<bool, String> {
Ok(false)
}
fn add_member(&self, _org_id: &str, _member_uid: &str) -> Result<(), String> {
Ok(())
}
fn get_members(&self, _org_id: &str) -> Result<Vec<MemberSummary>, 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<HashMap<String, HashMap<String, OrgAssetEntry>>, String> {
Ok(HashMap::new())
}
fn update_assets(
&self,
_org_id: &str,
_assets: &HashMap<String, HashMap<String, OrgAssetEntry>>,
) -> Result<(), String> {
Ok(())
}
fn get_fleet(&self, _org_id: &str) -> Result<HashMap<String, OrgFleetEntry>, String> {
Ok(HashMap::new())
}
fn update_fleet(
&self,
_org_id: &str,
_fleet: &HashMap<String, OrgFleetEntry>,
) -> 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<TestOrgRepository, InMemoryOrgHotRepository> {
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"));
}
}