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
This commit is contained in:
Jacob Schmidt 2026-03-12 06:36:24 -05:00
parent 09ab290b5a
commit 9771e375b6
33 changed files with 905 additions and 73 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
: "";
if (!memberName) {
this.showTreasuryNotice(
"error",
"Selected member was not found in the organization roster.",
);
return false;
}
const updatedLines = [...currentLines];
updatedLines[existingIndex] = {
member: memberName,
amount,
};
return updatedLines;
});
const bridge = window.RegistryApp
? window.RegistryApp.bridge
: null;
if (!bridge || typeof bridge.requestCreditLine !== "function") {
this.showTreasuryNotice(
"success",
`Credit line of ${getters.formatCurrency(amount)} assigned to ${memberName}.`,
"error",
"Credit line bridge is unavailable.",
);
return true;
return false;
}
return bridge.requestCreditLine({
memberUid,
memberName,
amount,
});
}
}

View File

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

View File

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

View File

@ -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", []]
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
};
})();

View File

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

View File

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

View File

@ -0,0 +1 @@
forge\forge_server\addons\store

View File

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

View File

@ -0,0 +1,3 @@
# forge_server_store
Description for this addon

View File

@ -0,0 +1,2 @@
// PREP(initStore);
// PREP(initStoreStore);

View File

@ -0,0 +1,3 @@
#include "script_component.hpp"
// call FUNC(initStore);

View File

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

View File

@ -0,0 +1,2 @@
#include "script_component.hpp"
#include "XEH_PREP.hpp"

View File

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

View File

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

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<Project name="FFE">
<Package name="Store">
<Key ID="STR_forge_server_store_displayName">
<English>Store</English>
</Key>
</Package>
</Project>

View File

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

View File

@ -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<String, CreditLineSummary>,
}
#[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(())
}

View File

@ -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<R: OrgRepository> OrgService<R> {
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<String, CreditLineSummary>,
>(value.clone())
.map_err(|e| {
format!(
"Credit lines must be an object of member credit entries: {}",
e
)
})?;
}
}
_ => {
return Err(format!("Unknown field: {}", field));
}

View File

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