diff --git a/api/beacon-sharding/resolve_business.cfm b/api/beacon-sharding/resolve_business.cfm index 0dd474d..5a52816 100644 --- a/api/beacon-sharding/resolve_business.cfm +++ b/api/beacon-sharding/resolve_business.cfm @@ -84,7 +84,7 @@ function resolveSingleBusiness(uuid, major) { var headerImageURL = ""; if (len(qBiz.HeaderImageExtension)) { - headerImageURL = "/uploads/businesses/#qBiz.ID#/header.#qBiz.HeaderImageExtension#"; + headerImageURL = "/uploads/headers/#qBiz.ID#.#qBiz.HeaderImageExtension#"; } return { diff --git a/api/stripe/createPaymentIntent.cfm b/api/stripe/createPaymentIntent.cfm index 9c2b721..a02849f 100644 --- a/api/stripe/createPaymentIntent.cfm +++ b/api/stripe/createPaymentIntent.cfm @@ -73,13 +73,23 @@ try { abort; } - // Get order's delivery fee and grant economics (if applicable) + // Get order's delivery fee, grant economics, and existing PaymentIntent qOrder = queryExecute(" - SELECT DeliveryFee, OrderTypeID, GrantID, GrantOwnerBusinessID, GrantEconomicsType, GrantEconomicsValue + 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); @@ -110,15 +120,18 @@ try { // ============================================================ // Customer fee from database (PayfritFee), default 5% if not set customerFeePercent = isNumeric(qBusiness.PayfritFee) && qBusiness.PayfritFee > 0 ? qBusiness.PayfritFee : 0.05; - businessFeePercent = 0.05; // 5% business pays to Payfrit (always) + 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; - cardFee = (totalBeforeCardFee * cardFeePercent) + cardFeeFixed; - totalCustomerPays = totalBeforeCardFee + cardFee; + + // 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); @@ -131,6 +144,9 @@ try { 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"); @@ -166,6 +182,20 @@ try { 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) diff --git a/api/stripe/webhook.cfm b/api/stripe/webhook.cfm index 0c2362b..228971d 100644 --- a/api/stripe/webhook.cfm +++ b/api/stripe/webhook.cfm @@ -233,18 +233,19 @@ try { refundAmount = eventData.amount_refunded / 100; if (paymentIntentID != "") { + // FIX: Use correct column name (was OrderStripePaymentIntentID) qOrder = queryTimed(" SELECT ID FROM Orders - WHERE OrderStripePaymentIntentID = :paymentIntentID + WHERE StripePaymentIntentID = :paymentIntentID ", { paymentIntentID: paymentIntentID }); if (qOrder.recordCount > 0) { queryTimed(" UPDATE Orders SET PaymentStatus = 'refunded', - OrderRefundAmount = :refundAmount, - OrderRefundedOn = NOW() - WHERE OrderStripePaymentIntentID = :paymentIntentID + RefundAmount = :refundAmount, + RefundedOn = NOW() + WHERE StripePaymentIntentID = :paymentIntentID ", { paymentIntentID: paymentIntentID, refundAmount: refundAmount @@ -261,16 +262,17 @@ try { paymentIntentID = eventData.payment_intent ?: ""; if (paymentIntentID != "") { + // FIX: Use correct column name (was OrderStripePaymentIntentID) qOrder = queryTimed(" SELECT ID, BusinessID FROM Orders - WHERE OrderStripePaymentIntentID = :paymentIntentID + WHERE StripePaymentIntentID = :paymentIntentID ", { paymentIntentID: paymentIntentID }); if (qOrder.recordCount > 0) { queryTimed(" UPDATE Orders SET PaymentStatus = 'disputed' - WHERE OrderStripePaymentIntentID = :paymentIntentID + WHERE StripePaymentIntentID = :paymentIntentID ", { paymentIntentID: paymentIntentID }); // Create a task for the dispute