/** * 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 ?: ""; // For now, skip signature verification in development // In production, uncomment and implement signature verification: /* if (webhookSecret != "" && sigHeader != "") { // Verify webhook signature // This requires computing HMAC-SHA256 and comparing // See: https://stripe.com/docs/webhooks/signatures } */ // 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 queryExecute(" 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 = 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 }); 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 }); // Look up worker's Stripe Connected Account qWorker = queryExecute(" 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); 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 }); 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) queryExecute(" 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#"); } 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) { queryExecute(" 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 != "") { qOrder = queryExecute(" SELECT ID FROM Orders WHERE OrderStripePaymentIntentID = :paymentIntentID ", { paymentIntentID: paymentIntentID }); if (qOrder.recordCount > 0) { queryExecute(" UPDATE Orders SET PaymentStatus = 'refunded', OrderRefundAmount = :refundAmount, OrderRefundedOn = NOW() WHERE OrderStripePaymentIntentID = :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 != "") { qOrder = queryExecute(" SELECT ID, BusinessID FROM Orders WHERE OrderStripePaymentIntentID = :paymentIntentID ", { paymentIntentID: paymentIntentID }); if (qOrder.recordCount > 0) { queryExecute(" UPDATE Orders SET PaymentStatus = 'disputed' WHERE OrderStripePaymentIntentID = :paymentIntentID ", { paymentIntentID: paymentIntentID }); // Create a task for the dispute queryExecute(" 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) { queryExecute(" 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 = queryExecute(" SELECT ID FROM Users WHERE StripeConnectedAccountID = :accountID LIMIT 1 ", { accountID: accountID }); if (qWorkerAcct.recordCount > 0) { queryExecute(" 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) { queryExecute(" UPDATE Users SET ActivationBalanceCents = ActivationCapCents WHERE ID = :userID ", { userID: metaUserID }); writeLog(file="stripe_webhooks", text="Activation completed via payment for user #metaUserID#"); } } 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));