/** * 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, and existing PaymentIntent qOrder = queryExecute(" SELECT DeliveryFee, OrderTypeID, GrantID, GrantOwnerBusinessID, GrantEconomicsType, GrantEconomicsValue, StripePaymentIntentID FROM Orders WHERE ID = :orderID ", { orderID: orderID }, { datasource: "payfrit" }); // Check if order already has a PaymentIntent (idempotency check) if (qOrder.recordCount > 0 && len(trim(qOrder.StripePaymentIntentID ?: "")) > 0) { response["OK"] = false; response["ERROR"] = "existing_payment_intent"; response["MESSAGE"] = "Order already has a PaymentIntent. Use existing checkout session or retry payment."; response["PAYMENT_INTENT_ID"] = qOrder.StripePaymentIntentID; writeOutput(serializeJSON(response)); abort; } 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); // 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"); 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 expected amount and PaymentIntent ID for webhook verification queryExecute(" UPDATE Orders SET ExpectedAmountCents = :expectedCents, StripePaymentIntentID = :piID WHERE ID = :orderID ", { expectedCents: totalAmountCents, 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));