/** * 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, 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 (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; // 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));