Add Service Point Sharing infrastructure

Grant-based system allowing businesses to share service points with
other businesses. Includes grant CRUD API, time/eligibility/economics
policies, enforcement at cart creation and order submit, Stripe payment
routing for owner fees, and portal UI for managing grants.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-02-01 21:34:03 -08:00
parent 82293c944d
commit f52d14bb7e
17 changed files with 1638 additions and 114 deletions

View file

@ -103,8 +103,6 @@ if (len(request._api_path)) {
if (findNoCase("/api/auth/verifyOTP.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/auth/loginOTP.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/auth/verifyLoginOTP.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/auth/sendLoginOTP.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/auth/verifyEmailOTP.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/auth/completeProfile.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/businesses/list.cfm", request._api_path)) request._api_isPublic = true;
@ -277,11 +275,22 @@ if (len(request._api_path)) {
// App info endpoints (public, no auth needed)
if (findNoCase("/api/app/about.cfm", request._api_path)) request._api_isPublic = true;
// Grant endpoints (SP-SM)
if (findNoCase("/api/grants/create.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/grants/list.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/grants/get.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/grants/update.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/grants/revoke.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/grants/accept.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/grants/decline.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/grants/searchBusiness.cfm", request._api_path)) request._api_isPublic = true;
// Stripe endpoints
if (findNoCase("/api/stripe/onboard.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/stripe/status.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/stripe/createPaymentIntent.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/stripe/webhook.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/stripe/createTipCheckout.cfm", request._api_path)) request._api_isPublic = true;
}
// Carry session values into request (if present)

136
api/grants/_grantUtils.cfm Normal file
View file

@ -0,0 +1,136 @@
<cfscript>
/**
* Grant utility functions for SP-SM enforcement.
* Include this file where grant time/eligibility checks are needed.
*/
/**
* Check if a grant's time policy is currently active.
* @timePolicyType always | schedule | date_range | event
* @timePolicyData JSON string or struct with policy parameters
* @return boolean
*/
function isGrantTimeActive(required string timePolicyType, any timePolicyData = "") {
if (arguments.timePolicyType == "always") return true;
var policy = {};
if (isSimpleValue(arguments.timePolicyData) && len(trim(arguments.timePolicyData))) {
try { policy = deserializeJSON(arguments.timePolicyData); } catch (any e) { return false; }
} else if (isStruct(arguments.timePolicyData)) {
policy = arguments.timePolicyData;
} else {
return false;
}
var nowDt = now();
switch (arguments.timePolicyType) {
case "schedule":
// policy: { days: [1,2,3,4,5], startTime: "09:00", endTime: "17:00" }
// days: 1=Sunday, 2=Monday, ... 7=Saturday (ColdFusion dayOfWeek)
if (!structKeyExists(policy, "days") || !isArray(policy.days)) return false;
var todayDow = dayOfWeek(nowDt);
if (!arrayFind(policy.days, todayDow)) return false;
if (structKeyExists(policy, "startTime") && structKeyExists(policy, "endTime")) {
var currentTime = timeFormat(nowDt, "HH:mm");
if (currentTime < policy.startTime || currentTime > policy.endTime) return false;
}
return true;
case "date_range":
// policy: { start: "2026-03-01", end: "2026-06-30" }
if (!structKeyExists(policy, "start") || !structKeyExists(policy, "end")) return false;
var today = dateFormat(nowDt, "yyyy-mm-dd");
return (today >= policy.start && today <= policy.end);
case "event":
// policy: { name: "Summer Festival", start: "2026-07-04 10:00", end: "2026-07-04 22:00" }
if (!structKeyExists(policy, "start") || !structKeyExists(policy, "end")) return false;
var nowStr = dateTimeFormat(nowDt, "yyyy-mm-dd HH:nn");
return (nowStr >= policy.start && nowStr <= policy.end);
default:
return false;
}
}
/**
* Check if a user meets the eligibility scope for a grant.
* @eligibilityScope public | employees | guests | internal
* @userID The ordering user's ID
* @ownerBusinessID The SP-owning business
* @guestBusinessID The granted business
* @return boolean
*/
function checkGrantEligibility(
required string eligibilityScope,
required numeric userID,
required numeric ownerBusinessID,
required numeric guestBusinessID
) {
if (arguments.eligibilityScope == "public") return true;
// Check if user is employee of a given business
var isGuestEmployee = queryExecute(
"SELECT 1 FROM Employees WHERE BusinessID = ? AND UserID = ? AND IsActive = 1 LIMIT 1",
[
{ value = arguments.guestBusinessID, cfsqltype = "cf_sql_integer" },
{ value = arguments.userID, cfsqltype = "cf_sql_integer" }
],
{ datasource = "payfrit" }
).recordCount > 0;
var isOwnerEmployee = queryExecute(
"SELECT 1 FROM Employees WHERE BusinessID = ? AND UserID = ? AND IsActive = 1 LIMIT 1",
[
{ value = arguments.ownerBusinessID, cfsqltype = "cf_sql_integer" },
{ value = arguments.userID, cfsqltype = "cf_sql_integer" }
],
{ datasource = "payfrit" }
).recordCount > 0;
switch (arguments.eligibilityScope) {
case "employees":
return isGuestEmployee;
case "guests":
return (!isGuestEmployee && !isOwnerEmployee);
case "internal":
return isOwnerEmployee;
default:
return false;
}
}
/**
* Record an entry in the immutable grant history table.
*/
function recordGrantHistory(
required numeric grantID,
required string action,
required numeric actorUserID,
required numeric actorBusinessID,
any previousData = "",
any newData = ""
) {
var prevJson = isStruct(arguments.previousData) ? serializeJSON(arguments.previousData) : (len(trim(arguments.previousData)) ? arguments.previousData : javaCast("null", ""));
var newJson = isStruct(arguments.newData) ? serializeJSON(arguments.newData) : (len(trim(arguments.newData)) ? arguments.newData : javaCast("null", ""));
var ip = "";
try { ip = cgi.REMOTE_ADDR; } catch (any e) {}
queryExecute(
"INSERT INTO ServicePointGrantHistory (GrantID, Action, ActorUserID, ActorBusinessID, PreviousData, NewData, IPAddress)
VALUES (?, ?, ?, ?, ?, ?, ?)",
[
{ value = arguments.grantID, cfsqltype = "cf_sql_integer" },
{ value = arguments.action, cfsqltype = "cf_sql_varchar" },
{ value = arguments.actorUserID, cfsqltype = "cf_sql_integer" },
{ value = arguments.actorBusinessID, cfsqltype = "cf_sql_integer" },
{ value = prevJson, cfsqltype = "cf_sql_varchar", null = isNull(prevJson) },
{ value = newJson, cfsqltype = "cf_sql_varchar", null = isNull(newJson) },
{ value = ip, cfsqltype = "cf_sql_varchar" }
],
{ datasource = "payfrit" }
);
}
</cfscript>

93
api/grants/accept.cfm Normal file
View file

@ -0,0 +1,93 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfinclude template="_grantUtils.cfm">
<cfscript>
data = {};
try {
raw = toString(getHttpRequestData().content);
if (len(trim(raw))) {
data = deserializeJSON(raw);
if (!isStruct(data)) data = {};
}
} catch (any e) { data = {}; }
grantID = val(data.GrantID ?: 0);
inviteToken = trim(data.InviteToken ?: "");
if (grantID LTE 0 && len(inviteToken) == 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "GrantID or InviteToken is required." });
}
callerUserID = val(structKeyExists(request, "UserID") ? request.UserID : 0);
if (callerUserID LTE 0) {
apiAbort({ "OK": false, "ERROR": "not_authenticated" });
}
// Load grant by ID or token
if (grantID GT 0) {
qGrant = queryExecute(
"SELECT g.*, b.UserID AS GuestOwnerUserID
FROM ServicePointGrants g
JOIN Businesses b ON b.ID = g.GuestBusinessID
WHERE g.ID = ?
LIMIT 1",
[{ value = grantID, cfsqltype = "cf_sql_integer" }],
{ datasource = "payfrit" }
);
} else {
qGrant = queryExecute(
"SELECT g.*, b.UserID AS GuestOwnerUserID
FROM ServicePointGrants g
JOIN Businesses b ON b.ID = g.GuestBusinessID
WHERE g.InviteToken = ?
LIMIT 1",
[{ value = inviteToken, cfsqltype = "cf_sql_varchar" }],
{ datasource = "payfrit" }
);
}
if (qGrant.recordCount == 0) {
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Grant not found." });
}
if (qGrant.GuestOwnerUserID != callerUserID) {
apiAbort({ "OK": false, "ERROR": "not_guest_owner", "MESSAGE": "Only the guest business owner can accept this invite." });
}
if (qGrant.StatusID != 0) {
statusLabels = { 1: "already active", 2: "declined", 3: "revoked" };
label = structKeyExists(statusLabels, qGrant.StatusID) ? statusLabels[qGrant.StatusID] : "not pending";
apiAbort({ "OK": false, "ERROR": "bad_state", "MESSAGE": "This grant is #label# and cannot be accepted." });
}
// Accept: activate grant
queryExecute(
"UPDATE ServicePointGrants
SET StatusID = 1, AcceptedOn = NOW(), AcceptedByUserID = ?, InviteToken = NULL
WHERE ID = ?",
[
{ value = callerUserID, cfsqltype = "cf_sql_integer" },
{ value = qGrant.ID, cfsqltype = "cf_sql_integer" }
],
{ datasource = "payfrit" }
);
recordGrantHistory(
grantID = qGrant.ID,
action = "accepted",
actorUserID = callerUserID,
actorBusinessID = qGrant.GuestBusinessID,
previousData = { "StatusID": 0 },
newData = { "StatusID": 1 }
);
writeOutput(serializeJSON({
"OK": true,
"GrantID": qGrant.ID,
"MESSAGE": "Grant accepted. Service point access is now active."
}));
</cfscript>

167
api/grants/create.cfm Normal file
View file

@ -0,0 +1,167 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfinclude template="_grantUtils.cfm">
<cfscript>
data = {};
try {
raw = toString(getHttpRequestData().content);
if (len(trim(raw))) {
data = deserializeJSON(raw);
if (!isStruct(data)) data = {};
}
} catch (any e) { data = {}; }
ownerBusinessID = val(data.OwnerBusinessID ?: 0);
guestBusinessID = val(data.GuestBusinessID ?: 0);
servicePointID = val(data.ServicePointID ?: 0);
economicsType = trim(data.EconomicsType ?: "none");
economicsValue = val(data.EconomicsValue ?: 0);
eligibilityScope = trim(data.EligibilityScope ?: "public");
timePolicyType = trim(data.TimePolicyType ?: "always");
timePolicyData = structKeyExists(data, "TimePolicyData") ? data.TimePolicyData : "";
// Validate required fields
if (ownerBusinessID LTE 0 || guestBusinessID LTE 0 || servicePointID LTE 0) {
apiAbort({ "OK": false, "ERROR": "missing_params", "MESSAGE": "OwnerBusinessID, GuestBusinessID, and ServicePointID are required." });
}
if (ownerBusinessID == guestBusinessID) {
apiAbort({ "OK": false, "ERROR": "self_grant", "MESSAGE": "Cannot grant access to your own business." });
}
// Validate caller is the owner of OwnerBusinessID
callerUserID = val(structKeyExists(request, "UserID") ? request.UserID : 0);
if (callerUserID LTE 0) {
apiAbort({ "OK": false, "ERROR": "not_authenticated", "MESSAGE": "Authentication required." });
}
qOwner = queryExecute(
"SELECT UserID FROM Businesses WHERE ID = ? LIMIT 1",
[{ value = ownerBusinessID, cfsqltype = "cf_sql_integer" }],
{ datasource = "payfrit" }
);
if (qOwner.recordCount == 0 || qOwner.UserID != callerUserID) {
apiAbort({ "OK": false, "ERROR": "not_owner", "MESSAGE": "You are not the owner of this business." });
}
// Validate ServicePoint belongs to OwnerBusinessID
qSP = queryExecute(
"SELECT ID FROM ServicePoints WHERE ID = ? AND BusinessID = ? LIMIT 1",
[
{ value = servicePointID, cfsqltype = "cf_sql_integer" },
{ value = ownerBusinessID, cfsqltype = "cf_sql_integer" }
],
{ datasource = "payfrit" }
);
if (qSP.recordCount == 0) {
apiAbort({ "OK": false, "ERROR": "sp_not_owned", "MESSAGE": "Service point does not belong to your business." });
}
// Validate GuestBusiness exists
qGuest = queryExecute(
"SELECT ID FROM Businesses WHERE ID = ? LIMIT 1",
[{ value = guestBusinessID, cfsqltype = "cf_sql_integer" }],
{ datasource = "payfrit" }
);
if (qGuest.recordCount == 0) {
apiAbort({ "OK": false, "ERROR": "guest_not_found", "MESSAGE": "Guest business not found." });
}
// Check no active or pending grant exists for this combo
qExisting = queryExecute(
"SELECT ID FROM ServicePointGrants
WHERE OwnerBusinessID = ? AND GuestBusinessID = ? AND ServicePointID = ? AND StatusID IN (0, 1)
LIMIT 1",
[
{ value = ownerBusinessID, cfsqltype = "cf_sql_integer" },
{ value = guestBusinessID, cfsqltype = "cf_sql_integer" },
{ value = servicePointID, cfsqltype = "cf_sql_integer" }
],
{ datasource = "payfrit" }
);
if (qExisting.recordCount > 0) {
apiAbort({ "OK": false, "ERROR": "grant_exists", "MESSAGE": "An active or pending grant already exists for this service point and guest business." });
}
// Validate enum values
validEconomics = ["none", "flat_fee", "percent_of_orders"];
validEligibility = ["public", "employees", "guests", "internal"];
validTimePolicy = ["always", "schedule", "date_range", "event"];
if (!arrayFind(validEconomics, economicsType)) economicsType = "none";
if (!arrayFind(validEligibility, eligibilityScope)) eligibilityScope = "public";
if (!arrayFind(validTimePolicy, timePolicyType)) timePolicyType = "always";
// Generate UUID and InviteToken
newUUID = createObject("java", "java.util.UUID").randomUUID().toString();
inviteToken = lcase(hash(generateSecretKey("AES", 256), "SHA-256"));
// Serialize TimePolicyData
timePolicyJson = javaCast("null", "");
if (isStruct(timePolicyData) && !structIsEmpty(timePolicyData)) {
timePolicyJson = serializeJSON(timePolicyData);
} else if (isSimpleValue(timePolicyData) && len(trim(timePolicyData))) {
timePolicyJson = timePolicyData;
}
// Insert grant
queryExecute(
"INSERT INTO ServicePointGrants
(UUID, OwnerBusinessID, GuestBusinessID, ServicePointID, StatusID,
EconomicsType, EconomicsValue, EligibilityScope, TimePolicyType, TimePolicyData,
InviteToken, CreatedByUserID)
VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?)",
[
{ value = newUUID, cfsqltype = "cf_sql_varchar" },
{ value = ownerBusinessID, cfsqltype = "cf_sql_integer" },
{ value = guestBusinessID, cfsqltype = "cf_sql_integer" },
{ value = servicePointID, cfsqltype = "cf_sql_integer" },
{ value = economicsType, cfsqltype = "cf_sql_varchar" },
{ value = economicsValue, cfsqltype = "cf_sql_decimal" },
{ value = eligibilityScope, cfsqltype = "cf_sql_varchar" },
{ value = timePolicyType, cfsqltype = "cf_sql_varchar" },
{ value = timePolicyJson, cfsqltype = "cf_sql_varchar", null = isNull(timePolicyJson) },
{ value = inviteToken, cfsqltype = "cf_sql_varchar" },
{ value = callerUserID, cfsqltype = "cf_sql_integer" }
],
{ datasource = "payfrit" }
);
// Get the inserted grant ID
qNew = queryExecute(
"SELECT ID FROM ServicePointGrants WHERE UUID = ? LIMIT 1",
[{ value = newUUID, cfsqltype = "cf_sql_varchar" }],
{ datasource = "payfrit" }
);
grantID = qNew.ID;
// Record history
recordGrantHistory(
grantID = grantID,
action = "created",
actorUserID = callerUserID,
actorBusinessID = ownerBusinessID,
newData = {
"OwnerBusinessID": ownerBusinessID,
"GuestBusinessID": guestBusinessID,
"ServicePointID": servicePointID,
"EconomicsType": economicsType,
"EconomicsValue": economicsValue,
"EligibilityScope": eligibilityScope,
"TimePolicyType": timePolicyType
}
);
writeOutput(serializeJSON({
"OK": true,
"GrantID": grantID,
"UUID": newUUID,
"InviteToken": inviteToken,
"StatusID": 0,
"MESSAGE": "Grant created. Awaiting guest acceptance."
}));
</cfscript>

70
api/grants/decline.cfm Normal file
View file

@ -0,0 +1,70 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfinclude template="_grantUtils.cfm">
<cfscript>
data = {};
try {
raw = toString(getHttpRequestData().content);
if (len(trim(raw))) {
data = deserializeJSON(raw);
if (!isStruct(data)) data = {};
}
} catch (any e) { data = {}; }
grantID = val(data.GrantID ?: 0);
if (grantID LTE 0) {
apiAbort({ "OK": false, "ERROR": "missing_grantid", "MESSAGE": "GrantID is required." });
}
callerUserID = val(structKeyExists(request, "UserID") ? request.UserID : 0);
if (callerUserID LTE 0) {
apiAbort({ "OK": false, "ERROR": "not_authenticated" });
}
qGrant = queryExecute(
"SELECT g.*, b.UserID AS GuestOwnerUserID
FROM ServicePointGrants g
JOIN Businesses b ON b.ID = g.GuestBusinessID
WHERE g.ID = ?
LIMIT 1",
[{ value = grantID, cfsqltype = "cf_sql_integer" }],
{ datasource = "payfrit" }
);
if (qGrant.recordCount == 0) {
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Grant not found." });
}
if (qGrant.GuestOwnerUserID != callerUserID) {
apiAbort({ "OK": false, "ERROR": "not_guest_owner", "MESSAGE": "Only the guest business owner can decline this invite." });
}
if (qGrant.StatusID != 0) {
apiAbort({ "OK": false, "ERROR": "bad_state", "MESSAGE": "Only pending grants can be declined." });
}
queryExecute(
"UPDATE ServicePointGrants SET StatusID = 2 WHERE ID = ?",
[{ value = grantID, cfsqltype = "cf_sql_integer" }],
{ datasource = "payfrit" }
);
recordGrantHistory(
grantID = grantID,
action = "declined",
actorUserID = callerUserID,
actorBusinessID = qGrant.GuestBusinessID,
previousData = { "StatusID": 0 },
newData = { "StatusID": 2 }
);
writeOutput(serializeJSON({
"OK": true,
"GrantID": grantID,
"MESSAGE": "Grant declined."
}));
</cfscript>

108
api/grants/get.cfm Normal file
View file

@ -0,0 +1,108 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
data = {};
try {
raw = toString(getHttpRequestData().content);
if (len(trim(raw))) {
data = deserializeJSON(raw);
if (!isStruct(data)) data = {};
}
} catch (any e) { data = {}; }
grantID = val(data.GrantID ?: 0);
if (grantID LTE 0) {
apiAbort({ "OK": false, "ERROR": "missing_grantid", "MESSAGE": "GrantID is required." });
}
callerUserID = val(structKeyExists(request, "UserID") ? request.UserID : 0);
if (callerUserID LTE 0) {
apiAbort({ "OK": false, "ERROR": "not_authenticated" });
}
// Load grant with business/SP names
qGrant = queryExecute(
"SELECT
g.*,
ob.Name AS OwnerBusinessName,
gb.Name AS GuestBusinessName,
sp.Name AS ServicePointName,
sp.TypeID AS ServicePointTypeID,
cu.FirstName AS CreatedByFirstName,
cu.LastName AS CreatedByLastName,
au.FirstName AS AcceptedByFirstName,
au.LastName AS AcceptedByLastName
FROM ServicePointGrants g
JOIN Businesses ob ON ob.ID = g.OwnerBusinessID
JOIN Businesses gb ON gb.ID = g.GuestBusinessID
JOIN ServicePoints sp ON sp.ID = g.ServicePointID
LEFT JOIN Users cu ON cu.ID = g.CreatedByUserID
LEFT JOIN Users au ON au.ID = g.AcceptedByUserID
WHERE g.ID = ?
LIMIT 1",
[{ value = grantID, cfsqltype = "cf_sql_integer" }],
{ datasource = "payfrit" }
);
if (qGrant.recordCount == 0) {
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Grant not found." });
}
// Load history
qHistory = queryExecute(
"SELECT h.*, u.FirstName, u.LastName
FROM ServicePointGrantHistory h
LEFT JOIN Users u ON u.ID = h.ActorUserID
WHERE h.GrantID = ?
ORDER BY h.CreatedOn DESC",
[{ value = grantID, cfsqltype = "cf_sql_integer" }],
{ datasource = "payfrit" }
);
history = [];
for (row in qHistory) {
arrayAppend(history, {
"ID": row.ID,
"Action": row.Action,
"ActorUserID": row.ActorUserID,
"ActorName": trim((row.FirstName ?: "") & " " & (row.LastName ?: "")),
"ActorBusinessID": row.ActorBusinessID,
"PreviousData": row.PreviousData ?: "",
"NewData": row.NewData ?: "",
"CreatedOn": row.CreatedOn
});
}
grant = {
"GrantID": qGrant.ID,
"UUID": qGrant.UUID,
"OwnerBusinessID": qGrant.OwnerBusinessID,
"GuestBusinessID": qGrant.GuestBusinessID,
"ServicePointID": qGrant.ServicePointID,
"StatusID": qGrant.StatusID,
"EconomicsType": qGrant.EconomicsType,
"EconomicsValue": qGrant.EconomicsValue,
"EligibilityScope": qGrant.EligibilityScope,
"TimePolicyType": qGrant.TimePolicyType,
"TimePolicyData": qGrant.TimePolicyData ?: "",
"CreatedOn": qGrant.CreatedOn,
"AcceptedOn": qGrant.AcceptedOn ?: "",
"RevokedOn": qGrant.RevokedOn ?: "",
"LastEditedOn": qGrant.LastEditedOn,
"OwnerBusinessName": qGrant.OwnerBusinessName,
"GuestBusinessName": qGrant.GuestBusinessName,
"ServicePointName": qGrant.ServicePointName,
"ServicePointTypeID": qGrant.ServicePointTypeID,
"CreatedByName": trim((qGrant.CreatedByFirstName ?: "") & " " & (qGrant.CreatedByLastName ?: "")),
"AcceptedByName": trim((qGrant.AcceptedByFirstName ?: "") & " " & (qGrant.AcceptedByLastName ?: ""))
};
writeOutput(serializeJSON({
"OK": true,
"Grant": grant,
"History": history
}));
</cfscript>

112
api/grants/list.cfm Normal file
View file

@ -0,0 +1,112 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
data = {};
try {
raw = toString(getHttpRequestData().content);
if (len(trim(raw))) {
data = deserializeJSON(raw);
if (!isStruct(data)) data = {};
}
} catch (any e) { data = {}; }
businessID = val(data.BusinessID ?: 0);
role = lcase(trim(data.Role ?: "owner")); // "owner" or "guest"
statusFilter = structKeyExists(data, "StatusFilter") ? val(data.StatusFilter) : -1; // -1 = all
if (businessID LTE 0) {
// Fall back to request.BusinessID
businessID = val(structKeyExists(request, "BusinessID") ? request.BusinessID : 0);
}
if (businessID LTE 0) {
apiAbort({ "OK": false, "ERROR": "missing_businessid", "MESSAGE": "BusinessID is required." });
}
callerUserID = val(structKeyExists(request, "UserID") ? request.UserID : 0);
if (callerUserID LTE 0) {
apiAbort({ "OK": false, "ERROR": "not_authenticated" });
}
// Build WHERE clause based on role
if (role == "guest") {
whereClause = "g.GuestBusinessID = :bizId";
} else {
whereClause = "g.OwnerBusinessID = :bizId";
}
statusClause = "";
if (statusFilter >= 0) {
statusClause = " AND g.StatusID = :statusFilter";
}
sql = "
SELECT
g.ID AS GrantID,
g.UUID,
g.OwnerBusinessID,
g.GuestBusinessID,
g.ServicePointID,
g.StatusID,
g.EconomicsType,
g.EconomicsValue,
g.EligibilityScope,
g.TimePolicyType,
g.TimePolicyData,
g.CreatedOn,
g.AcceptedOn,
g.RevokedOn,
ob.Name AS OwnerBusinessName,
gb.Name AS GuestBusinessName,
sp.Name AS ServicePointName,
sp.TypeID AS ServicePointTypeID
FROM ServicePointGrants g
JOIN Businesses ob ON ob.ID = g.OwnerBusinessID
JOIN Businesses gb ON gb.ID = g.GuestBusinessID
JOIN ServicePoints sp ON sp.ID = g.ServicePointID
WHERE #whereClause##statusClause#
ORDER BY g.CreatedOn DESC
LIMIT 200
";
params = { bizId: { value = businessID, cfsqltype = "cf_sql_integer" } };
if (statusFilter >= 0) {
params.statusFilter = { value = statusFilter, cfsqltype = "cf_sql_integer" };
}
qGrants = queryExecute(sql, params, { datasource = "payfrit" });
grants = [];
for (row in qGrants) {
arrayAppend(grants, {
"GrantID": row.GrantID,
"UUID": row.UUID,
"OwnerBusinessID": row.OwnerBusinessID,
"GuestBusinessID": row.GuestBusinessID,
"ServicePointID": row.ServicePointID,
"StatusID": row.StatusID,
"EconomicsType": row.EconomicsType,
"EconomicsValue": row.EconomicsValue,
"EligibilityScope": row.EligibilityScope,
"TimePolicyType": row.TimePolicyType,
"TimePolicyData": row.TimePolicyData ?: "",
"CreatedOn": row.CreatedOn,
"AcceptedOn": row.AcceptedOn ?: "",
"RevokedOn": row.RevokedOn ?: "",
"OwnerBusinessName": row.OwnerBusinessName,
"GuestBusinessName": row.GuestBusinessName,
"ServicePointName": row.ServicePointName,
"ServicePointTypeID": row.ServicePointTypeID
});
}
writeOutput(serializeJSON({
"OK": true,
"Role": role,
"BusinessID": businessID,
"Count": arrayLen(grants),
"Grants": grants
}));
</cfscript>

76
api/grants/revoke.cfm Normal file
View file

@ -0,0 +1,76 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfinclude template="_grantUtils.cfm">
<cfscript>
data = {};
try {
raw = toString(getHttpRequestData().content);
if (len(trim(raw))) {
data = deserializeJSON(raw);
if (!isStruct(data)) data = {};
}
} catch (any e) { data = {}; }
grantID = val(data.GrantID ?: 0);
if (grantID LTE 0) {
apiAbort({ "OK": false, "ERROR": "missing_grantid", "MESSAGE": "GrantID is required." });
}
callerUserID = val(structKeyExists(request, "UserID") ? request.UserID : 0);
if (callerUserID LTE 0) {
apiAbort({ "OK": false, "ERROR": "not_authenticated" });
}
// Load grant + verify ownership
qGrant = queryExecute(
"SELECT g.ID, g.OwnerBusinessID, g.GuestBusinessID, g.StatusID, b.UserID AS OwnerUserID
FROM ServicePointGrants g
JOIN Businesses b ON b.ID = g.OwnerBusinessID
WHERE g.ID = ?
LIMIT 1",
[{ value = grantID, cfsqltype = "cf_sql_integer" }],
{ datasource = "payfrit" }
);
if (qGrant.recordCount == 0) {
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Grant not found." });
}
if (qGrant.OwnerUserID != callerUserID) {
apiAbort({ "OK": false, "ERROR": "not_owner", "MESSAGE": "Only the owner business can revoke a grant." });
}
if (qGrant.StatusID == 3) {
apiAbort({ "OK": true, "MESSAGE": "Grant is already revoked.", "GrantID": grantID });
}
if (qGrant.StatusID != 0 && qGrant.StatusID != 1) {
apiAbort({ "OK": false, "ERROR": "bad_state", "MESSAGE": "Only pending or active grants can be revoked." });
}
// Revoke immediately
queryExecute(
"UPDATE ServicePointGrants SET StatusID = 3, RevokedOn = NOW() WHERE ID = ?",
[{ value = grantID, cfsqltype = "cf_sql_integer" }],
{ datasource = "payfrit" }
);
recordGrantHistory(
grantID = grantID,
action = "revoked",
actorUserID = callerUserID,
actorBusinessID = qGrant.OwnerBusinessID,
previousData = { "StatusID": qGrant.StatusID },
newData = { "StatusID": 3 }
);
writeOutput(serializeJSON({
"OK": true,
"GrantID": grantID,
"MESSAGE": "Grant revoked. All access stopped immediately."
}));
</cfscript>

View file

@ -0,0 +1,57 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
data = {};
try {
raw = toString(getHttpRequestData().content);
if (len(trim(raw))) {
data = deserializeJSON(raw);
if (!isStruct(data)) data = {};
}
} catch (any e) { data = {}; }
query = trim(data.Query ?: "");
excludeBusinessID = val(data.ExcludeBusinessID ?: 0);
if (len(query) < 2) {
apiAbort({ "OK": false, "ERROR": "query_too_short", "MESSAGE": "Search query must be at least 2 characters." });
}
// Search by name or ID
params = {};
sql = "SELECT ID, Name FROM Businesses WHERE 1=1";
if (isNumeric(query)) {
sql &= " AND ID = :bizId";
params.bizId = { value = int(query), cfsqltype = "cf_sql_integer" };
} else {
sql &= " AND Name LIKE :namePattern";
params.namePattern = { value = "%" & query & "%", cfsqltype = "cf_sql_varchar" };
}
if (excludeBusinessID GT 0) {
sql &= " AND ID != :excludeId";
params.excludeId = { value = excludeBusinessID, cfsqltype = "cf_sql_integer" };
}
sql &= " ORDER BY Name LIMIT 20";
qResults = queryExecute(sql, params, { datasource = "payfrit" });
businesses = [];
for (row in qResults) {
arrayAppend(businesses, {
"BusinessID": row.ID,
"Name": row.Name
});
}
writeOutput(serializeJSON({
"OK": true,
"Count": arrayLen(businesses),
"Businesses": businesses
}));
</cfscript>

153
api/grants/update.cfm Normal file
View file

@ -0,0 +1,153 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfinclude template="_grantUtils.cfm">
<cfscript>
data = {};
try {
raw = toString(getHttpRequestData().content);
if (len(trim(raw))) {
data = deserializeJSON(raw);
if (!isStruct(data)) data = {};
}
} catch (any e) { data = {}; }
grantID = val(data.GrantID ?: 0);
if (grantID LTE 0) {
apiAbort({ "OK": false, "ERROR": "missing_grantid", "MESSAGE": "GrantID is required." });
}
callerUserID = val(structKeyExists(request, "UserID") ? request.UserID : 0);
if (callerUserID LTE 0) {
apiAbort({ "OK": false, "ERROR": "not_authenticated" });
}
// Load current grant
qGrant = queryExecute(
"SELECT g.*, b.UserID AS OwnerUserID
FROM ServicePointGrants g
JOIN Businesses b ON b.ID = g.OwnerBusinessID
WHERE g.ID = ?
LIMIT 1",
[{ value = grantID, cfsqltype = "cf_sql_integer" }],
{ datasource = "payfrit" }
);
if (qGrant.recordCount == 0) {
apiAbort({ "OK": false, "ERROR": "not_found", "MESSAGE": "Grant not found." });
}
if (qGrant.OwnerUserID != callerUserID) {
apiAbort({ "OK": false, "ERROR": "not_owner", "MESSAGE": "Only the owner business can update grant terms." });
}
if (qGrant.StatusID != 0 && qGrant.StatusID != 1) {
apiAbort({ "OK": false, "ERROR": "bad_state", "MESSAGE": "Only pending or active grants can be updated." });
}
// Collect updates
setClauses = [];
setParams = [];
previousData = {};
newData = {};
validEconomics = ["none", "flat_fee", "percent_of_orders"];
validEligibility = ["public", "employees", "guests", "internal"];
validTimePolicy = ["always", "schedule", "date_range", "event"];
// Economics
if (structKeyExists(data, "EconomicsType") || structKeyExists(data, "EconomicsValue")) {
eType = trim(data.EconomicsType ?: qGrant.EconomicsType);
eValue = val(structKeyExists(data, "EconomicsValue") ? data.EconomicsValue : qGrant.EconomicsValue);
if (!arrayFind(validEconomics, eType)) eType = qGrant.EconomicsType;
if (eType != qGrant.EconomicsType || eValue != qGrant.EconomicsValue) {
previousData["EconomicsType"] = qGrant.EconomicsType;
previousData["EconomicsValue"] = qGrant.EconomicsValue;
newData["EconomicsType"] = eType;
newData["EconomicsValue"] = eValue;
arrayAppend(setClauses, "EconomicsType = ?");
arrayAppend(setParams, { value = eType, cfsqltype = "cf_sql_varchar" });
arrayAppend(setClauses, "EconomicsValue = ?");
arrayAppend(setParams, { value = eValue, cfsqltype = "cf_sql_decimal" });
}
}
// Eligibility
if (structKeyExists(data, "EligibilityScope")) {
eScope = trim(data.EligibilityScope);
if (!arrayFind(validEligibility, eScope)) eScope = qGrant.EligibilityScope;
if (eScope != qGrant.EligibilityScope) {
previousData["EligibilityScope"] = qGrant.EligibilityScope;
newData["EligibilityScope"] = eScope;
arrayAppend(setClauses, "EligibilityScope = ?");
arrayAppend(setParams, { value = eScope, cfsqltype = "cf_sql_varchar" });
}
}
// Time policy
if (structKeyExists(data, "TimePolicyType") || structKeyExists(data, "TimePolicyData")) {
tType = trim(data.TimePolicyType ?: qGrant.TimePolicyType);
if (!arrayFind(validTimePolicy, tType)) tType = qGrant.TimePolicyType;
tData = structKeyExists(data, "TimePolicyData") ? data.TimePolicyData : qGrant.TimePolicyData;
changed = (tType != qGrant.TimePolicyType);
if (!changed && isStruct(tData)) {
changed = (serializeJSON(tData) != (qGrant.TimePolicyData ?: ""));
}
if (changed) {
previousData["TimePolicyType"] = qGrant.TimePolicyType;
previousData["TimePolicyData"] = qGrant.TimePolicyData ?: "";
newData["TimePolicyType"] = tType;
newData["TimePolicyData"] = tData;
arrayAppend(setClauses, "TimePolicyType = ?");
arrayAppend(setParams, { value = tType, cfsqltype = "cf_sql_varchar" });
tDataJson = javaCast("null", "");
if (isStruct(tData) && !structIsEmpty(tData)) {
tDataJson = serializeJSON(tData);
} else if (isSimpleValue(tData) && len(trim(tData))) {
tDataJson = tData;
}
arrayAppend(setClauses, "TimePolicyData = ?");
arrayAppend(setParams, { value = tDataJson, cfsqltype = "cf_sql_varchar", null = isNull(tDataJson) });
}
}
if (arrayLen(setClauses) == 0) {
apiAbort({ "OK": true, "MESSAGE": "No changes detected.", "GrantID": grantID });
}
// Execute update
arrayAppend(setParams, { value = grantID, cfsqltype = "cf_sql_integer" });
queryExecute(
"UPDATE ServicePointGrants SET #arrayToList(setClauses, ', ')# WHERE ID = ?",
setParams,
{ datasource = "payfrit" }
);
// Determine action name for history
action = "updated";
if (structKeyExists(newData, "EconomicsType") || structKeyExists(newData, "EconomicsValue")) action = "updated_economics";
if (structKeyExists(newData, "EligibilityScope")) action = "updated_eligibility";
if (structKeyExists(newData, "TimePolicyType")) action = "updated_time_policy";
recordGrantHistory(
grantID = grantID,
action = action,
actorUserID = callerUserID,
actorBusinessID = qGrant.OwnerBusinessID,
previousData = previousData,
newData = newData
);
writeOutput(serializeJSON({
"OK": true,
"GrantID": grantID,
"MESSAGE": "Grant updated."
}));
</cfscript>

View file

@ -37,7 +37,7 @@
UUID,
UserID,
BusinessID,
DeliveryMultiplier,
BusinessDeliveryMultiplier,
OrderTypeID,
DeliveryFee,
StatusID,
@ -47,7 +47,11 @@
AddedOn,
LastEditedOn,
SubmittedOn,
ServicePointID
ServicePointID,
GrantID,
GrantOwnerBusinessID,
GrantEconomicsType,
GrantEconomicsValue
FROM Orders
WHERE ID = ?
LIMIT 1
@ -73,7 +77,7 @@
"UUID": qOrder.UUID ?: "",
"UserID": val(qOrder.UserID),
"BusinessID": val(qOrder.BusinessID),
"DeliveryMultiplier": val(qOrder.DeliveryMultiplier),
"DeliveryMultiplier": val(qOrder.BusinessDeliveryMultiplier),
"OrderTypeID": val(qOrder.OrderTypeID),
"DeliveryFee": val(qOrder.DeliveryFee),
"BusinessDeliveryFee": val(businessDeliveryFee),
@ -84,7 +88,11 @@
"AddedOn": qOrder.AddedOn,
"LastEditedOn": qOrder.LastEditedOn,
"SubmittedOn": qOrder.SubmittedOn,
"ServicePointID": val(qOrder.ServicePointID)
"ServicePointID": val(qOrder.ServicePointID),
"GrantID": val(qOrder.GrantID),
"GrantOwnerBusinessID": val(qOrder.GrantOwnerBusinessID),
"GrantEconomicsType": qOrder.GrantEconomicsType ?: "",
"GrantEconomicsValue": val(qOrder.GrantEconomicsValue)
}>
<cfset var qLI = queryExecute(
@ -140,6 +148,7 @@
<cfreturn out>
</cffunction>
<cfinclude template="../grants/_grantUtils.cfm">
<cfset data = readJsonBody()>
<cfset BusinessID = val( structKeyExists(data,"BusinessID") ? data.BusinessID : 0 )>
@ -242,8 +251,50 @@
<cfset apiAbort({ "OK": false, "ERROR": "bad_business", "MESSAGE": "Business not found", "DETAIL": "" })>
</cfif>
<!--- SP-SM: Resolve grant if ServicePoint doesn't belong to this business --->
<cfset grantID = 0>
<cfset grantOwnerBusinessID = 0>
<cfset grantEconomicsType = "">
<cfset grantEconomicsValue = 0>
<cfif ServicePointID GT 0>
<cfset qSPOwner = queryExecute(
"SELECT BusinessID FROM ServicePoints WHERE ID = ? LIMIT 1",
[ { value = ServicePointID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfif qSPOwner.recordCount GT 0 AND qSPOwner.BusinessID NEQ BusinessID>
<!--- SP belongs to another business - check for active grant --->
<cfset qGrant = queryExecute(
"SELECT ID, OwnerBusinessID, EconomicsType, EconomicsValue, EligibilityScope, TimePolicyType, TimePolicyData
FROM ServicePointGrants
WHERE GuestBusinessID = ? AND ServicePointID = ? AND StatusID = 1
LIMIT 1",
[
{ value = BusinessID, cfsqltype = "cf_sql_integer" },
{ value = ServicePointID, cfsqltype = "cf_sql_integer" }
],
{ datasource = "payfrit" }
)>
<cfif qGrant.recordCount EQ 0>
<cfset apiAbort({ "OK": false, "ERROR": "sp_not_accessible", "MESSAGE": "Service point is not accessible to your business.", "DETAIL": "" })>
</cfif>
<!--- Validate time policy --->
<cfif NOT isGrantTimeActive(qGrant.TimePolicyType, qGrant.TimePolicyData)>
<cfset apiAbort({ "OK": false, "ERROR": "grant_time_inactive", "MESSAGE": "Service point access is not available at this time.", "DETAIL": "" })>
</cfif>
<!--- Validate eligibility --->
<cfif NOT checkGrantEligibility(qGrant.EligibilityScope, UserID, qGrant.OwnerBusinessID, BusinessID)>
<cfset apiAbort({ "OK": false, "ERROR": "grant_eligibility_failed", "MESSAGE": "You are not eligible to order at this service point.", "DETAIL": "" })>
</cfif>
<cfset grantID = qGrant.ID>
<cfset grantOwnerBusinessID = qGrant.OwnerBusinessID>
<cfset grantEconomicsType = qGrant.EconomicsType>
<cfset grantEconomicsValue = qGrant.EconomicsValue>
</cfif>
</cfif>
<cfset nowDt = now()>
<cfset newUUID = createUUID()>
<cfset newUUID = createObject("java", "java.util.UUID").randomUUID().toString()>
<!--- Calculate delivery fee: only for delivery orders (OrderTypeID = 3)
OrderTypeID: 0=undecided, 1=dine-in, 2=takeaway, 3=delivery
@ -266,7 +317,7 @@
UUID,
UserID,
BusinessID,
DeliveryMultiplier,
BusinessDeliveryMultiplier,
OrderTypeID,
DeliveryFee,
StatusID,
@ -276,7 +327,11 @@
AddedOn,
LastEditedOn,
SubmittedOn,
ServicePointID
ServicePointID,
GrantID,
GrantOwnerBusinessID,
GrantEconomicsType,
GrantEconomicsValue
) VALUES (
?,
?,
@ -292,6 +347,10 @@
?,
?,
NULL,
?,
?,
?,
?,
?
)
",
@ -305,7 +364,11 @@
{ value = deliveryFee, cfsqltype = "cf_sql_decimal" },
{ value = nowDt, cfsqltype = "cf_sql_timestamp" },
{ value = nowDt, cfsqltype = "cf_sql_timestamp" },
{ value = ServicePointID, cfsqltype = "cf_sql_integer" }
{ value = ServicePointID, cfsqltype = "cf_sql_integer" },
{ value = grantID, cfsqltype = "cf_sql_integer", null = (grantID EQ 0) },
{ value = grantOwnerBusinessID, cfsqltype = "cf_sql_integer", null = (grantOwnerBusinessID EQ 0) },
{ value = grantEconomicsType, cfsqltype = "cf_sql_varchar", null = (len(grantEconomicsType) EQ 0) },
{ value = grantEconomicsValue, cfsqltype = "cf_sql_decimal", null = (grantEconomicsType EQ "" OR grantEconomicsType EQ "none") }
],
{ datasource = "payfrit" }
)>

View file

@ -83,14 +83,14 @@
RequiresChildSelection,
MaxNumSelectionReq
FROM Items
WHERE ID IN (:itemIds)
WHERE ID IN (#inList#)
",
{ itemIds: { value: inList, cfsqltype: "cf_sql_integer", list: true } },
[],
{ datasource = "payfrit" }
)>
<cfloop query="qMeta">
<cfset out.itemMeta[qMeta.ID] = {
<cfset out.itemMeta[qMeta.ItemID] = {
"requires": qMeta.RequiresChildSelection,
"maxSel": qMeta.MaxNumSelectionReq
}>
@ -130,6 +130,7 @@
<cfreturn false>
</cffunction>
<cfinclude template="../grants/_grantUtils.cfm">
<cfset data = readJsonBody()>
<cfset OrderID = val( structKeyExists(data,"OrderID") ? data.OrderID : 0 )>
@ -141,9 +142,10 @@
<cfset qOrder = queryExecute(
"
SELECT o.ID, o.StatusID, o.OrderTypeID, o.BusinessID, o.ServicePointID,
sp.Name AS Name
o.GrantID, o.GrantOwnerBusinessID,
sp.ServicePointName AS Name
FROM Orders o
LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID
LEFT JOIN ServicePoints sp ON sp.ServicePointID = o.ServicePointID
WHERE o.ID = ?
LIMIT 1
",
@ -176,6 +178,22 @@
</cfif>
</cfif>
<!--- SP-SM: Re-validate grant is still active (instant revocation enforcement) --->
<cfif val(qOrder.GrantID) GT 0>
<cfset qGrantCheck = queryExecute(
"SELECT StatusID, TimePolicyType, TimePolicyData
FROM ServicePointGrants WHERE ID = ? LIMIT 1",
[ { value = qOrder.GrantID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfif qGrantCheck.recordCount EQ 0 OR qGrantCheck.StatusID NEQ 1>
<cfset apiAbort({ "OK": false, "ERROR": "grant_revoked", "MESSAGE": "Access to this service point has been revoked.", "DETAIL": "" })>
</cfif>
<cfif NOT isGrantTimeActive(qGrantCheck.TimePolicyType, qGrantCheck.TimePolicyData)>
<cfset apiAbort({ "OK": false, "ERROR": "grant_time_expired", "MESSAGE": "Service point access is no longer available at this time.", "DETAIL": "" })>
</cfif>
</cfif>
<!--- Must have at least one non-deleted root line item --->
<cfset qRoots = queryExecute(
"

View file

@ -80,10 +80,60 @@ if (structKeyExists(data, "onlyActive")) {
})>
</cfloop>
<!--- Query granted service points (SP-SM) --->
<cfinclude template="../grants/_grantUtils.cfm">
<cfset grantedServicePoints = []>
<cfquery name="qGranted" datasource="payfrit">
SELECT
sp.ID,
sp.Name,
sp.TypeID,
sp.Code,
sp.Description,
sp.SortOrder,
sp.IsActive,
g.ID AS GrantID,
g.OwnerBusinessID,
g.EconomicsType,
g.EconomicsValue,
g.EligibilityScope,
g.TimePolicyType,
g.TimePolicyData,
ob.Name AS OwnerBusinessName
FROM ServicePointGrants g
JOIN ServicePoints sp ON sp.ID = g.ServicePointID
JOIN Businesses ob ON ob.ID = g.OwnerBusinessID
WHERE g.GuestBusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#bizId#">
AND g.StatusID = 1
AND sp.IsActive = 1
</cfquery>
<cfloop query="qGranted">
<cfif isGrantTimeActive(qGranted.TimePolicyType, qGranted.TimePolicyData)>
<cfset arrayAppend(grantedServicePoints, {
"ServicePointID" = qGranted.ID,
"Name" = qGranted.Name,
"TypeID" = qGranted.TypeID,
"Code" = qGranted.Code,
"Description" = qGranted.Description,
"SortOrder" = qGranted.SortOrder,
"IsActive" = qGranted.IsActive,
"IsGranted" = true,
"GrantID" = qGranted.GrantID,
"OwnerBusinessID" = qGranted.OwnerBusinessID,
"OwnerBusinessName" = qGranted.OwnerBusinessName,
"EconomicsType" = qGranted.EconomicsType,
"EconomicsValue" = qGranted.EconomicsValue,
"EligibilityScope" = qGranted.EligibilityScope
})>
</cfif>
</cfloop>
<cfoutput>#serializeJSON({
"OK": true,
"ERROR": "",
"BusinessID": bizId,
"COUNT": arrayLen(servicePoints),
"SERVICEPOINTS": servicePoints
"SERVICEPOINTS": servicePoints,
"GRANTED_COUNT": arrayLen(grantedServicePoints),
"GRANTED_SERVICEPOINTS": grantedServicePoints
})#</cfoutput>

View file

@ -64,7 +64,7 @@ try {
qBusiness = queryExecute("
SELECT StripeAccountID AS BusinessStripeAccountID, StripeOnboardingComplete AS BusinessStripeOnboardingComplete, Name AS BusinessName
FROM Businesses
WHERE ID = :businessID
WHERE BusinessID = :businessID
", { businessID: businessID }, { datasource: "payfrit" });
if (qBusiness.recordCount == 0) {
@ -73,16 +73,33 @@ try {
abort;
}
// Get order's delivery fee (if delivery order)
// Get order's delivery fee and grant economics (if applicable)
qOrder = queryExecute("
SELECT DeliveryFee, OrderTypeID
SELECT OrderDeliveryFee, OrderTypeID, GrantID, GrantOwnerBusinessID, GrantEconomicsType, GrantEconomicsValue
FROM Orders
WHERE ID = :orderID
WHERE OrderID = :orderID
", { orderID: orderID }, { datasource: "payfrit" });
deliveryFee = 0;
if (qOrder.recordCount > 0 && qOrder.OrderTypeID == 3) {
deliveryFee = val(qOrder.DeliveryFee);
deliveryFee = val(qOrder.OrderDeliveryFee);
}
// SP-SM: Resolve grant economics
grantOwnerFeeCents = 0;
grantOwnerBusinessID = 0;
grantID = 0;
if (qOrder.recordCount > 0 && val(qOrder.GrantID) > 0) {
grantID = val(qOrder.GrantID);
grantOwnerBusinessID = val(qOrder.GrantOwnerBusinessID);
grantEconType = qOrder.GrantEconomicsType ?: "none";
grantEconValue = val(qOrder.GrantEconomicsValue);
if (grantEconType == "flat_fee" && grantEconValue > 0) {
grantOwnerFeeCents = round(grantEconValue * 100);
} else if (grantEconType == "percent_of_orders" && grantEconValue > 0) {
grantOwnerFeeCents = round(subtotal * (grantEconValue / 100) * 100);
}
}
// For testing, allow orders even without Stripe Connect setup
@ -118,12 +135,21 @@ try {
httpService.addParam(type="formfield", name="automatic_payment_methods[enabled]", value="true");
if (hasStripeConnect) {
httpService.addParam(type="formfield", name="application_fee_amount", value=totalPlatformFeeCents);
// SP-SM: Add grant owner fee to platform fee (Payfrit collects, then transfers to owner)
effectivePlatformFeeCents = totalPlatformFeeCents + grantOwnerFeeCents;
httpService.addParam(type="formfield", name="application_fee_amount", value=effectivePlatformFeeCents);
httpService.addParam(type="formfield", name="transfer_data[destination]", value=qBusiness.BusinessStripeAccountID);
}
httpService.addParam(type="formfield", name="metadata[order_id]", value=orderID);
httpService.addParam(type="formfield", name="metadata[business_id]", value=businessID);
// SP-SM: Store grant metadata for webhook transfer
if (grantOwnerFeeCents > 0) {
httpService.addParam(type="formfield", name="metadata[grant_id]", value=grantID);
httpService.addParam(type="formfield", name="metadata[grant_owner_business_id]", value=grantOwnerBusinessID);
httpService.addParam(type="formfield", name="metadata[grant_owner_fee_cents]", value=grantOwnerFeeCents);
}
httpService.addParam(type="formfield", name="description", value="Order ###orderID# at #qBusiness.BusinessName#");
if (customerEmail != "") {
@ -169,7 +195,8 @@ try {
"DELIVERY_FEE": deliveryFee,
"PAYFRIT_FEE": payfritCustomerFee,
"CARD_FEE": cardFee,
"TOTAL": totalCustomerPays
"TOTAL": totalCustomerPays,
"GRANT_OWNER_FEE_CENTS": grantOwnerFeeCents
};
response["STRIPE_CONNECT_ENABLED"] = hasStripeConnect;

View file

@ -158,6 +158,51 @@ try {
writeLog(file="stripe_webhooks", text="Worker transfer error: #transferErr.message#");
}
// === SP-SM: GRANT OWNER TRANSFER ===
try {
grantOwnerFeeCents = val(eventData.metadata.grant_owner_fee_cents ?: 0);
grantOwnerBizID = val(eventData.metadata.grant_owner_business_id ?: 0);
grantMetaID = val(eventData.metadata.grant_id ?: 0);
if (grantOwnerFeeCents > 0 && grantOwnerBizID > 0) {
// Look up owner business Stripe account
qOwnerBiz = queryExecute("
SELECT StripeAccountID FROM Businesses WHERE ID = :bizID
", { bizID: grantOwnerBizID });
ownerStripeAcct = qOwnerBiz.recordCount > 0 ? (qOwnerBiz.StripeAccountID ?: "") : "";
if (len(trim(ownerStripeAcct)) > 0) {
stripeSecretKey = application.stripeSecretKey ?: "";
httpGrantTransfer = new http();
httpGrantTransfer.setMethod("POST");
httpGrantTransfer.setUrl("https://api.stripe.com/v1/transfers");
httpGrantTransfer.setUsername(stripeSecretKey);
httpGrantTransfer.setPassword("");
httpGrantTransfer.addParam(type="formfield", name="amount", value=grantOwnerFeeCents);
httpGrantTransfer.addParam(type="formfield", name="currency", value="usd");
httpGrantTransfer.addParam(type="formfield", name="destination", value=ownerStripeAcct);
httpGrantTransfer.addParam(type="formfield", name="metadata[grant_id]", value=grantMetaID);
httpGrantTransfer.addParam(type="formfield", name="metadata[order_id]", value=orderID);
httpGrantTransfer.addParam(type="formfield", name="metadata[type]", value="grant_owner_fee");
httpGrantTransfer.addParam(type="header", name="Idempotency-Key", value="grant-transfer-#orderID#-#grantMetaID#");
grantTransferResult = httpGrantTransfer.send().getPrefix();
grantTransferData = deserializeJSON(grantTransferResult.fileContent);
if (structKeyExists(grantTransferData, "id")) {
writeLog(file="stripe_webhooks", text="Grant owner transfer #grantTransferData.id# created: #grantOwnerFeeCents# cents to biz #grantOwnerBizID# for order #orderID#");
} else {
writeLog(file="stripe_webhooks", text="Grant owner transfer failed for order #orderID#: #grantTransferData.error.message ?: 'unknown'#");
}
} else {
writeLog(file="stripe_webhooks", text="Grant owner biz #grantOwnerBizID# has no Stripe account - transfer skipped for order #orderID#");
}
}
} catch (any grantTransferErr) {
writeLog(file="stripe_webhooks", text="Grant owner transfer error: #grantTransferErr.message#");
}
break;
case "payment_intent.payment_failed":
@ -301,6 +346,80 @@ try {
writeLog(file="stripe_webhooks", text="Activation completed via payment for user #metaUserID#");
}
// Tip payment completed
if (metaType == "tip") {
metaTipID = val(sessionMetadata.tip_id ?: 0);
metaWorkerID = val(sessionMetadata.worker_user_id ?: 0);
tipPaymentIntent = eventData.payment_intent ?: "";
if (metaTipID > 0) {
// Mark tip as paid
queryExecute("
UPDATE Tips
SET Status = 'paid',
PaidOn = NOW(),
StripePaymentIntentID = :piID
WHERE ID = :tipID AND Status = 'pending'
", {
piID: tipPaymentIntent,
tipID: metaTipID
}, { datasource: "payfrit" });
writeLog(file="stripe_webhooks", text="Tip ##metaTipID# paid (PI: #tipPaymentIntent#)");
// Transfer tip to worker's Stripe Connect account
if (metaWorkerID > 0) {
qTipWorker = queryExecute("
SELECT StripeConnectedAccountID FROM Users WHERE ID = :userID
", { userID: metaWorkerID }, { datasource: "payfrit" });
tipWorkerAcct = qTipWorker.recordCount > 0 ? (qTipWorker.StripeConnectedAccountID ?: "") : "";
if (len(trim(tipWorkerAcct)) > 0) {
qTipAmt = queryExecute("
SELECT AmountCents FROM Tips WHERE ID = :tipID
", { tipID: metaTipID }, { datasource: "payfrit" });
if (qTipAmt.recordCount > 0 && qTipAmt.AmountCents > 0) {
stripeSecretKey = application.stripeSecretKey ?: "";
httpTipTransfer = new http();
httpTipTransfer.setMethod("POST");
httpTipTransfer.setUrl("https://api.stripe.com/v1/transfers");
httpTipTransfer.setUsername(stripeSecretKey);
httpTipTransfer.setPassword("");
httpTipTransfer.addParam(type="formfield", name="amount", value=qTipAmt.AmountCents);
httpTipTransfer.addParam(type="formfield", name="currency", value="usd");
httpTipTransfer.addParam(type="formfield", name="destination", value=tipWorkerAcct);
httpTipTransfer.addParam(type="formfield", name="metadata[type]", value="tip");
httpTipTransfer.addParam(type="formfield", name="metadata[tip_id]", value=metaTipID);
httpTipTransfer.addParam(type="formfield", name="metadata[worker_user_id]", value=metaWorkerID);
httpTipTransfer.addParam(type="header", name="Idempotency-Key", value="tip-transfer-#metaTipID#");
tipTransferResult = httpTipTransfer.send().getPrefix();
tipTransferData = deserializeJSON(tipTransferResult.fileContent);
if (structKeyExists(tipTransferData, "id")) {
queryExecute("
UPDATE Tips
SET Status = 'transferred', StripeTransferID = :transferID
WHERE ID = :tipID
", {
transferID: tipTransferData.id,
tipID: metaTipID
}, { datasource: "payfrit" });
writeLog(file="stripe_webhooks", text="Tip ##metaTipID# transferred (#tipTransferData.id#) to worker ##metaWorkerID#");
} else {
writeLog(file="stripe_webhooks", text="Tip transfer failed for tip ##metaTipID#: #tipTransferData.error.message ?: 'unknown'#");
}
}
} else {
writeLog(file="stripe_webhooks", text="Tip ##metaTipID#: worker ##metaWorkerID# has no Stripe Connect account - transfer skipped");
}
}
}
}
} catch (any checkoutErr) {
writeLog(file="stripe_webhooks", text="Checkout session error: #checkoutErr.message#");
}

View file

@ -31,14 +31,6 @@
</button>
</div>
<div class="business-info" style="padding: 12px 16px; border-bottom: 1px solid var(--gray-200);">
<div class="business-avatar" id="businessAvatar">B</div>
<div class="business-details">
<div class="business-name" id="businessName">Loading...</div>
<div class="business-status online">Online</div>
</div>
</div>
<nav class="sidebar-nav">
<a href="#dashboard" class="nav-item active" data-page="dashboard">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -90,6 +82,12 @@
</svg>
<span>Services</span>
</a>
<a href="#sp-sharing" class="nav-item" data-page="sp-sharing">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5"/>
</svg>
<span>SP Sharing</span>
</a>
<a href="#admin-tasks" class="nav-item" data-page="admin-tasks">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
@ -105,6 +103,33 @@
</a>
</nav>
<div class="sidebar-footer">
<div class="business-info">
<div class="business-avatar" id="businessAvatar">B</div>
<div class="business-details">
<div class="business-name" id="businessName">Loading...</div>
<div class="business-status online">Online</div>
</div>
</div>
<a href="#" class="nav-item" onclick="Portal.switchBusiness(); return false;">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 3h5v5M8 3H3v5M3 16v5h5M21 16v5h-5M7 12h10"/>
</svg>
<span>Switch Business</span>
</a>
<a href="#" class="nav-item" onclick="Portal.addNewBusiness(); return false;">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/>
</svg>
<span>Add New Business</span>
</a>
<a href="#logout" class="nav-item logout" data-page="logout">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/>
</svg>
<span>Logout</span>
</a>
</div>
</aside>
<!-- Main Content -->
@ -120,28 +145,10 @@
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
</svg>
</button>
<div class="user-menu" style="position: relative;">
<button class="user-btn" id="userBtn" onclick="Portal.toggleUserMenu()">
<div class="user-menu">
<button class="user-btn" id="userBtn">
<span class="user-avatar" id="userAvatar">U</span>
</button>
<div id="userDropdown" style="display: none; position: absolute; right: 0; top: 44px; background: #fff; border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,0.15); min-width: 200px; z-index: 1000; overflow: hidden;">
<a href="#settings" onclick="Portal.navigate('settings'); Portal.toggleUserMenu();" style="display: flex; align-items: center; gap: 8px; padding: 12px 16px; color: var(--gray-700); text-decoration: none; font-size: 14px; border-bottom: 1px solid var(--gray-200);">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
Settings
</a>
<a href="#" onclick="Portal.switchBusiness(); Portal.toggleUserMenu(); return false;" style="display: flex; align-items: center; gap: 8px; padding: 12px 16px; color: var(--gray-700); text-decoration: none; font-size: 14px; border-bottom: 1px solid var(--gray-200);">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 3h5v5M8 3H3v5M3 16v5h5M21 16v5h-5M7 12h10"/></svg>
Switch Business
</a>
<a href="#" onclick="Portal.addNewBusiness(); Portal.toggleUserMenu(); return false;" style="display: flex; align-items: center; gap: 8px; padding: 12px 16px; color: var(--gray-700); text-decoration: none; font-size: 14px; border-bottom: 1px solid var(--gray-200);">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
Add New Business
</a>
<a href="#" onclick="Portal.logout(); return false;" style="display: flex; align-items: center; gap: 8px; padding: 12px 16px; color: var(--danger, #ef4444); text-decoration: none; font-size: 14px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/></svg>
Logout
</a>
</div>
</div>
</div>
</header>
@ -510,6 +517,87 @@
</div>
</section>
<!-- SP Sharing Page -->
<section class="page" id="page-sp-sharing">
<div class="sp-sharing-container">
<!-- Owner Section -->
<div class="card" id="spSharingOwnerCard">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center">
<h3>Grants You've Created</h3>
<button class="btn btn-primary btn-sm" onclick="Portal.showInviteBusinessModal()">Invite Business</button>
</div>
<div class="card-body" id="ownerGrantsList">
<div class="loading-text">Loading...</div>
</div>
</div>
<!-- Guest Section -->
<div class="card" id="spSharingGuestCard">
<div class="card-header">
<h3>Invites &amp; Active Grants</h3>
</div>
<div class="card-body" id="guestGrantsList">
<div class="loading-text">Loading...</div>
</div>
</div>
</div>
<!-- Invite Business Modal -->
<div class="modal-overlay" id="inviteBusinessModal" style="display:none">
<div class="modal" style="max-width:500px">
<div class="modal-header">
<h3>Invite Business to Service Point</h3>
<button class="modal-close" onclick="Portal.closeInviteModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Search Business</label>
<input type="text" id="inviteBizSearch" class="form-input" placeholder="Business name or ID..." oninput="Portal.searchBusinessForInvite()">
<div id="inviteBizResults" style="margin-top:8px"></div>
</div>
<div class="form-group" id="inviteBizSelected" style="display:none">
<label>Selected Business</label>
<div id="inviteBizSelectedName" class="form-input" style="background:var(--bg-secondary)"></div>
</div>
<div class="form-group">
<label>Service Point</label>
<select id="inviteSPSelect" class="form-input"></select>
</div>
<div class="form-group">
<label>Economics</label>
<select id="inviteEconType" class="form-input" onchange="Portal.toggleEconValue()">
<option value="none">None</option>
<option value="flat_fee">Flat Fee ($/order)</option>
<option value="percent_of_orders">Percentage of Orders</option>
</select>
<input type="number" id="inviteEconValue" class="form-input" placeholder="0" step="0.01" min="0" style="display:none;margin-top:4px">
</div>
<div class="form-group">
<label>Eligibility</label>
<select id="inviteEligibility" class="form-input">
<option value="public">Public (anyone)</option>
<option value="employees">Employees only</option>
<option value="guests">Guests only</option>
<option value="internal">Internal (owner's employees)</option>
</select>
</div>
<div class="form-group">
<label>Time Policy</label>
<select id="inviteTimePolicy" class="form-input">
<option value="always">Always</option>
<option value="schedule">Schedule</option>
<option value="date_range">Date Range</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="Portal.closeInviteModal()">Cancel</button>
<button class="btn btn-primary" onclick="Portal.submitGrantInvite()">Send Invite</button>
</div>
</div>
</div>
</section>
<!-- Settings Page -->
<section class="page" id="page-settings">
<div class="settings-grid">
@ -578,9 +666,7 @@
<div>
<label style="display: block; margin-bottom: 8px; font-weight: 500;">Header Image</label>
<p style="color: #666; font-size: 13px; margin-bottom: 8px;">Displayed at the top of your menu. Recommended: 1200x400px</p>
<div id="headerPreviewWrapper" style="max-width: 1200px; border-radius: 8px; margin-bottom: 8px; overflow: hidden; background-color: #fff; display: none;">
<img id="headerPreview" src="" alt="" style="width: 100%; display: block;">
</div>
<div id="headerPreview" style="width: 100%; height: 120px; background: #333; border-radius: 8px; margin-bottom: 8px; background-size: contain; background-position: center; background-repeat: no-repeat;"></div>
<button class="btn btn-secondary" onclick="Portal.uploadHeader()" style="width: 100%;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 8px;">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
@ -592,7 +678,7 @@
<label style="display: block; margin-bottom: 8px; font-weight: 500;">Brand Color</label>
<p style="color: #666; font-size: 13px; margin-bottom: 8px;">Used for accents and fallback backgrounds</p>
<div style="display: flex; gap: 12px; align-items: center;">
<div id="brandColorSwatch" style="min-width: 40px; width: 40px; min-height: 40px; height: 40px; border-radius: 8px; background-color: #1B4D3E; border: 2px solid rgba(0,0,0,0.1); cursor: pointer; flex-shrink: 0;" onclick="Portal.showBrandColorPicker()"></div>
<span id="brandColorSwatch" style="width: 40px; height: 40px; border-radius: 8px; background: #1B4D3E; border: 2px solid rgba(255,255,255,0.2); cursor: pointer;" onclick="Portal.showBrandColorPicker()"></span>
<button class="btn btn-secondary" onclick="Portal.showBrandColorPicker()" style="flex: 1;">
Change Color
</button>

View file

@ -111,40 +111,20 @@ const Portal = {
if (data.OK && data.BUSINESS) {
const biz = data.BUSINESS;
this.businessData = biz; // Store for later use
const bizName = biz.Name || 'Business';
const bizInitial = bizName.charAt(0).toUpperCase();
document.getElementById('businessName').textContent = bizName;
document.getElementById('businessAvatar').textContent = bizInitial;
document.getElementById('userAvatar').textContent = bizInitial;
document.getElementById('businessName').textContent = biz.Name || 'Business';
document.getElementById('businessAvatar').textContent = (biz.Name || 'B').charAt(0).toUpperCase();
document.getElementById('userAvatar').textContent = 'U';
} else {
this.businessData = null;
document.getElementById('businessName').textContent = 'Business #' + this.config.businessId;
document.getElementById('businessAvatar').textContent = 'B';
document.getElementById('userAvatar').textContent = 'B';
document.getElementById('userAvatar').textContent = 'U';
}
} catch (err) {
console.error('[Portal] Business info error:', err);
document.getElementById('businessName').textContent = 'Business #' + this.config.businessId;
document.getElementById('businessAvatar').textContent = 'B';
document.getElementById('userAvatar').textContent = 'B';
}
},
// Toggle user dropdown menu
toggleUserMenu() {
const dd = document.getElementById('userDropdown');
if (!dd) return;
const showing = dd.style.display !== 'none';
dd.style.display = showing ? 'none' : 'block';
if (!showing) {
// Close on outside click
const close = (e) => {
if (!dd.contains(e.target) && e.target.id !== 'userBtn' && !e.target.closest('#userBtn')) {
dd.style.display = 'none';
document.removeEventListener('click', close);
}
};
setTimeout(() => document.addEventListener('click', close), 0);
document.getElementById('userAvatar').textContent = 'U';
}
},
@ -153,7 +133,6 @@ const Portal = {
localStorage.removeItem('payfrit_portal_token');
localStorage.removeItem('payfrit_portal_userid');
localStorage.removeItem('payfrit_portal_business');
localStorage.removeItem('payfrit_portal_firstname');
window.location.href = BASE_PATH + '/portal/login.html';
},
@ -239,6 +218,7 @@ const Portal = {
beacons: 'Beacons',
services: 'Service Requests',
'admin-tasks': 'Task Admin',
'sp-sharing': 'SP Sharing',
settings: 'Settings'
};
document.getElementById('pageTitle').textContent = titles[page] || page;
@ -279,6 +259,9 @@ const Portal = {
case 'admin-tasks':
await this.loadAdminTasksPage();
break;
case 'sp-sharing':
await this.loadSPSharingPage();
break;
case 'settings':
await this.loadSettings();
break;
@ -745,43 +728,35 @@ const Portal = {
const data = await response.json();
if (data.OK && data.BUSINESS) {
// Normalize all keys to uppercase for consistent access
// (Lucee serializeJSON casing varies by server config)
const raw = data.BUSINESS;
const biz = {};
Object.keys(raw).forEach(k => { biz[k.toUpperCase()] = raw[k]; });
const biz = data.BUSINESS;
this.currentBusiness = biz;
// Populate form fields
document.getElementById('settingBusinessName').value = biz.NAME || biz.BUSINESSNAME || '';
document.getElementById('settingPhone').value = biz.PHONE || biz.BUSINESSPHONE || '';
document.getElementById('settingTaxRate').value = biz.TAXRATEPERCENT || '';
document.getElementById('settingAddressLine1').value = biz.LINE1 || biz.ADDRESSLINE1 || '';
document.getElementById('settingCity').value = biz.CITY || biz.ADDRESSCITY || '';
document.getElementById('settingState').value = biz.ADDRESSSTATE || '';
document.getElementById('settingZip').value = biz.ADDRESSZIP || '';
// Populate form fields (Lucee serializes all keys as uppercase)
document.getElementById('settingName').value = biz.BUSINESSNAME || biz.Name || '';
document.getElementById('settingPhone').value = biz.BUSINESSPHONE || biz.Phone || '';
document.getElementById('settingTaxRate').value = biz.TAXRATEPERCENT || biz.TaxRatePercent || '';
document.getElementById('settingLine1').value = biz.ADDRESSLINE1 || biz.Line1 || '';
document.getElementById('settingCity').value = biz.ADDRESSCITY || biz.City || '';
document.getElementById('settingState').value = biz.ADDRESSSTATE || biz.AddressState || '';
document.getElementById('settingZip').value = biz.ADDRESSZIP || biz.AddressZip || '';
// Load brand color if set (DB stores without #, CSS needs it)
const brandColor = biz.BRANDCOLOR || '';
// Load brand color if set
const brandColor = biz.BRANDCOLOR || biz.BrandColor;
if (brandColor) {
this.brandColor = brandColor.charAt(0) === '#' ? brandColor : '#' + brandColor;
this.brandColor = brandColor;
const swatch = document.getElementById('brandColorSwatch');
if (swatch) swatch.style.backgroundColor = this.brandColor;
if (swatch) swatch.style.background = brandColor;
}
// Load header preview
const headerPreview = document.getElementById('headerPreview');
const headerWrapper = document.getElementById('headerPreviewWrapper');
const headerUrl = biz.HEADERIMAGEURL || '';
const headerUrl = biz.HEADERIMAGEURL || biz.HeaderImageURL;
if (headerPreview && headerUrl) {
headerPreview.onload = function() {
if (headerWrapper) headerWrapper.style.display = 'block';
};
headerPreview.src = `${BASE_PATH}${headerUrl}?t=${Date.now()}`;
headerPreview.style.backgroundImage = `url(${headerUrl}?t=${Date.now()})`;
}
// Render hours editor
this.renderHoursEditor(biz.HOURSDETAIL || biz.BUSINESSHOURSDETAIL || []);
this.renderHoursEditor(biz.BUSINESSHOURSDETAIL || biz.HoursDetail || []);
}
} catch (err) {
console.error('[Portal] Error loading business info:', err);
@ -870,10 +845,10 @@ const Portal = {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
Name: document.getElementById('settingBusinessName').value,
Name: document.getElementById('settingName').value,
Phone: document.getElementById('settingPhone').value,
TaxRatePercent: parseFloat(document.getElementById('settingTaxRate').value) || 0,
Line1: document.getElementById('settingAddressLine1').value,
Line1: document.getElementById('settingLine1').value,
City: document.getElementById('settingCity').value,
State: document.getElementById('settingState').value,
Zip: document.getElementById('settingZip').value
@ -1132,7 +1107,7 @@ const Portal = {
openCustomerPreview() {
const businessId = this.config.businessId;
const businessName = encodeURIComponent(
this.currentBusiness?.NAME || this.currentBusiness?.BUSINESSNAME || 'Preview'
this.currentBusiness?.BUSINESSNAME || this.currentBusiness?.Name || 'Preview'
);
const deepLink = `payfrit://open?businessId=${businessId}&businessName=${businessName}`;
window.location.href = deepLink;
@ -1192,12 +1167,8 @@ const Portal = {
this.toast('Header uploaded successfully!', 'success');
// Update preview using the URL from the response
const preview = document.getElementById('headerPreview');
const wrapper = document.getElementById('headerPreviewWrapper');
if (preview && data.HEADERURL) {
preview.onload = function() {
if (wrapper) wrapper.style.display = 'block';
};
preview.src = `${BASE_PATH}${data.HEADERURL}?t=${Date.now()}`;
preview.style.backgroundImage = `url(${BASE_PATH}${data.HEADERURL}?t=${Date.now()})`;
}
} else {
this.toast(data.MESSAGE || 'Failed to upload header', 'error');
@ -1275,7 +1246,7 @@ const Portal = {
if (data.OK) {
this.brandColor = color;
const swatch = document.getElementById('brandColorSwatch');
if (swatch) swatch.style.backgroundColor = color;
if (swatch) swatch.style.background = color;
this.closeModal();
this.toast('Brand color saved!', 'success');
} else {
@ -3810,6 +3781,215 @@ const Portal = {
console.error('[Portal] Error submitting rating:', err);
this.toast('Error submitting rating', 'error');
}
},
// ========== SP SHARING ==========
_spSharingSelectedBizID: 0,
_spSharingSearchTimer: null,
async loadSPSharingPage() {
const bizId = this.config.businessId;
if (!bizId) return;
const STATUS_LABELS = { 0: 'Pending', 1: 'Active', 2: 'Declined', 3: 'Revoked' };
const STATUS_COLORS = { 0: '#f59e0b', 1: '#10b981', 2: '#ef4444', 3: '#ef4444' };
// Load owner grants
try {
const ownerData = await this.api('/api/grants/list.cfm', { BusinessID: bizId, Role: 'owner' });
const ownerEl = document.getElementById('ownerGrantsList');
if (ownerData.OK && ownerData.Grants.length) {
let html = '<table class="data-table"><thead><tr><th>Guest Business</th><th>Service Point</th><th>Economics</th><th>Status</th><th>Actions</th></tr></thead><tbody>';
ownerData.Grants.forEach(g => {
let econ = g.EconomicsType === 'flat_fee' ? `$${parseFloat(g.EconomicsValue).toFixed(2)}/order` : g.EconomicsType === 'percent_of_orders' ? `${parseFloat(g.EconomicsValue).toFixed(1)}%` : 'None';
let actions = '';
if (g.StatusID === 0 || g.StatusID === 1) {
actions = `<button class="btn btn-sm btn-danger" onclick="Portal.revokeGrant(${g.GrantID})">Revoke</button>`;
}
html += `<tr><td>${this.escapeHtml(g.GuestBusinessName)} (#${g.GuestBusinessID})</td><td>${this.escapeHtml(g.ServicePointName)}</td><td>${econ}</td><td><span style="color:${STATUS_COLORS[g.StatusID]}">${STATUS_LABELS[g.StatusID]}</span></td><td>${actions}</td></tr>`;
});
html += '</tbody></table>';
ownerEl.innerHTML = html;
} else {
ownerEl.innerHTML = '<p style="color:#999;text-align:center;padding:20px">No grants created yet. Use "Invite Business" to share your service points.</p>';
}
} catch (err) {
console.error('[Portal] Error loading owner grants:', err);
}
// Load guest grants
try {
const guestData = await this.api('/api/grants/list.cfm', { BusinessID: bizId, Role: 'guest' });
const guestEl = document.getElementById('guestGrantsList');
if (guestData.OK && guestData.Grants.length) {
let html = '<table class="data-table"><thead><tr><th>Owner Business</th><th>Service Point</th><th>Economics</th><th>Eligibility</th><th>Status</th><th>Actions</th></tr></thead><tbody>';
guestData.Grants.forEach(g => {
let econ = g.EconomicsType === 'flat_fee' ? `$${parseFloat(g.EconomicsValue).toFixed(2)}/order` : g.EconomicsType === 'percent_of_orders' ? `${parseFloat(g.EconomicsValue).toFixed(1)}%` : 'None';
let actions = '';
if (g.StatusID === 0) {
actions = `<button class="btn btn-sm btn-primary" onclick="Portal.acceptGrant(${g.GrantID})">Accept</button> <button class="btn btn-sm" onclick="Portal.declineGrant(${g.GrantID})">Decline</button>`;
}
html += `<tr><td>${this.escapeHtml(g.OwnerBusinessName)} (#${g.OwnerBusinessID})</td><td>${this.escapeHtml(g.ServicePointName)}</td><td>${econ}</td><td>${g.EligibilityScope}</td><td><span style="color:${STATUS_COLORS[g.StatusID]}">${STATUS_LABELS[g.StatusID]}</span></td><td>${actions}</td></tr>`;
});
html += '</tbody></table>';
guestEl.innerHTML = html;
} else {
guestEl.innerHTML = '<p style="color:#999;text-align:center;padding:20px">No invites or active grants from other businesses.</p>';
}
} catch (err) {
console.error('[Portal] Error loading guest grants:', err);
}
// Pre-load service points for the invite modal
try {
const spData = await this.api('/api/servicepoints/list.cfm', { BusinessID: bizId });
const select = document.getElementById('inviteSPSelect');
if (spData.OK && spData.SERVICEPOINTS) {
select.innerHTML = spData.SERVICEPOINTS.map(sp => `<option value="${sp.ServicePointID}">${this.escapeHtml(sp.Name)}</option>`).join('');
}
} catch (err) {
console.error('[Portal] Error loading service points:', err);
}
},
showInviteBusinessModal() {
this._spSharingSelectedBizID = 0;
document.getElementById('inviteBizSearch').value = '';
document.getElementById('inviteBizResults').innerHTML = '';
document.getElementById('inviteBizSelected').style.display = 'none';
document.getElementById('inviteEconType').value = 'none';
document.getElementById('inviteEconValue').style.display = 'none';
document.getElementById('inviteEconValue').value = '';
document.getElementById('inviteEligibility').value = 'public';
document.getElementById('inviteTimePolicy').value = 'always';
document.getElementById('inviteBusinessModal').style.display = 'flex';
},
closeInviteModal() {
document.getElementById('inviteBusinessModal').style.display = 'none';
},
toggleEconValue() {
const type = document.getElementById('inviteEconType').value;
const valEl = document.getElementById('inviteEconValue');
valEl.style.display = (type === 'none') ? 'none' : 'block';
if (type === 'flat_fee') valEl.placeholder = 'Dollar amount per order';
else if (type === 'percent_of_orders') valEl.placeholder = 'Percentage (e.g., 10)';
},
async searchBusinessForInvite() {
clearTimeout(this._spSharingSearchTimer);
const query = document.getElementById('inviteBizSearch').value.trim();
if (query.length < 2) {
document.getElementById('inviteBizResults').innerHTML = '';
return;
}
this._spSharingSearchTimer = setTimeout(async () => {
try {
const data = await this.api('/api/grants/searchBusiness.cfm', {
Query: query,
ExcludeBusinessID: this.config.businessId
});
const resultsEl = document.getElementById('inviteBizResults');
if (data.OK && data.Businesses.length) {
resultsEl.innerHTML = data.Businesses.map(b =>
`<div style="padding:6px 8px;cursor:pointer;border-radius:4px;margin-bottom:2px" class="search-result-item" onmouseover="this.style.background='var(--bg-hover,#f0f0f0)'" onmouseout="this.style.background=''" onclick="Portal.selectInviteBiz(${b.BusinessID},'${this.escapeHtml(b.Name).replace(/'/g, "\\'")}')">${this.escapeHtml(b.Name)} (#${b.BusinessID})</div>`
).join('');
} else {
resultsEl.innerHTML = '<div style="padding:6px;color:#999">No businesses found</div>';
}
} catch (err) {
console.error('[Portal] Business search error:', err);
}
}, 300);
},
selectInviteBiz(bizID, name) {
this._spSharingSelectedBizID = bizID;
document.getElementById('inviteBizResults').innerHTML = '';
document.getElementById('inviteBizSearch').value = '';
document.getElementById('inviteBizSelected').style.display = 'block';
document.getElementById('inviteBizSelectedName').textContent = `${name} (#${bizID})`;
},
async submitGrantInvite() {
if (!this._spSharingSelectedBizID) {
this.toast('Please select a business to invite', 'error');
return;
}
const spID = parseInt(document.getElementById('inviteSPSelect').value);
if (!spID) {
this.toast('Please select a service point', 'error');
return;
}
const payload = {
OwnerBusinessID: this.config.businessId,
GuestBusinessID: this._spSharingSelectedBizID,
ServicePointID: spID,
EconomicsType: document.getElementById('inviteEconType').value,
EconomicsValue: parseFloat(document.getElementById('inviteEconValue').value) || 0,
EligibilityScope: document.getElementById('inviteEligibility').value,
TimePolicyType: document.getElementById('inviteTimePolicy').value
};
try {
const data = await this.api('/api/grants/create.cfm', payload);
if (data.OK) {
this.toast('Invite sent successfully', 'success');
this.closeInviteModal();
await this.loadSPSharingPage();
} else {
this.toast(data.MESSAGE || data.ERROR || 'Error creating grant', 'error');
}
} catch (err) {
this.toast('Error sending invite', 'error');
}
},
async revokeGrant(grantID) {
if (!confirm('Revoke this grant? All access will stop immediately.')) return;
try {
const data = await this.api('/api/grants/revoke.cfm', { GrantID: grantID });
if (data.OK) {
this.toast('Grant revoked', 'success');
await this.loadSPSharingPage();
} else {
this.toast(data.MESSAGE || 'Error revoking grant', 'error');
}
} catch (err) {
this.toast('Error revoking grant', 'error');
}
},
async acceptGrant(grantID) {
try {
const data = await this.api('/api/grants/accept.cfm', { GrantID: grantID });
if (data.OK) {
this.toast('Grant accepted! Service point access is now active.', 'success');
await this.loadSPSharingPage();
} else {
this.toast(data.MESSAGE || 'Error accepting grant', 'error');
}
} catch (err) {
this.toast('Error accepting grant', 'error');
}
},
async declineGrant(grantID) {
if (!confirm('Decline this invite?')) return;
try {
const data = await this.api('/api/grants/decline.cfm', { GrantID: grantID });
if (data.OK) {
this.toast('Grant declined', 'success');
await this.loadSPSharingPage();
} else {
this.toast(data.MESSAGE || 'Error declining grant', 'error');
}
} catch (err) {
this.toast('Error declining grant', 'error');
}
}
};