Add credit line repayment to bank UI

- Wire bank client and server for credit line repayment requests
- Show credit line balance and repay action in the banking view
- Extend org/bank payloads and models with credit line fields
This commit is contained in:
Jacob Schmidt 2026-04-02 16:41:10 -05:00
parent b8dd3ef651
commit 5ded3a60e5
22 changed files with 688 additions and 40 deletions

View File

@ -68,6 +68,11 @@ switch (_event) do {
GVAR(BankUIBridge) call ["handleDepositEarningsRequest", [_data]]; GVAR(BankUIBridge) call ["handleDepositEarningsRequest", [_data]];
}; };
}; };
case "bank::repayCreditLine::request": {
if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["handleRepayCreditLineRequest", [_data]];
};
};
case "bank::pin::request": { case "bank::pin::request": {
if !(isNil QGVAR(BankUIBridge)) then { if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["handleSubmitPinRequest", [_data]]; GVAR(BankUIBridge) call ["handleSubmitPinRequest", [_data]];

View File

@ -63,6 +63,13 @@ GVAR(BankUIBridgeBaseClass) = compileFinal createHashMapFromArray [
[SRPC(bank,requestDeposit), [getPlayerUID player, _amount]] call CFUNC(serverEvent); [SRPC(bank,requestDeposit), [getPlayerUID player, _amount]] call CFUNC(serverEvent);
true true
}], }],
["handleRepayCreditLineRequest", compileFinal {
params [["_data", createHashMap, [createHashMap]]];
private _amount = floor (_data getOrDefault ["amount", 0]);
[SRPC(bank,requestRepayCreditLine), [getPlayerUID player, _amount]] call CFUNC(serverEvent);
true
}],
["handleHydrateResponse", compileFinal { ["handleHydrateResponse", compileFinal {
params [["_data", createHashMap, [createHashMap]], ["_event", "bank::hydrate", [""]]]; params [["_data", createHashMap, [createHashMap]], ["_event", "bank::hydrate", [""]]];

File diff suppressed because one or more lines are too long

View File

@ -43,6 +43,9 @@
requestDepositEarnings(payload) { requestDepositEarnings(payload) {
return bridge.send("bank::depositEarnings::request", payload); return bridge.send("bank::depositEarnings::request", payload);
}, },
requestRepayCreditLine(payload) {
return bridge.send("bank::repayCreditLine::request", payload);
},
requestRefresh() { requestRefresh() {
return bridge.send("bank::refresh", {}); return bridge.send("bank::refresh", {});
}, },

View File

@ -3,6 +3,13 @@
const defaultSession = { const defaultSession = {
atmAuthorized: false, atmAuthorized: false,
creditLine: {
amountDue: 0,
approvedAmount: 0,
availableAmount: 0,
interestRate: 0.1,
outstandingPrincipal: 0,
},
mode: "bank", mode: "bank",
orgFunds: 0, orgFunds: 0,
orgName: "", orgName: "",

View File

@ -89,6 +89,16 @@
"Reference value pulled from the organization treasury.", "Reference value pulled from the organization treasury.",
session.orgFunds > 0 ? "success" : "", session.orgFunds > 0 ? "success" : "",
), ),
metricCard(
"Credit Due",
formatCurrency(session.creditLine?.amountDue || 0),
Number(session.creditLine?.amountDue || 0) > 0
? `Outstanding principal ${formatCurrency(session.creditLine?.outstandingPrincipal || 0)} at ${Math.round(Number(session.creditLine?.interestRate || 0) * 100)}% interest.`
: "No active credit repayment is currently due.",
Number(session.creditLine?.amountDue || 0) > 0
? "warning"
: "",
),
), ),
); );
} }
@ -238,6 +248,63 @@
), ),
), ),
), ),
h(
"section",
{ className: "bank-page-section" },
h(
"div",
{ className: "bank-section-header" },
h(
"div",
null,
h("span", { className: "bank-eyebrow" }, "Credit"),
h(
"h2",
{ className: "bank-section-title" },
"Repay Org Credit",
),
),
),
h(
"div",
{ className: "bank-form-stack" },
h(
"p",
{ className: "bank-card-copy" },
Number(session.creditLine?.amountDue || 0) > 0
? `Outstanding due ${formatCurrency(session.creditLine.amountDue || 0)}. Available reserved credit ${formatCurrency(session.creditLine.availableAmount || 0)}.`
: "No repayment is currently due on the assigned organization credit line.",
),
h("input", {
id: "bank-credit-line-amount",
className: "bank-input",
type: "number",
min: "1",
placeholder: "Enter repayment amount",
}),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-primary",
disabled:
pending("repaycreditline") ||
Number(session.creditLine?.amountDue || 0) <= 0,
onClick: () => {
const sent = actions.requestRepayCreditLine(
readInputValue("bank-credit-line-amount"),
);
if (sent) {
clearInputValue("bank-credit-line-amount");
}
},
},
pending("repaycreditline")
? "Posting Repayment..."
: "Repay Credit Line",
),
),
),
); );
} }

View File

@ -130,6 +130,25 @@
return true; return true;
} }
function requestRepayCreditLine(amountValue) {
const amount = normalizeAmount(amountValue);
const bridge = BankApp.bridge;
if (!bridge || typeof bridge.requestRepayCreditLine !== "function") {
showNotice("error", "Credit repayment bridge is unavailable.");
return false;
}
store.startAction("repaycreditline");
const sent = bridge.requestRepayCreditLine({ amount });
if (!sent) {
store.finishAction();
showNotice("error", "Credit repayment bridge is unavailable.");
return false;
}
return true;
}
function appendPinDigit(digit) { function appendPinDigit(digit) {
const nextDigit = String(digit || "").trim(); const nextDigit = String(digit || "").trim();
if (!nextDigit) { if (!nextDigit) {
@ -259,6 +278,7 @@
requestAtmAmount, requestAtmAmount,
requestDeposit, requestDeposit,
requestDepositEarnings, requestDepositEarnings,
requestRepayCreditLine,
requestTransfer, requestTransfer,
requestWithdraw, requestWithdraw,
selectAtmView, selectAtmView,

File diff suppressed because one or more lines are too long

View File

@ -136,8 +136,18 @@
OrgPortal.store.setCreditLines((currentLines) => { OrgPortal.store.setCreditLines((currentLines) => {
const nextLine = { const nextLine = {
amount: payloadData.amount || 0, amount: payloadData.availableAmount || payloadData.amount || 0,
amountDue: payloadData.amountDue || 0,
approvedAmount:
payloadData.approvedAmount ||
payloadData.availableAmount ||
payloadData.amount ||
0,
availableAmount:
payloadData.availableAmount || payloadData.amount || 0,
interestRate: payloadData.interestRate || 0.1,
member: payloadData.memberName || "", member: payloadData.memberName || "",
outstandingPrincipal: payloadData.outstandingPrincipal || 0,
uid: payloadData.memberUid || "", uid: payloadData.memberUid || "",
}; };
const matchIndex = currentLines.findIndex( const matchIndex = currentLines.findIndex(

View File

@ -215,6 +215,15 @@ ${scopeSelector} .org-credit-line-empty {
const allowTreasuryActions = getters.canManageTreasury(); const allowTreasuryActions = getters.canManageTreasury();
const activeTab = getTreasuryTab(); const activeTab = getTreasuryTab();
const isMenuOpen = getTreasuryMenuOpen(); const isMenuOpen = getTreasuryMenuOpen();
const totalReserved = creditLines.reduce(
(sum, line) =>
sum + Number(line.availableAmount || line.amount || 0),
0,
);
const totalDue = creditLines.reduce(
(sum, line) => sum + Number(line.amountDue || 0),
0,
);
const activeCreditLabel = const activeCreditLabel =
creditLines.length === 1 creditLines.length === 1
? "1 active credit line" ? "1 active credit line"
@ -331,16 +340,59 @@ ${scopeSelector} .org-credit-line-empty {
className: className:
"org-credit-line-label", "org-credit-line-label",
}, },
"Amount", "Available",
), ),
h( h(
"strong", "strong",
null, null,
getters.formatCurrency( getters.formatCurrency(
line.amount, line.availableAmount ||
line.amount,
), ),
), ),
), ),
h(
"div",
{
className:
"org-credit-line-member",
},
h(
"span",
{
className:
"org-credit-line-label",
},
"Amount Due",
),
h(
"strong",
null,
getters.formatCurrency(
line.amountDue,
),
),
),
h(
"div",
{
className:
"org-credit-line-member",
},
h(
"span",
{
className:
"org-credit-line-label",
},
"Interest",
),
h(
"strong",
null,
`${Math.round(Number(line.interestRate || 0) * 100)}%`,
),
),
), ),
), ),
) )
@ -379,6 +431,34 @@ ${scopeSelector} .org-credit-line-empty {
), ),
h("strong", null, `${reputation}`), h("strong", null, `${reputation}`),
), ),
h(
"div",
null,
h(
"span",
{ className: "org-meta-label" },
"Reserved Credit",
),
h(
"strong",
null,
getters.formatCurrency(totalReserved),
),
),
h(
"div",
null,
h(
"span",
{ className: "org-meta-label" },
"Outstanding Due",
),
h(
"strong",
null,
getters.formatCurrency(totalDue),
),
),
), ),
allowTreasuryActions allowTreasuryActions
? h( ? h(
@ -432,7 +512,7 @@ ${scopeSelector} .org-credit-line-empty {
"span", "span",
null, null,
creditLines.length > 0 creditLines.length > 0
? "Open the Credit Lines tab to review assigned members and amounts." ? "Open the Credit Lines tab to review reserved balances, due amounts, and member exposure."
: "Assign a credit line to create the first approved member limit.", : "Assign a credit line to create the first approved member limit.",
), ),
), ),

View File

@ -49,3 +49,9 @@ PREP_RECOMPILE_END;
GVAR(BankStore) call ["depositEarnings", [_uid, _amount]]; GVAR(BankStore) call ["depositEarnings", [_uid, _amount]];
}] call CFUNC(addEventHandler); }] call CFUNC(addEventHandler);
[QGVAR(requestRepayCreditLine), {
params [["_uid", "", [""]], ["_amount", 0, [0]]];
GVAR(BankStore) call ["repayCreditLine", [_uid, _amount]];
}] call CFUNC(addEventHandler);

View File

@ -48,7 +48,18 @@ GVAR(BankPayloadBuilder) = createHashMapObject [[
["resolveOrgState", compileFinal { ["resolveOrgState", compileFinal {
params [["_uid", "", [""]]]; params [["_uid", "", [""]]];
private _defaultState = createHashMapFromArray [["funds", 0], ["name", ""]]; private _defaultCreditLine = createHashMapFromArray [
["approvedAmount", 0],
["availableAmount", 0],
["outstandingPrincipal", 0],
["interestRate", 0.1],
["amountDue", 0]
];
private _defaultState = createHashMapFromArray [
["funds", 0],
["name", ""],
["creditLine", _defaultCreditLine]
];
if (_uid isEqualTo "") exitWith { _defaultState }; if (_uid isEqualTo "") exitWith { _defaultState };
private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap];
@ -61,7 +72,33 @@ GVAR(BankPayloadBuilder) = createHashMapObject [[
}; };
if (_org isEqualTo createHashMap) exitWith { _defaultState }; if (_org isEqualTo createHashMap) exitWith { _defaultState };
createHashMapFromArray [["funds", _org getOrDefault ["funds", 0]], ["name", _org getOrDefault ["name", ""]]] private _creditLines = _org getOrDefault ["credit_lines", createHashMap];
if !(_creditLines isEqualType createHashMap) then {
_creditLines = createHashMap;
};
private _creditLine = _creditLines getOrDefault [_uid, createHashMap];
if !(_creditLine isEqualType createHashMap) then {
_creditLine = createHashMap;
};
createHashMapFromArray [
["funds", _org getOrDefault ["funds", 0]],
["name", _org getOrDefault ["name", ""]],
["creditLine", createHashMapFromArray [
["approvedAmount", _creditLine getOrDefault [
"approved_amount",
_creditLine getOrDefault ["amount", 0]
]],
["availableAmount", _creditLine getOrDefault [
"available_amount",
_creditLine getOrDefault ["amount", 0]
]],
["outstandingPrincipal", _creditLine getOrDefault ["outstanding_principal", 0]],
["interestRate", _creditLine getOrDefault ["interest_rate", 0.1]],
["amountDue", _creditLine getOrDefault ["amount_due", 0]]
]]
]
}], }],
["buildTransferTargets", compileFinal { ["buildTransferTargets", compileFinal {
params [["_sourceUid", "", [""]]]; params [["_sourceUid", "", [""]]];
@ -101,6 +138,7 @@ GVAR(BankPayloadBuilder) = createHashMapObject [[
["mode", _session getOrDefault ["mode", "bank"]], ["mode", _session getOrDefault ["mode", "bank"]],
["orgFunds", _orgState getOrDefault ["funds", 0]], ["orgFunds", _orgState getOrDefault ["funds", 0]],
["orgName", _orgState getOrDefault ["name", ""]], ["orgName", _orgState getOrDefault ["name", ""]],
["creditLine", _orgState getOrDefault ["creditLine", createHashMap]],
["playerName", _playerName], ["playerName", _playerName],
["transferTargets", _self call ["buildTransferTargets", [_uid]]], ["transferTargets", _self call ["buildTransferTargets", [_uid]]],
["uid", _uid] ["uid", _uid]

View File

@ -167,6 +167,83 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [
_result set ["patch", _patch]; _result set ["patch", _patch];
_result _result
}], }],
["repayCreditLine", compileFinal {
params [["_uid", "", [""]], ["_amount", 0, [0]]];
if (_uid isEqualTo "" || { _amount <= 0 }) exitWith {
GVAR(BankMessenger) call ["sendAlert", [_uid, "error", "Enter a valid repayment amount."]];
false
};
private _originalAccount = _self call ["loadHotBank", [_uid, false, ""]];
if (_originalAccount isEqualTo createHashMap) then {
_originalAccount = _self call ["loadHotBank", [_uid, true, ""]];
};
if (_originalAccount isEqualTo createHashMap) exitWith {
GVAR(BankMessenger) call ["sendAlert", [_uid, "error", "Bank account could not be loaded."]];
false
};
private _checkoutContext = GVAR(BankPayloadBuilder) call ["buildCheckoutContext", ["bank", false]];
private _previewEnvelope = _self call [
"callHotBankEnvelope",
[
"bank:hot:charge_checkout",
[_uid, str _amount, toJSON _checkoutContext]
]
];
private _previewResult = _previewEnvelope getOrDefault ["data", createHashMap];
private _bankPatch = _self call ["finalizeMutation", [_uid, _previewResult, false]];
if (_bankPatch isEqualTo createHashMap) exitWith {
GVAR(BankMessenger) call ["sendAlert", [_uid, "error", _previewEnvelope getOrDefault ["error", "Credit repayment could not be funded from the bank account."]]];
false
};
private _nextAccount = _previewResult getOrDefault ["account", createHashMap];
if (_nextAccount isEqualTo createHashMap) exitWith {
GVAR(BankMessenger) call ["sendAlert", [_uid, "error", "Bank repayment preview returned an invalid account state."]];
false
};
private _overrideEnvelope = _self call [
"callHotBankEnvelope",
["bank:hot:override", [_uid, _self call ["toJSON", [_nextAccount]]]]
];
if ((_overrideEnvelope getOrDefault ["data", createHashMap]) isEqualTo createHashMap) exitWith {
GVAR(BankMessenger) call ["sendAlert", [_uid, "error", _overrideEnvelope getOrDefault ["error", "Credit repayment could not reserve bank funds."]]];
false
};
private _orgResult = EGVAR(org,OrgStore) call ["repayCreditLine", [_uid, _amount]];
if !(_orgResult getOrDefault ["success", false]) exitWith {
private _rollbackEnvelope = _self call [
"callHotBankEnvelope",
["bank:hot:override", [_uid, _self call ["toJSON", [_originalAccount]]]]
];
if ((_rollbackEnvelope getOrDefault ["data", createHashMap]) isEqualTo createHashMap) then {
["ERROR", format ["Failed to roll back bank state for %1 after org credit repayment failure.", _uid]] call EFUNC(common,log);
};
GVAR(BankMessenger) call ["sendAlert", [_uid, "error", _orgResult getOrDefault ["message", "Credit repayment failed."]]];
false
};
GVAR(BankMessenger) call ["sendAccountSync", [_uid, _bankPatch]];
GVAR(BankMessenger) call ["sendNotification", [_uid, "info", "Bank", _orgResult getOrDefault ["message", format ["Repaid $%1 toward the organization credit line.", [_amount] call EFUNC(common,formatNumber)]]]];
private _orgPatch = _orgResult getOrDefault ["patch", createHashMap];
if (_orgPatch isNotEqualTo createHashMap) then {
{
private _memberPlayer = [_x] call EFUNC(common,getPlayer);
if (_memberPlayer isNotEqualTo objNull) then {
[CRPC(org,responseSyncOrg), [_orgPatch], _memberPlayer] call CFUNC(targetEvent);
};
} forEach (_orgResult getOrDefault ["memberUids", []]);
};
_self call ["hydrateSession", [_uid, "", false]];
true
}],
["deposit", compileFinal { ["deposit", compileFinal {
params [["_uid", "", [""]], ["_amount", 0, [0]]]; params [["_uid", "", [""]], ["_amount", 0, [0]]];

View File

@ -83,6 +83,34 @@ GVAR(OrgModel) = compileFinal createHashMapObject [[
_org set ["assets", _migratedAssets]; _org set ["assets", _migratedAssets];
private _creditLines = _org getOrDefault ["credit_lines", createHashMap];
if !(_creditLines isEqualType createHashMap) then {
_creditLines = createHashMap;
};
{
if !(_y isEqualType createHashMap) then { continue; };
private _line = +_y;
private _legacyAmount = _line getOrDefault ["amount", 0];
private _approvedAmount = _line getOrDefault ["approved_amount", _legacyAmount];
private _availableAmount = _line getOrDefault ["available_amount", _approvedAmount];
private _outstandingPrincipal = _line getOrDefault ["outstanding_principal", 0];
private _interestRate = _line getOrDefault ["interest_rate", 0.1];
private _amountDue = _line getOrDefault ["amount_due", 0];
_line set ["uid", _line getOrDefault ["uid", _x]];
_line set ["approved_amount", _approvedAmount];
_line set ["available_amount", _availableAmount];
_line set ["outstanding_principal", _outstandingPrincipal];
_line set ["interest_rate", _interestRate];
_line set ["amount_due", _amountDue];
_line set ["amount", _availableAmount];
_creditLines set [_x, _line];
} forEach _creditLines;
_org set ["credit_lines", _creditLines];
_org _org
}], }],
["validate", compileFinal { ["validate", compileFinal {
@ -483,6 +511,43 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [
_result set ["memberUids", _envelope getOrDefault ["memberUids", []]]; _result set ["memberUids", _envelope getOrDefault ["memberUids", []]];
_result _result
}], }],
["repayCreditLine", compileFinal {
params [["_requesterUid", "", [""]], ["_amount", 0, [0]]];
private _result = createHashMapFromArray [
["success", false],
["message", ""],
["patch", createHashMap],
["memberUids", []]
];
if (_requesterUid isEqualTo "" || { _amount <= 0 }) exitWith {
_result set ["message", "A valid repayment amount is required."];
_result
};
private _requesterActor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap];
private _orgID = _requesterActor getOrDefault ["organization", "default"];
if (_orgID isEqualTo "") then { _orgID = "default"; };
private _context = createHashMapFromArray [
["requesterUid", _requesterUid],
["orgId", _orgID],
["amount", _amount]
];
private _envelope = _self call ["callHotOrgEnvelope", ["org:hot:repay_credit_line", [toJSON _context]]];
if (_envelope isEqualTo createHashMap) exitWith {
_result set ["message", "Unable to apply credit repayment."];
_result
};
_result set ["success", true];
_result set ["message", _envelope getOrDefault ["message", "Credit repayment posted."]];
_result set ["patch", _envelope getOrDefault ["patch", createHashMap]];
_result set ["memberUids", _envelope getOrDefault ["memberUids", []]];
_result
}],
["buildPortalPayload", compileFinal { ["buildPortalPayload", compileFinal {
params [["_uid", "", [""]]]; params [["_uid", "", [""]]];

View File

@ -134,10 +134,19 @@ GVAR(OrgPayloadBuilder) = createHashMapObject [[
private _creditLinesList = []; private _creditLinesList = [];
{ {
private _creditLineData = _y; private _creditLineData = _y;
private _availableAmount = _creditLineData getOrDefault [
"available_amount",
_creditLineData getOrDefault ["amount", 0]
];
_creditLinesList pushBack [ _creditLinesList pushBack [
["uid", _creditLineData getOrDefault ["uid", _x]], ["uid", _creditLineData getOrDefault ["uid", _x]],
["member", _creditLineData getOrDefault ["name", "Unknown Member"]], ["member", _creditLineData getOrDefault ["name", "Unknown Member"]],
["amount", _creditLineData getOrDefault ["amount", 0]] ["approvedAmount", _creditLineData getOrDefault ["approved_amount", _availableAmount]],
["availableAmount", _availableAmount],
["outstandingPrincipal", _creditLineData getOrDefault ["outstanding_principal", 0]],
["interestRate", _creditLineData getOrDefault ["interest_rate", 0.1]],
["amountDue", _creditLineData getOrDefault ["amount_due", 0]],
["amount", _availableAmount]
]; ];
} forEach _creditLinesRaw; } forEach _creditLinesRaw;

View File

@ -29,6 +29,7 @@ GVAR(StoreBaseStore) = compileFinal createHashMapFromArray [
private _budget = 50000; private _budget = 50000;
private _creditLine = 0; private _creditLine = 0;
private _creditLineDue = 0;
private _cashBalance = 0; private _cashBalance = 0;
private _bankBalance = 0; private _bankBalance = 0;
private _orgFunds = 0; private _orgFunds = 0;
@ -72,7 +73,11 @@ GVAR(StoreBaseStore) = compileFinal createHashMapFromArray [
if (_orgCreditLines isEqualType createHashMap) then { if (_orgCreditLines isEqualType createHashMap) then {
private _playerCreditLine = _orgCreditLines getOrDefault [_uid, createHashMap]; private _playerCreditLine = _orgCreditLines getOrDefault [_uid, createHashMap];
if (_playerCreditLine isEqualType createHashMap) then { if (_playerCreditLine isEqualType createHashMap) then {
_creditLine = _playerCreditLine getOrDefault ["amount", 0]; _creditLine = _playerCreditLine getOrDefault [
"available_amount",
_playerCreditLine getOrDefault ["amount", 0]
];
_creditLineDue = _playerCreditLine getOrDefault ["amount_due", 0];
}; };
}; };
@ -113,7 +118,10 @@ GVAR(StoreBaseStore) = compileFinal createHashMapFromArray [
["enabled", _creditLine > 0], ["enabled", _creditLine > 0],
["detail", [ ["detail", [
"No approved credit line is assigned to this member.", "No approved credit line is assigned to this member.",
"Use the approved procurement credit line." format [
"Use the approved procurement credit line. Outstanding due: $%1.",
[_creditLineDue] call EFUNC(common,formatNumber)
]
] select (_creditLine > 0)] ] select (_creditLine > 0)]
] ]
]; ];

View File

@ -5,7 +5,8 @@
use arma_rs::Group; use arma_rs::Group;
use forge_models::{ use forge_models::{
HotOrgRecord, OrgAssetGrantSeed, OrgCheckoutContext, OrgCreditLineContext, OrgDisbandResult, HotOrgRecord, OrgAssetGrantSeed, OrgCheckoutContext, OrgCreditLineContext,
OrgCreditLineRepaymentContext, OrgCreditLineRepaymentResult, OrgDisbandResult,
OrgEnsureMemberContext, OrgFleetGrantSeed, OrgGrantContext, OrgLeaveContext, OrgLeaveResult, OrgEnsureMemberContext, OrgFleetGrantSeed, OrgGrantContext, OrgLeaveContext, OrgLeaveResult,
OrgRegisterContext, OrgRegisterContext,
}; };
@ -59,6 +60,7 @@ pub fn group() -> Group {
.command("ensure_member", ensure_hot_org_member) .command("ensure_member", ensure_hot_org_member)
.command("register", register_hot_org) .command("register", register_hot_org)
.command("assign_credit_line", assign_credit_line_hot_org) .command("assign_credit_line", assign_credit_line_hot_org)
.command("repay_credit_line", repay_credit_line_hot_org)
.command("charge_checkout", charge_checkout_hot_org) .command("charge_checkout", charge_checkout_hot_org)
.command("add_assets", add_assets_hot_org) .command("add_assets", add_assets_hot_org)
.command("add_fleet", add_fleet_hot_org) .command("add_fleet", add_fleet_hot_org)
@ -176,6 +178,20 @@ pub(crate) fn charge_checkout_hot_org(json_data: String) -> String {
} }
} }
pub(crate) fn repay_credit_line_hot_org(json_data: String) -> String {
let context: OrgCreditLineRepaymentContext = match serde_json::from_str(&json_data) {
Ok(data) => data,
Err(error) => return format!("Error: Invalid org credit repayment JSON: {}", error),
};
match HOT_ORG_SERVICE.repay_credit_line(context) {
Ok(result) => {
serialize_result::<OrgCreditLineRepaymentResult>(&result, "org credit repayment result")
}
Err(error) => format!("Error: {}", error),
}
}
pub(crate) fn add_assets_hot_org(context_json: String, assets_json: String) -> String { pub(crate) fn add_assets_hot_org(context_json: String, assets_json: String) -> String {
let context: OrgGrantContext = match serde_json::from_str(&context_json) { let context: OrgGrantContext = match serde_json::from_str(&context_json) {
Ok(data) => data, Ok(data) => data,

View File

@ -363,6 +363,10 @@ fn route_command(
expect_arg_count(function_name, &arguments, 1)?; expect_arg_count(function_name, &arguments, 1)?;
Ok(org::assign_credit_line_hot_org(arguments[0].clone())) Ok(org::assign_credit_line_hot_org(arguments[0].clone()))
} }
"org:hot:repay_credit_line" => {
expect_arg_count(function_name, &arguments, 1)?;
Ok(org::repay_credit_line_hot_org(arguments[0].clone()))
}
"org:hot:charge_checkout" => { "org:hot:charge_checkout" => {
expect_arg_count(function_name, &arguments, 1)?; expect_arg_count(function_name, &arguments, 1)?;
Ok(org::charge_checkout_hot_org(arguments[0].clone())) Ok(org::charge_checkout_hot_org(arguments[0].clone()))

View File

@ -23,10 +23,11 @@ pub use cad::{
pub use garage::{Garage, HitPoints, Vehicle}; pub use garage::{Garage, HitPoints, Vehicle};
pub use locker::{Item, Locker}; pub use locker::{Item, Locker};
pub use org::{ pub use org::{
CreditLineSummary, HotOrgRecord, MemberSummary, Org, OrgAssetEntry, OrgAssetGrantSeed, CreditLineSummary, DEFAULT_CREDIT_LINE_INTEREST_RATE, HotOrgRecord, MemberSummary, Org,
OrgCheckoutContext, OrgCreditLineContext, OrgDisbandMemberResult, OrgDisbandResult, OrgAssetEntry, OrgAssetGrantSeed, OrgCheckoutContext, OrgCreditLineContext,
OrgEnsureMemberContext, OrgFleetEntry, OrgFleetGrantSeed, OrgGrantContext, OrgLeaveContext, OrgCreditLineRepaymentContext, OrgCreditLineRepaymentResult, OrgDisbandMemberResult,
OrgLeaveResult, OrgMutationResult, OrgRegisterContext, OrgRegisterResult, OrgDisbandResult, OrgEnsureMemberContext, OrgFleetEntry, OrgFleetGrantSeed, OrgGrantContext,
OrgLeaveContext, OrgLeaveResult, OrgMutationResult, OrgRegisterContext, OrgRegisterResult,
}; };
pub use store::{ pub use store::{
StoreCheckoutContext, StoreCheckoutItemSeed, StoreCheckoutResult, StoreCheckoutVehicleSeed, StoreCheckoutContext, StoreCheckoutItemSeed, StoreCheckoutResult, StoreCheckoutVehicleSeed,

View File

@ -3,13 +3,34 @@ use forge_shared::OrgValidationError;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
pub const DEFAULT_CREDIT_LINE_INTEREST_RATE: f64 = 0.10;
fn round_currency(value: f64) -> f64 {
(value.max(0.0) * 100.0).round() / 100.0
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreditLineSummary { pub struct CreditLineSummary {
pub uid: String, pub uid: String,
pub name: String, pub name: String,
#[serde(default)]
pub approved_amount: f64,
#[serde(default)]
pub available_amount: f64,
#[serde(default)]
pub outstanding_principal: f64,
#[serde(default = "default_credit_line_interest_rate")]
pub interest_rate: f64,
#[serde(default)]
pub amount_due: f64,
#[serde(default)]
pub amount: f64, pub amount: f64,
} }
fn default_credit_line_interest_rate() -> f64 {
DEFAULT_CREDIT_LINE_INTEREST_RATE
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrgAssetEntry { pub struct OrgAssetEntry {
pub classname: String, pub classname: String,
@ -113,6 +134,14 @@ pub struct OrgCheckoutContext {
pub commit: bool, pub commit: bool,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OrgCreditLineRepaymentContext {
pub requester_uid: String,
pub org_id: String,
pub amount: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct OrgAssetGrantSeed { pub struct OrgAssetGrantSeed {
@ -145,6 +174,19 @@ pub struct OrgMutationResult {
pub message: String, pub message: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OrgCreditLineRepaymentResult {
pub org: HotOrgRecord,
pub patch: HashMap<String, serde_json::Value>,
pub member_uids: Vec<String>,
pub paid_amount: f64,
pub principal_paid: f64,
pub interest_paid: f64,
pub remaining_amount_due: f64,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct OrgLeaveContext { pub struct OrgLeaveContext {
@ -241,7 +283,12 @@ impl Org {
)); ));
} }
if credit_line.amount < 0.0 { if credit_line.approved_amount < 0.0
|| credit_line.available_amount < 0.0
|| credit_line.outstanding_principal < 0.0
|| credit_line.amount_due < 0.0
|| credit_line.amount < 0.0
{
return Err(OrgValidationError::NegativeCreditLine( return Err(OrgValidationError::NegativeCreditLine(
resolved_uid.to_string(), resolved_uid.to_string(),
)); ));
@ -254,6 +301,12 @@ impl Org {
pub fn id(&self) -> &str { pub fn id(&self) -> &str {
&self.id &self.id
} }
pub fn normalize_credit_lines(&mut self) {
for credit_line in self.credit_lines.values_mut() {
credit_line.normalize();
}
}
} }
impl HotOrgRecord { impl HotOrgRecord {
@ -280,14 +333,47 @@ impl HotOrgRecord {
} }
pub fn into_org(self) -> Org { pub fn into_org(self) -> Org {
Org { let mut org = Org {
id: self.id, id: self.id,
owner: self.owner, owner: self.owner,
name: self.name, name: self.name,
funds: self.funds, funds: self.funds,
reputation: self.reputation, reputation: self.reputation,
credit_lines: self.credit_lines, credit_lines: self.credit_lines,
};
org.normalize_credit_lines();
org
}
}
impl CreditLineSummary {
pub fn normalize(&mut self) {
let legacy_amount = round_currency(self.amount);
self.approved_amount = round_currency(self.approved_amount);
self.available_amount = round_currency(self.available_amount);
self.outstanding_principal = round_currency(self.outstanding_principal);
self.amount_due = round_currency(self.amount_due);
if self.approved_amount <= 0.0 && self.available_amount <= 0.0 && legacy_amount > 0.0 {
self.approved_amount = legacy_amount;
self.available_amount = legacy_amount;
} else if self.approved_amount <= 0.0 && self.available_amount > 0.0 {
self.approved_amount = self.available_amount;
} else if self.available_amount <= 0.0 && self.approved_amount > 0.0 {
self.available_amount = self.approved_amount;
} }
if self.interest_rate <= 0.0 {
self.interest_rate = DEFAULT_CREDIT_LINE_INTEREST_RATE;
}
if self.amount_due <= 0.0 && self.outstanding_principal > 0.0 {
self.amount_due =
round_currency(self.outstanding_principal * (1.0 + self.interest_rate));
}
self.amount = self.available_amount;
} }
} }

View File

@ -6,10 +6,11 @@
//! For full documentation, architecture, and examples, see the [crate README](../README.md). //! For full documentation, architecture, and examples, see the [crate README](../README.md).
use forge_models::{ use forge_models::{
CreditLineSummary, HotOrgRecord, MemberSummary, Org, OrgAssetEntry, OrgAssetGrantSeed, CreditLineSummary, DEFAULT_CREDIT_LINE_INTEREST_RATE, HotOrgRecord, MemberSummary, Org,
OrgCheckoutContext, OrgCreditLineContext, OrgDisbandMemberResult, OrgDisbandResult, OrgAssetEntry, OrgAssetGrantSeed, OrgCheckoutContext, OrgCreditLineContext,
OrgEnsureMemberContext, OrgFleetEntry, OrgFleetGrantSeed, OrgGrantContext, OrgLeaveContext, OrgCreditLineRepaymentContext, OrgCreditLineRepaymentResult, OrgDisbandMemberResult,
OrgLeaveResult, OrgMutationResult, OrgRegisterContext, OrgRegisterResult, OrgDisbandResult, OrgEnsureMemberContext, OrgFleetEntry, OrgFleetGrantSeed, OrgGrantContext,
OrgLeaveContext, OrgLeaveResult, OrgMutationResult, OrgRegisterContext, OrgRegisterResult,
}; };
use forge_repositories::{OrgHotRepository, OrgRepository}; use forge_repositories::{OrgHotRepository, OrgRepository};
use serde_json::{Value, json}; use serde_json::{Value, json};
@ -58,7 +59,10 @@ impl<R: OrgRepository> OrgService<R> {
); );
} }
serde_json::from_value::<Org>(org_value).map_err(|e| format!("Invalid Org JSON: {}", e)) let mut org = serde_json::from_value::<Org>(org_value)
.map_err(|e| format!("Invalid Org JSON: {}", e))?;
org.normalize_credit_lines();
Ok(org)
} }
/// Creates a new organization service with the provided repository. /// Creates a new organization service with the provided repository.
@ -94,9 +98,12 @@ impl<R: OrgRepository> OrgService<R> {
} }
pub fn get_org(&self, key: String) -> Result<Org, String> { pub fn get_org(&self, key: String) -> Result<Org, String> {
self.repository let mut org = self
.repository
.get_by_id(&key)? .get_by_id(&key)?
.ok_or_else(|| format!("Organization with ID '{}' not found", key)) .ok_or_else(|| format!("Organization with ID '{}' not found", key))?;
org.normalize_credit_lines();
Ok(org)
} }
/// Updates an existing organization with new data from JSON. /// Updates an existing organization with new data from JSON.
@ -191,6 +198,7 @@ impl<R: OrgRepository> OrgService<R> {
} }
// Validate the updated organization before committing changes // Validate the updated organization before committing changes
updated_org.normalize_credit_lines();
updated_org updated_org
.validate() .validate()
.map_err(|e| format!("Validation failed: {}", e))?; .map_err(|e| format!("Validation failed: {}", e))?;
@ -532,23 +540,50 @@ impl<R: OrgRepository, H: OrgHotRepository> OrgHotStateService<R, H> {
context.member_name context.member_name
}; };
org.credit_lines.insert( let mut credit_line = org
context.member_uid.clone(), .credit_lines
CreditLineSummary { .get(&context.member_uid)
.cloned()
.unwrap_or_else(|| CreditLineSummary {
uid: context.member_uid.clone(), uid: context.member_uid.clone(),
name: member_name.clone(), name: member_name.clone(),
amount: context.amount, 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();
let next_reserved_amount = round_currency(context.amount);
let previous_reserved_amount = round_currency(credit_line.available_amount);
let treasury_delta = round_currency(next_reserved_amount - previous_reserved_amount);
if treasury_delta > 0.0 && org.funds < treasury_delta {
return Err("Organization funds cannot cover that credit assignment.".to_string());
}
org.funds = round_currency(org.funds - treasury_delta);
credit_line.uid = context.member_uid.clone();
credit_line.name = member_name.clone();
credit_line.approved_amount = next_reserved_amount;
credit_line.available_amount = next_reserved_amount;
credit_line.amount = next_reserved_amount;
if credit_line.interest_rate <= 0.0 {
credit_line.interest_rate = DEFAULT_CREDIT_LINE_INTEREST_RATE;
}
org.credit_lines
.insert(context.member_uid.clone(), credit_line);
self.repository.save(&org)?; self.repository.save(&org)?;
Ok(OrgMutationResult { Ok(OrgMutationResult {
patch: build_org_patch(&org, &["credit_lines"])?, patch: build_org_patch(&org, &["funds", "credit_lines"])?,
member_uids: resolve_member_uids(&org, Some(&context.requester_uid)), member_uids: resolve_member_uids(&org, Some(&context.requester_uid)),
message: format!( message: format!(
"Credit line of ${} assigned to {}.", "Credit line for {} set to ${}.",
format_currency(context.amount), member_name,
member_name format_currency(next_reserved_amount)
), ),
org, org,
}) })
@ -602,11 +637,22 @@ impl<R: OrgRepository, H: OrgHotRepository> OrgHotStateService<R, H> {
"Assigned credit line cannot cover this checkout.".to_string() "Assigned credit line cannot cover this checkout.".to_string()
})?; })?;
if credit_line.amount < context.amount { credit_line.normalize();
if credit_line.available_amount < context.amount {
return Err("Assigned credit line cannot cover this checkout.".to_string()); return Err("Assigned credit line cannot cover this checkout.".to_string());
} }
credit_line.amount -= context.amount; let charged_amount = round_currency(context.amount);
credit_line.available_amount =
round_currency(credit_line.available_amount - charged_amount);
credit_line.approved_amount = credit_line.available_amount;
credit_line.outstanding_principal =
round_currency(credit_line.outstanding_principal + charged_amount);
credit_line.amount_due = round_currency(
credit_line.amount_due + (charged_amount * (1.0 + credit_line.interest_rate)),
);
credit_line.amount = credit_line.available_amount;
org.credit_lines org.credit_lines
.insert(context.requester_uid.clone(), credit_line); .insert(context.requester_uid.clone(), credit_line);
self.repository.save(&org)?; self.repository.save(&org)?;
@ -622,6 +668,81 @@ impl<R: OrgRepository, H: OrgHotRepository> OrgHotStateService<R, H> {
} }
} }
pub fn repay_credit_line(
&self,
context: OrgCreditLineRepaymentContext,
) -> Result<OrgCreditLineRepaymentResult, String> {
if context.requester_uid.trim().is_empty() || context.org_id.trim().is_empty() {
return Err("A valid requester and organization are required.".to_string());
}
if context.amount <= 0.0 {
return Err("Repayment amount must be greater than zero.".to_string());
}
let mut org = self.get_org(context.org_id)?;
let member_uids = resolve_member_uids(&org, Some(&context.requester_uid));
let mut credit_line = org
.credit_lines
.get(&context.requester_uid)
.cloned()
.ok_or_else(|| "No active credit line is assigned to this member.".to_string())?;
credit_line.normalize();
if credit_line.amount_due <= 0.0 {
return Err("This credit line has no outstanding balance.".to_string());
}
let paid_amount = round_currency(context.amount.min(credit_line.amount_due));
let principal_paid = if paid_amount >= credit_line.amount_due {
credit_line.outstanding_principal
} else {
round_currency(
paid_amount * (credit_line.outstanding_principal / credit_line.amount_due),
)
.min(credit_line.outstanding_principal)
.min(paid_amount)
};
let interest_paid = round_currency(paid_amount - principal_paid);
credit_line.outstanding_principal =
round_currency(credit_line.outstanding_principal - principal_paid);
credit_line.amount_due = round_currency(credit_line.amount_due - paid_amount);
if credit_line.outstanding_principal <= 0.0 {
credit_line.outstanding_principal = 0.0;
}
if credit_line.amount_due <= 0.0 {
credit_line.amount_due = 0.0;
}
credit_line.amount = credit_line.available_amount;
org.funds = round_currency(org.funds + paid_amount);
org.credit_lines
.insert(context.requester_uid.clone(), credit_line.clone());
self.repository.save(&org)?;
Ok(OrgCreditLineRepaymentResult {
patch: build_org_patch(&org, &["funds", "credit_lines"])?,
member_uids,
paid_amount,
principal_paid,
interest_paid,
remaining_amount_due: credit_line.amount_due,
message: if credit_line.amount_due > 0.0 {
format!(
"Credit repayment posted. ${} paid with ${} still due.",
format_currency(paid_amount),
format_currency(credit_line.amount_due)
)
} else {
format!(
"Credit repayment posted. ${} cleared the outstanding balance.",
format_currency(paid_amount)
)
},
org,
})
}
pub fn add_assets( pub fn add_assets(
&self, &self,
context: OrgGrantContext, context: OrgGrantContext,
@ -891,7 +1012,7 @@ fn current_org_field_value(org: &HotOrgRecord, field: &str) -> Result<Value, Str
} }
fn format_currency(amount: f64) -> String { fn format_currency(amount: f64) -> String {
let rounded = amount.max(0.0).round() as i64; let rounded = round_currency(amount).round() as i64;
let digits = rounded.to_string(); let digits = rounded.to_string();
let mut formatted = String::new(); let mut formatted = String::new();
@ -904,3 +1025,7 @@ fn format_currency(amount: f64) -> String {
formatted.chars().rev().collect() formatted.chars().rev().collect()
} }
fn round_currency(amount: f64) -> f64 {
(amount.max(0.0) * 100.0).round() / 100.0
}

View File

@ -451,13 +451,23 @@ where
org.credit_lines.get_mut(requester_uid).ok_or_else(|| { org.credit_lines.get_mut(requester_uid).ok_or_else(|| {
"Assigned credit line cannot cover this checkout.".to_string() "Assigned credit line cannot cover this checkout.".to_string()
})?; })?;
if credit_line.amount < charged_total { credit_line.normalize();
if credit_line.available_amount < charged_total {
return Err( return Err(
"Assigned credit line cannot cover this checkout.".to_string() "Assigned credit line cannot cover this checkout.".to_string()
); );
} }
credit_line.amount -= charged_total; credit_line.available_amount =
round_currency(credit_line.available_amount - charged_total);
credit_line.approved_amount = credit_line.available_amount;
credit_line.outstanding_principal =
round_currency(credit_line.outstanding_principal + charged_total);
credit_line.amount_due = round_currency(
credit_line.amount_due
+ (charged_total * (1.0 + credit_line.interest_rate)),
);
credit_line.amount = credit_line.available_amount;
org_patch.insert("credit_lines".to_string(), json!(org.credit_lines)); org_patch.insert("credit_lines".to_string(), json!(org.credit_lines));
} }
_ => unreachable!(), _ => unreachable!(),
@ -683,3 +693,7 @@ fn format_currency(amount: f64) -> String {
format!("${}", formatted.chars().rev().collect::<String>()) format!("${}", formatted.chars().rev().collect::<String>())
} }
fn round_currency(amount: f64) -> f64 {
(amount.max(0.0) * 100.0).round() / 100.0
}