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 bbfbbf1963 Update PaymentIntent amount if cart changed on retry
Moved fee calculation before PI check so we can compare amounts.
If existing PaymentIntent has different amount than current cart,
update it via Stripe API before returning.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 19:06:01 -08:00

369 lines
16 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:
* - Customer pays: Subtotal + tax + tip + 5% Payfrit fee + card processing (2.9% + $0.30)
* - Restaurant receives: Subtotal - 5% Payfrit fee + tax + tip
* - Payfrit receives: 10% of subtotal (5% from customer + 5% from restaurant)
*
* 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, u.StripeCustomerId, u.EmailAddress, u.FirstName, u.LastName
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
// ============================================================
// Customer fee from database (PayfritFee), default 5% if not set
customerFeePercent = isNumeric(qBusiness.PayfritFee) && qBusiness.PayfritFee > 0 ? qBusiness.PayfritFee : 0.05;
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;
// 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 = (totalBeforeCardFee + cardFeeFixed) / (1 - cardFeePercent);
cardFee = totalCustomerPays - totalBeforeCardFee;
// 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;
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,
"TOTAL": totalCustomerPays,
"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>