/** * Stripe Webhook Handler * Handles payment confirmations, refunds, and disputes * * 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 ?: ""; // Verify webhook signature (Stripe HMAC-SHA256) 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) { 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) { 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="Signature mismatch! Expected=#left(expectedSig,16)#... Got=#left(sigV1,16)#..."); response["ERROR"] = "signature_mismatch"; writeOutput(serializeJSON(response)); abort; } } // Parse event event = deserializeJSON(payload); eventType = event.type ?: ""; eventData = event.data.object ?: {}; // Log the webhook writeLog(file="stripe_webhooks", text="Received: #eventType# - #eventData.id ?: 'unknown'#"); switch (eventType) { case "payment_intent.succeeded": // Payment was successful paymentIntentID = eventData.id; orderID = val(eventData.metadata.order_id ?: 0); if (orderID > 0) { // Update order status to paid/submitted (status 1) // Note: Task is created later when order status changes to Ready (3) in updateStatus.cfm queryTimed(" UPDATE Orders SET PaymentStatus = 'paid', PaymentCompletedOn = NOW(), StatusID = CASE WHEN StatusID = 0 THEN 1 ELSE StatusID END WHERE ID = :orderID ", { orderID: orderID }); writeLog(file="stripe_webhooks", text="Order #orderID# marked as paid"); } // === WORKER PAYOUT TRANSFER === // Find ledger row for this PaymentIntent try { qLedger = queryTimed(" SELECT wpl.ID, wpl.UserID, wpl.TaskID, wpl.NetTransferCents, wpl.StripeTransferID, wpl.Status FROM WorkPayoutLedgers wpl WHERE wpl.StripePaymentIntentID = :piID LIMIT 1 ", { piID: paymentIntentID }); if (qLedger.recordCount > 0 && isNull(qLedger.StripeTransferID) && qLedger.Status == "pending_charge") { // Mark as charged queryTimed(" UPDATE WorkPayoutLedgers SET Status = 'charged', UpdatedAt = NOW() WHERE ID = :ledgerID ", { ledgerID: qLedger.ID }); // Look up worker's Stripe Connected Account qWorker = queryTimed(" SELECT StripeConnectedAccountID FROM Users WHERE ID = :userID ", { userID: qLedger.UserID }); workerAccountID = qWorker.recordCount > 0 ? (qWorker.StripeConnectedAccountID ?: "") : ""; if (len(trim(workerAccountID)) > 0 && qLedger.NetTransferCents > 0) { // Create Stripe Transfer — regardless of payoutsEnabled (Stripe holds funds) 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="formfield", name="metadata[activation_withheld_cents]", value=0); httpTransfer.addParam(type="header", name="Idempotency-Key", value="transfer-ledger-#qLedger.ID#"); transferResult = httpTransfer.send().getPrefix(); transferData = deserializeJSON(transferResult.fileContent); if (structKeyExists(transferData, "id")) { queryTimed(" UPDATE WorkPayoutLedgers SET StripeTransferID = :transferID, Status = 'transferred', UpdatedAt = NOW() WHERE ID = :ledgerID ", { transferID: transferData.id, ledgerID: qLedger.ID }); 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) { // Nothing to transfer (entire amount withheld for activation) queryTimed(" UPDATE WorkPayoutLedgers SET Status = 'transferred', UpdatedAt = NOW() WHERE ID = :ledgerID ", { ledgerID: qLedger.ID }); } // else: no connected account yet — stays 'charged', transfer deferred } } 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) { // Look up owner business Stripe account qOwnerBiz = queryTimed(" SELECT StripeAccountID FROM Businesses WHERE ID = :bizID ", { bizID: grantOwnerBizID }); 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: #grantOwnerFeeCents# cents to biz #grantOwnerBizID# for order #orderID#"); } else { writeLog(file="stripe_webhooks", text="Grant owner transfer failed for order #orderID#: #grantTransferData.error.message ?: 'unknown'#"); } } else { writeLog(file="stripe_webhooks", text="Grant owner biz #grantOwnerBizID# has no Stripe account - transfer skipped for order #orderID#"); } } } catch (any grantTransferErr) { writeLog(file="stripe_webhooks", text="Grant owner transfer error: #grantTransferErr.message#"); } break; case "payment_intent.payment_failed": // Payment failed paymentIntentID = eventData.id; orderID = val(eventData.metadata.order_id ?: 0); failureMessage = eventData.last_payment_error.message ?: "Payment failed"; if (orderID > 0) { queryTimed(" UPDATE Orders SET PaymentStatus = 'failed', PaymentError = :failureMessage WHERE ID = :orderID ", { orderID: orderID, failureMessage: failureMessage }); writeLog(file="stripe_webhooks", text="Order #orderID# payment failed: #failureMessage#"); } break; case "charge.refunded": // Handle refund chargeID = eventData.id; paymentIntentID = eventData.payment_intent ?: ""; refundAmount = eventData.amount_refunded / 100; if (paymentIntentID != "") { // FIX: Use correct column name (was OrderStripePaymentIntentID) qOrder = queryTimed(" SELECT ID FROM Orders WHERE StripePaymentIntentID = :paymentIntentID ", { paymentIntentID: paymentIntentID }); if (qOrder.recordCount > 0) { queryTimed(" UPDATE Orders SET PaymentStatus = 'refunded', RefundAmount = :refundAmount, RefundedOn = NOW() WHERE StripePaymentIntentID = :paymentIntentID ", { paymentIntentID: paymentIntentID, refundAmount: refundAmount }); writeLog(file="stripe_webhooks", text="Order refunded: $#refundAmount#"); } } break; case "charge.dispute.created": // Customer disputed a charge chargeID = eventData.id; paymentIntentID = eventData.payment_intent ?: ""; if (paymentIntentID != "") { // FIX: Use correct column name (was OrderStripePaymentIntentID) qOrder = queryTimed(" SELECT ID, BusinessID FROM Orders WHERE StripePaymentIntentID = :paymentIntentID ", { paymentIntentID: paymentIntentID }); if (qOrder.recordCount > 0) { queryTimed(" UPDATE Orders SET PaymentStatus = 'disputed' WHERE StripePaymentIntentID = :paymentIntentID ", { paymentIntentID: paymentIntentID }); // Create a task for the dispute queryTimed(" INSERT INTO Tasks (BusinessID, CategoryID, Title, Details, CreatedOn, StatusID, SourceType, SourceID) VALUES (:businessID, 4, 'Payment Dispute', 'Order ###qOrder.ID# has been disputed. Review immediately.', NOW(), 0, 'dispute', :orderID) ", { businessID: qOrder.BusinessID, orderID: qOrder.ID }); writeLog(file="stripe_webhooks", text="Dispute created for order #qOrder.ID#"); } } break; case "account.updated": // Connected account was updated accountID = eventData.id; chargesEnabled = eventData.charges_enabled ?: false; payoutsEnabled = eventData.payouts_enabled ?: false; // Business accounts if (chargesEnabled && payoutsEnabled) { queryTimed(" UPDATE Businesses SET StripeOnboardingComplete = 1 WHERE StripeAccountID = :accountID ", { accountID: accountID }); writeLog(file="stripe_webhooks", text="Business account #accountID# is now fully active"); } // Worker accounts — update StripePayoutsEnabled for tier derivation try { qWorkerAcct = queryTimed(" SELECT ID FROM Users WHERE StripeConnectedAccountID = :accountID LIMIT 1 ", { accountID: accountID }); if (qWorkerAcct.recordCount > 0) { queryTimed(" UPDATE Users SET StripePayoutsEnabled = :payoutsEnabled WHERE StripeConnectedAccountID = :accountID ", { payoutsEnabled: payoutsEnabled ? 1 : 0, accountID: accountID }); 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": // Activation early unlock payment completed try { sessionMetadata = eventData.metadata ?: {}; metaType = sessionMetadata.type ?: ""; metaUserID = val(sessionMetadata.user_id ?: 0); if (metaType == "activation_unlock" && metaUserID > 0) { queryTimed(" UPDATE Users SET ActivationBalanceCents = ActivationCapCents WHERE ID = :userID ", { userID: metaUserID }); writeLog(file="stripe_webhooks", text="Activation completed via payment for user #metaUserID#"); } // Tip payment completed if (metaType == "tip") { metaTipID = val(sessionMetadata.tip_id ?: 0); metaWorkerID = val(sessionMetadata.worker_user_id ?: 0); tipPaymentIntent = eventData.payment_intent ?: ""; if (metaTipID > 0) { // Mark tip as paid queryTimed(" 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 (PI: #tipPaymentIntent#)"); // Transfer tip to worker's Stripe Connect account if (metaWorkerID > 0) { qTipWorker = queryTimed(" SELECT StripeConnectedAccountID FROM Users WHERE ID = :userID ", { userID: metaWorkerID }, { datasource: "payfrit" }); tipWorkerAcct = qTipWorker.recordCount > 0 ? (qTipWorker.StripeConnectedAccountID ?: "") : ""; if (len(trim(tipWorkerAcct)) > 0) { qTipAmt = queryTimed(" 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="formfield", name="metadata[worker_user_id]", value=metaWorkerID); httpTipTransfer.addParam(type="header", name="Idempotency-Key", value="tip-transfer-#metaTipID#"); tipTransferResult = httpTipTransfer.send().getPrefix(); tipTransferData = deserializeJSON(tipTransferResult.fileContent); if (structKeyExists(tipTransferData, "id")) { queryTimed(" 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 (#tipTransferData.id#) to worker ##metaWorkerID#"); } else { writeLog(file="stripe_webhooks", text="Tip transfer failed for tip ##metaTipID#: #tipTransferData.error.message ?: 'unknown'#"); } } } else { writeLog(file="stripe_webhooks", text="Tip ##metaTipID#: worker ##metaWorkerID# has no Stripe Connect account - transfer skipped"); } } } } } catch (any checkoutErr) { writeLog(file="stripe_webhooks", text="Checkout session error: #checkoutErr.message#"); } break; default: writeLog(file="stripe_webhooks", text="Unhandled event type: #eventType#"); } response["OK"] = true; response["RECEIVED"] = true; } catch (any e) { writeLog(file="stripe_webhooks", text="Error: #e.message#"); response["ERROR"] = e.message; } writeOutput(serializeJSON(response));