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.hardened
John Mizerek 3e936728db Preserve payment hardening files and migrations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 13:26:17 -07:00

566 lines
28 KiB
Text

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