From 9771e375b6f9d2621f6fee15f1c4c618671ccbf4 Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Thu, 12 Mar 2026 06:36:24 -0500 Subject: [PATCH] Add org credit lines and multi-source store checkout state - Wire org portal credit-line requests/responses through SQF bridge and UI events - Sync `creditLines` in org payloads and refresh portal state after org sync - Add store payment sources (cash, bank, org funds, credit line) and expose selection in cart UI - Scaffold server-side store addon initialization/config files --- arma/client/addons/org/XEH_postInitClient.sqf | 8 + .../org/functions/fnc_handleUIEvents.sqf | 3 + .../addons/org/functions/fnc_initOrgClass.sqf | 13 + .../org/functions/fnc_initOrgUIBridge.sqf | 27 +- arma/client/addons/org/ui/_site/bridge.js | 50 ++++ .../ui/_site/components/portal/modalLayer.js | 2 +- .../org/ui/_site/logic/portalActions.js | 63 +++-- .../addons/org/ui/_site/logic/portalStore.js | 6 +- .../client/addons/org/ui/_site/portal/data.js | 5 + .../store/functions/fnc_initStoreClass.sqf | 96 ++++++- .../store/functions/fnc_initStoreUIBridge.sqf | 5 +- .../store/ui/_site/components/AppShell.js | 26 ++ .../addons/store/ui/_site/components/cart.js | 235 ++++++++++++++++-- arma/client/addons/store/ui/_site/data.js | 37 +++ .../addons/store/ui/_site/logic/events.js | 52 +++- .../addons/store/ui/_site/logic/store.js | 57 +++++ .../addons/store/ui/_site/pages/StoreView.js | 26 +- arma/server/addons/org/XEH_preInit.sqf | 33 +++ .../addons/org/functions/fnc_initOrgStore.sqf | 101 +++++++- arma/server/addons/store/$PBOPREFIX$ | 1 + arma/server/addons/store/CfgEventHandlers.hpp | 17 ++ arma/server/addons/store/README.md | 3 + arma/server/addons/store/XEH_PREP.hpp | 2 + arma/server/addons/store/XEH_postInit.sqf | 3 + arma/server/addons/store/XEH_preInit.sqf | 7 + arma/server/addons/store/XEH_preStart.sqf | 2 + arma/server/addons/store/config.cpp | 20 ++ arma/server/addons/store/script_component.hpp | 9 + arma/server/addons/store/stringtable.xml | 8 + lib/models/src/lib.rs | 2 +- lib/models/src/org.rs | 31 +++ lib/services/src/org.rs | 18 +- lib/shared/src/validation.rs | 10 + 33 files changed, 905 insertions(+), 73 deletions(-) create mode 100644 arma/server/addons/store/$PBOPREFIX$ create mode 100644 arma/server/addons/store/CfgEventHandlers.hpp create mode 100644 arma/server/addons/store/README.md create mode 100644 arma/server/addons/store/XEH_PREP.hpp create mode 100644 arma/server/addons/store/XEH_postInit.sqf create mode 100644 arma/server/addons/store/XEH_preInit.sqf create mode 100644 arma/server/addons/store/XEH_preStart.sqf create mode 100644 arma/server/addons/store/config.cpp create mode 100644 arma/server/addons/store/script_component.hpp create mode 100644 arma/server/addons/store/stringtable.xml diff --git a/arma/client/addons/org/XEH_postInitClient.sqf b/arma/client/addons/org/XEH_postInitClient.sqf index 13e220b..2e9b559 100644 --- a/arma/client/addons/org/XEH_postInitClient.sqf +++ b/arma/client/addons/org/XEH_postInitClient.sqf @@ -11,12 +11,14 @@ if (isNil QGVAR(OrgUIBridge)) then { call FUNC(initOrgUIBridge); }; params [["_data", createHashMap, [createHashMap]]]; GVAR(OrgClass) call ["sync", [_data, true]]; + GVAR(OrgUIBridge) call ["refreshPortal", []]; }] call CFUNC(addEventHandler); [QGVAR(responseSyncOrg), { params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]]; GVAR(OrgClass) call ["sync", [_data, _jip]]; + GVAR(OrgUIBridge) call ["refreshPortal", []]; }] call CFUNC(addEventHandler); [QGVAR(responseCreateOrg), { @@ -37,6 +39,12 @@ if (isNil QGVAR(OrgUIBridge)) then { call FUNC(initOrgUIBridge); }; GVAR(OrgUIBridge) call ["handleLeaveResponse", [_payload]]; }] call CFUNC(addEventHandler); +[QGVAR(responseCreditLine), { + params [["_payload", createHashMap, [createHashMap]]]; + + GVAR(OrgUIBridge) call ["handleCreditLineResponse", [_payload]]; +}] call CFUNC(addEventHandler); + [{ EGVAR(actor,ActorClass) get "isLoaded"; }, { diff --git a/arma/client/addons/org/functions/fnc_handleUIEvents.sqf b/arma/client/addons/org/functions/fnc_handleUIEvents.sqf index c6d8ef2..a9657c3 100644 --- a/arma/client/addons/org/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/org/functions/fnc_handleUIEvents.sqf @@ -38,6 +38,9 @@ switch (_event) do { case "org::leave::request": { GVAR(OrgUIBridge) call ["requestLeave", []]; }; + case "org::credit::request": { + GVAR(OrgUIBridge) call ["requestCreditLine", [_data]]; + }; case "org::ready": { GVAR(OrgUIBridge) call ["handleReady", [_control]]; }; diff --git a/arma/client/addons/org/functions/fnc_initOrgClass.sqf b/arma/client/addons/org/functions/fnc_initOrgClass.sqf index 1a5f6c5..b4ee8d5 100644 --- a/arma/client/addons/org/functions/fnc_initOrgClass.sqf +++ b/arma/client/addons/org/functions/fnc_initOrgClass.sqf @@ -35,6 +35,7 @@ GVAR(OrgBaseClass) = compileFinal createHashMapFromArray [ _org set ["name", ""]; _org set ["funds", 0]; _org set ["reputation", 0]; + _org set ["credit_lines", createHashMap]; _org set ["assets", createHashMap]; _org set ["fleet", createHashMap]; _org set ["members", createHashMap]; @@ -78,6 +79,7 @@ GVAR(OrgBaseClass) = compileFinal createHashMapFromArray [ private _ownerUid = _orgData get "owner"; private _funds = _orgData get "funds"; private _reputation = _orgData get "reputation"; + private _creditLinesRaw = _orgData getOrDefault ["credit_lines", createHashMap]; private _assetsRaw = _orgData get "assets"; private _fleetRaw = _orgData get "fleet"; private _membersRaw = _orgData get "members"; @@ -132,6 +134,16 @@ GVAR(OrgBaseClass) = compileFinal createHashMapFromArray [ ]); } forEach _fleetRaw; + private _creditLinesList = []; + { + private _creditLineData = _y; + _creditLinesList pushBack (createHashMapFromArray [ + ["uid", _creditLineData getOrDefault ["uid", _x]], + ["member", _creditLineData getOrDefault ["name", "Unknown Member"]], + ["amount", _creditLineData getOrDefault ["amount", 0]] + ]); + } forEach _creditLinesRaw; + createHashMapFromArray [ ["session", createHashMapFromArray [ ["actorName", _playerName], @@ -149,6 +161,7 @@ GVAR(OrgBaseClass) = compileFinal createHashMapFromArray [ ]], ["funds", _funds], ["reputation", _reputation], + ["creditLines", _creditLinesList], ["members", _membersList], ["fleet", _fleetList], ["assets", _assetsList], diff --git a/arma/client/addons/org/functions/fnc_initOrgUIBridge.sqf b/arma/client/addons/org/functions/fnc_initOrgUIBridge.sqf index d0c4fc5..36f7184 100644 --- a/arma/client/addons/org/functions/fnc_initOrgUIBridge.sqf +++ b/arma/client/addons/org/functions/fnc_initOrgUIBridge.sqf @@ -4,7 +4,7 @@ * File: fnc_initOrgUIBridge.sqf * Author: IDSolutions * Date: 2026-03-10 - * Last Update: 2026-03-10 + * Last Update: 2026-03-12 * Public: No * * Description: @@ -150,12 +150,37 @@ GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [ _self call ["sendBridgeEvent", [_eventName, _payload]]; }], + ["handleCreditLineResponse", compileFinal { + params [["_payload", createHashMap, [createHashMap]]]; + + private _eventName = [ + "org::credit::failure", + "org::credit::success" + ] select (_payload getOrDefault ["success", false]); + + _self call ["sendBridgeEvent", [_eventName, _payload]]; + }], ["requestDisband", compileFinal { [SRPC(org,requestDisbandOrg), [getPlayerUID player]] call CFUNC(serverEvent); }], ["requestLeave", compileFinal { [SRPC(org,requestLeaveOrg), [getPlayerUID player]] call CFUNC(serverEvent); }], + ["requestCreditLine", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + private _memberUid = _data getOrDefault ["memberUid", ""]; + private _memberName = _data getOrDefault ["memberName", ""]; + private _amount = _data getOrDefault ["amount", 0]; + + [SRPC(org,requestAssignCreditLine), [getPlayerUID player, _memberUid, _memberName, _amount]] call CFUNC(serverEvent); + }], + ["refreshPortal", compileFinal { + private _control = _self call ["getActiveBrowserControl", []]; + if (isNull _control) exitWith { false }; + + _self call ["sendBridgeEvent", ["org::sync", GVAR(OrgClass) call ["buildPortalPayload", []], _control]] + }], ["handleReady", compileFinal { params [["_control", controlNull, [controlNull]]]; diff --git a/arma/client/addons/org/ui/_site/bridge.js b/arma/client/addons/org/ui/_site/bridge.js index ecc7062..94626c6 100644 --- a/arma/client/addons/org/ui/_site/bridge.js +++ b/arma/client/addons/org/ui/_site/bridge.js @@ -71,6 +71,23 @@ } } + function requestCreditLine(payload) { + const sent = sendEvent("org::credit::request", payload); + if (sent) { + return true; + } + + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + "Arma credit line bridge is unavailable.", + ); + } + + return false; + } + function receive(eventOrPayload, data = {}) { const event = typeof eventOrPayload === "object" && eventOrPayload !== null @@ -103,7 +120,38 @@ return; } + if (event === "org::sync") { + if (store && typeof store.hydratePortal === "function") { + store.hydratePortal(payloadData); + } + return; + } + const OrgPortal = window.OrgPortal; + if (event === "org::credit::success") { + if (OrgPortal && OrgPortal.store) { + OrgPortal.store.setModal(null); + } + + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "success", + payloadData.message || "Credit line assigned.", + ); + } + return; + } + + if (event === "org::credit::failure") { + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + payloadData.message || "Unable to assign credit line.", + ); + } + return; + } + if (event === "org::disband::success") { if (OrgPortal && OrgPortal.store) { OrgPortal.store.setModal(null); @@ -170,6 +218,7 @@ requestCreateOrg, requestDisbandOrg, requestLeaveOrg, + requestCreditLine, receive, sendEvent, }; @@ -179,6 +228,7 @@ requestCreateOrg, requestDisbandOrg, requestLeaveOrg, + requestCreditLine, receive, receiveLoginSuccess: (data) => receive("org::login::success", data), receiveLoginFailure: (data) => receive("org::login::failure", data), diff --git a/arma/client/addons/org/ui/_site/components/portal/modalLayer.js b/arma/client/addons/org/ui/_site/components/portal/modalLayer.js index 611ecfb..db83264 100644 --- a/arma/client/addons/org/ui/_site/components/portal/modalLayer.js +++ b/arma/client/addons/org/ui/_site/components/portal/modalLayer.js @@ -156,7 +156,7 @@ "select", { id: "treasury-credit-member", ...memberSelectProps }, ...members.map((member) => - h("option", { value: member.name }, member.name), + h("option", { value: member.uid }, member.name), ), ), ), diff --git a/arma/client/addons/org/ui/_site/logic/portalActions.js b/arma/client/addons/org/ui/_site/logic/portalActions.js index 81e1d72..a9b23ee 100644 --- a/arma/client/addons/org/ui/_site/logic/portalActions.js +++ b/arma/client/addons/org/ui/_site/logic/portalActions.js @@ -103,7 +103,11 @@ ), ); store.setCreditLines((currentLines) => - currentLines.filter((line) => line.member !== memberName), + currentLines.filter((line) => + memberUid + ? line.uid !== memberUid + : line.member !== memberName, + ), ); return true; } @@ -240,7 +244,7 @@ return true; } - grantCreditLine(memberName, amount) { + grantCreditLine(memberUid, amount) { if (!getters.canManageTreasury()) { this.showTreasuryNotice( "error", @@ -249,7 +253,7 @@ return false; } - if (!memberName) { + if (!memberUid) { this.showTreasuryNotice( "error", "Select a member for the credit line.", @@ -265,30 +269,41 @@ return false; } - store.setCreditLines((currentLines) => { - const existingIndex = currentLines.findIndex( - (line) => line.member === memberName, + const member = store + .getMembers() + .find( + (entry) => + getters.getMemberUid(entry) === memberUid, ); - if (existingIndex === -1) { - return [ - ...currentLines, - { member: memberName, amount }, - ]; - } + const memberName = member + ? getters.getMemberName(member) + : ""; - const updatedLines = [...currentLines]; - updatedLines[existingIndex] = { - member: memberName, - amount, - }; - return updatedLines; + if (!memberName) { + this.showTreasuryNotice( + "error", + "Selected member was not found in the organization roster.", + ); + return false; + } + + const bridge = window.RegistryApp + ? window.RegistryApp.bridge + : null; + + if (!bridge || typeof bridge.requestCreditLine !== "function") { + this.showTreasuryNotice( + "error", + "Credit line bridge is unavailable.", + ); + return false; + } + + return bridge.requestCreditLine({ + memberUid, + memberName, + amount, }); - - this.showTreasuryNotice( - "success", - `Credit line of ${getters.formatCurrency(amount)} assigned to ${memberName}.`, - ); - return true; } } diff --git a/arma/client/addons/org/ui/_site/logic/portalStore.js b/arma/client/addons/org/ui/_site/logic/portalStore.js index 99b8a19..2d52184 100644 --- a/arma/client/addons/org/ui/_site/logic/portalStore.js +++ b/arma/client/addons/org/ui/_site/logic/portalStore.js @@ -11,7 +11,9 @@ [this.getMembers, this.setMembers] = createSignal([ ...portalData.members, ]); - [this.getCreditLines, this.setCreditLines] = createSignal([]); + [this.getCreditLines, this.setCreditLines] = createSignal([ + ...portalData.creditLines, + ]); [this.getTreasuryNotice, this.setTreasuryNotice] = createSignal( { type: "", @@ -26,7 +28,7 @@ hydrateFromPayload(payload) { this.setFunds(payload.portalData.funds || 0); this.setMembers([...(payload.portalData.members || [])]); - this.setCreditLines([]); + this.setCreditLines([...(payload.portalData.creditLines || [])]); this.setTreasuryNotice({ type: "", text: "" }); this.setModal(null); this.setOrgDisbanded(false); diff --git a/arma/client/addons/org/ui/_site/portal/data.js b/arma/client/addons/org/ui/_site/portal/data.js index b3a57d1..72b4c3b 100644 --- a/arma/client/addons/org/ui/_site/portal/data.js +++ b/arma/client/addons/org/ui/_site/portal/data.js @@ -33,6 +33,7 @@ ), funds: 0, reputation: 0, + creditLines: [], members: [], fleet: [], assets: [], @@ -77,6 +78,10 @@ ); this.portalData.funds = payload.portalData.funds || 0; this.portalData.reputation = payload.portalData.reputation || 0; + replaceArray( + this.portalData.creditLines, + payload.portalData.creditLines || [], + ); replaceArray( this.portalData.members, diff --git a/arma/client/addons/store/functions/fnc_initStoreClass.sqf b/arma/client/addons/store/functions/fnc_initStoreClass.sqf index 8d06a3e..0c1f781 100644 --- a/arma/client/addons/store/functions/fnc_initStoreClass.sqf +++ b/arma/client/addons/store/functions/fnc_initStoreClass.sqf @@ -4,7 +4,7 @@ * File: fnc_initStoreClass.sqf * Author: IDSolutions * Date: 2026-01-28 - * Last Update: 2026-03-11 + * Last Update: 2026-03-12 * Public: Yes * * Description: @@ -28,6 +28,7 @@ GVAR(StoreBaseClass) = compileFinal createHashMapFromArray [ _self set ["store", createHashMap]; _self set ["workspace", createHashMapFromArray [ ["budget", 48000], + ["creditLine", 0], ["availability", "Open"], ["approval", "Field Access"], ["moduleState", "Preview"], @@ -41,19 +42,106 @@ GVAR(StoreBaseClass) = compileFinal createHashMapFromArray [ }], ["buildUIPayload", compileFinal { private _workspace = _self getOrDefault ["workspace", createHashMap]; + private _budget = _workspace getOrDefault ["budget", 48000]; + private _creditLine = _workspace getOrDefault ["creditLine", 0]; + private _cashBalance = 0; + private _bankBalance = 0; + private _orgFunds = 0; + private _orgId = ""; + private _orgName = ""; + private _orgOwnerUid = ""; + private _orgCreditLines = createHashMap; + private _playerUid = getPlayerUID player; + private _playerVar = toLowerANSI (vehicleVarName player); + private _isOrgLeader = false; + private _isDefaultOrg = false; + private _isDefaultOrgCeo = false; + + if !(isNil QEGVAR(bank,BankClass)) then { + _cashBalance = EGVAR(bank,BankClass) call ["get", ["cash", 0]]; + _bankBalance = EGVAR(bank,BankClass) call ["get", ["bank", 0]]; + }; + + if !(isNil QEGVAR(org,OrgClass)) then { + _orgId = EGVAR(org,OrgClass) call ["get", ["id", ""]]; + _orgName = EGVAR(org,OrgClass) call ["get", ["name", ""]]; + _orgOwnerUid = EGVAR(org,OrgClass) call ["get", ["owner", ""]]; + _orgFunds = EGVAR(org,OrgClass) call ["get", ["funds", 0]]; + _orgCreditLines = EGVAR(org,OrgClass) call ["get", ["credit_lines", createHashMap]]; + _isDefaultOrg = (_orgId isEqualTo "default") || { toLowerANSI _orgOwnerUid isEqualTo "server" }; + _isOrgLeader = _orgOwnerUid isEqualTo _playerUid; + _isDefaultOrgCeo = _isDefaultOrg && { _playerVar isEqualTo "ceo" }; + }; + + if (_orgCreditLines isEqualType createHashMap) then { + private _playerCreditLine = _orgCreditLines getOrDefault [_playerUid, createHashMap]; + if (_playerCreditLine isEqualType createHashMap) then { + _creditLine = _playerCreditLine getOrDefault ["amount", _creditLine]; + }; + }; + + private _canUseOrgFunds = _isOrgLeader || _isDefaultOrgCeo; + private _orgFundsEnabled = _canUseOrgFunds && {_orgFunds > 0}; + private _paymentSources = [ + createHashMapFromArray [ + ["id", "cash"], + ["label", "Cash"], + ["balance", _cashBalance], + ["enabled", _cashBalance > 0], + ["detail", "Use on-hand cash carried by the player."] + ], + createHashMapFromArray [ + ["id", "bank"], + ["label", "Bank"], + ["balance", _bankBalance], + ["enabled", _bankBalance > 0], + ["detail", "Charge the player bank account."] + ], + createHashMapFromArray [ + ["id", "org_funds"], + ["label", "Org Funds"], + ["balance", _orgFunds], + ["enabled", _orgFundsEnabled], + ["detail", [ + "Only organization leaders or the default-org CEO can use treasury funds.", + [ + "Charge organization treasury funds.", + "No organization funds are currently available." + ] select _orgFundsEnabled + ] select _canUseOrgFunds] + ], + createHashMapFromArray [ + ["id", "credit_line"], + ["label", "Credit Line"], + ["balance", _creditLine], + ["enabled", _creditLine > 0], + ["detail", [ + "No approved credit line is assigned to this member.", + "Use the approved procurement credit line." + ] select (_creditLine > 0)] + ] + ]; createHashMapFromArray [ ["session", createHashMapFromArray [ ["actorName", name player], ["actorUid", getPlayerUID player], - ["approvalRole", _workspace getOrDefault ["approval", "Field Access"]] + ["approvalRole", _workspace getOrDefault ["approval", "Field Access"]], + ["orgId", _orgId], + ["orgName", _orgName], + ["orgLeader", _isOrgLeader], + ["defaultOrgCeo", _isDefaultOrgCeo], + ["canUseOrgFunds", _canUseOrgFunds] ]], ["workspace", createHashMapFromArray [ - ["budget", _workspace getOrDefault ["budget", 48000]], + ["budget", _budget], + ["creditLine", _creditLine], ["availability", _workspace getOrDefault ["availability", "Open"]], ["approval", _workspace getOrDefault ["approval", "Field Access"]], ["moduleState", _workspace getOrDefault ["moduleState", "Preview"]], - ["searchTags", _workspace getOrDefault ["searchTags", ["Field", "Logistics", "Issued", "Restricted"]]] + ["searchTags", _workspace getOrDefault ["searchTags", ["Field", "Logistics", "Issued", "Restricted"]]], + ["paymentSources", _paymentSources], + ["defaultPaymentSource", "cash"] ]], ["cartItems", []] ] diff --git a/arma/client/addons/store/functions/fnc_initStoreUIBridge.sqf b/arma/client/addons/store/functions/fnc_initStoreUIBridge.sqf index 3bb1a44..d5d99d4 100644 --- a/arma/client/addons/store/functions/fnc_initStoreUIBridge.sqf +++ b/arma/client/addons/store/functions/fnc_initStoreUIBridge.sqf @@ -4,7 +4,7 @@ * File: fnc_initStoreUIBridge.sqf * Author: IDSolutions * Date: 2026-03-10 - * Last Update: 2026-03-11 + * Last Update: 2026-03-12 * Public: No * * Description: @@ -302,7 +302,8 @@ GVAR(StoreUIBridgeBaseClass) = compileFinal createHashMapFromArray [ params [["_data", createHashMap, [createHashMap]]]; private _items = _data getOrDefault ["items", []]; - private _message = format ["Checkout integration is not wired yet. Received %1 queued line(s).", count _items]; + private _paymentMethod = _data getOrDefault ["paymentMethod", "cash"]; + private _message = format ["Checkout integration is not wired yet. Received %1 queued line(s) using %2.", count _items, _paymentMethod]; diag_log format ["[FORGE:Client:Store] Checkout request received: %1", _data]; _self call ["sendBridgeEvent", ["store::checkout::failure", createHashMapFromArray [["message", _message]]]]; diff --git a/arma/client/addons/store/ui/_site/components/AppShell.js b/arma/client/addons/store/ui/_site/components/AppShell.js index ba997a4..70c2f27 100644 --- a/arma/client/addons/store/ui/_site/components/AppShell.js +++ b/arma/client/addons/store/ui/_site/components/AppShell.js @@ -491,6 +491,11 @@ ${scopeSelector} .store-toast.is-error { getters.getSelectionKey(state) || "Catalog", ) : actions.formatTitle(state.view); + const selectedPaymentSource = + getters.getPaymentSourceById( + storeConfig, + state.selectedPaymentSource, + ) || null; ensureScopedStyle("storefront-app-shell", appShellCss); @@ -687,6 +692,27 @@ ${scopeSelector} .store-toast.is-error { ), ), ), + h( + "div", + { className: "filter-group" }, + h( + "span", + { className: "filter-label" }, + "Payment", + ), + h( + "div", + { className: "filter-value" }, + h("span", null, "Checkout Source"), + h( + "span", + { className: "filter-placeholder" }, + selectedPaymentSource + ? selectedPaymentSource.label + : "Cash", + ), + ), + ), ), ), ), diff --git a/arma/client/addons/store/ui/_site/components/cart.js b/arma/client/addons/store/ui/_site/components/cart.js index 68f6ff8..57be6ad 100644 --- a/arma/client/addons/store/ui/_site/components/cart.js +++ b/arma/client/addons/store/ui/_site/components/cart.js @@ -55,8 +55,39 @@ ${scopeSelector} .cart-header { } ${scopeSelector} .cart-close { - min-width: 2rem; - height: 2rem; + min-width: 2.1rem; + height: 2.1rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border-radius: 0.6rem; + border: 1px solid rgb(173 48 48 / 0.9); + background: linear-gradient( + 180deg, + rgb(214 92 92) 0%, + rgb(175 52 52) 100% + ); + color: #fff; + font-size: 0.92rem; + font-weight: 800; + line-height: 1; + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 0.26), + 0 8px 18px rgb(138 61 61 / 0.28); +} + +${scopeSelector} .cart-close:hover { + background: linear-gradient( + 180deg, + rgb(226 107 107) 0%, + rgb(187 61 61) 100% + ); + border-color: rgb(173 48 48); +} + +${scopeSelector} .cart-close:focus-visible { + outline: 2px solid rgb(191 80 80 / 0.35); } ${scopeSelector} .cart-status, @@ -175,6 +206,60 @@ ${scopeSelector} .cart-summary { gap: 0.7rem; } +${scopeSelector} .payment-source-field { + display: grid; + gap: 0.65rem; +} + +${scopeSelector} .payment-source-select { + width: 100%; + min-height: 2.9rem; + padding: 0 0.95rem; + border-radius: 0.8rem; + border: 1px solid var(--store-border); + background: rgb(255 255 255 / 0.78); + color: var(--store-text-main); +} + +${scopeSelector} .payment-source-meta, +${scopeSelector} .payment-source-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +${scopeSelector} .payment-source-meta { + padding: 0.85rem 0.9rem; + border-radius: 0.95rem; + border: 1px solid var(--store-border); + background: rgb(255 255 255 / 0.44); +} + +${scopeSelector} .payment-source-detail { + margin: 0.2rem 0 0; + font-size: 0.82rem; + line-height: 1.4; + color: var(--store-text-muted); +} + +${scopeSelector} .payment-source-label { + font-weight: 700; + color: var(--store-text-main); +} + +${scopeSelector} .payment-source-balance { + font-weight: 700; + color: var(--store-success); +} + +${scopeSelector} .payment-source-state { + font-size: 0.7rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--store-text-subtle); +} + ${scopeSelector} .summary-row.total { font-size: 1rem; font-weight: 700; @@ -217,9 +302,24 @@ ${scopeSelector} .cart-empty { StorefrontApp.componentFns.Cart = function Cart() { const state = getters.getStoreState(store); const summary = getters.summarizeCart(state.cartItems); - const remainingBudget = Math.max( + const paymentSources = getters.getPaymentSources(storeConfig); + const selectedPaymentSource = + getters.getPaymentSourceById( + storeConfig, + state.selectedPaymentSource, + ) || paymentSources[0] || null; + const availablePaymentSourceCount = paymentSources.filter( + (source) => source.enabled !== false, + ).length; + const selectedPaymentLabel = selectedPaymentSource + ? selectedPaymentSource.label + : "Unavailable"; + const selectedPaymentBalance = selectedPaymentSource + ? Number(selectedPaymentSource.balance || 0) + : 0; + const remainingSourceBalance = Math.max( 0, - Number(storeConfig.budget || 0) - summary.total, + selectedPaymentBalance - summary.total, ); ensureScopedStyle("storefront-cart", cartCss); @@ -254,8 +354,7 @@ ${scopeSelector} .cart-empty { "button", { type: "button", - className: - "window-control-btn cart-close is-close", + className: "cart-close", "aria-label": "Close cart", title: "Close cart", onClick: () => actions.closeCart(), @@ -263,25 +362,13 @@ ${scopeSelector} .cart-empty { "X", ), ), - h( - "div", - { className: "cart-status" }, - h("span", { className: "eyebrow" }, "Status"), - h( - "p", - { className: "section-copy" }, - state.isCheckingOut - ? "Checkout request sent through the browser bridge." - : "Local cart state is active. Checkout is routed through the same bridge contract used by the org UI.", - ), - ), h( "div", { className: "cart-kpi" }, h( "div", { className: "cart-kpi-card" }, - h("span", { className: "kpi-label" }, "Lines"), + h("span", { className: "kpi-label" }, "Items"), h( "span", { className: "kpi-value" }, @@ -291,14 +378,114 @@ ${scopeSelector} .cart-empty { h( "div", { className: "cart-kpi-card" }, - h("span", { className: "kpi-label" }, "Budget"), + h( + "span", + { className: "kpi-label" }, + "Payment", + ), h( "span", { className: "kpi-value" }, - getters.formatCurrency(storeConfig.budget), + selectedPaymentLabel, ), ), ), + h( + "div", + { className: "cart-status" }, + h( + "span", + { className: "eyebrow" }, + "Payment Source", + ), + h( + "div", + { className: "payment-source-field" }, + h( + "select", + { + className: "payment-source-select", + value: state.selectedPaymentSource, + onChange: (event) => + actions.selectPaymentSource( + event.target.value, + ), + }, + paymentSources.map((source) => + h( + "option", + { + value: source.id, + disabled: + source.enabled === false, + }, + source.enabled === false + ? `${source.label} (Locked)` + : source.label, + ), + ), + ), + selectedPaymentSource + ? h( + "div", + { + className: + "payment-source-meta", + }, + h( + "div", + null, + h( + "div", + { + className: + "payment-source-row", + }, + h( + "span", + { + className: + "payment-source-label", + }, + selectedPaymentSource.label, + ), + h( + "span", + { + className: + "payment-source-balance", + }, + getters.formatCurrency( + selectedPaymentSource.balance, + ), + ), + ), + h( + "p", + { + className: + "payment-source-detail", + }, + selectedPaymentSource.detail, + ), + ), + h( + "span", + { + className: + "payment-source-state", + }, + availablePaymentSourceCount > 0 + ? selectedPaymentSource.enabled === + false + ? "Locked" + : "Available" + : "Unavailable", + ), + ) + : null, + ), + ), h( "div", { @@ -436,12 +623,14 @@ ${scopeSelector} .cart-empty { h( "span", { className: "summary-label" }, - "Remaining Budget", + "Remaining Source", ), h( "span", { className: "summary-value" }, - getters.formatCurrency(remainingBudget), + getters.formatCurrency( + remainingSourceBalance, + ), ), ), h( diff --git a/arma/client/addons/store/ui/_site/data.js b/arma/client/addons/store/ui/_site/data.js index 15639c3..6f191e2 100644 --- a/arma/client/addons/store/ui/_site/data.js +++ b/arma/client/addons/store/ui/_site/data.js @@ -5,14 +5,51 @@ actorName: "", actorUid: "", approvalRole: "Field Access", + orgId: "", + orgName: "", + orgLeader: false, + defaultOrgCeo: false, + canUseOrgFunds: false, }; const defaultStoreConfig = { budget: 48000, + creditLine: 0, availability: "Open", approval: "Field Access", moduleState: "Preview", searchTags: ["Field", "Logistics", "Issued", "Restricted"], + paymentSources: [ + { + id: "cash", + label: "Cash", + balance: 0, + enabled: false, + detail: "Use on-hand cash carried by the player.", + }, + { + id: "bank", + label: "Bank", + balance: 0, + enabled: false, + detail: "Charge the player bank account.", + }, + { + id: "org_funds", + label: "Org Funds", + balance: 0, + enabled: false, + detail: "Only organization leaders or the default-org CEO can use treasury funds.", + }, + { + id: "credit_line", + label: "Credit Line", + balance: 0, + enabled: false, + detail: "No approved credit line is assigned to this member.", + }, + ], + defaultPaymentSource: "cash", }; function cloneValue(value) { diff --git a/arma/client/addons/store/ui/_site/logic/events.js b/arma/client/addons/store/ui/_site/logic/events.js index 3ae085b..a9203d4 100644 --- a/arma/client/addons/store/ui/_site/logic/events.js +++ b/arma/client/addons/store/ui/_site/logic/events.js @@ -165,7 +165,6 @@ return nextItems; }); - store.setCartOpen(true); showNotice("success", `${item.name} added to the acquisition queue.`); } @@ -199,6 +198,31 @@ ); } + function selectPaymentSource(paymentSourceId) { + const sourceId = String(paymentSourceId || "").trim(); + const paymentSources = getters.getPaymentSources(storeConfig); + const selectedSource = paymentSources.find( + (source) => source.id === sourceId, + ); + + if (!selectedSource) { + showNotice("error", "Selected payment source is unavailable."); + return false; + } + + if (selectedSource.enabled === false) { + showNotice( + "error", + selectedSource.detail || + "Selected payment source is not available.", + ); + return false; + } + + store.setSelectedPaymentSource(sourceId); + return true; + } + function requestCheckout() { const cartItems = store.getCartItems(); if (cartItems.length === 0) { @@ -207,10 +231,29 @@ } const summary = getters.summarizeCart(cartItems); - if (summary.total > Number(storeConfig.budget || 0)) { + const selectedPaymentSource = getters.getPaymentSourceById( + storeConfig, + store.getSelectedPaymentSource(), + ); + + if (!selectedPaymentSource) { + showNotice("error", "Select a payment source before checkout."); + return false; + } + + if (selectedPaymentSource.enabled === false) { showNotice( "error", - "Checkout total exceeds the current procurement budget.", + selectedPaymentSource.detail || + "Selected payment source is unavailable.", + ); + return false; + } + + if (summary.total > Number(selectedPaymentSource.balance || 0)) { + showNotice( + "error", + `${selectedPaymentSource.label} cannot cover this checkout total.`, ); return false; } @@ -226,6 +269,8 @@ const sent = bridge.requestCheckout({ actorUid: session.actorUid, actorName: session.actorName, + paymentMethod: selectedPaymentSource.id, + paymentLabel: selectedPaymentSource.label, items: cartItems, subtotal: summary.subtotal, total: summary.total, @@ -257,6 +302,7 @@ incrementCartItem, decrementCartItem, removeCartItem, + selectPaymentSource, requestCheckout, formatTitle: getters.formatTitle, formatCurrency: getters.formatCurrency, diff --git a/arma/client/addons/store/ui/_site/logic/store.js b/arma/client/addons/store/ui/_site/logic/store.js index ab7e05f..86132a0 100644 --- a/arma/client/addons/store/ui/_site/logic/store.js +++ b/arma/client/addons/store/ui/_site/logic/store.js @@ -29,6 +29,8 @@ }); [this.getIsCheckingOut, this.setIsCheckingOut] = createSignal(false); + [this.getSelectedPaymentSource, this.setSelectedPaymentSource] = + createSignal("cash"); } resetToCategories() { @@ -163,6 +165,58 @@ this.finishCategoryRequest(categoryKey); } + ensureSelectedPaymentSource(workspace) { + const paymentSources = Array.isArray(workspace?.paymentSources) + ? workspace.paymentSources + : []; + const currentSource = String( + this.getSelectedPaymentSource() || "", + ).trim(); + const defaultSource = String( + workspace?.defaultPaymentSource || "", + ).trim(); + const sourceIds = paymentSources.map((source) => + String(source?.id || "").trim(), + ); + const enabledSource = paymentSources.find( + (source) => source && source.enabled !== false, + ); + const defaultAvailable = + defaultSource && sourceIds.includes(defaultSource) + ? paymentSources.find( + (source) => + String(source?.id || "").trim() === + defaultSource, + ) + : null; + + if ( + currentSource && + sourceIds.includes(currentSource) && + paymentSources.some( + (source) => + String(source?.id || "").trim() === currentSource && + source?.enabled !== false, + ) + ) { + return; + } + + if (defaultAvailable && defaultAvailable.enabled !== false) { + this.setSelectedPaymentSource(defaultSource); + return; + } + + if (enabledSource) { + this.setSelectedPaymentSource( + String(enabledSource.id || "cash"), + ); + return; + } + + this.setSelectedPaymentSource(defaultSource || "cash"); + } + navigateToBreadcrumb(target) { switch (target) { case "categories": @@ -198,6 +252,9 @@ this.setCatalogRequestKey(""); this.setIsCatalogLoading(false); this.setCatalogPage(1); + this.ensureSelectedPaymentSource( + payload?.workspace || payload?.storeConfig || {}, + ); } } diff --git a/arma/client/addons/store/ui/_site/pages/StoreView.js b/arma/client/addons/store/ui/_site/pages/StoreView.js index 3224d08..93f6144 100644 --- a/arma/client/addons/store/ui/_site/pages/StoreView.js +++ b/arma/client/addons/store/ui/_site/pages/StoreView.js @@ -1,6 +1,6 @@ (function () { const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); - const CATALOG_PAGE_SIZE = 24; + const CATALOG_PAGE_SIZE = 6; function getSelectionKey(state) { return ( @@ -54,6 +54,7 @@ selectedCategory: store.getSelectedCategory(), selectedWeaponSlot: store.getSelectedWeaponSlot(), selectedVehicleSlot: store.getSelectedVehicleSlot(), + selectedPaymentSource: store.getSelectedPaymentSource(), cartOpen: store.getCartOpen(), searchQuery: store.getSearchQuery(), cartItems: store.getCartItems(), @@ -241,6 +242,27 @@ }; } + function getPaymentSources(storeConfig) { + const paymentSources = Array.isArray(storeConfig?.paymentSources) + ? storeConfig.paymentSources + : []; + + return paymentSources.map((source) => ({ + id: String(source?.id || "").trim(), + label: String(source?.label || source?.id || "").trim(), + balance: Number(source?.balance || 0), + enabled: source?.enabled !== false, + detail: String(source?.detail || "").trim(), + })); + } + + function getPaymentSourceById(storeConfig, paymentSourceId) { + const sourceId = String(paymentSourceId || "").trim(); + return getPaymentSources(storeConfig).find( + (source) => source.id === sourceId, + ); + } + StorefrontApp.getters = { formatTitle, formatCurrency, @@ -255,5 +277,7 @@ getVisibleItemsPage, getCatalogPagination, summarizeCart, + getPaymentSources, + getPaymentSourceById, }; })(); diff --git a/arma/server/addons/org/XEH_preInit.sqf b/arma/server/addons/org/XEH_preInit.sqf index 1ebf302..41fab57 100644 --- a/arma/server/addons/org/XEH_preInit.sqf +++ b/arma/server/addons/org/XEH_preInit.sqf @@ -69,6 +69,39 @@ PREP_RECOMPILE_END; GVAR(OrgStore) call ["mset", [GVAR(Registry), "org:update", _key, _fieldValuePairs, _sync]]; }] call CFUNC(addEventHandler); +[QGVAR(requestAssignCreditLine), { + params [ + ["_uid", "", [""]], + ["_memberUid", "", [""]], + ["_memberName", "", [""]], + ["_amount", 0, [0]] + ]; + + if (_uid isEqualTo "" || { _memberUid isEqualTo "" } || { _amount <= 0 }) exitWith { + diag_log "[FORGE:Server:Org] Invalid credit line request payload!" + }; + + private _requester = [_uid] call EFUNC(common,getPlayer); + if (_requester isEqualTo objNull) exitWith {}; + + private _result = GVAR(OrgStore) call ["assignCreditLine", [_uid, _memberUid, _memberName, _amount]]; + if (_result getOrDefault ["success", false]) then { + private _patch = _result getOrDefault ["patch", createHashMap]; + + { + private _memberPlayer = [_x] call EFUNC(common,getPlayer); + if (_memberPlayer isNotEqualTo objNull && { _patch isNotEqualTo createHashMap }) then { + [CRPC(org,responseSyncOrg), [_patch], _memberPlayer] call CFUNC(targetEvent); + }; + } forEach (_result getOrDefault ["memberUids", []]); + }; + + [CRPC(org,responseCreditLine), [createHashMapFromArray [ + ["success", _result getOrDefault ["success", false]], + ["message", _result getOrDefault ["message", "Unable to assign credit line."]] + ]], _requester] call CFUNC(targetEvent); +}] call CFUNC(addEventHandler); + [QGVAR(requestSaveOrg), { params [["_uid", "", [""]]]; diff --git a/arma/server/addons/org/functions/fnc_initOrgStore.sqf b/arma/server/addons/org/functions/fnc_initOrgStore.sqf index 83d3c83..9c9ff53 100644 --- a/arma/server/addons/org/functions/fnc_initOrgStore.sqf +++ b/arma/server/addons/org/functions/fnc_initOrgStore.sqf @@ -32,6 +32,7 @@ GVAR(OrgModel) = compileFinal createHashMapObject [[ _org set ["name", ""]; _org set ["funds", 0]; _org set ["reputation", 0]; + _org set ["credit_lines", createHashMap]; _org set ["assets", createHashMap]; _org set ["fleet", createHashMap]; _org set ["members", createHashMap]; @@ -57,13 +58,15 @@ GVAR(OrgModel) = compileFinal createHashMapObject [[ private _name = _org get "name"; private _funds = _org get "funds"; private _reputation = _org get "reputation"; + private _creditLines = _org getOrDefault ["credit_lines", createHashMap]; - [_id, _owner, _name, _funds, _reputation] try { + [_id, _owner, _name, _funds, _reputation, _creditLines] try { if (_id isEqualTo "" || !(_id isEqualType "")) then { throw "Invalid ID!"; }; if (_owner isEqualTo "" || !(_owner isEqualType "")) then { throw "Invalid Owner!"; }; if (_name isEqualTo "" || !(_name isEqualType "")) then { throw "Invalid Name!"; }; if (_funds isEqualTo 0 || !(_funds isEqualType 0)) then { throw "Invalid Funds!"; }; if (_reputation isEqualTo 0 || !(_reputation isEqualType 0)) then { throw "Invalid Reputation!"; }; + if !(_creditLines isEqualType createHashMap) then { throw "Invalid Credit Lines!"; }; } catch { ["ERROR", format ["Failed to validate org %1!", _exception]] call EFUNC(common,log); false @@ -91,6 +94,7 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ ["name", "Forge Dynamics"], ["funds", 200000], ["reputation", 0], + ["credit_lines", createHashMap], ["assets", createHashMap], ["fleet", createHashMap], ["members", createHashMap] @@ -110,6 +114,7 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _finalOrg set ["name", "Forge Dynamics"]; _finalOrg set ["funds", 200000]; _finalOrg set ["reputation", 0]; + _finalOrg set ["credit_lines", createHashMap]; private _json = _self call ["toJSON", [_finalOrg]]; ["org:create", ["default", _json]] call EFUNC(extension,extCall); @@ -153,18 +158,15 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ private _org = _self call ["fetch", ["org:get", _orgID]]; if (_org isEqualTo createHashMap) exitWith { _org }; + _org = GVAR(OrgModel) call ["migrate", [_org]]; private _memberRows = _self call ["fetch", ["org:members:get", _orgID]]; - if !(_memberRows isEqualType []) then { - _memberRows = []; - }; + if !(_memberRows isEqualType []) then { _memberRows = []; }; private _memberMap = createHashMap; { private _memberUid = _x getOrDefault ["uid", ""]; - if (_memberUid isNotEqualTo "") then { - _memberMap set [_memberUid, _x]; - }; + if (_memberUid isNotEqualTo "") then { _memberMap set [_memberUid, _x]; }; } forEach _memberRows; _org set ["members", _memberMap]; @@ -407,6 +409,86 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _result set ["members", _memberResults]; _result }], + ["assignCreditLine", compileFinal { + params [ + ["_requesterUid", "", [""]], + ["_memberUid", "", [""]], + ["_memberName", "", [""]], + ["_amount", 0, [0]] + ]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", ""], + ["patch", createHashMap], + ["memberUids", []] + ]; + + if ( + _requesterUid isEqualTo "" + || { _memberUid isEqualTo "" } + || { _amount <= 0 } + ) exitWith { + _result set ["message", "A valid requester, member, and credit amount are required."]; + _result + }; + + private _requesterActor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; + private _orgID = _requesterActor getOrDefault ["organization", "default"]; + if (_orgID isEqualTo "") then { + _orgID = "default"; + }; + + private _org = _self call ["loadById", [_orgID]]; + if (_org isEqualTo createHashMap) exitWith { + _result set ["message", "Unable to load organization data for credit line assignment."]; + _result + }; + + private _ownerUid = _org getOrDefault ["owner", ""]; + private _requesterPlayer = [_requesterUid] call EFUNC(common,getPlayer); + private _isDefaultOrg = (_orgID isEqualTo "default") || { toLower _ownerUid isEqualTo "server" }; + private _isDefaultOrgCeo = _isDefaultOrg + && { _requesterPlayer isNotEqualTo objNull } + && { toLowerANSI (vehicleVarName _requesterPlayer) isEqualTo "ceo" }; + private _canManageTreasury = (_ownerUid isEqualTo _requesterUid) || _isDefaultOrgCeo; + + if !_canManageTreasury exitWith { + _result set ["message", "Only the organization leader or CEO can manage treasury actions."]; + _result + }; + + private _members = _org getOrDefault ["members", createHashMap]; + private _memberRecord = _members getOrDefault [_memberUid, createHashMap]; + if (_memberRecord isEqualTo createHashMap) exitWith { + _result set ["message", "Selected member was not found in the organization roster."]; + _result + }; + + private _resolvedMemberName = _memberRecord getOrDefault ["name", _memberName]; + if (_resolvedMemberName isEqualTo "") then { + _resolvedMemberName = _memberName; + }; + + private _creditLines = +(_org getOrDefault ["credit_lines", createHashMap]); + _creditLines set [_memberUid, createHashMapFromArray [ + ["uid", _memberUid], + ["name", _resolvedMemberName], + ["amount", _amount] + ]]; + + private _patch = _self call ["set", [GVAR(Registry), "org:update", _orgID, "credit_lines", _creditLines, true]]; + private _memberUids = keys _members; + if !(_requesterUid in _memberUids) then { + _memberUids pushBack _requesterUid; + }; + + _result set ["success", true]; + _result set ["message", format ["Credit line of $%1 assigned to %2.", [_amount] call BIS_fnc_numberText, _resolvedMemberName]]; + _result set ["patch", _patch]; + _result set ["memberUids", _memberUids]; + _result + }], ["register", compileFinal { params [["_uid", "", [""]], ["_orgName", "", [""]]]; @@ -454,6 +536,7 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ ["name", _orgName], ["funds", 0], ["reputation", 0], + ["credit_lines", createHashMap], ["assets", createHashMap], ["fleet", createHashMap], ["members", createHashMap] @@ -466,9 +549,7 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _result }; - if (_createResult isNotEqualTo "") then { - _org = _self call ["toHashMap", [_createResult]]; - }; + if (_createResult isNotEqualTo "") then { _org = _self call ["toHashMap", [_createResult]]; }; _org set ["members", createHashMap]; _org = _self call ["verifyMember", [_org, _orgID, _uid, _player, _actor]]; diff --git a/arma/server/addons/store/$PBOPREFIX$ b/arma/server/addons/store/$PBOPREFIX$ new file mode 100644 index 0000000..ed419a2 --- /dev/null +++ b/arma/server/addons/store/$PBOPREFIX$ @@ -0,0 +1 @@ +forge\forge_server\addons\store diff --git a/arma/server/addons/store/CfgEventHandlers.hpp b/arma/server/addons/store/CfgEventHandlers.hpp new file mode 100644 index 0000000..f6503c2 --- /dev/null +++ b/arma/server/addons/store/CfgEventHandlers.hpp @@ -0,0 +1,17 @@ +class Extended_PreStart_EventHandlers { + class ADDON { + init = QUOTE(call COMPILE_SCRIPT(XEH_preStart)); + }; +}; + +class Extended_PreInit_EventHandlers { + class ADDON { + init = QUOTE(call COMPILE_SCRIPT(XEH_preInit)); + }; +}; + +class Extended_PostInit_EventHandlers { + class ADDON { + init = QUOTE(call COMPILE_SCRIPT(XEH_postInit)); + }; +}; diff --git a/arma/server/addons/store/README.md b/arma/server/addons/store/README.md new file mode 100644 index 0000000..84beb40 --- /dev/null +++ b/arma/server/addons/store/README.md @@ -0,0 +1,3 @@ +# forge_server_store + +Description for this addon diff --git a/arma/server/addons/store/XEH_PREP.hpp b/arma/server/addons/store/XEH_PREP.hpp new file mode 100644 index 0000000..7845920 --- /dev/null +++ b/arma/server/addons/store/XEH_PREP.hpp @@ -0,0 +1,2 @@ +// PREP(initStore); +// PREP(initStoreStore); diff --git a/arma/server/addons/store/XEH_postInit.sqf b/arma/server/addons/store/XEH_postInit.sqf new file mode 100644 index 0000000..b911595 --- /dev/null +++ b/arma/server/addons/store/XEH_postInit.sqf @@ -0,0 +1,3 @@ +#include "script_component.hpp" + +// call FUNC(initStore); diff --git a/arma/server/addons/store/XEH_preInit.sqf b/arma/server/addons/store/XEH_preInit.sqf new file mode 100644 index 0000000..9dd4ebf --- /dev/null +++ b/arma/server/addons/store/XEH_preInit.sqf @@ -0,0 +1,7 @@ +#include "script_component.hpp" + +PREP_RECOMPILE_START; +#include "XEH_PREP.hpp" +PREP_RECOMPILE_END; + +// private _category = [QUOTE(MOD_NAME), LLSTRING(displayName)]; diff --git a/arma/server/addons/store/XEH_preStart.sqf b/arma/server/addons/store/XEH_preStart.sqf new file mode 100644 index 0000000..a51262a --- /dev/null +++ b/arma/server/addons/store/XEH_preStart.sqf @@ -0,0 +1,2 @@ +#include "script_component.hpp" +#include "XEH_PREP.hpp" diff --git a/arma/server/addons/store/config.cpp b/arma/server/addons/store/config.cpp new file mode 100644 index 0000000..cbd0a75 --- /dev/null +++ b/arma/server/addons/store/config.cpp @@ -0,0 +1,20 @@ +#include "script_component.hpp" + +class CfgPatches { + class ADDON { + author = AUTHOR; + authors[] = {"J.Schmidt"}; + url = ECSTRING(main,url); + name = COMPONENT_NAME; + requiredVersion = REQUIRED_VERSION; + requiredAddons[] = { + "forge_server_main", + "forge_server_common" + }; + units[] = {}; + weapons[] = {}; + VERSION_CONFIG; + }; +}; + +#include "CfgEventHandlers.hpp" diff --git a/arma/server/addons/store/script_component.hpp b/arma/server/addons/store/script_component.hpp new file mode 100644 index 0000000..0b59e38 --- /dev/null +++ b/arma/server/addons/store/script_component.hpp @@ -0,0 +1,9 @@ +#define COMPONENT store +#define COMPONENT_BEAUTIFIED Store +#include "\forge\forge_server\addons\main\script_mod.hpp" + +// #define DEBUG_MODE_FULL +// #define DISABLE_COMPILE_CACHE +// #define ENABLE_PERFORMANCE_COUNTERS + +#include "\forge\forge_server\addons\main\script_macros.hpp" diff --git a/arma/server/addons/store/stringtable.xml b/arma/server/addons/store/stringtable.xml new file mode 100644 index 0000000..464a672 --- /dev/null +++ b/arma/server/addons/store/stringtable.xml @@ -0,0 +1,8 @@ + + + + + Store + + + diff --git a/lib/models/src/lib.rs b/lib/models/src/lib.rs index b3406aa..b710d4f 100644 --- a/lib/models/src/lib.rs +++ b/lib/models/src/lib.rs @@ -10,6 +10,6 @@ pub use actor::Actor; pub use bank::Bank; pub use garage::{Garage, HitPoints, Vehicle}; pub use locker::{Item, Locker}; -pub use org::{MemberSummary, Org}; +pub use org::{CreditLineSummary, MemberSummary, Org}; pub use v_garage::{VGarage, VehicleCategory}; pub use v_locker::{EquipmentCategory, VLocker}; diff --git a/lib/models/src/org.rs b/lib/models/src/org.rs index a6953d6..b4da3fa 100644 --- a/lib/models/src/org.rs +++ b/lib/models/src/org.rs @@ -1,6 +1,14 @@ use arma_rs::{FromArma, IntoArma}; use forge_shared::OrgValidationError; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreditLineSummary { + pub uid: String, + pub name: String, + pub amount: f64, +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Org { @@ -12,6 +20,8 @@ pub struct Org { pub funds: f64, #[serde(default)] pub reputation: i64, + #[serde(default)] + pub credit_lines: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -28,6 +38,7 @@ impl Org { name: name.into(), funds: 0.0, reputation: 0, + credit_lines: HashMap::new(), }; org.validate()?; @@ -65,6 +76,26 @@ impl Org { return Err(OrgValidationError::InvalidName(self.name.clone())); } + for (uid, credit_line) in &self.credit_lines { + let resolved_uid = if credit_line.uid.trim().is_empty() { + uid + } else { + &credit_line.uid + }; + + if !resolved_uid.chars().all(|c| c.is_numeric()) || resolved_uid.len() != 17 { + return Err(OrgValidationError::InvalidCreditLineUid( + resolved_uid.to_string(), + )); + } + + if credit_line.amount < 0.0 { + return Err(OrgValidationError::NegativeCreditLine( + resolved_uid.to_string(), + )); + } + } + Ok(()) } diff --git a/lib/services/src/org.rs b/lib/services/src/org.rs index bead927..76f8b60 100644 --- a/lib/services/src/org.rs +++ b/lib/services/src/org.rs @@ -5,8 +5,9 @@ //! //! For full documentation, architecture, and examples, see the [crate README](../README.md). -use forge_models::{MemberSummary, Org}; +use forge_models::{CreditLineSummary, MemberSummary, Org}; use forge_repositories::OrgRepository; +use std::collections::HashMap; /// Service layer implementation for organization business logic and operations. /// @@ -138,6 +139,21 @@ impl OrgService { return Err("Reputation must be an integer".to_string()); } } + "credit_lines" => { + if value.is_null() { + updated_org.credit_lines = HashMap::new(); + } else { + updated_org.credit_lines = serde_json::from_value::< + HashMap, + >(value.clone()) + .map_err(|e| { + format!( + "Credit lines must be an object of member credit entries: {}", + e + ) + })?; + } + } _ => { return Err(format!("Unknown field: {}", field)); } diff --git a/lib/shared/src/validation.rs b/lib/shared/src/validation.rs index 5aa69eb..2c2152d 100644 --- a/lib/shared/src/validation.rs +++ b/lib/shared/src/validation.rs @@ -80,9 +80,11 @@ pub enum OrgValidationError { EmptyOwner, EmptyName, NegativeFunds, + NegativeCreditLine(String), InvalidId(String), InvalidOwner(String), InvalidName(String), + InvalidCreditLineUid(String), } impl fmt::Display for OrgValidationError { @@ -94,6 +96,9 @@ impl fmt::Display for OrgValidationError { OrgValidationError::NegativeFunds => { write!(f, "Organization funds cannot be negative") } + OrgValidationError::NegativeCreditLine(uid) => { + write!(f, "Credit line for '{}' cannot be negative", uid) + } OrgValidationError::InvalidId(id) => write!( f, "Invalid organization ID '{}' - must contain only alphanumeric characters and underscores", @@ -107,6 +112,11 @@ impl fmt::Display for OrgValidationError { "Invalid organization name '{}' - cannot exceed 100 characters or contain control characters", name ), + OrgValidationError::InvalidCreditLineUid(uid) => write!( + f, + "Invalid credit line UID '{}' - must be a 17-digit Steam ID", + uid + ), } } }