diff --git a/api/Application.cfm b/api/Application.cfm index bf2cbe7..8a5723d 100644 --- a/api/Application.cfm +++ b/api/Application.cfm @@ -103,8 +103,6 @@ if (len(request._api_path)) { if (findNoCase("/api/auth/verifyOTP.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/auth/loginOTP.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/auth/verifyLoginOTP.cfm", request._api_path)) request._api_isPublic = true; - if (findNoCase("/api/auth/sendLoginOTP.cfm", request._api_path)) request._api_isPublic = true; - if (findNoCase("/api/auth/verifyEmailOTP.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/auth/completeProfile.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/businesses/list.cfm", request._api_path)) request._api_isPublic = true; @@ -277,11 +275,22 @@ if (len(request._api_path)) { // App info endpoints (public, no auth needed) if (findNoCase("/api/app/about.cfm", request._api_path)) request._api_isPublic = true; + // Grant endpoints (SP-SM) + if (findNoCase("/api/grants/create.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/grants/list.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/grants/get.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/grants/update.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/grants/revoke.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/grants/accept.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/grants/decline.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/grants/searchBusiness.cfm", request._api_path)) request._api_isPublic = true; + // Stripe endpoints if (findNoCase("/api/stripe/onboard.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/stripe/status.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/stripe/createPaymentIntent.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/stripe/webhook.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/stripe/createTipCheckout.cfm", request._api_path)) request._api_isPublic = true; } // Carry session values into request (if present) diff --git a/api/grants/_grantUtils.cfm b/api/grants/_grantUtils.cfm new file mode 100644 index 0000000..84ee891 --- /dev/null +++ b/api/grants/_grantUtils.cfm @@ -0,0 +1,136 @@ + +/** + * 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" } + ); +} + diff --git a/api/grants/accept.cfm b/api/grants/accept.cfm new file mode 100644 index 0000000..422bc6b --- /dev/null +++ b/api/grants/accept.cfm @@ -0,0 +1,93 @@ + + + + + + + + +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." +})); + diff --git a/api/grants/create.cfm b/api/grants/create.cfm new file mode 100644 index 0000000..464fbca --- /dev/null +++ b/api/grants/create.cfm @@ -0,0 +1,167 @@ + + + + + + + + +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." +})); + diff --git a/api/grants/decline.cfm b/api/grants/decline.cfm new file mode 100644 index 0000000..de4e1b9 --- /dev/null +++ b/api/grants/decline.cfm @@ -0,0 +1,70 @@ + + + + + + + + +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." +})); + diff --git a/api/grants/get.cfm b/api/grants/get.cfm new file mode 100644 index 0000000..ae08851 --- /dev/null +++ b/api/grants/get.cfm @@ -0,0 +1,108 @@ + + + + + + +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 +})); + diff --git a/api/grants/list.cfm b/api/grants/list.cfm new file mode 100644 index 0000000..bf3605b --- /dev/null +++ b/api/grants/list.cfm @@ -0,0 +1,112 @@ + + + + + + +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 +})); + diff --git a/api/grants/revoke.cfm b/api/grants/revoke.cfm new file mode 100644 index 0000000..18e83fd --- /dev/null +++ b/api/grants/revoke.cfm @@ -0,0 +1,76 @@ + + + + + + + + +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." +})); + diff --git a/api/grants/searchBusiness.cfm b/api/grants/searchBusiness.cfm new file mode 100644 index 0000000..970b57c --- /dev/null +++ b/api/grants/searchBusiness.cfm @@ -0,0 +1,57 @@ + + + + + + +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 +})); + diff --git a/api/grants/update.cfm b/api/grants/update.cfm new file mode 100644 index 0000000..342827e --- /dev/null +++ b/api/grants/update.cfm @@ -0,0 +1,153 @@ + + + + + + + + +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." +})); + diff --git a/api/orders/getOrCreateCart.cfm b/api/orders/getOrCreateCart.cfm index dfea049..9ec14fd 100644 --- a/api/orders/getOrCreateCart.cfm +++ b/api/orders/getOrCreateCart.cfm @@ -37,7 +37,7 @@ UUID, UserID, BusinessID, - DeliveryMultiplier, + BusinessDeliveryMultiplier, OrderTypeID, DeliveryFee, StatusID, @@ -47,7 +47,11 @@ AddedOn, LastEditedOn, SubmittedOn, - ServicePointID + ServicePointID, + GrantID, + GrantOwnerBusinessID, + GrantEconomicsType, + GrantEconomicsValue FROM Orders WHERE ID = ? LIMIT 1 @@ -73,7 +77,7 @@ "UUID": qOrder.UUID ?: "", "UserID": val(qOrder.UserID), "BusinessID": val(qOrder.BusinessID), - "DeliveryMultiplier": val(qOrder.DeliveryMultiplier), + "DeliveryMultiplier": val(qOrder.BusinessDeliveryMultiplier), "OrderTypeID": val(qOrder.OrderTypeID), "DeliveryFee": val(qOrder.DeliveryFee), "BusinessDeliveryFee": val(businessDeliveryFee), @@ -84,7 +88,11 @@ "AddedOn": qOrder.AddedOn, "LastEditedOn": qOrder.LastEditedOn, "SubmittedOn": qOrder.SubmittedOn, - "ServicePointID": val(qOrder.ServicePointID) + "ServicePointID": val(qOrder.ServicePointID), + "GrantID": val(qOrder.GrantID), + "GrantOwnerBusinessID": val(qOrder.GrantOwnerBusinessID), + "GrantEconomicsType": qOrder.GrantEconomicsType ?: "", + "GrantEconomicsValue": val(qOrder.GrantEconomicsValue) }> + @@ -242,8 +251,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + 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 = + AND g.StatusID = 1 + AND sp.IsActive = 1 + + + + + + + #serializeJSON({ "OK": true, "ERROR": "", "BusinessID": bizId, "COUNT": arrayLen(servicePoints), - "SERVICEPOINTS": servicePoints + "SERVICEPOINTS": servicePoints, + "GRANTED_COUNT": arrayLen(grantedServicePoints), + "GRANTED_SERVICEPOINTS": grantedServicePoints })# diff --git a/api/stripe/createPaymentIntent.cfm b/api/stripe/createPaymentIntent.cfm index 6d848ee..25f5041 100644 --- a/api/stripe/createPaymentIntent.cfm +++ b/api/stripe/createPaymentIntent.cfm @@ -64,7 +64,7 @@ try { qBusiness = queryExecute(" SELECT StripeAccountID AS BusinessStripeAccountID, StripeOnboardingComplete AS BusinessStripeOnboardingComplete, Name AS BusinessName FROM Businesses - WHERE ID = :businessID + WHERE BusinessID = :businessID ", { businessID: businessID }, { datasource: "payfrit" }); if (qBusiness.recordCount == 0) { @@ -73,16 +73,33 @@ try { abort; } - // Get order's delivery fee (if delivery order) + // Get order's delivery fee and grant economics (if applicable) qOrder = queryExecute(" - SELECT DeliveryFee, OrderTypeID + SELECT OrderDeliveryFee, OrderTypeID, GrantID, GrantOwnerBusinessID, GrantEconomicsType, GrantEconomicsValue FROM Orders - WHERE ID = :orderID + WHERE OrderID = :orderID ", { orderID: orderID }, { datasource: "payfrit" }); deliveryFee = 0; if (qOrder.recordCount > 0 && qOrder.OrderTypeID == 3) { - deliveryFee = val(qOrder.DeliveryFee); + deliveryFee = val(qOrder.OrderDeliveryFee); + } + + // SP-SM: Resolve grant economics + grantOwnerFeeCents = 0; + grantOwnerBusinessID = 0; + grantID = 0; + if (qOrder.recordCount > 0 && val(qOrder.GrantID) > 0) { + grantID = val(qOrder.GrantID); + grantOwnerBusinessID = val(qOrder.GrantOwnerBusinessID); + grantEconType = qOrder.GrantEconomicsType ?: "none"; + grantEconValue = val(qOrder.GrantEconomicsValue); + + if (grantEconType == "flat_fee" && grantEconValue > 0) { + grantOwnerFeeCents = round(grantEconValue * 100); + } else if (grantEconType == "percent_of_orders" && grantEconValue > 0) { + grantOwnerFeeCents = round(subtotal * (grantEconValue / 100) * 100); + } } // For testing, allow orders even without Stripe Connect setup @@ -118,12 +135,21 @@ try { httpService.addParam(type="formfield", name="automatic_payment_methods[enabled]", value="true"); if (hasStripeConnect) { - httpService.addParam(type="formfield", name="application_fee_amount", value=totalPlatformFeeCents); + // SP-SM: Add grant owner fee to platform fee (Payfrit collects, then transfers to owner) + effectivePlatformFeeCents = totalPlatformFeeCents + grantOwnerFeeCents; + httpService.addParam(type="formfield", name="application_fee_amount", value=effectivePlatformFeeCents); httpService.addParam(type="formfield", name="transfer_data[destination]", value=qBusiness.BusinessStripeAccountID); } httpService.addParam(type="formfield", name="metadata[order_id]", value=orderID); httpService.addParam(type="formfield", name="metadata[business_id]", value=businessID); + + // SP-SM: Store grant metadata for webhook transfer + if (grantOwnerFeeCents > 0) { + httpService.addParam(type="formfield", name="metadata[grant_id]", value=grantID); + httpService.addParam(type="formfield", name="metadata[grant_owner_business_id]", value=grantOwnerBusinessID); + httpService.addParam(type="formfield", name="metadata[grant_owner_fee_cents]", value=grantOwnerFeeCents); + } httpService.addParam(type="formfield", name="description", value="Order ###orderID# at #qBusiness.BusinessName#"); if (customerEmail != "") { @@ -169,7 +195,8 @@ try { "DELIVERY_FEE": deliveryFee, "PAYFRIT_FEE": payfritCustomerFee, "CARD_FEE": cardFee, - "TOTAL": totalCustomerPays + "TOTAL": totalCustomerPays, + "GRANT_OWNER_FEE_CENTS": grantOwnerFeeCents }; response["STRIPE_CONNECT_ENABLED"] = hasStripeConnect; diff --git a/api/stripe/webhook.cfm b/api/stripe/webhook.cfm index bdec203..323bfed 100644 --- a/api/stripe/webhook.cfm +++ b/api/stripe/webhook.cfm @@ -158,6 +158,51 @@ try { writeLog(file="stripe_webhooks", text="Worker transfer error: #transferErr.message#"); } + // === SP-SM: GRANT OWNER TRANSFER === + try { + grantOwnerFeeCents = val(eventData.metadata.grant_owner_fee_cents ?: 0); + grantOwnerBizID = val(eventData.metadata.grant_owner_business_id ?: 0); + grantMetaID = val(eventData.metadata.grant_id ?: 0); + + if (grantOwnerFeeCents > 0 && grantOwnerBizID > 0) { + // Look up owner business Stripe account + qOwnerBiz = queryExecute(" + SELECT StripeAccountID FROM Businesses WHERE ID = :bizID + ", { bizID: grantOwnerBizID }); + + ownerStripeAcct = qOwnerBiz.recordCount > 0 ? (qOwnerBiz.StripeAccountID ?: "") : ""; + + if (len(trim(ownerStripeAcct)) > 0) { + stripeSecretKey = application.stripeSecretKey ?: ""; + httpGrantTransfer = new http(); + httpGrantTransfer.setMethod("POST"); + httpGrantTransfer.setUrl("https://api.stripe.com/v1/transfers"); + httpGrantTransfer.setUsername(stripeSecretKey); + httpGrantTransfer.setPassword(""); + httpGrantTransfer.addParam(type="formfield", name="amount", value=grantOwnerFeeCents); + httpGrantTransfer.addParam(type="formfield", name="currency", value="usd"); + httpGrantTransfer.addParam(type="formfield", name="destination", value=ownerStripeAcct); + httpGrantTransfer.addParam(type="formfield", name="metadata[grant_id]", value=grantMetaID); + httpGrantTransfer.addParam(type="formfield", name="metadata[order_id]", value=orderID); + httpGrantTransfer.addParam(type="formfield", name="metadata[type]", value="grant_owner_fee"); + httpGrantTransfer.addParam(type="header", name="Idempotency-Key", value="grant-transfer-#orderID#-#grantMetaID#"); + + grantTransferResult = httpGrantTransfer.send().getPrefix(); + grantTransferData = deserializeJSON(grantTransferResult.fileContent); + + if (structKeyExists(grantTransferData, "id")) { + writeLog(file="stripe_webhooks", text="Grant owner transfer #grantTransferData.id# created: #grantOwnerFeeCents# cents to biz #grantOwnerBizID# for order #orderID#"); + } else { + writeLog(file="stripe_webhooks", text="Grant owner transfer failed for order #orderID#: #grantTransferData.error.message ?: 'unknown'#"); + } + } else { + writeLog(file="stripe_webhooks", text="Grant owner biz #grantOwnerBizID# has no Stripe account - transfer skipped for order #orderID#"); + } + } + } catch (any grantTransferErr) { + writeLog(file="stripe_webhooks", text="Grant owner transfer error: #grantTransferErr.message#"); + } + break; case "payment_intent.payment_failed": @@ -301,6 +346,80 @@ try { writeLog(file="stripe_webhooks", text="Activation completed via payment for user #metaUserID#"); } + + // Tip payment completed + if (metaType == "tip") { + metaTipID = val(sessionMetadata.tip_id ?: 0); + metaWorkerID = val(sessionMetadata.worker_user_id ?: 0); + tipPaymentIntent = eventData.payment_intent ?: ""; + + if (metaTipID > 0) { + // Mark tip as paid + queryExecute(" + UPDATE Tips + SET Status = 'paid', + PaidOn = NOW(), + StripePaymentIntentID = :piID + WHERE ID = :tipID AND Status = 'pending' + ", { + piID: tipPaymentIntent, + tipID: metaTipID + }, { datasource: "payfrit" }); + + writeLog(file="stripe_webhooks", text="Tip ##metaTipID# paid (PI: #tipPaymentIntent#)"); + + // Transfer tip to worker's Stripe Connect account + if (metaWorkerID > 0) { + qTipWorker = queryExecute(" + SELECT StripeConnectedAccountID FROM Users WHERE ID = :userID + ", { userID: metaWorkerID }, { datasource: "payfrit" }); + + tipWorkerAcct = qTipWorker.recordCount > 0 ? (qTipWorker.StripeConnectedAccountID ?: "") : ""; + + if (len(trim(tipWorkerAcct)) > 0) { + qTipAmt = queryExecute(" + SELECT AmountCents FROM Tips WHERE ID = :tipID + ", { tipID: metaTipID }, { datasource: "payfrit" }); + + if (qTipAmt.recordCount > 0 && qTipAmt.AmountCents > 0) { + stripeSecretKey = application.stripeSecretKey ?: ""; + httpTipTransfer = new http(); + httpTipTransfer.setMethod("POST"); + httpTipTransfer.setUrl("https://api.stripe.com/v1/transfers"); + httpTipTransfer.setUsername(stripeSecretKey); + httpTipTransfer.setPassword(""); + httpTipTransfer.addParam(type="formfield", name="amount", value=qTipAmt.AmountCents); + httpTipTransfer.addParam(type="formfield", name="currency", value="usd"); + httpTipTransfer.addParam(type="formfield", name="destination", value=tipWorkerAcct); + httpTipTransfer.addParam(type="formfield", name="metadata[type]", value="tip"); + httpTipTransfer.addParam(type="formfield", name="metadata[tip_id]", value=metaTipID); + httpTipTransfer.addParam(type="formfield", name="metadata[worker_user_id]", value=metaWorkerID); + httpTipTransfer.addParam(type="header", name="Idempotency-Key", value="tip-transfer-#metaTipID#"); + + tipTransferResult = httpTipTransfer.send().getPrefix(); + tipTransferData = deserializeJSON(tipTransferResult.fileContent); + + if (structKeyExists(tipTransferData, "id")) { + queryExecute(" + UPDATE Tips + SET Status = 'transferred', StripeTransferID = :transferID + WHERE ID = :tipID + ", { + transferID: tipTransferData.id, + tipID: metaTipID + }, { datasource: "payfrit" }); + + writeLog(file="stripe_webhooks", text="Tip ##metaTipID# transferred (#tipTransferData.id#) to worker ##metaWorkerID#"); + } else { + writeLog(file="stripe_webhooks", text="Tip transfer failed for tip ##metaTipID#: #tipTransferData.error.message ?: 'unknown'#"); + } + } + } else { + writeLog(file="stripe_webhooks", text="Tip ##metaTipID#: worker ##metaWorkerID# has no Stripe Connect account - transfer skipped"); + } + } + } + } } catch (any checkoutErr) { writeLog(file="stripe_webhooks", text="Checkout session error: #checkoutErr.message#"); } diff --git a/portal/index.html b/portal/index.html index be7934a..1c62e92 100644 --- a/portal/index.html +++ b/portal/index.html @@ -31,14 +31,6 @@ -
-
B
-
-
Loading...
-
Online
-
-
- + @@ -120,28 +145,10 @@ - @@ -510,6 +517,87 @@ + +
+
+ +
+
+

Grants You've Created

+ +
+
+
Loading...
+
+
+ + +
+
+

Invites & Active Grants

+
+
+
Loading...
+
+
+
+ + + +
+
@@ -578,9 +666,7 @@

Displayed at the top of your menu. Recommended: 1200x400px

- +
diff --git a/portal/portal.js b/portal/portal.js index f56c919..c8a1667 100644 --- a/portal/portal.js +++ b/portal/portal.js @@ -111,40 +111,20 @@ const Portal = { if (data.OK && data.BUSINESS) { const biz = data.BUSINESS; this.businessData = biz; // Store for later use - const bizName = biz.Name || 'Business'; - const bizInitial = bizName.charAt(0).toUpperCase(); - document.getElementById('businessName').textContent = bizName; - document.getElementById('businessAvatar').textContent = bizInitial; - document.getElementById('userAvatar').textContent = bizInitial; + document.getElementById('businessName').textContent = biz.Name || 'Business'; + document.getElementById('businessAvatar').textContent = (biz.Name || 'B').charAt(0).toUpperCase(); + document.getElementById('userAvatar').textContent = 'U'; } else { this.businessData = null; document.getElementById('businessName').textContent = 'Business #' + this.config.businessId; document.getElementById('businessAvatar').textContent = 'B'; - document.getElementById('userAvatar').textContent = 'B'; + document.getElementById('userAvatar').textContent = 'U'; } } catch (err) { console.error('[Portal] Business info error:', err); document.getElementById('businessName').textContent = 'Business #' + this.config.businessId; document.getElementById('businessAvatar').textContent = 'B'; - document.getElementById('userAvatar').textContent = 'B'; - } - }, - - // Toggle user dropdown menu - toggleUserMenu() { - const dd = document.getElementById('userDropdown'); - if (!dd) return; - const showing = dd.style.display !== 'none'; - dd.style.display = showing ? 'none' : 'block'; - if (!showing) { - // Close on outside click - const close = (e) => { - if (!dd.contains(e.target) && e.target.id !== 'userBtn' && !e.target.closest('#userBtn')) { - dd.style.display = 'none'; - document.removeEventListener('click', close); - } - }; - setTimeout(() => document.addEventListener('click', close), 0); + document.getElementById('userAvatar').textContent = 'U'; } }, @@ -153,7 +133,6 @@ const Portal = { localStorage.removeItem('payfrit_portal_token'); localStorage.removeItem('payfrit_portal_userid'); localStorage.removeItem('payfrit_portal_business'); - localStorage.removeItem('payfrit_portal_firstname'); window.location.href = BASE_PATH + '/portal/login.html'; }, @@ -239,6 +218,7 @@ const Portal = { beacons: 'Beacons', services: 'Service Requests', 'admin-tasks': 'Task Admin', + 'sp-sharing': 'SP Sharing', settings: 'Settings' }; document.getElementById('pageTitle').textContent = titles[page] || page; @@ -279,6 +259,9 @@ const Portal = { case 'admin-tasks': await this.loadAdminTasksPage(); break; + case 'sp-sharing': + await this.loadSPSharingPage(); + break; case 'settings': await this.loadSettings(); break; @@ -745,43 +728,35 @@ const Portal = { const data = await response.json(); if (data.OK && data.BUSINESS) { - // Normalize all keys to uppercase for consistent access - // (Lucee serializeJSON casing varies by server config) - const raw = data.BUSINESS; - const biz = {}; - Object.keys(raw).forEach(k => { biz[k.toUpperCase()] = raw[k]; }); + const biz = data.BUSINESS; this.currentBusiness = biz; - // Populate form fields - document.getElementById('settingBusinessName').value = biz.NAME || biz.BUSINESSNAME || ''; - document.getElementById('settingPhone').value = biz.PHONE || biz.BUSINESSPHONE || ''; - document.getElementById('settingTaxRate').value = biz.TAXRATEPERCENT || ''; - document.getElementById('settingAddressLine1').value = biz.LINE1 || biz.ADDRESSLINE1 || ''; - document.getElementById('settingCity').value = biz.CITY || biz.ADDRESSCITY || ''; - document.getElementById('settingState').value = biz.ADDRESSSTATE || ''; - document.getElementById('settingZip').value = biz.ADDRESSZIP || ''; + // Populate form fields (Lucee serializes all keys as uppercase) + document.getElementById('settingName').value = biz.BUSINESSNAME || biz.Name || ''; + document.getElementById('settingPhone').value = biz.BUSINESSPHONE || biz.Phone || ''; + document.getElementById('settingTaxRate').value = biz.TAXRATEPERCENT || biz.TaxRatePercent || ''; + document.getElementById('settingLine1').value = biz.ADDRESSLINE1 || biz.Line1 || ''; + document.getElementById('settingCity').value = biz.ADDRESSCITY || biz.City || ''; + document.getElementById('settingState').value = biz.ADDRESSSTATE || biz.AddressState || ''; + document.getElementById('settingZip').value = biz.ADDRESSZIP || biz.AddressZip || ''; - // Load brand color if set (DB stores without #, CSS needs it) - const brandColor = biz.BRANDCOLOR || ''; + // Load brand color if set + const brandColor = biz.BRANDCOLOR || biz.BrandColor; if (brandColor) { - this.brandColor = brandColor.charAt(0) === '#' ? brandColor : '#' + brandColor; + this.brandColor = brandColor; const swatch = document.getElementById('brandColorSwatch'); - if (swatch) swatch.style.backgroundColor = this.brandColor; + if (swatch) swatch.style.background = brandColor; } // Load header preview const headerPreview = document.getElementById('headerPreview'); - const headerWrapper = document.getElementById('headerPreviewWrapper'); - const headerUrl = biz.HEADERIMAGEURL || ''; + const headerUrl = biz.HEADERIMAGEURL || biz.HeaderImageURL; if (headerPreview && headerUrl) { - headerPreview.onload = function() { - if (headerWrapper) headerWrapper.style.display = 'block'; - }; - headerPreview.src = `${BASE_PATH}${headerUrl}?t=${Date.now()}`; + headerPreview.style.backgroundImage = `url(${headerUrl}?t=${Date.now()})`; } // Render hours editor - this.renderHoursEditor(biz.HOURSDETAIL || biz.BUSINESSHOURSDETAIL || []); + this.renderHoursEditor(biz.BUSINESSHOURSDETAIL || biz.HoursDetail || []); } } catch (err) { console.error('[Portal] Error loading business info:', err); @@ -870,10 +845,10 @@ const Portal = { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId, - Name: document.getElementById('settingBusinessName').value, + Name: document.getElementById('settingName').value, Phone: document.getElementById('settingPhone').value, TaxRatePercent: parseFloat(document.getElementById('settingTaxRate').value) || 0, - Line1: document.getElementById('settingAddressLine1').value, + Line1: document.getElementById('settingLine1').value, City: document.getElementById('settingCity').value, State: document.getElementById('settingState').value, Zip: document.getElementById('settingZip').value @@ -1132,7 +1107,7 @@ const Portal = { openCustomerPreview() { const businessId = this.config.businessId; const businessName = encodeURIComponent( - this.currentBusiness?.NAME || this.currentBusiness?.BUSINESSNAME || 'Preview' + this.currentBusiness?.BUSINESSNAME || this.currentBusiness?.Name || 'Preview' ); const deepLink = `payfrit://open?businessId=${businessId}&businessName=${businessName}`; window.location.href = deepLink; @@ -1192,12 +1167,8 @@ const Portal = { this.toast('Header uploaded successfully!', 'success'); // Update preview using the URL from the response const preview = document.getElementById('headerPreview'); - const wrapper = document.getElementById('headerPreviewWrapper'); if (preview && data.HEADERURL) { - preview.onload = function() { - if (wrapper) wrapper.style.display = 'block'; - }; - preview.src = `${BASE_PATH}${data.HEADERURL}?t=${Date.now()}`; + preview.style.backgroundImage = `url(${BASE_PATH}${data.HEADERURL}?t=${Date.now()})`; } } else { this.toast(data.MESSAGE || 'Failed to upload header', 'error'); @@ -1275,7 +1246,7 @@ const Portal = { if (data.OK) { this.brandColor = color; const swatch = document.getElementById('brandColorSwatch'); - if (swatch) swatch.style.backgroundColor = color; + if (swatch) swatch.style.background = color; this.closeModal(); this.toast('Brand color saved!', 'success'); } else { @@ -3810,6 +3781,215 @@ const Portal = { console.error('[Portal] Error submitting rating:', err); this.toast('Error submitting rating', 'error'); } + }, + + // ========== SP SHARING ========== + + _spSharingSelectedBizID: 0, + _spSharingSearchTimer: null, + + async loadSPSharingPage() { + const bizId = this.config.businessId; + if (!bizId) return; + + const STATUS_LABELS = { 0: 'Pending', 1: 'Active', 2: 'Declined', 3: 'Revoked' }; + const STATUS_COLORS = { 0: '#f59e0b', 1: '#10b981', 2: '#ef4444', 3: '#ef4444' }; + + // Load owner grants + try { + const ownerData = await this.api('/api/grants/list.cfm', { BusinessID: bizId, Role: 'owner' }); + const ownerEl = document.getElementById('ownerGrantsList'); + if (ownerData.OK && ownerData.Grants.length) { + let html = ''; + 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 = ``; + } + html += ``; + }); + html += '
Guest BusinessService PointEconomicsStatusActions
${this.escapeHtml(g.GuestBusinessName)} (#${g.GuestBusinessID})${this.escapeHtml(g.ServicePointName)}${econ}${STATUS_LABELS[g.StatusID]}${actions}
'; + ownerEl.innerHTML = html; + } else { + ownerEl.innerHTML = '

No grants created yet. Use "Invite Business" to share your service points.

'; + } + } 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 = ''; + 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 = ``; + } + html += ``; + }); + html += '
Owner BusinessService PointEconomicsEligibilityStatusActions
${this.escapeHtml(g.OwnerBusinessName)} (#${g.OwnerBusinessID})${this.escapeHtml(g.ServicePointName)}${econ}${g.EligibilityScope}${STATUS_LABELS[g.StatusID]}${actions}
'; + guestEl.innerHTML = html; + } else { + guestEl.innerHTML = '

No invites or active grants from other businesses.

'; + } + } 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 => ``).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 => + `
${this.escapeHtml(b.Name)} (#${b.BusinessID})
` + ).join(''); + } else { + resultsEl.innerHTML = '
No businesses found
'; + } + } 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'); + } } };