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>
369 lines
16 KiB
Text
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>
|