/** * Close Tab * Calculates aggregate fees, adds tip, captures the Stripe PaymentIntent. * * POST: { * TabID: int, * UserID: int (must be tab owner or business employee), * TipPercent: number (optional, e.g. 0.18 for 18%), * TipAmount: number (optional, in dollars - one of TipPercent or TipAmount) * } */ try { requestData = deserializeJSON(toString(getHttpRequestData().content)); tabID = val(requestData.TabID ?: 0); userID = val(requestData.UserID ?: 0); tipPercent = structKeyExists(requestData, "TipPercent") ? val(requestData.TipPercent) : -1; tipAmount = structKeyExists(requestData, "TipAmount") ? val(requestData.TipAmount) : -1; if (tabID == 0) apiAbort({ "OK": false, "ERROR": "missing_TabID" }); if (userID == 0) apiAbort({ "OK": false, "ERROR": "missing_UserID" }); // Get tab + business info qTab = queryTimed(" SELECT t.*, b.PayfritFee, b.StripeAccountID, b.StripeOnboardingComplete FROM Tabs t JOIN Businesses b ON b.ID = t.BusinessID WHERE t.ID = :tabID LIMIT 1 ", { tabID: { value: tabID, cfsqltype: "cf_sql_integer" } }); if (qTab.recordCount == 0) apiAbort({ "OK": false, "ERROR": "tab_not_found" }); if (qTab.StatusID != 1) apiAbort({ "OK": false, "ERROR": "tab_not_open", "MESSAGE": "Tab is not open (status: #qTab.StatusID#)." }); // Verify: must be tab owner or business employee isTabOwner = qTab.OwnerUserID == userID; if (!isTabOwner) { qEmp = queryTimed(" SELECT ID FROM Employees WHERE BusinessID = :bizID AND UserID = :userID AND IsActive = 1 LIMIT 1 ", { bizID: { value: qTab.BusinessID, cfsqltype: "cf_sql_integer" }, userID: { value: userID, cfsqltype: "cf_sql_integer" } }); if (qEmp.recordCount == 0) apiAbort({ "OK": false, "ERROR": "not_authorized", "MESSAGE": "Only the tab owner or a business employee can close the tab." }); } // Reject any pending orders queryTimed(" UPDATE TabOrders SET ApprovalStatus = 'rejected' WHERE TabID = :tabID AND ApprovalStatus = 'pending' ", { tabID: { value: tabID, cfsqltype: "cf_sql_integer" } }); // Calculate aggregate totals from approved orders qTotals = queryTimed(" SELECT COALESCE(SUM(SubtotalCents), 0) AS TotalSubtotalCents, COALESCE(SUM(TaxCents), 0) AS TotalTaxCents FROM TabOrders WHERE TabID = :tabID AND ApprovalStatus = 'approved' ", { tabID: { value: tabID, cfsqltype: "cf_sql_integer" } }); totalSubtotalCents = val(qTotals.TotalSubtotalCents); totalTaxCents = val(qTotals.TotalTaxCents); // If no orders, just cancel if (totalSubtotalCents == 0) { // Cancel the PI cfhttp(method="POST", url="https://api.stripe.com/v1/payment_intents/#qTab.StripePaymentIntentID#/cancel", result="cancelResp") { cfhttpparam(type="header", name="Authorization", value="Bearer #application.stripeSecretKey#"); } queryTimed(" UPDATE Tabs SET StatusID = 4, ClosedOn = NOW(), PaymentStatus = 'cancelled' WHERE ID = :tabID ", { tabID: { value: tabID, cfsqltype: "cf_sql_integer" } }); apiAbort({ "OK": true, "MESSAGE": "Tab cancelled (no orders).", "FINAL_CAPTURE_CENTS": 0 }); } // Calculate tip tipCents = 0; tipPct = 0; totalSubtotalDollars = totalSubtotalCents / 100; if (tipPercent >= 0) { tipPct = tipPercent; tipCents = round(totalSubtotalCents * tipPercent); } else if (tipAmount >= 0) { tipCents = round(tipAmount * 100); if (totalSubtotalCents > 0) tipPct = tipCents / totalSubtotalCents; } // Fee calculation (same formula as createPaymentIntent.cfm) payfritFeeRate = val(qTab.PayfritFee); if (payfritFeeRate <= 0) apiAbort({ "OK": false, "ERROR": "no_fee_configured", "MESSAGE": "Business PayfritFee not set." }); payfritFeeCents = round(totalSubtotalCents * payfritFeeRate); totalBeforeCardFeeCents = totalSubtotalCents + totalTaxCents + tipCents + payfritFeeCents; // Card fee: (total + $0.30) / (1 - 0.029) - total cardFeeFixedCents = 30; // $0.30 cardFeePercent = 0.029; totalWithCardFeeCents = ceiling((totalBeforeCardFeeCents + cardFeeFixedCents) / (1 - cardFeePercent)); cardFeeCents = totalWithCardFeeCents - totalBeforeCardFeeCents; finalCaptureCents = totalWithCardFeeCents; // Stripe Connect: application_fee_amount = 2x payfritFee (customer + business share) applicationFeeCents = payfritFeeCents * 2; // Ensure capture doesn't exceed authorization if (finalCaptureCents > qTab.AuthAmountCents) { // Cap at authorized amount - customer underpays slightly // In practice this shouldn't happen if we enforce limits on addOrder finalCaptureCents = qTab.AuthAmountCents; // Recalculate application fee proportionally if (totalWithCardFeeCents > 0) { applicationFeeCents = round(applicationFeeCents * (finalCaptureCents / totalWithCardFeeCents)); } } // Mark tab as closing queryTimed("UPDATE Tabs SET StatusID = 2 WHERE ID = :tabID", { tabID: { value: tabID, cfsqltype: "cf_sql_integer" } }); // Capture the PaymentIntent cfhttp(method="POST", url="https://api.stripe.com/v1/payment_intents/#qTab.StripePaymentIntentID#/capture", result="captureResp") { cfhttpparam(type="header", name="Authorization", value="Bearer #application.stripeSecretKey#"); cfhttpparam(type="formfield", name="amount_to_capture", value=finalCaptureCents); if (len(trim(qTab.StripeAccountID)) && val(qTab.StripeOnboardingComplete) == 1) { cfhttpparam(type="formfield", name="application_fee_amount", value=applicationFeeCents); } cfhttpparam(type="formfield", name="metadata[type]", value="tab_close"); cfhttpparam(type="formfield", name="metadata[tab_id]", value=tabID); cfhttpparam(type="formfield", name="metadata[tip_cents]", value=tipCents); } captureData = deserializeJSON(captureResp.fileContent); if (structKeyExists(captureData, "status") && captureData.status == "succeeded") { // Success - update tab queryTimed(" UPDATE Tabs SET StatusID = 3, ClosedOn = NOW(), CapturedOn = NOW(), TipAmountCents = :tipCents, FinalCaptureCents = :captureCents, RunningTotalCents = :runningTotal, PaymentStatus = 'captured' WHERE ID = :tabID ", { tipCents: { value: tipCents, cfsqltype: "cf_sql_integer" }, captureCents: { value: finalCaptureCents, cfsqltype: "cf_sql_integer" }, runningTotal: { value: totalSubtotalCents + totalTaxCents, cfsqltype: "cf_sql_integer" }, tabID: { value: tabID, cfsqltype: "cf_sql_integer" } }); // Mark all approved orders as paid queryTimed(" UPDATE Orders o JOIN TabOrders tbo ON tbo.OrderID = o.ID SET o.PaymentStatus = 'paid', o.PaymentCompletedOn = NOW() WHERE tbo.TabID = :tabID AND tbo.ApprovalStatus = 'approved' ", { tabID: { value: tabID, cfsqltype: "cf_sql_integer" } }); // Mark all tab members as left queryTimed(" UPDATE TabMembers SET StatusID = 3, LeftOn = NOW() WHERE TabID = :tabID AND StatusID = 1 ", { tabID: { value: tabID, cfsqltype: "cf_sql_integer" } }); apiAbort({ "OK": true, "FINAL_CAPTURE_CENTS": finalCaptureCents, "TAB_UUID": qTab.UUID, "FEE_BREAKDOWN": { "SUBTOTAL_CENTS": totalSubtotalCents, "TAX_CENTS": totalTaxCents, "TIP_CENTS": tipCents, "TIP_PERCENT": tipPct, "PAYFRIT_FEE_CENTS": payfritFeeCents, "CARD_FEE_CENTS": cardFeeCents, "TOTAL_CENTS": finalCaptureCents } }); } else { // Capture failed errMsg = structKeyExists(captureData, "error") && structKeyExists(captureData.error, "message") ? captureData.error.message : "Capture failed"; queryTimed(" UPDATE Tabs SET PaymentStatus = 'capture_failed', PaymentError = :err WHERE ID = :tabID ", { err: { value: errMsg, cfsqltype: "cf_sql_varchar" }, tabID: { value: tabID, cfsqltype: "cf_sql_integer" } }); apiAbort({ "OK": false, "ERROR": "capture_failed", "MESSAGE": errMsg }); } } catch (any e) { apiAbort({ "OK": false, "ERROR": "server_error", "MESSAGE": e.message }); }