Sweep of 26 API files to use prefixed column names matching the database schema (e.g. BusinessID not ID, BusinessName not Name, BusinessDeliveryFlatFee not DeliveryFlatFee, ServicePointName not Name). Files fixed: auth, beacons, businesses, menu, orders, setup, stripe, tasks, and workers endpoints. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
322 lines
14 KiB
Text
322 lines
14 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 ?: "";
|
|
|
|
// Verify webhook signature (Stripe HMAC-SHA256)
|
|
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) {
|
|
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) {
|
|
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="Signature mismatch! Expected=#left(expectedSig,16)#... Got=#left(sigV1,16)#...");
|
|
response["ERROR"] = "signature_mismatch";
|
|
writeOutput(serializeJSON(response));
|
|
abort;
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
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 });
|
|
|
|
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>
|