/** * 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; // Validate existing customer with Stripe (catches live/test mode mismatch) needNewCustomer = true; if (len(trim(stripeCustomerId))) { cfhttp(method="GET", url="https://api.stripe.com/v1/customers/#stripeCustomerId#", result="checkResp") { cfhttpparam(type="header", name="Authorization", value="Bearer #application.stripeSecretKey#"); } checkData = deserializeJSON(checkResp.fileContent); if (structKeyExists(checkData, "id") && !structKeyExists(checkData, "error")) { needNewCustomer = false; } } if (needNewCustomer) { // Create Stripe Customer (or replace invalid one) 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 }); }