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 8dff081407 Fix remaining old column names missed by initial batch rename
Second pass fixing 70+ references across 32 files:
- Orders: DeliveryMultiplier→BusinessDeliveryMultiplier, OrderTipAmount→TipAmount,
  OrderPaymentCompletedOn→PaymentCompletedOn, OrderPaymentError→PaymentError
- Orders PK: WHERE OrderID=? → WHERE ID=? on Orders table
- OrderLineItems PK: OrderLineItemID→ID in INSERT, WHERE, and query results
- Items: parent.ItemID→parent.ID in JOIN conditions
- Tasks: t.TaskID→t.ID in JOIN conditions
- Users PK: WHERE UserID=X → WHERE ID=X on Users table
- Addresses PK: A.AddressID→A.ID in JOIN conditions
- tt_States: tt_StateID→ID, remove nonexistent tt_StateCountryID/tt_StateSortOrder
- tt_OrderTypes: tt_OrderTypeID→ID, tt_OrderTypeName→Name
- tt_Days: D.tt_DayID→D.ID
- confirm_email.cfm: Add missing SELECT/FROM to queries
- setLineItem.cfm: Fix 13 old column references
- Stripe webhook/payment: Fix column names and PK references

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:58:46 -08:00

293 lines
12 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 ?: "";
// 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));
</cfscript>