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>
185 lines
6.9 KiB
Text
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>
|