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:
parent
b8dd3ef651
commit
5ded3a60e5
@ -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]];
|
||||||
|
|||||||
@ -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
@ -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", {});
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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: "",
|
||||||
|
|||||||
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
@ -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(
|
||||||
|
|||||||
@ -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.",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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]]];
|
||||||
|
|
||||||
|
|||||||
@ -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", "", [""]]];
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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)]
|
||||||
]
|
]
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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()))
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user