payfrit-works/api/stripe/webhook.cfm
John Mizerek 0765dc1e27 Add business portal, Stripe Connect, beacon APIs, and task system
Portal:
- New business portal UI (portal/index.html, portal.css, portal.js)
- Dashboard with real-time stats (orders today, revenue, pending, menu items)
- Business info endpoint (api/businesses/get.cfm)
- Portal stats endpoint (api/portal/stats.cfm)
- Menu page links to existing full-featured menu editor

Stripe Connect:
- Onboarding endpoint (api/stripe/onboard.cfm)
- Status check endpoint (api/stripe/status.cfm)
- Payment intent creation (api/stripe/createPaymentIntent.cfm)
- Webhook handler (api/stripe/webhook.cfm)

Beacon APIs:
- List all beacons (api/beacons/list_all.cfm)
- Get business from beacon (api/beacons/getBusinessFromBeacon.cfm)

Task System:
- List pending tasks (api/tasks/listPending.cfm)
- Accept task (api/tasks/accept.cfm)

Other:
- HUD interface for quick order status display
- KDS debug/test pages
- Updated Application.cfm with public endpoint allowlist
- Order status check improvements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 23:38:26 -08:00

185 lines
6.9 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
queryExecute("
UPDATE Orders
SET OrderPaymentStatus = 'paid',
OrderPaymentCompletedOn = NOW(),
OrderStatusID = CASE WHEN OrderStatusID = 0 THEN 1 ELSE OrderStatusID END
WHERE OrderID = :orderID
", { orderID: orderID });
// Create a task for the new order
queryExecute("
INSERT INTO Tasks (TaskBusinessID, TaskCategoryID, TaskTitle, TaskCreatedOn, TaskStatusID, TaskSourceType, TaskSourceID)
SELECT o.OrderBusinessID, 1, CONCAT('Order #', o.OrderID), NOW(), 0, 'order', o.OrderID
FROM Orders o
WHERE o.OrderID = :orderID
AND NOT EXISTS (SELECT 1 FROM Tasks t WHERE t.TaskSourceType = 'order' AND t.TaskSourceID = :orderID)
", { orderID: orderID });
writeLog(file="stripe_webhooks", text="Order #orderID# marked as paid");
}
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 OrderPaymentStatus = 'failed',
OrderPaymentError = :failureMessage
WHERE OrderID = :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 OrderID FROM Orders
WHERE OrderStripePaymentIntentID = :paymentIntentID
", { paymentIntentID: paymentIntentID });
if (qOrder.recordCount > 0) {
queryExecute("
UPDATE Orders
SET OrderPaymentStatus = '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 OrderID, OrderBusinessID FROM Orders
WHERE OrderStripePaymentIntentID = :paymentIntentID
", { paymentIntentID: paymentIntentID });
if (qOrder.recordCount > 0) {
queryExecute("
UPDATE Orders
SET OrderPaymentStatus = 'disputed'
WHERE OrderStripePaymentIntentID = :paymentIntentID
", { paymentIntentID: paymentIntentID });
// Create a task for the dispute
queryExecute("
INSERT INTO Tasks (TaskBusinessID, TaskCategoryID, TaskTitle, TaskDetails, TaskCreatedOn, TaskStatusID, TaskSourceType, TaskSourceID)
VALUES (:businessID, 4, 'Payment Dispute', 'Order ###qOrder.OrderID# has been disputed. Review immediately.', NOW(), 0, 'dispute', :orderID)
", {
businessID: qOrder.OrderBusinessID,
orderID: qOrder.OrderID
});
writeLog(file="stripe_webhooks", text="Dispute created for order #qOrder.OrderID#");
}
}
break;
case "account.updated":
// Connected account was updated
accountID = eventData.id;
chargesEnabled = eventData.charges_enabled ?: false;
payoutsEnabled = eventData.payouts_enabled ?: false;
if (chargesEnabled && payoutsEnabled) {
queryExecute("
UPDATE Businesses
SET BusinessStripeOnboardingComplete = 1
WHERE BusinessStripeAccountID = :accountID
", { accountID: accountID });
writeLog(file="stripe_webhooks", text="Account #accountID# is now fully active");
}
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>