/** * Stripe Webhook Handler (HARDENED) * - Idempotent via StripeEventID deduplication * - Atomic via cftransaction * - Amount verification before marking paid * - Audit logging for all events * * Webhook events to configure in Stripe: * - payment_intent.succeeded * - payment_intent.payment_failed * - charge.refunded * - charge.dispute.created */ response = { "OK": false }; try { // Get raw request body payload = toString(getHttpRequestData().content); // Get Stripe signature header sigHeader = getHttpRequestData().headers["Stripe-Signature"] ?: ""; // Webhook secret (set in Stripe dashboard) webhookSecret = application.stripeWebhookSecret ?: ""; // === SIGNATURE VERIFICATION === if (len(trim(webhookSecret)) > 0 && len(trim(sigHeader)) > 0) { // Parse signature header: t=timestamp,v1=signature sigParts = {}; for (part in listToArray(sigHeader, ",")) { kv = listToArray(trim(part), "="); if (arrayLen(kv) >= 2) sigParts[trim(kv[1])] = trim(kv[2]); } sigTimestamp = sigParts["t"] ?: ""; sigV1 = sigParts["v1"] ?: ""; if (len(sigTimestamp) == 0 || len(sigV1) == 0) { writeLog(file="stripe_webhooks", text="REJECTED: Missing signature components"); response["ERROR"] = "invalid_signature"; writeOutput(serializeJSON(response)); abort; } // Check timestamp tolerance (5 minutes) timestampAge = dateDiff("s", dateAdd("s", val(sigTimestamp), createDate(1970,1,1)), now()); if (abs(timestampAge) > 300) { writeLog(file="stripe_webhooks", text="REJECTED: Timestamp expired (age=#timestampAge#s)"); response["ERROR"] = "timestamp_expired"; writeOutput(serializeJSON(response)); abort; } // Compute expected signature: HMAC-SHA256(timestamp + "." + payload, secret) signedPayload = sigTimestamp & "." & payload; expectedSig = lcase(hmac(signedPayload, webhookSecret, "HmacSHA256")); if (expectedSig != lcase(sigV1)) { writeLog(file="stripe_webhooks", text="REJECTED: Signature mismatch"); response["ERROR"] = "signature_mismatch"; writeOutput(serializeJSON(response)); abort; } writeLog(file="stripe_webhooks", text="Signature VERIFIED"); } else { writeLog(file="stripe_webhooks", text="WARNING: No webhook secret configured - signature not verified"); } // Parse event event = deserializeJSON(payload); eventType = event.type ?: ""; eventID = event.id ?: ""; eventData = event.data.object ?: {}; // === IDEMPOTENCY: CHECK IF EVENT ALREADY PROCESSED === qExisting = queryExecute(" SELECT ID, ProcessingResult FROM PaymentAudit WHERE StripeEventID = :eventID ", { eventID: eventID }, { datasource: "payfrit" }); if (qExisting.recordCount > 0) { writeLog(file="stripe_webhooks", text="SKIPPED: Duplicate event #eventID# (already processed as #qExisting.ProcessingResult#)"); response["OK"] = true; response["SKIPPED"] = "duplicate_event"; writeOutput(serializeJSON(response)); abort; } // Log receipt before processing writeLog(file="stripe_webhooks", text="RECEIVED: #eventType# - #eventData.id ?: 'unknown'# (event=#eventID#)"); // Prepare audit record auditOrderID = javaCast("null", ""); auditPaymentIntentID = ""; auditChargeID = ""; auditAmountCents = javaCast("null", ""); auditResult = "success"; auditError = ""; switch (eventType) { case "payment_intent.succeeded": // Payment was successful paymentIntentID = eventData.id; orderID = val(eventData.metadata.order_id ?: 0); amountReceived = val(eventData.amount_received ?: 0); currency = eventData.currency ?: "usd"; auditPaymentIntentID = paymentIntentID; auditAmountCents = amountReceived; if (orderID > 0) { auditOrderID = orderID; // === ATOMIC TRANSACTION === transaction action="begin" { try { // Lock order row for update (prevent race conditions) qOrder = queryExecute(" SELECT ID, PaymentStatus, ExpectedAmountCents, StripePaymentIntentID FROM Orders WHERE ID = :orderID FOR UPDATE ", { orderID: orderID }, { datasource: "payfrit" }); if (qOrder.recordCount == 0) { auditResult = "error"; auditError = "Order not found"; writeLog(file="stripe_webhooks", text="ERROR: Order #orderID# not found"); transaction action="rollback"; } else if (qOrder.PaymentStatus == "paid") { // Already paid - idempotent success auditResult = "skipped_already_paid"; writeLog(file="stripe_webhooks", text="SKIPPED: Order #orderID# already paid"); transaction action="rollback"; } else { // === AMOUNT VERIFICATION === expectedAmount = val(qOrder.ExpectedAmountCents); if (expectedAmount > 0 && amountReceived != expectedAmount) { auditResult = "error"; auditError = "Amount mismatch: expected #expectedAmount# cents, received #amountReceived# cents"; writeLog(file="stripe_webhooks", text="REJECTED: Order #orderID# amount mismatch (expected=#expectedAmount#, received=#amountReceived#)"); transaction action="rollback"; } else { // Update order status to paid/submitted queryExecute(" UPDATE Orders SET PaymentStatus = 'paid', PaymentCompletedOn = NOW(), ReceivedAmountCents = :amountReceived, StripePaymentIntentID = :paymentIntentID, StatusID = CASE WHEN StatusID = 0 THEN 1 ELSE StatusID END WHERE ID = :orderID AND PaymentStatus IS NULL OR PaymentStatus NOT IN ('paid', 'refunded') ", { orderID: orderID, amountReceived: amountReceived, paymentIntentID: paymentIntentID }, { datasource: "payfrit" }); writeLog(file="stripe_webhooks", text="Order #orderID# marked as paid (#amountReceived# cents)"); transaction action="commit"; } } } catch (any txErr) { auditResult = "error"; auditError = txErr.message; writeLog(file="stripe_webhooks", text="TRANSACTION ERROR: #txErr.message#"); transaction action="rollback"; } } } // === WORKER PAYOUT TRANSFER (separate transaction) === if (auditResult == "success") { try { qLedger = queryExecute(" SELECT wpl.ID, wpl.UserID, wpl.TaskID, wpl.NetTransferCents, wpl.StripeTransferID, wpl.Status FROM WorkPayoutLedgers wpl WHERE wpl.StripePaymentIntentID = :piID LIMIT 1 ", { piID: paymentIntentID }, { datasource: "payfrit" }); if (qLedger.recordCount > 0 && isNull(qLedger.StripeTransferID) && qLedger.Status == "pending_charge") { // Mark as charged queryExecute(" UPDATE WorkPayoutLedgers SET Status = 'charged', UpdatedAt = NOW() WHERE ID = :ledgerID ", { ledgerID: qLedger.ID }, { datasource: "payfrit" }); // Look up worker's Stripe Connected Account qWorker = queryExecute(" SELECT StripeConnectedAccountID FROM Users WHERE ID = :userID ", { userID: qLedger.UserID }, { datasource: "payfrit" }); workerAccountID = qWorker.recordCount > 0 ? (qWorker.StripeConnectedAccountID ?: "") : ""; if (len(trim(workerAccountID)) > 0 && qLedger.NetTransferCents > 0) { stripeSecretKey = application.stripeSecretKey ?: ""; httpTransfer = new http(); httpTransfer.setMethod("POST"); httpTransfer.setUrl("https://api.stripe.com/v1/transfers"); httpTransfer.setUsername(stripeSecretKey); httpTransfer.setPassword(""); httpTransfer.addParam(type="formfield", name="amount", value=qLedger.NetTransferCents); httpTransfer.addParam(type="formfield", name="currency", value="usd"); httpTransfer.addParam(type="formfield", name="destination", value=workerAccountID); httpTransfer.addParam(type="formfield", name="metadata[user_id]", value=qLedger.UserID); httpTransfer.addParam(type="formfield", name="metadata[task_id]", value=qLedger.TaskID); httpTransfer.addParam(type="formfield", name="metadata[ledger_id]", value=qLedger.ID); httpTransfer.addParam(type="header", name="Idempotency-Key", value="transfer-ledger-#qLedger.ID#"); transferResult = httpTransfer.send().getPrefix(); transferData = deserializeJSON(transferResult.fileContent); if (structKeyExists(transferData, "id")) { queryExecute(" UPDATE WorkPayoutLedgers SET StripeTransferID = :transferID, Status = 'transferred', UpdatedAt = NOW() WHERE ID = :ledgerID ", { transferID: transferData.id, ledgerID: qLedger.ID }, { datasource: "payfrit" }); writeLog(file="stripe_webhooks", text="Transfer #transferData.id# created for ledger #qLedger.ID#"); } else { writeLog(file="stripe_webhooks", text="Transfer failed for ledger #qLedger.ID#: #transferData.error.message ?: 'unknown'#"); } } else if (qLedger.NetTransferCents == 0) { queryExecute(" UPDATE WorkPayoutLedgers SET Status = 'transferred', UpdatedAt = NOW() WHERE ID = :ledgerID ", { ledgerID: qLedger.ID }, { datasource: "payfrit" }); } } } catch (any transferErr) { writeLog(file="stripe_webhooks", text="Worker transfer error: #transferErr.message#"); } // === SP-SM: GRANT OWNER TRANSFER === try { grantOwnerFeeCents = val(eventData.metadata.grant_owner_fee_cents ?: 0); grantOwnerBizID = val(eventData.metadata.grant_owner_business_id ?: 0); grantMetaID = val(eventData.metadata.grant_id ?: 0); if (grantOwnerFeeCents > 0 && grantOwnerBizID > 0) { qOwnerBiz = queryExecute(" SELECT StripeAccountID FROM Businesses WHERE ID = :bizID ", { bizID: grantOwnerBizID }, { datasource: "payfrit" }); ownerStripeAcct = qOwnerBiz.recordCount > 0 ? (qOwnerBiz.StripeAccountID ?: "") : ""; if (len(trim(ownerStripeAcct)) > 0) { stripeSecretKey = application.stripeSecretKey ?: ""; httpGrantTransfer = new http(); httpGrantTransfer.setMethod("POST"); httpGrantTransfer.setUrl("https://api.stripe.com/v1/transfers"); httpGrantTransfer.setUsername(stripeSecretKey); httpGrantTransfer.setPassword(""); httpGrantTransfer.addParam(type="formfield", name="amount", value=grantOwnerFeeCents); httpGrantTransfer.addParam(type="formfield", name="currency", value="usd"); httpGrantTransfer.addParam(type="formfield", name="destination", value=ownerStripeAcct); httpGrantTransfer.addParam(type="formfield", name="metadata[grant_id]", value=grantMetaID); httpGrantTransfer.addParam(type="formfield", name="metadata[order_id]", value=orderID); httpGrantTransfer.addParam(type="formfield", name="metadata[type]", value="grant_owner_fee"); httpGrantTransfer.addParam(type="header", name="Idempotency-Key", value="grant-transfer-#orderID#-#grantMetaID#"); grantTransferResult = httpGrantTransfer.send().getPrefix(); grantTransferData = deserializeJSON(grantTransferResult.fileContent); if (structKeyExists(grantTransferData, "id")) { writeLog(file="stripe_webhooks", text="Grant owner transfer #grantTransferData.id# created"); } else { writeLog(file="stripe_webhooks", text="Grant owner transfer failed: #grantTransferData.error.message ?: 'unknown'#"); } } } } catch (any grantTransferErr) { writeLog(file="stripe_webhooks", text="Grant owner transfer error: #grantTransferErr.message#"); } } break; case "payment_intent.payment_failed": paymentIntentID = eventData.id; orderID = val(eventData.metadata.order_id ?: 0); failureMessage = eventData.last_payment_error.message ?: "Payment failed"; auditPaymentIntentID = paymentIntentID; if (orderID > 0) { auditOrderID = orderID; queryExecute(" UPDATE Orders SET PaymentStatus = 'failed', PaymentError = :failureMessage WHERE ID = :orderID AND (PaymentStatus IS NULL OR PaymentStatus = 'failed') ", { orderID: orderID, failureMessage: failureMessage }, { datasource: "payfrit" }); writeLog(file="stripe_webhooks", text="Order #orderID# payment failed: #failureMessage#"); } break; case "charge.refunded": chargeID = eventData.id; paymentIntentID = eventData.payment_intent ?: ""; refundAmount = eventData.amount_refunded / 100; auditChargeID = chargeID; auditPaymentIntentID = paymentIntentID; auditAmountCents = eventData.amount_refunded; if (paymentIntentID != "") { // FIX: Use correct column name (StripePaymentIntentID, not OrderStripePaymentIntentID) qOrder = queryExecute(" SELECT ID FROM Orders WHERE StripePaymentIntentID = :paymentIntentID ", { paymentIntentID: paymentIntentID }, { datasource: "payfrit" }); if (qOrder.recordCount > 0) { auditOrderID = qOrder.ID; queryExecute(" UPDATE Orders SET PaymentStatus = 'refunded', RefundAmount = :refundAmount, RefundedOn = NOW() WHERE StripePaymentIntentID = :paymentIntentID ", { paymentIntentID: paymentIntentID, refundAmount: refundAmount }, { datasource: "payfrit" }); writeLog(file="stripe_webhooks", text="Order #qOrder.ID# refunded: $#refundAmount#"); } } break; case "charge.dispute.created": chargeID = eventData.id; paymentIntentID = eventData.payment_intent ?: ""; auditChargeID = chargeID; auditPaymentIntentID = paymentIntentID; if (paymentIntentID != "") { // FIX: Use correct column name qOrder = queryExecute(" SELECT ID, BusinessID FROM Orders WHERE StripePaymentIntentID = :paymentIntentID ", { paymentIntentID: paymentIntentID }, { datasource: "payfrit" }); if (qOrder.recordCount > 0) { auditOrderID = qOrder.ID; transaction action="begin" { try { queryExecute(" UPDATE Orders SET PaymentStatus = 'disputed' WHERE StripePaymentIntentID = :paymentIntentID ", { paymentIntentID: paymentIntentID }, { datasource: "payfrit" }); // Check if dispute task already exists (idempotency) qExistingTask = queryExecute(" SELECT ID FROM Tasks WHERE SourceType = 'dispute' AND SourceID = :orderID ", { orderID: qOrder.ID }, { datasource: "payfrit" }); if (qExistingTask.recordCount == 0) { queryExecute(" INSERT INTO Tasks (BusinessID, CategoryID, Title, Details, CreatedOn, SourceType, SourceID) VALUES (:businessID, 4, 'Payment Dispute', 'Order ##:orderID has been disputed. Review immediately.', NOW(), 'dispute', :orderID) ", { businessID: qOrder.BusinessID, orderID: qOrder.ID }, { datasource: "payfrit" }); } transaction action="commit"; writeLog(file="stripe_webhooks", text="Dispute created for order #qOrder.ID#"); } catch (any err) { transaction action="rollback"; writeLog(file="stripe_webhooks", text="Dispute task creation failed: #err.message#"); } } } } break; case "account.updated": accountID = eventData.id; chargesEnabled = eventData.charges_enabled ?: false; payoutsEnabled = eventData.payouts_enabled ?: false; if (chargesEnabled && payoutsEnabled) { queryExecute(" UPDATE Businesses SET StripeOnboardingComplete = 1 WHERE StripeAccountID = :accountID ", { accountID: accountID }, { datasource: "payfrit" }); writeLog(file="stripe_webhooks", text="Business account #accountID# is now fully active"); } // Worker accounts try { qWorkerAcct = queryExecute(" SELECT ID FROM Users WHERE StripeConnectedAccountID = :accountID LIMIT 1 ", { accountID: accountID }, { datasource: "payfrit" }); if (qWorkerAcct.recordCount > 0) { queryExecute(" UPDATE Users SET StripePayoutsEnabled = :payoutsEnabled WHERE StripeConnectedAccountID = :accountID ", { payoutsEnabled: payoutsEnabled ? 1 : 0, accountID: accountID }, { datasource: "payfrit" }); writeLog(file="stripe_webhooks", text="Worker user #qWorkerAcct.ID# payoutsEnabled=#payoutsEnabled#"); } } catch (any workerErr) { writeLog(file="stripe_webhooks", text="Worker account update error: #workerErr.message#"); } break; case "checkout.session.completed": // (existing checkout.session.completed handling unchanged) try { sessionMetadata = eventData.metadata ?: {}; metaType = sessionMetadata.type ?: ""; metaUserID = val(sessionMetadata.user_id ?: 0); if (metaType == "activation_unlock" && metaUserID > 0) { queryExecute(" UPDATE Users SET ActivationBalanceCents = ActivationCapCents WHERE ID = :userID ", { userID: metaUserID }, { datasource: "payfrit" }); writeLog(file="stripe_webhooks", text="Activation completed via payment for user #metaUserID#"); } // Tip payment handling... if (metaType == "tip") { metaTipID = val(sessionMetadata.tip_id ?: 0); metaWorkerID = val(sessionMetadata.worker_user_id ?: 0); tipPaymentIntent = eventData.payment_intent ?: ""; if (metaTipID > 0) { queryExecute(" UPDATE Tips SET Status = 'paid', PaidOn = NOW(), StripePaymentIntentID = :piID WHERE ID = :tipID AND Status = 'pending' ", { piID: tipPaymentIntent, tipID: metaTipID }, { datasource: "payfrit" }); writeLog(file="stripe_webhooks", text="Tip #metaTipID# paid"); // Transfer to worker (with idempotency key) if (metaWorkerID > 0) { qTipWorker = queryExecute(" SELECT StripeConnectedAccountID FROM Users WHERE ID = :userID ", { userID: metaWorkerID }, { datasource: "payfrit" }); tipWorkerAcct = qTipWorker.recordCount > 0 ? (qTipWorker.StripeConnectedAccountID ?: "") : ""; if (len(trim(tipWorkerAcct)) > 0) { qTipAmt = queryExecute(" SELECT AmountCents FROM Tips WHERE ID = :tipID ", { tipID: metaTipID }, { datasource: "payfrit" }); if (qTipAmt.recordCount > 0 && qTipAmt.AmountCents > 0) { stripeSecretKey = application.stripeSecretKey ?: ""; httpTipTransfer = new http(); httpTipTransfer.setMethod("POST"); httpTipTransfer.setUrl("https://api.stripe.com/v1/transfers"); httpTipTransfer.setUsername(stripeSecretKey); httpTipTransfer.setPassword(""); httpTipTransfer.addParam(type="formfield", name="amount", value=qTipAmt.AmountCents); httpTipTransfer.addParam(type="formfield", name="currency", value="usd"); httpTipTransfer.addParam(type="formfield", name="destination", value=tipWorkerAcct); httpTipTransfer.addParam(type="formfield", name="metadata[type]", value="tip"); httpTipTransfer.addParam(type="formfield", name="metadata[tip_id]", value=metaTipID); httpTipTransfer.addParam(type="header", name="Idempotency-Key", value="tip-transfer-#metaTipID#"); tipTransferResult = httpTipTransfer.send().getPrefix(); tipTransferData = deserializeJSON(tipTransferResult.fileContent); if (structKeyExists(tipTransferData, "id")) { queryExecute(" UPDATE Tips SET Status = 'transferred', StripeTransferID = :transferID WHERE ID = :tipID ", { transferID: tipTransferData.id, tipID: metaTipID }, { datasource: "payfrit" }); writeLog(file="stripe_webhooks", text="Tip #metaTipID# transferred"); } } } } } } } catch (any checkoutErr) { writeLog(file="stripe_webhooks", text="Checkout session error: #checkoutErr.message#"); } break; default: writeLog(file="stripe_webhooks", text="Unhandled event type: #eventType#"); } // === WRITE AUDIT RECORD === try { queryExecute(" INSERT INTO PaymentAudit ( OrderID, PaymentIntentID, StripeChargeID, StripeEventID, EventType, AmountCents, Currency, RawPayload, ProcessedAt, ProcessingResult, ErrorMessage ) VALUES ( :orderID, :piID, :chargeID, :eventID, :eventType, :amountCents, 'usd', :payload, NOW(), :result, :errorMsg ) ", { orderID: { value: auditOrderID, null: isNull(auditOrderID), cfsqltype: "cf_sql_integer" }, piID: auditPaymentIntentID, chargeID: auditChargeID, eventID: eventID, eventType: eventType, amountCents: { value: auditAmountCents, null: isNull(auditAmountCents), cfsqltype: "cf_sql_integer" }, payload: payload, result: auditResult, errorMsg: auditError }, { datasource: "payfrit" }); } catch (any auditErr) { writeLog(file="stripe_webhooks", text="Audit insert failed: #auditErr.message#"); } response["OK"] = true; response["RECEIVED"] = true; if (auditResult != "success") { response["PROCESSING_RESULT"] = auditResult; } } catch (any e) { writeLog(file="stripe_webhooks", text="FATAL ERROR: #e.message#"); response["ERROR"] = e.message; } writeOutput(serializeJSON(response));