This repository has been archived on 2026-03-21. You can view files and clone it, but cannot push or open issues or pull requests.
payfrit-biz/api/tabs/open.cfm
John Mizerek 9ed200d7ea Fix open tab: accept AuthAmountCents from Android client
Android sends cents, server was reading dollars only, falling back to default $150.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:43:52 -08:00

188 lines
8 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);
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, OpenedOn, LastActivityOn)
VALUES (:uuid, :bizID, :userID, :spID, 1, :authCents, :piID, :custID, 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" }
});
// 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>