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:
parent
82293c944d
commit
f52d14bb7e
17 changed files with 1638 additions and 114 deletions
|
|
@ -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/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/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/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/auth/completeProfile.cfm", request._api_path)) request._api_isPublic = true;
|
||||||
|
|
||||||
if (findNoCase("/api/businesses/list.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)
|
// App info endpoints (public, no auth needed)
|
||||||
if (findNoCase("/api/app/about.cfm", request._api_path)) request._api_isPublic = true;
|
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
|
// Stripe endpoints
|
||||||
if (findNoCase("/api/stripe/onboard.cfm", request._api_path)) request._api_isPublic = true;
|
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/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/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/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)
|
// Carry session values into request (if present)
|
||||||
|
|
|
||||||
136
api/grants/_grantUtils.cfm
Normal file
136
api/grants/_grantUtils.cfm
Normal 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
93
api/grants/accept.cfm
Normal 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
167
api/grants/create.cfm
Normal 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
70
api/grants/decline.cfm
Normal 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
108
api/grants/get.cfm
Normal 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
112
api/grants/list.cfm
Normal 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
76
api/grants/revoke.cfm
Normal 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>
|
||||||
57
api/grants/searchBusiness.cfm
Normal file
57
api/grants/searchBusiness.cfm
Normal 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
153
api/grants/update.cfm
Normal 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>
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
UUID,
|
UUID,
|
||||||
UserID,
|
UserID,
|
||||||
BusinessID,
|
BusinessID,
|
||||||
DeliveryMultiplier,
|
BusinessDeliveryMultiplier,
|
||||||
OrderTypeID,
|
OrderTypeID,
|
||||||
DeliveryFee,
|
DeliveryFee,
|
||||||
StatusID,
|
StatusID,
|
||||||
|
|
@ -47,7 +47,11 @@
|
||||||
AddedOn,
|
AddedOn,
|
||||||
LastEditedOn,
|
LastEditedOn,
|
||||||
SubmittedOn,
|
SubmittedOn,
|
||||||
ServicePointID
|
ServicePointID,
|
||||||
|
GrantID,
|
||||||
|
GrantOwnerBusinessID,
|
||||||
|
GrantEconomicsType,
|
||||||
|
GrantEconomicsValue
|
||||||
FROM Orders
|
FROM Orders
|
||||||
WHERE ID = ?
|
WHERE ID = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
|
|
@ -73,7 +77,7 @@
|
||||||
"UUID": qOrder.UUID ?: "",
|
"UUID": qOrder.UUID ?: "",
|
||||||
"UserID": val(qOrder.UserID),
|
"UserID": val(qOrder.UserID),
|
||||||
"BusinessID": val(qOrder.BusinessID),
|
"BusinessID": val(qOrder.BusinessID),
|
||||||
"DeliveryMultiplier": val(qOrder.DeliveryMultiplier),
|
"DeliveryMultiplier": val(qOrder.BusinessDeliveryMultiplier),
|
||||||
"OrderTypeID": val(qOrder.OrderTypeID),
|
"OrderTypeID": val(qOrder.OrderTypeID),
|
||||||
"DeliveryFee": val(qOrder.DeliveryFee),
|
"DeliveryFee": val(qOrder.DeliveryFee),
|
||||||
"BusinessDeliveryFee": val(businessDeliveryFee),
|
"BusinessDeliveryFee": val(businessDeliveryFee),
|
||||||
|
|
@ -84,7 +88,11 @@
|
||||||
"AddedOn": qOrder.AddedOn,
|
"AddedOn": qOrder.AddedOn,
|
||||||
"LastEditedOn": qOrder.LastEditedOn,
|
"LastEditedOn": qOrder.LastEditedOn,
|
||||||
"SubmittedOn": qOrder.SubmittedOn,
|
"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(
|
<cfset var qLI = queryExecute(
|
||||||
|
|
@ -140,6 +148,7 @@
|
||||||
<cfreturn out>
|
<cfreturn out>
|
||||||
</cffunction>
|
</cffunction>
|
||||||
|
|
||||||
|
<cfinclude template="../grants/_grantUtils.cfm">
|
||||||
<cfset data = readJsonBody()>
|
<cfset data = readJsonBody()>
|
||||||
|
|
||||||
<cfset BusinessID = val( structKeyExists(data,"BusinessID") ? data.BusinessID : 0 )>
|
<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": "" })>
|
<cfset apiAbort({ "OK": false, "ERROR": "bad_business", "MESSAGE": "Business not found", "DETAIL": "" })>
|
||||||
</cfif>
|
</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 nowDt = now()>
|
||||||
<cfset newUUID = createUUID()>
|
<cfset newUUID = createObject("java", "java.util.UUID").randomUUID().toString()>
|
||||||
|
|
||||||
<!--- Calculate delivery fee: only for delivery orders (OrderTypeID = 3)
|
<!--- Calculate delivery fee: only for delivery orders (OrderTypeID = 3)
|
||||||
OrderTypeID: 0=undecided, 1=dine-in, 2=takeaway, 3=delivery
|
OrderTypeID: 0=undecided, 1=dine-in, 2=takeaway, 3=delivery
|
||||||
|
|
@ -266,7 +317,7 @@
|
||||||
UUID,
|
UUID,
|
||||||
UserID,
|
UserID,
|
||||||
BusinessID,
|
BusinessID,
|
||||||
DeliveryMultiplier,
|
BusinessDeliveryMultiplier,
|
||||||
OrderTypeID,
|
OrderTypeID,
|
||||||
DeliveryFee,
|
DeliveryFee,
|
||||||
StatusID,
|
StatusID,
|
||||||
|
|
@ -276,7 +327,11 @@
|
||||||
AddedOn,
|
AddedOn,
|
||||||
LastEditedOn,
|
LastEditedOn,
|
||||||
SubmittedOn,
|
SubmittedOn,
|
||||||
ServicePointID
|
ServicePointID,
|
||||||
|
GrantID,
|
||||||
|
GrantOwnerBusinessID,
|
||||||
|
GrantEconomicsType,
|
||||||
|
GrantEconomicsValue
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?,
|
?,
|
||||||
?,
|
?,
|
||||||
|
|
@ -292,6 +347,10 @@
|
||||||
?,
|
?,
|
||||||
?,
|
?,
|
||||||
NULL,
|
NULL,
|
||||||
|
?,
|
||||||
|
?,
|
||||||
|
?,
|
||||||
|
?,
|
||||||
?
|
?
|
||||||
)
|
)
|
||||||
",
|
",
|
||||||
|
|
@ -305,7 +364,11 @@
|
||||||
{ value = deliveryFee, cfsqltype = "cf_sql_decimal" },
|
{ value = deliveryFee, cfsqltype = "cf_sql_decimal" },
|
||||||
{ value = nowDt, cfsqltype = "cf_sql_timestamp" },
|
{ value = nowDt, cfsqltype = "cf_sql_timestamp" },
|
||||||
{ 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" }
|
{ datasource = "payfrit" }
|
||||||
)>
|
)>
|
||||||
|
|
|
||||||
|
|
@ -83,14 +83,14 @@
|
||||||
RequiresChildSelection,
|
RequiresChildSelection,
|
||||||
MaxNumSelectionReq
|
MaxNumSelectionReq
|
||||||
FROM Items
|
FROM Items
|
||||||
WHERE ID IN (:itemIds)
|
WHERE ID IN (#inList#)
|
||||||
",
|
",
|
||||||
{ itemIds: { value: inList, cfsqltype: "cf_sql_integer", list: true } },
|
[],
|
||||||
{ datasource = "payfrit" }
|
{ datasource = "payfrit" }
|
||||||
)>
|
)>
|
||||||
|
|
||||||
<cfloop query="qMeta">
|
<cfloop query="qMeta">
|
||||||
<cfset out.itemMeta[qMeta.ID] = {
|
<cfset out.itemMeta[qMeta.ItemID] = {
|
||||||
"requires": qMeta.RequiresChildSelection,
|
"requires": qMeta.RequiresChildSelection,
|
||||||
"maxSel": qMeta.MaxNumSelectionReq
|
"maxSel": qMeta.MaxNumSelectionReq
|
||||||
}>
|
}>
|
||||||
|
|
@ -130,6 +130,7 @@
|
||||||
<cfreturn false>
|
<cfreturn false>
|
||||||
</cffunction>
|
</cffunction>
|
||||||
|
|
||||||
|
<cfinclude template="../grants/_grantUtils.cfm">
|
||||||
<cfset data = readJsonBody()>
|
<cfset data = readJsonBody()>
|
||||||
<cfset OrderID = val( structKeyExists(data,"OrderID") ? data.OrderID : 0 )>
|
<cfset OrderID = val( structKeyExists(data,"OrderID") ? data.OrderID : 0 )>
|
||||||
|
|
||||||
|
|
@ -141,9 +142,10 @@
|
||||||
<cfset qOrder = queryExecute(
|
<cfset qOrder = queryExecute(
|
||||||
"
|
"
|
||||||
SELECT o.ID, o.StatusID, o.OrderTypeID, o.BusinessID, o.ServicePointID,
|
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
|
FROM Orders o
|
||||||
LEFT JOIN ServicePoints sp ON sp.ID = o.ServicePointID
|
LEFT JOIN ServicePoints sp ON sp.ServicePointID = o.ServicePointID
|
||||||
WHERE o.ID = ?
|
WHERE o.ID = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
",
|
",
|
||||||
|
|
@ -176,6 +178,22 @@
|
||||||
</cfif>
|
</cfif>
|
||||||
</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 --->
|
<!--- Must have at least one non-deleted root line item --->
|
||||||
<cfset qRoots = queryExecute(
|
<cfset qRoots = queryExecute(
|
||||||
"
|
"
|
||||||
|
|
|
||||||
|
|
@ -80,10 +80,60 @@ if (structKeyExists(data, "onlyActive")) {
|
||||||
})>
|
})>
|
||||||
</cfloop>
|
</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({
|
<cfoutput>#serializeJSON({
|
||||||
"OK": true,
|
"OK": true,
|
||||||
"ERROR": "",
|
"ERROR": "",
|
||||||
"BusinessID": bizId,
|
"BusinessID": bizId,
|
||||||
"COUNT": arrayLen(servicePoints),
|
"COUNT": arrayLen(servicePoints),
|
||||||
"SERVICEPOINTS": servicePoints
|
"SERVICEPOINTS": servicePoints,
|
||||||
|
"GRANTED_COUNT": arrayLen(grantedServicePoints),
|
||||||
|
"GRANTED_SERVICEPOINTS": grantedServicePoints
|
||||||
})#</cfoutput>
|
})#</cfoutput>
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ try {
|
||||||
qBusiness = queryExecute("
|
qBusiness = queryExecute("
|
||||||
SELECT StripeAccountID AS BusinessStripeAccountID, StripeOnboardingComplete AS BusinessStripeOnboardingComplete, Name AS BusinessName
|
SELECT StripeAccountID AS BusinessStripeAccountID, StripeOnboardingComplete AS BusinessStripeOnboardingComplete, Name AS BusinessName
|
||||||
FROM Businesses
|
FROM Businesses
|
||||||
WHERE ID = :businessID
|
WHERE BusinessID = :businessID
|
||||||
", { businessID: businessID }, { datasource: "payfrit" });
|
", { businessID: businessID }, { datasource: "payfrit" });
|
||||||
|
|
||||||
if (qBusiness.recordCount == 0) {
|
if (qBusiness.recordCount == 0) {
|
||||||
|
|
@ -73,16 +73,33 @@ try {
|
||||||
abort;
|
abort;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get order's delivery fee (if delivery order)
|
// Get order's delivery fee and grant economics (if applicable)
|
||||||
qOrder = queryExecute("
|
qOrder = queryExecute("
|
||||||
SELECT DeliveryFee, OrderTypeID
|
SELECT OrderDeliveryFee, OrderTypeID, GrantID, GrantOwnerBusinessID, GrantEconomicsType, GrantEconomicsValue
|
||||||
FROM Orders
|
FROM Orders
|
||||||
WHERE ID = :orderID
|
WHERE OrderID = :orderID
|
||||||
", { orderID: orderID }, { datasource: "payfrit" });
|
", { orderID: orderID }, { datasource: "payfrit" });
|
||||||
|
|
||||||
deliveryFee = 0;
|
deliveryFee = 0;
|
||||||
if (qOrder.recordCount > 0 && qOrder.OrderTypeID == 3) {
|
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
|
// 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");
|
httpService.addParam(type="formfield", name="automatic_payment_methods[enabled]", value="true");
|
||||||
|
|
||||||
if (hasStripeConnect) {
|
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="transfer_data[destination]", value=qBusiness.BusinessStripeAccountID);
|
||||||
}
|
}
|
||||||
|
|
||||||
httpService.addParam(type="formfield", name="metadata[order_id]", value=orderID);
|
httpService.addParam(type="formfield", name="metadata[order_id]", value=orderID);
|
||||||
httpService.addParam(type="formfield", name="metadata[business_id]", value=businessID);
|
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#");
|
httpService.addParam(type="formfield", name="description", value="Order ###orderID# at #qBusiness.BusinessName#");
|
||||||
|
|
||||||
if (customerEmail != "") {
|
if (customerEmail != "") {
|
||||||
|
|
@ -169,7 +195,8 @@ try {
|
||||||
"DELIVERY_FEE": deliveryFee,
|
"DELIVERY_FEE": deliveryFee,
|
||||||
"PAYFRIT_FEE": payfritCustomerFee,
|
"PAYFRIT_FEE": payfritCustomerFee,
|
||||||
"CARD_FEE": cardFee,
|
"CARD_FEE": cardFee,
|
||||||
"TOTAL": totalCustomerPays
|
"TOTAL": totalCustomerPays,
|
||||||
|
"GRANT_OWNER_FEE_CENTS": grantOwnerFeeCents
|
||||||
};
|
};
|
||||||
response["STRIPE_CONNECT_ENABLED"] = hasStripeConnect;
|
response["STRIPE_CONNECT_ENABLED"] = hasStripeConnect;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,51 @@ try {
|
||||||
writeLog(file="stripe_webhooks", text="Worker transfer error: #transferErr.message#");
|
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;
|
break;
|
||||||
|
|
||||||
case "payment_intent.payment_failed":
|
case "payment_intent.payment_failed":
|
||||||
|
|
@ -301,6 +346,80 @@ try {
|
||||||
|
|
||||||
writeLog(file="stripe_webhooks", text="Activation completed via payment for user #metaUserID#");
|
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) {
|
} catch (any checkoutErr) {
|
||||||
writeLog(file="stripe_webhooks", text="Checkout session error: #checkoutErr.message#");
|
writeLog(file="stripe_webhooks", text="Checkout session error: #checkoutErr.message#");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,14 +31,6 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<nav class="sidebar-nav">
|
||||||
<a href="#dashboard" class="nav-item active" data-page="dashboard">
|
<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">
|
<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>
|
</svg>
|
||||||
<span>Services</span>
|
<span>Services</span>
|
||||||
</a>
|
</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">
|
<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">
|
<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"/>
|
<path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
|
@ -105,6 +103,33 @@
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</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>
|
</aside>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- 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"/>
|
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="user-menu" style="position: relative;">
|
<div class="user-menu">
|
||||||
<button class="user-btn" id="userBtn" onclick="Portal.toggleUserMenu()">
|
<button class="user-btn" id="userBtn">
|
||||||
<span class="user-avatar" id="userAvatar">U</span>
|
<span class="user-avatar" id="userAvatar">U</span>
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -510,6 +517,87 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 & 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()">×</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 -->
|
<!-- Settings Page -->
|
||||||
<section class="page" id="page-settings">
|
<section class="page" id="page-settings">
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
|
|
@ -578,9 +666,7 @@
|
||||||
<div>
|
<div>
|
||||||
<label style="display: block; margin-bottom: 8px; font-weight: 500;">Header Image</label>
|
<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>
|
<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;">
|
<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>
|
||||||
<img id="headerPreview" src="" alt="" style="width: 100%; display: block;">
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-secondary" onclick="Portal.uploadHeader()" style="width: 100%;">
|
<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;">
|
<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"/>
|
<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>
|
<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>
|
<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 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;">
|
<button class="btn btn-secondary" onclick="Portal.showBrandColorPicker()" style="flex: 1;">
|
||||||
Change Color
|
Change Color
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
298
portal/portal.js
298
portal/portal.js
|
|
@ -111,40 +111,20 @@ const Portal = {
|
||||||
if (data.OK && data.BUSINESS) {
|
if (data.OK && data.BUSINESS) {
|
||||||
const biz = data.BUSINESS;
|
const biz = data.BUSINESS;
|
||||||
this.businessData = biz; // Store for later use
|
this.businessData = biz; // Store for later use
|
||||||
const bizName = biz.Name || 'Business';
|
document.getElementById('businessName').textContent = biz.Name || 'Business';
|
||||||
const bizInitial = bizName.charAt(0).toUpperCase();
|
document.getElementById('businessAvatar').textContent = (biz.Name || 'B').charAt(0).toUpperCase();
|
||||||
document.getElementById('businessName').textContent = bizName;
|
document.getElementById('userAvatar').textContent = 'U';
|
||||||
document.getElementById('businessAvatar').textContent = bizInitial;
|
|
||||||
document.getElementById('userAvatar').textContent = bizInitial;
|
|
||||||
} else {
|
} else {
|
||||||
this.businessData = null;
|
this.businessData = null;
|
||||||
document.getElementById('businessName').textContent = 'Business #' + this.config.businessId;
|
document.getElementById('businessName').textContent = 'Business #' + this.config.businessId;
|
||||||
document.getElementById('businessAvatar').textContent = 'B';
|
document.getElementById('businessAvatar').textContent = 'B';
|
||||||
document.getElementById('userAvatar').textContent = 'B';
|
document.getElementById('userAvatar').textContent = 'U';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Portal] Business info error:', err);
|
console.error('[Portal] Business info error:', err);
|
||||||
document.getElementById('businessName').textContent = 'Business #' + this.config.businessId;
|
document.getElementById('businessName').textContent = 'Business #' + this.config.businessId;
|
||||||
document.getElementById('businessAvatar').textContent = 'B';
|
document.getElementById('businessAvatar').textContent = 'B';
|
||||||
document.getElementById('userAvatar').textContent = 'B';
|
document.getElementById('userAvatar').textContent = 'U';
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -153,7 +133,6 @@ const Portal = {
|
||||||
localStorage.removeItem('payfrit_portal_token');
|
localStorage.removeItem('payfrit_portal_token');
|
||||||
localStorage.removeItem('payfrit_portal_userid');
|
localStorage.removeItem('payfrit_portal_userid');
|
||||||
localStorage.removeItem('payfrit_portal_business');
|
localStorage.removeItem('payfrit_portal_business');
|
||||||
localStorage.removeItem('payfrit_portal_firstname');
|
|
||||||
window.location.href = BASE_PATH + '/portal/login.html';
|
window.location.href = BASE_PATH + '/portal/login.html';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -239,6 +218,7 @@ const Portal = {
|
||||||
beacons: 'Beacons',
|
beacons: 'Beacons',
|
||||||
services: 'Service Requests',
|
services: 'Service Requests',
|
||||||
'admin-tasks': 'Task Admin',
|
'admin-tasks': 'Task Admin',
|
||||||
|
'sp-sharing': 'SP Sharing',
|
||||||
settings: 'Settings'
|
settings: 'Settings'
|
||||||
};
|
};
|
||||||
document.getElementById('pageTitle').textContent = titles[page] || page;
|
document.getElementById('pageTitle').textContent = titles[page] || page;
|
||||||
|
|
@ -279,6 +259,9 @@ const Portal = {
|
||||||
case 'admin-tasks':
|
case 'admin-tasks':
|
||||||
await this.loadAdminTasksPage();
|
await this.loadAdminTasksPage();
|
||||||
break;
|
break;
|
||||||
|
case 'sp-sharing':
|
||||||
|
await this.loadSPSharingPage();
|
||||||
|
break;
|
||||||
case 'settings':
|
case 'settings':
|
||||||
await this.loadSettings();
|
await this.loadSettings();
|
||||||
break;
|
break;
|
||||||
|
|
@ -745,43 +728,35 @@ const Portal = {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.OK && data.BUSINESS) {
|
if (data.OK && data.BUSINESS) {
|
||||||
// Normalize all keys to uppercase for consistent access
|
const biz = data.BUSINESS;
|
||||||
// (Lucee serializeJSON casing varies by server config)
|
|
||||||
const raw = data.BUSINESS;
|
|
||||||
const biz = {};
|
|
||||||
Object.keys(raw).forEach(k => { biz[k.toUpperCase()] = raw[k]; });
|
|
||||||
this.currentBusiness = biz;
|
this.currentBusiness = biz;
|
||||||
|
|
||||||
// Populate form fields
|
// Populate form fields (Lucee serializes all keys as uppercase)
|
||||||
document.getElementById('settingBusinessName').value = biz.NAME || biz.BUSINESSNAME || '';
|
document.getElementById('settingName').value = biz.BUSINESSNAME || biz.Name || '';
|
||||||
document.getElementById('settingPhone').value = biz.PHONE || biz.BUSINESSPHONE || '';
|
document.getElementById('settingPhone').value = biz.BUSINESSPHONE || biz.Phone || '';
|
||||||
document.getElementById('settingTaxRate').value = biz.TAXRATEPERCENT || '';
|
document.getElementById('settingTaxRate').value = biz.TAXRATEPERCENT || biz.TaxRatePercent || '';
|
||||||
document.getElementById('settingAddressLine1').value = biz.LINE1 || biz.ADDRESSLINE1 || '';
|
document.getElementById('settingLine1').value = biz.ADDRESSLINE1 || biz.Line1 || '';
|
||||||
document.getElementById('settingCity').value = biz.CITY || biz.ADDRESSCITY || '';
|
document.getElementById('settingCity').value = biz.ADDRESSCITY || biz.City || '';
|
||||||
document.getElementById('settingState').value = biz.ADDRESSSTATE || '';
|
document.getElementById('settingState').value = biz.ADDRESSSTATE || biz.AddressState || '';
|
||||||
document.getElementById('settingZip').value = biz.ADDRESSZIP || '';
|
document.getElementById('settingZip').value = biz.ADDRESSZIP || biz.AddressZip || '';
|
||||||
|
|
||||||
// Load brand color if set (DB stores without #, CSS needs it)
|
// Load brand color if set
|
||||||
const brandColor = biz.BRANDCOLOR || '';
|
const brandColor = biz.BRANDCOLOR || biz.BrandColor;
|
||||||
if (brandColor) {
|
if (brandColor) {
|
||||||
this.brandColor = brandColor.charAt(0) === '#' ? brandColor : '#' + brandColor;
|
this.brandColor = brandColor;
|
||||||
const swatch = document.getElementById('brandColorSwatch');
|
const swatch = document.getElementById('brandColorSwatch');
|
||||||
if (swatch) swatch.style.backgroundColor = this.brandColor;
|
if (swatch) swatch.style.background = brandColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load header preview
|
// Load header preview
|
||||||
const headerPreview = document.getElementById('headerPreview');
|
const headerPreview = document.getElementById('headerPreview');
|
||||||
const headerWrapper = document.getElementById('headerPreviewWrapper');
|
const headerUrl = biz.HEADERIMAGEURL || biz.HeaderImageURL;
|
||||||
const headerUrl = biz.HEADERIMAGEURL || '';
|
|
||||||
if (headerPreview && headerUrl) {
|
if (headerPreview && headerUrl) {
|
||||||
headerPreview.onload = function() {
|
headerPreview.style.backgroundImage = `url(${headerUrl}?t=${Date.now()})`;
|
||||||
if (headerWrapper) headerWrapper.style.display = 'block';
|
|
||||||
};
|
|
||||||
headerPreview.src = `${BASE_PATH}${headerUrl}?t=${Date.now()}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render hours editor
|
// Render hours editor
|
||||||
this.renderHoursEditor(biz.HOURSDETAIL || biz.BUSINESSHOURSDETAIL || []);
|
this.renderHoursEditor(biz.BUSINESSHOURSDETAIL || biz.HoursDetail || []);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Portal] Error loading business info:', err);
|
console.error('[Portal] Error loading business info:', err);
|
||||||
|
|
@ -870,10 +845,10 @@ const Portal = {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
BusinessID: this.config.businessId,
|
BusinessID: this.config.businessId,
|
||||||
Name: document.getElementById('settingBusinessName').value,
|
Name: document.getElementById('settingName').value,
|
||||||
Phone: document.getElementById('settingPhone').value,
|
Phone: document.getElementById('settingPhone').value,
|
||||||
TaxRatePercent: parseFloat(document.getElementById('settingTaxRate').value) || 0,
|
TaxRatePercent: parseFloat(document.getElementById('settingTaxRate').value) || 0,
|
||||||
Line1: document.getElementById('settingAddressLine1').value,
|
Line1: document.getElementById('settingLine1').value,
|
||||||
City: document.getElementById('settingCity').value,
|
City: document.getElementById('settingCity').value,
|
||||||
State: document.getElementById('settingState').value,
|
State: document.getElementById('settingState').value,
|
||||||
Zip: document.getElementById('settingZip').value
|
Zip: document.getElementById('settingZip').value
|
||||||
|
|
@ -1132,7 +1107,7 @@ const Portal = {
|
||||||
openCustomerPreview() {
|
openCustomerPreview() {
|
||||||
const businessId = this.config.businessId;
|
const businessId = this.config.businessId;
|
||||||
const businessName = encodeURIComponent(
|
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}`;
|
const deepLink = `payfrit://open?businessId=${businessId}&businessName=${businessName}`;
|
||||||
window.location.href = deepLink;
|
window.location.href = deepLink;
|
||||||
|
|
@ -1192,12 +1167,8 @@ const Portal = {
|
||||||
this.toast('Header uploaded successfully!', 'success');
|
this.toast('Header uploaded successfully!', 'success');
|
||||||
// Update preview using the URL from the response
|
// Update preview using the URL from the response
|
||||||
const preview = document.getElementById('headerPreview');
|
const preview = document.getElementById('headerPreview');
|
||||||
const wrapper = document.getElementById('headerPreviewWrapper');
|
|
||||||
if (preview && data.HEADERURL) {
|
if (preview && data.HEADERURL) {
|
||||||
preview.onload = function() {
|
preview.style.backgroundImage = `url(${BASE_PATH}${data.HEADERURL}?t=${Date.now()})`;
|
||||||
if (wrapper) wrapper.style.display = 'block';
|
|
||||||
};
|
|
||||||
preview.src = `${BASE_PATH}${data.HEADERURL}?t=${Date.now()}`;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.toast(data.MESSAGE || 'Failed to upload header', 'error');
|
this.toast(data.MESSAGE || 'Failed to upload header', 'error');
|
||||||
|
|
@ -1275,7 +1246,7 @@ const Portal = {
|
||||||
if (data.OK) {
|
if (data.OK) {
|
||||||
this.brandColor = color;
|
this.brandColor = color;
|
||||||
const swatch = document.getElementById('brandColorSwatch');
|
const swatch = document.getElementById('brandColorSwatch');
|
||||||
if (swatch) swatch.style.backgroundColor = color;
|
if (swatch) swatch.style.background = color;
|
||||||
this.closeModal();
|
this.closeModal();
|
||||||
this.toast('Brand color saved!', 'success');
|
this.toast('Brand color saved!', 'success');
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -3810,6 +3781,215 @@ const Portal = {
|
||||||
console.error('[Portal] Error submitting rating:', err);
|
console.error('[Portal] Error submitting rating:', err);
|
||||||
this.toast('Error submitting rating', 'error');
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Reference in a new issue