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 1210249f54 Normalize database column and table names across entire codebase
Update all SQL queries, query result references, and ColdFusion code to match
the renamed database schema. Tables use plural CamelCase, PKs are all `ID`,
column prefixes stripped (e.g. BusinessName→Name, UserFirstName→FirstName).

Key changes:
- Strip table-name prefixes from all column references (Businesses, Users,
  Addresses, Hours, Menus, Categories, Items, Stations, Orders,
  OrderLineItems, Tasks, TaskCategories, TaskRatings, QuickTaskTemplates,
  ScheduledTaskDefinitions, ChatMessages, Beacons, ServicePoints, Employees,
  VisitorTrackings, ApiPerfLogs, tt_States, tt_Days, tt_AddressTypes,
  tt_OrderTypes, tt_TaskTypes)
- Rename PK references from {TableName}ID to ID in all queries
- Rewrite 7 admin beacon files to use ServicePoints.BeaconID instead of
  dropped lt_Beacon_Businesses_ServicePoints link table
- Rewrite beacon assignment files (list, save, delete) for new schema
- Fix FK references incorrectly changed to ID (OrderLineItems.OrderID,
  Categories.MenuID, Tasks.CategoryID, ServicePoints.BeaconID)
- Update Addresses: AddressLat→Latitude, AddressLng→Longitude
- Update Users: UserPassword→Password, UserIsEmailVerified→IsEmailVerified,
  UserIsActive→IsActive, UserBalance→Balance, etc.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:39:12 -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',
OrderPaymentCompletedOn = NOW(),
StatusID = CASE WHEN StatusID = 0 THEN 1 ELSE StatusID END
WHERE OrderID = :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',
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 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>