This repository has been archived on 2026-03-21. You can view files and clone it, but cannot push or open issues or pull requests.
payfrit-biz/api/stripe/webhook.cfm
John Mizerek 6b66d2cef8 Fix normalized DB column names across all API files
Sweep of 26 API files to use prefixed column names matching the
database schema (e.g. BusinessID not ID, BusinessName not Name,
BusinessDeliveryFlatFee not DeliveryFlatFee, ServicePointName not Name).

Files fixed: auth, beacons, businesses, menu, orders, setup, stripe,
tasks, and workers endpoints.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 16:56:41 -08:00

322 lines
14 KiB
Text

<cfscript>
/**
* 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
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);
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 });
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));
</cfscript>