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/stripe/createPaymentIntent.cfm
John Mizerek c65cd8242b Round balance amounts to cents before applying
Prevents sub-cent precision (e.g. $0.883125) from accumulating in
BalanceApplied and payment records. All balance math now rounds to
nearest cent first.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:32:54 -08:00

412 lines
18 KiB
Text

<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
/**
* Create Payment Intent for Order (v2 - with Payfrit fee structure)
*
* Fee Structure:
* - Fee rate read from Businesses.PayfritFee column (e.g., 0.065 = 6.5%)
* - Customer pays: Subtotal + tax + tip + PayfritFee% + card processing (2.9% + $0.30)
* - Restaurant receives: Subtotal - PayfritFee% + tax + tip
* - Payfrit receives: 2x PayfritFee% of subtotal (from customer + business)
*
* POST: {
* BusinessID: int,
* OrderID: int,
* Subtotal: number (order subtotal in dollars, before fees),
* Tax: number (tax amount in dollars),
* Tip: number (optional, in dollars),
* CustomerEmail: string (optional)
* }
*/
response = { "OK": false };
try {
requestData = deserializeJSON(toString(getHttpRequestData().content));
businessID = val(requestData.BusinessID ?: 0);
orderID = val(requestData.OrderID ?: 0);
subtotal = val(requestData.Subtotal ?: 0);
tax = val(requestData.Tax ?: 0);
tip = val(requestData.Tip ?: 0);
customerEmail = requestData.CustomerEmail ?: "";
if (businessID == 0) {
response["ERROR"] = "BusinessID is required";
writeOutput(serializeJSON(response));
abort;
}
if (orderID == 0) {
response["ERROR"] = "OrderID is required";
writeOutput(serializeJSON(response));
abort;
}
if (subtotal <= 0) {
response["ERROR"] = "Invalid subtotal";
writeOutput(serializeJSON(response));
abort;
}
// Use test keys
stripeSecretKey = application.stripeSecretKey ?: "sk_test_LfbmDduJxTwbVZmvcByYmirw";
if (stripeSecretKey == "") {
response["ERROR"] = "Stripe is not configured";
writeOutput(serializeJSON(response));
abort;
}
// Get business Stripe account and fee settings
qBusiness = queryExecute("
SELECT StripeAccountID, StripeOnboardingComplete, Name, PayfritFee
FROM Businesses
WHERE ID = :businessID
", { businessID: businessID }, { datasource: "payfrit" });
if (qBusiness.recordCount == 0) {
response["ERROR"] = "Business not found";
writeOutput(serializeJSON(response));
abort;
}
// Get order's delivery fee, grant economics, existing PaymentIntent, and user info
qOrder = queryExecute("
SELECT o.DeliveryFee, o.OrderTypeID, o.GrantID, o.GrantOwnerBusinessID,
o.GrantEconomicsType, o.GrantEconomicsValue, o.StripePaymentIntentID,
o.UserID, o.BalanceApplied AS PrevBalanceApplied,
u.StripeCustomerId, u.EmailAddress, u.FirstName, u.LastName, u.Balance
FROM Orders o
LEFT JOIN Users u ON u.ID = o.UserID
WHERE o.ID = :orderID
", { orderID: orderID }, { datasource: "payfrit" });
deliveryFee = 0;
if (qOrder.recordCount > 0 && qOrder.OrderTypeID == 3) {
deliveryFee = val(qOrder.DeliveryFee);
}
// 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
hasStripeConnect = qBusiness.StripeOnboardingComplete == 1 && len(trim(qBusiness.StripeAccountID)) > 0;
// ============================================================
// FEE CALCULATION (from Businesses.PayfritFee)
// ============================================================
if (!isNumeric(qBusiness.PayfritFee) || qBusiness.PayfritFee <= 0) {
response["ERROR"] = "Business PayfritFee not configured";
writeOutput(serializeJSON(response));
abort;
}
customerFeePercent = qBusiness.PayfritFee;
businessFeePercent = customerFeePercent; // Business fee mirrors customer fee
cardFeePercent = 0.029; // 2.9% Stripe fee
cardFeeFixed = 0.30; // $0.30 Stripe fixed fee
payfritCustomerFee = subtotal * customerFeePercent;
payfritBusinessFee = subtotal * businessFeePercent;
totalBeforeCardFee = subtotal + tax + tip + deliveryFee + payfritCustomerFee;
// ============================================================
// AUTO-APPLY USER BALANCE (silently reduces card charge)
// ============================================================
balanceApplied = 0;
userBalance = val(qOrder.Balance ?: 0);
orderUserID = val(qOrder.UserID ?: 0);
if (userBalance > 0 && orderUserID > 0) {
// Round to cents to avoid sub-cent precision issues
balanceApplied = round(min(userBalance, totalBeforeCardFee) * 100) / 100;
// Ensure Stripe minimum: adjusted amount after card fee must be >= $0.50
adjustedTest = ((totalBeforeCardFee - balanceApplied) + cardFeeFixed) / (1 - cardFeePercent);
if (adjustedTest < 0.50) {
// Cap balance so Stripe charge stays >= $0.50
maxBalance = totalBeforeCardFee - ((0.50 * (1 - cardFeePercent)) - cardFeeFixed);
balanceApplied = round(max(0, min(userBalance, maxBalance)) * 100) / 100;
}
// Store intent on order (actual deduction happens in webhook after payment succeeds)
if (balanceApplied > 0) {
queryExecute("UPDATE Orders SET BalanceApplied = :bal WHERE ID = :orderID",
{ bal: { value: balanceApplied, cfsqltype: "cf_sql_decimal" }, orderID: orderID },
{ datasource: "payfrit" });
}
}
// Apply balance: reduce the pre-card-fee amount, recalculate card fee on smaller amount
adjustedBeforeCardFee = totalBeforeCardFee - balanceApplied;
// Stripe charges 2.9% + $0.30 on the TOTAL charged, not the pre-fee amount.
// To fully pass through Stripe fees: charge = (net + fixed) / (1 - percent)
totalCustomerPays = (adjustedBeforeCardFee + cardFeeFixed) / (1 - cardFeePercent);
cardFee = totalCustomerPays - adjustedBeforeCardFee;
// Convert to cents for Stripe
totalAmountCents = round(totalCustomerPays * 100);
totalPlatformFeeCents = round((payfritCustomerFee + payfritBusinessFee) * 100);
// ============================================================
// CHECK FOR EXISTING PAYMENTINTENT - REUSE OR UPDATE IF VALID
// ============================================================
existingPiId = qOrder.StripePaymentIntentID ?: "";
if (qOrder.recordCount > 0 && len(trim(existingPiId)) > 0) {
// Retrieve existing PaymentIntent from Stripe
piRetrieve = new http();
piRetrieve.setMethod("GET");
piRetrieve.setUrl("https://api.stripe.com/v1/payment_intents/#existingPiId#");
piRetrieve.setUsername(stripeSecretKey);
piRetrieve.setPassword("");
piResult = piRetrieve.send().getPrefix();
existingPi = deserializeJSON(piResult.fileContent);
if (!structKeyExists(existingPi, "error")) {
piStatus = existingPi.status ?: "";
// Reusable states: can still complete payment
if (listFindNoCase("requires_payment_method,requires_confirmation,requires_action", piStatus)) {
// Check if amount changed (cart modified)
if (existingPi.amount != totalAmountCents) {
// Update the PaymentIntent with new amount
piUpdate = new http();
piUpdate.setMethod("POST");
piUpdate.setUrl("https://api.stripe.com/v1/payment_intents/#existingPiId#");
piUpdate.setUsername(stripeSecretKey);
piUpdate.setPassword("");
piUpdate.addParam(type="formfield", name="amount", value=totalAmountCents);
piUpdateResult = piUpdate.send().getPrefix();
existingPi = deserializeJSON(piUpdateResult.fileContent);
}
// Return existing PaymentIntent
response["OK"] = true;
response["CLIENT_SECRET"] = existingPi.client_secret;
response["PAYMENT_INTENT_ID"] = existingPi.id;
response["PUBLISHABLE_KEY"] = application.stripePublishableKey ?: "pk_test_sPBNzSyJ9HcEPJGC7dSo8NqN";
response["REUSED"] = true;
response["FEE_BREAKDOWN"] = {
"SUBTOTAL": subtotal, "TAX": tax, "TIP": tip,
"DELIVERY_FEE": deliveryFee, "PAYFRIT_FEE": payfritCustomerFee,
"CARD_FEE": cardFee, "BALANCE_APPLIED": balanceApplied,
"TOTAL": totalCustomerPays,
"TOTAL_BEFORE_BALANCE": (balanceApplied > 0) ? totalCustomerPays + balanceApplied : 0,
"GRANT_OWNER_FEE_CENTS": grantOwnerFeeCents
};
writeOutput(serializeJSON(response));
abort;
} else if (piStatus == "succeeded") {
// Already paid - don't create another
response["OK"] = false;
response["ERROR"] = "already_paid";
response["MESSAGE"] = "This order has already been paid.";
writeOutput(serializeJSON(response));
abort;
}
// Other terminal states (canceled, etc.) - clear and create new
}
// PaymentIntent not found or terminal - clear it from order
queryExecute("UPDATE Orders SET StripePaymentIntentID = NULL WHERE ID = :orderID",
{ orderID: orderID }, { datasource: "payfrit" });
}
// ============================================================
// STRIPE CUSTOMER (for saving payment methods)
// ============================================================
stripeCustomerId = "";
orderUserID = val(qOrder.UserID ?: 0);
if (orderUserID > 0) {
stripeCustomerId = qOrder.StripeCustomerId ?: "";
// Create Stripe Customer if user doesn't have one
if (len(trim(stripeCustomerId)) == 0) {
customerService = new http();
customerService.setMethod("POST");
customerService.setUrl("https://api.stripe.com/v1/customers");
customerService.setUsername(stripeSecretKey);
customerService.setPassword("");
// Build customer name
customerName = trim((qOrder.FirstName ?: "") & " " & (qOrder.LastName ?: ""));
if (len(customerName) > 0) {
customerService.addParam(type="formfield", name="name", value=customerName);
}
if (len(trim(qOrder.EmailAddress ?: "")) > 0) {
customerService.addParam(type="formfield", name="email", value=qOrder.EmailAddress);
}
customerService.addParam(type="formfield", name="metadata[user_id]", value=orderUserID);
customerResult = customerService.send().getPrefix();
customerData = deserializeJSON(customerResult.fileContent);
if (!structKeyExists(customerData, "error") && structKeyExists(customerData, "id")) {
stripeCustomerId = customerData.id;
// Save to Users table
queryExecute("
UPDATE Users SET StripeCustomerId = :custId WHERE ID = :userId
", {
custId: stripeCustomerId,
userId: orderUserID
}, { datasource: "payfrit" });
writeLog(file="stripe_webhooks", text="Created Stripe Customer #stripeCustomerId# for user #orderUserID#");
}
}
}
// Create PaymentIntent
httpService = new http();
httpService.setMethod("POST");
httpService.setUrl("https://api.stripe.com/v1/payment_intents");
httpService.setUsername(stripeSecretKey);
httpService.setPassword("");
// IDEMPOTENCY: Prevent duplicate PaymentIntents on client retry
httpService.addParam(type="header", name="Idempotency-Key", value="pi-order-#orderID#");
httpService.addParam(type="formfield", name="amount", value=totalAmountCents);
httpService.addParam(type="formfield", name="currency", value="usd");
httpService.addParam(type="formfield", name="automatic_payment_methods[enabled]", value="true");
// Attach customer and save payment method for future use
if (len(trim(stripeCustomerId)) > 0) {
httpService.addParam(type="formfield", name="customer", value=stripeCustomerId);
httpService.addParam(type="formfield", name="setup_future_usage", value="off_session");
}
if (hasStripeConnect) {
// 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.StripeAccountID);
}
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.Name#");
if (customerEmail != "") {
httpService.addParam(type="formfield", name="receipt_email", value=customerEmail);
}
result = httpService.send().getPrefix();
piData = deserializeJSON(result.fileContent);
if (structKeyExists(piData, "error")) {
response["ERROR"] = piData.error.message;
writeOutput(serializeJSON(response));
abort;
}
// Store PaymentIntent ID (ExpectedAmountCents requires migration)
queryExecute("
UPDATE Orders
SET StripePaymentIntentID = :piID
WHERE ID = :orderID
", {
piID: piData.id,
orderID: orderID
}, { datasource: "payfrit" });
writeLog(file="stripe_webhooks", text="PaymentIntent #piData.id# created for order #orderID# (#totalAmountCents# cents, idempotency key: pi-order-#orderID#)");
// Fees are calculated dynamically, not stored in DB
// Link PaymentIntent to worker payout ledger (if a ledger row exists for this order's task)
try {
queryExecute("
UPDATE WorkPayoutLedgers wpl
INNER JOIN Tasks t ON t.ID = wpl.TaskID AND t.OrderID = :orderID
SET wpl.StripePaymentIntentID = :piID
WHERE wpl.Status = 'pending_charge'
AND wpl.StripePaymentIntentID IS NULL
", {
orderID: orderID,
piID: piData.id
}, { datasource: "payfrit" });
} catch (any e) {
// Non-fatal: ledger row may not exist yet (task not completed before payment)
writeLog(file="stripe_webhooks", text="Ledger link skipped for order #orderID#: #e.message#");
}
// Create Checkout Session for web-based payment (iOS app uses this)
checkoutService = new http();
checkoutService.setMethod("POST");
checkoutService.setUrl("https://api.stripe.com/v1/checkout/sessions");
checkoutService.setUsername(stripeSecretKey);
checkoutService.setPassword("");
checkoutService.addParam(type="formfield", name="mode", value="payment");
checkoutService.addParam(type="formfield", name="line_items[0][price_data][currency]", value="usd");
checkoutService.addParam(type="formfield", name="line_items[0][price_data][product_data][name]", value="Order ###orderID# at #qBusiness.Name#");
checkoutService.addParam(type="formfield", name="line_items[0][price_data][unit_amount]", value=totalAmountCents);
checkoutService.addParam(type="formfield", name="line_items[0][quantity]", value="1");
checkoutService.addParam(type="formfield", name="success_url", value="payfrit://stripe-redirect?success=true&order_id=#orderID#");
checkoutService.addParam(type="formfield", name="cancel_url", value="payfrit://stripe-redirect?success=false&error=cancelled&order_id=#orderID#");
checkoutService.addParam(type="formfield", name="metadata[order_id]", value=orderID);
checkoutService.addParam(type="formfield", name="metadata[business_id]", value=businessID);
if (hasStripeConnect) {
effectivePlatformFeeCents = totalPlatformFeeCents + grantOwnerFeeCents;
checkoutService.addParam(type="formfield", name="payment_intent_data[application_fee_amount]", value=effectivePlatformFeeCents);
checkoutService.addParam(type="formfield", name="payment_intent_data[transfer_data][destination]", value=qBusiness.StripeAccountID);
}
checkoutResult = checkoutService.send().getPrefix();
checkoutData = deserializeJSON(checkoutResult.fileContent);
checkoutUrl = "";
if (structKeyExists(checkoutData, "url")) {
checkoutUrl = checkoutData.url;
} else if (structKeyExists(checkoutData, "error")) {
// Log checkout error but don't fail - client_secret still works for SDK
writeLog(file="stripe_webhooks", text="Checkout session error: #checkoutData.error.message#");
}
response["OK"] = true;
response["CLIENT_SECRET"] = piData.client_secret;
response["PAYMENT_INTENT_ID"] = piData.id;
response["PUBLISHABLE_KEY"] = application.stripePublishableKey ?: "pk_test_sPBNzSyJ9HcEPJGC7dSo8NqN";
response["CHECKOUT_URL"] = checkoutUrl;
response["FEE_BREAKDOWN"] = {
"SUBTOTAL": subtotal,
"TAX": tax,
"TIP": tip,
"DELIVERY_FEE": deliveryFee,
"PAYFRIT_FEE": payfritCustomerFee,
"CARD_FEE": cardFee,
"BALANCE_APPLIED": balanceApplied,
"TOTAL": totalCustomerPays,
"TOTAL_BEFORE_BALANCE": (balanceApplied > 0) ? totalCustomerPays + balanceApplied : 0,
"GRANT_OWNER_FEE_CENTS": grantOwnerFeeCents
};
response["STRIPE_CONNECT_ENABLED"] = hasStripeConnect;
} catch (any e) {
response["ERROR"] = e.message;
response["DETAIL"] = e.detail ?: "";
}
writeOutput(serializeJSON(response));
</cfscript>