- open.cfm: accept optional ApprovalMode param (0=auto, 1=manual, NULL=business default) - addOrder.cfm: check tab ApprovalMode first, fall back to business TabApprovalRequired - getActive.cfm: return resolved ApprovalRequired in tab response Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
190 lines
8.3 KiB
Text
190 lines
8.3 KiB
Text
<cfsetting showdebugoutput="false">
|
|
<cfsetting enablecfoutputonly="true">
|
|
<cfcontent type="application/json; charset=utf-8" reset="true">
|
|
<cfheader name="Cache-Control" value="no-store">
|
|
|
|
<cfscript>
|
|
/**
|
|
* Open a Tab
|
|
*
|
|
* Creates a Stripe PaymentIntent with capture_method=manual (card hold).
|
|
* Returns client_secret for the Android PaymentSheet to confirm the hold.
|
|
*
|
|
* POST: {
|
|
* UserID: int,
|
|
* BusinessID: int,
|
|
* AuthAmount: number (dollars, customer-chosen),
|
|
* ServicePointID: int (optional)
|
|
* }
|
|
*/
|
|
|
|
response = { "OK": false };
|
|
|
|
try {
|
|
requestData = deserializeJSON(toString(getHttpRequestData().content));
|
|
|
|
userID = val(requestData.UserID ?: 0);
|
|
businessID = val(requestData.BusinessID ?: 0);
|
|
// Accept AuthAmount (dollars) or AuthAmountCents (cents)
|
|
if (structKeyExists(requestData, "AuthAmountCents") && val(requestData.AuthAmountCents) > 0) {
|
|
authAmount = val(requestData.AuthAmountCents) / 100;
|
|
} else {
|
|
authAmount = val(requestData.AuthAmount ?: 0);
|
|
}
|
|
servicePointID = val(requestData.ServicePointID ?: 0);
|
|
approvalMode = structKeyExists(requestData, "ApprovalMode") ? val(requestData.ApprovalMode) : javaCast("null", "");
|
|
|
|
if (userID == 0) apiAbort({ "OK": false, "ERROR": "missing_UserID" });
|
|
if (businessID == 0) apiAbort({ "OK": false, "ERROR": "missing_BusinessID" });
|
|
|
|
// Get business config
|
|
qBiz = queryTimed("
|
|
SELECT SessionEnabled, TabMinAuthAmount, TabDefaultAuthAmount, TabMaxAuthAmount,
|
|
StripeAccountID, StripeOnboardingComplete
|
|
FROM Businesses WHERE ID = :bizID LIMIT 1
|
|
", { bizID: { value: businessID, cfsqltype: "cf_sql_integer" } });
|
|
|
|
if (qBiz.recordCount == 0) apiAbort({ "OK": false, "ERROR": "business_not_found" });
|
|
if (!qBiz.SessionEnabled) apiAbort({ "OK": false, "ERROR": "tabs_not_enabled", "MESSAGE": "This business does not accept tabs." });
|
|
|
|
// Validate auth amount
|
|
minAuth = val(qBiz.TabMinAuthAmount);
|
|
maxAuth = val(qBiz.TabMaxAuthAmount);
|
|
if (authAmount <= 0) authAmount = val(qBiz.TabDefaultAuthAmount);
|
|
if (authAmount < minAuth) apiAbort({ "OK": false, "ERROR": "auth_too_low", "MESSAGE": "Minimum authorization is $#numberFormat(minAuth, '0.00')#", "MIN": minAuth });
|
|
if (authAmount > maxAuth) apiAbort({ "OK": false, "ERROR": "auth_too_high", "MESSAGE": "Maximum authorization is $#numberFormat(maxAuth, '0.00')#", "MAX": maxAuth });
|
|
|
|
// Check user not already on a tab (globally)
|
|
qExisting = queryTimed("
|
|
SELECT t.ID, t.BusinessID, b.Name AS BusinessName
|
|
FROM TabMembers tm
|
|
JOIN Tabs t ON t.ID = tm.TabID
|
|
JOIN Businesses b ON b.ID = t.BusinessID
|
|
WHERE tm.UserID = :userID AND tm.StatusID = 1 AND t.StatusID = 1
|
|
LIMIT 1
|
|
", { userID: { value: userID, cfsqltype: "cf_sql_integer" } });
|
|
|
|
if (qExisting.recordCount > 0) {
|
|
apiAbort({
|
|
"OK": false, "ERROR": "already_on_tab",
|
|
"MESSAGE": "You're already on a tab at #qExisting.BusinessName#.",
|
|
"EXISTING_TAB_ID": qExisting.ID,
|
|
"EXISTING_BUSINESS_NAME": qExisting.BusinessName
|
|
});
|
|
}
|
|
|
|
// Get or create Stripe Customer
|
|
qUser = queryTimed("
|
|
SELECT StripeCustomerId, EmailAddress, FirstName, LastName
|
|
FROM Users WHERE ID = :userID LIMIT 1
|
|
", { userID: { value: userID, cfsqltype: "cf_sql_integer" } });
|
|
|
|
if (qUser.recordCount == 0) apiAbort({ "OK": false, "ERROR": "user_not_found" });
|
|
|
|
stripeCustomerId = qUser.StripeCustomerId;
|
|
if (!len(trim(stripeCustomerId))) {
|
|
// Create Stripe Customer
|
|
cfhttp(method="POST", url="https://api.stripe.com/v1/customers", result="custResp") {
|
|
cfhttpparam(type="header", name="Authorization", value="Bearer #application.stripeSecretKey#");
|
|
cfhttpparam(type="formfield", name="name", value="#qUser.FirstName# #qUser.LastName#");
|
|
if (len(trim(qUser.EmailAddress))) {
|
|
cfhttpparam(type="formfield", name="email", value=qUser.EmailAddress);
|
|
}
|
|
cfhttpparam(type="formfield", name="metadata[payfrit_user_id]", value=userID);
|
|
}
|
|
custData = deserializeJSON(custResp.fileContent);
|
|
if (structKeyExists(custData, "id")) {
|
|
stripeCustomerId = custData.id;
|
|
queryTimed("UPDATE Users SET StripeCustomerId = :cid WHERE ID = :uid", {
|
|
cid: { value: stripeCustomerId, cfsqltype: "cf_sql_varchar" },
|
|
uid: { value: userID, cfsqltype: "cf_sql_integer" }
|
|
});
|
|
} else {
|
|
apiAbort({ "OK": false, "ERROR": "stripe_customer_failed", "MESSAGE": "Could not create Stripe customer." });
|
|
}
|
|
}
|
|
|
|
// Generate tab UUID
|
|
tabUUID = createObject("java", "java.util.UUID").randomUUID().toString();
|
|
authAmountCents = round(authAmount * 100);
|
|
|
|
// Create PaymentIntent with manual capture
|
|
piParams = {
|
|
"amount": authAmountCents,
|
|
"currency": "usd",
|
|
"capture_method": "manual",
|
|
"customer": stripeCustomerId,
|
|
"setup_future_usage": "off_session",
|
|
"automatic_payment_methods[enabled]": "true",
|
|
"metadata[type]": "tab_authorization",
|
|
"metadata[tab_uuid]": tabUUID,
|
|
"metadata[business_id]": businessID,
|
|
"metadata[user_id]": userID
|
|
};
|
|
|
|
// Stripe Connect: if business has a connected account, set transfer_data
|
|
if (len(trim(qBiz.StripeAccountID)) && val(qBiz.StripeOnboardingComplete) == 1) {
|
|
piParams["transfer_data[destination]"] = qBiz.StripeAccountID;
|
|
// application_fee_amount set at capture time, not authorization
|
|
}
|
|
|
|
// Idempotency key to prevent duplicate tab creation on retry
|
|
idempotencyKey = "tab-open-#userID#-#businessID#-#dateFormat(now(), 'yyyymmdd')#-#timeFormat(now(), 'HHmmss')#";
|
|
|
|
cfhttp(method="POST", url="https://api.stripe.com/v1/payment_intents", result="piResp") {
|
|
cfhttpparam(type="header", name="Authorization", value="Bearer #application.stripeSecretKey#");
|
|
cfhttpparam(type="header", name="Idempotency-Key", value=idempotencyKey);
|
|
for (key in piParams) {
|
|
cfhttpparam(type="formfield", name=key, value=piParams[key]);
|
|
}
|
|
}
|
|
|
|
piData = deserializeJSON(piResp.fileContent);
|
|
if (!structKeyExists(piData, "id")) {
|
|
errMsg = structKeyExists(piData, "error") && structKeyExists(piData.error, "message") ? piData.error.message : "Stripe error";
|
|
apiAbort({ "OK": false, "ERROR": "stripe_pi_failed", "MESSAGE": errMsg });
|
|
}
|
|
|
|
// Insert tab
|
|
queryTimed("
|
|
INSERT INTO Tabs (UUID, BusinessID, OwnerUserID, ServicePointID, StatusID,
|
|
AuthAmountCents, StripePaymentIntentID, StripeCustomerID, ApprovalMode, OpenedOn, LastActivityOn)
|
|
VALUES (:uuid, :bizID, :userID, :spID, 1, :authCents, :piID, :custID, :approvalMode, NOW(), NOW())
|
|
", {
|
|
uuid: { value: tabUUID, cfsqltype: "cf_sql_varchar" },
|
|
bizID: { value: businessID, cfsqltype: "cf_sql_integer" },
|
|
userID: { value: userID, cfsqltype: "cf_sql_integer" },
|
|
spID: { value: servicePointID > 0 ? servicePointID : javaCast("null", ""), cfsqltype: "cf_sql_integer", null: servicePointID <= 0 },
|
|
authCents: { value: authAmountCents, cfsqltype: "cf_sql_integer" },
|
|
piID: { value: piData.id, cfsqltype: "cf_sql_varchar" },
|
|
custID: { value: stripeCustomerId, cfsqltype: "cf_sql_varchar" },
|
|
approvalMode: { value: isNull(approvalMode) ? javaCast("null", "") : approvalMode, cfsqltype: "cf_sql_tinyint", null: isNull(approvalMode) }
|
|
});
|
|
|
|
// Get the tab ID
|
|
qTab = queryTimed("SELECT LAST_INSERT_ID() AS TabID");
|
|
tabID = qTab.TabID;
|
|
|
|
// Add owner as TabMember with RoleID=1
|
|
queryTimed("
|
|
INSERT INTO TabMembers (TabID, UserID, RoleID, StatusID, JoinedOn)
|
|
VALUES (:tabID, :userID, 1, 1, NOW())
|
|
", {
|
|
tabID: { value: tabID, cfsqltype: "cf_sql_integer" },
|
|
userID: { value: userID, cfsqltype: "cf_sql_integer" }
|
|
});
|
|
|
|
apiAbort({
|
|
"OK": true,
|
|
"TAB_ID": tabID,
|
|
"TAB_UUID": tabUUID,
|
|
"CLIENT_SECRET": piData.client_secret,
|
|
"PAYMENT_INTENT_ID": piData.id,
|
|
"AUTH_AMOUNT_CENTS": authAmountCents,
|
|
"PUBLISHABLE_KEY": application.stripePublishableKey
|
|
});
|
|
|
|
} catch (any e) {
|
|
apiAbort({ "OK": false, "ERROR": "server_error", "MESSAGE": e.message });
|
|
}
|
|
</cfscript>
|