Preserve payment hardening files and migrations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fc43d2af9c
commit
3e936728db
3 changed files with 1021 additions and 0 deletions
566
api/stripe/webhook.cfm.hardened
Normal file
566
api/stripe/webhook.cfm.hardened
Normal file
|
|
@ -0,0 +1,566 @@
|
|||
<cfscript>
|
||||
/**
|
||||
* Stripe Webhook Handler (HARDENED)
|
||||
* - Idempotent via StripeEventID deduplication
|
||||
* - Atomic via cftransaction
|
||||
* - Amount verification before marking paid
|
||||
* - Audit logging for all events
|
||||
*
|
||||
* 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 ?: "";
|
||||
|
||||
// === SIGNATURE VERIFICATION ===
|
||||
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) {
|
||||
writeLog(file="stripe_webhooks", text="REJECTED: Missing signature components");
|
||||
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) {
|
||||
writeLog(file="stripe_webhooks", text="REJECTED: Timestamp expired (age=#timestampAge#s)");
|
||||
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="REJECTED: Signature mismatch");
|
||||
response["ERROR"] = "signature_mismatch";
|
||||
writeOutput(serializeJSON(response));
|
||||
abort;
|
||||
}
|
||||
|
||||
writeLog(file="stripe_webhooks", text="Signature VERIFIED");
|
||||
} else {
|
||||
writeLog(file="stripe_webhooks", text="WARNING: No webhook secret configured - signature not verified");
|
||||
}
|
||||
|
||||
// Parse event
|
||||
event = deserializeJSON(payload);
|
||||
eventType = event.type ?: "";
|
||||
eventID = event.id ?: "";
|
||||
eventData = event.data.object ?: {};
|
||||
|
||||
// === IDEMPOTENCY: CHECK IF EVENT ALREADY PROCESSED ===
|
||||
qExisting = queryExecute("
|
||||
SELECT ID, ProcessingResult FROM PaymentAudit WHERE StripeEventID = :eventID
|
||||
", { eventID: eventID }, { datasource: "payfrit" });
|
||||
|
||||
if (qExisting.recordCount > 0) {
|
||||
writeLog(file="stripe_webhooks", text="SKIPPED: Duplicate event #eventID# (already processed as #qExisting.ProcessingResult#)");
|
||||
response["OK"] = true;
|
||||
response["SKIPPED"] = "duplicate_event";
|
||||
writeOutput(serializeJSON(response));
|
||||
abort;
|
||||
}
|
||||
|
||||
// Log receipt before processing
|
||||
writeLog(file="stripe_webhooks", text="RECEIVED: #eventType# - #eventData.id ?: 'unknown'# (event=#eventID#)");
|
||||
|
||||
// Prepare audit record
|
||||
auditOrderID = javaCast("null", "");
|
||||
auditPaymentIntentID = "";
|
||||
auditChargeID = "";
|
||||
auditAmountCents = javaCast("null", "");
|
||||
auditResult = "success";
|
||||
auditError = "";
|
||||
|
||||
switch (eventType) {
|
||||
|
||||
case "payment_intent.succeeded":
|
||||
// Payment was successful
|
||||
paymentIntentID = eventData.id;
|
||||
orderID = val(eventData.metadata.order_id ?: 0);
|
||||
amountReceived = val(eventData.amount_received ?: 0);
|
||||
currency = eventData.currency ?: "usd";
|
||||
|
||||
auditPaymentIntentID = paymentIntentID;
|
||||
auditAmountCents = amountReceived;
|
||||
|
||||
if (orderID > 0) {
|
||||
auditOrderID = orderID;
|
||||
|
||||
// === ATOMIC TRANSACTION ===
|
||||
transaction action="begin" {
|
||||
try {
|
||||
// Lock order row for update (prevent race conditions)
|
||||
qOrder = queryExecute("
|
||||
SELECT ID, PaymentStatus, ExpectedAmountCents, StripePaymentIntentID
|
||||
FROM Orders
|
||||
WHERE ID = :orderID
|
||||
FOR UPDATE
|
||||
", { orderID: orderID }, { datasource: "payfrit" });
|
||||
|
||||
if (qOrder.recordCount == 0) {
|
||||
auditResult = "error";
|
||||
auditError = "Order not found";
|
||||
writeLog(file="stripe_webhooks", text="ERROR: Order #orderID# not found");
|
||||
transaction action="rollback";
|
||||
} else if (qOrder.PaymentStatus == "paid") {
|
||||
// Already paid - idempotent success
|
||||
auditResult = "skipped_already_paid";
|
||||
writeLog(file="stripe_webhooks", text="SKIPPED: Order #orderID# already paid");
|
||||
transaction action="rollback";
|
||||
} else {
|
||||
// === AMOUNT VERIFICATION ===
|
||||
expectedAmount = val(qOrder.ExpectedAmountCents);
|
||||
if (expectedAmount > 0 && amountReceived != expectedAmount) {
|
||||
auditResult = "error";
|
||||
auditError = "Amount mismatch: expected #expectedAmount# cents, received #amountReceived# cents";
|
||||
writeLog(file="stripe_webhooks", text="REJECTED: Order #orderID# amount mismatch (expected=#expectedAmount#, received=#amountReceived#)");
|
||||
transaction action="rollback";
|
||||
} else {
|
||||
// Update order status to paid/submitted
|
||||
queryExecute("
|
||||
UPDATE Orders
|
||||
SET PaymentStatus = 'paid',
|
||||
PaymentCompletedOn = NOW(),
|
||||
ReceivedAmountCents = :amountReceived,
|
||||
StripePaymentIntentID = :paymentIntentID,
|
||||
StatusID = CASE WHEN StatusID = 0 THEN 1 ELSE StatusID END
|
||||
WHERE ID = :orderID
|
||||
AND PaymentStatus IS NULL OR PaymentStatus NOT IN ('paid', 'refunded')
|
||||
", {
|
||||
orderID: orderID,
|
||||
amountReceived: amountReceived,
|
||||
paymentIntentID: paymentIntentID
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
writeLog(file="stripe_webhooks", text="Order #orderID# marked as paid (#amountReceived# cents)");
|
||||
transaction action="commit";
|
||||
}
|
||||
}
|
||||
} catch (any txErr) {
|
||||
auditResult = "error";
|
||||
auditError = txErr.message;
|
||||
writeLog(file="stripe_webhooks", text="TRANSACTION ERROR: #txErr.message#");
|
||||
transaction action="rollback";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === WORKER PAYOUT TRANSFER (separate transaction) ===
|
||||
if (auditResult == "success") {
|
||||
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 }, { datasource: "payfrit" });
|
||||
|
||||
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 }, { datasource: "payfrit" });
|
||||
|
||||
// Look up worker's Stripe Connected Account
|
||||
qWorker = queryExecute("
|
||||
SELECT StripeConnectedAccountID FROM Users WHERE ID = :userID
|
||||
", { userID: qLedger.UserID }, { datasource: "payfrit" });
|
||||
|
||||
workerAccountID = qWorker.recordCount > 0 ? (qWorker.StripeConnectedAccountID ?: "") : "";
|
||||
|
||||
if (len(trim(workerAccountID)) > 0 && qLedger.NetTransferCents > 0) {
|
||||
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="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 }, { datasource: "payfrit" });
|
||||
|
||||
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) {
|
||||
queryExecute("
|
||||
UPDATE WorkPayoutLedgers SET Status = 'transferred', UpdatedAt = NOW()
|
||||
WHERE ID = :ledgerID
|
||||
", { ledgerID: qLedger.ID }, { datasource: "payfrit" });
|
||||
}
|
||||
}
|
||||
} catch (any transferErr) {
|
||||
writeLog(file="stripe_webhooks", text="Worker transfer error: #transferErr.message#");
|
||||
}
|
||||
|
||||
// === SP-SM: GRANT OWNER TRANSFER ===
|
||||
try {
|
||||
grantOwnerFeeCents = val(eventData.metadata.grant_owner_fee_cents ?: 0);
|
||||
grantOwnerBizID = val(eventData.metadata.grant_owner_business_id ?: 0);
|
||||
grantMetaID = val(eventData.metadata.grant_id ?: 0);
|
||||
|
||||
if (grantOwnerFeeCents > 0 && grantOwnerBizID > 0) {
|
||||
qOwnerBiz = queryExecute("
|
||||
SELECT StripeAccountID FROM Businesses WHERE ID = :bizID
|
||||
", { bizID: grantOwnerBizID }, { datasource: "payfrit" });
|
||||
|
||||
ownerStripeAcct = qOwnerBiz.recordCount > 0 ? (qOwnerBiz.StripeAccountID ?: "") : "";
|
||||
|
||||
if (len(trim(ownerStripeAcct)) > 0) {
|
||||
stripeSecretKey = application.stripeSecretKey ?: "";
|
||||
httpGrantTransfer = new http();
|
||||
httpGrantTransfer.setMethod("POST");
|
||||
httpGrantTransfer.setUrl("https://api.stripe.com/v1/transfers");
|
||||
httpGrantTransfer.setUsername(stripeSecretKey);
|
||||
httpGrantTransfer.setPassword("");
|
||||
httpGrantTransfer.addParam(type="formfield", name="amount", value=grantOwnerFeeCents);
|
||||
httpGrantTransfer.addParam(type="formfield", name="currency", value="usd");
|
||||
httpGrantTransfer.addParam(type="formfield", name="destination", value=ownerStripeAcct);
|
||||
httpGrantTransfer.addParam(type="formfield", name="metadata[grant_id]", value=grantMetaID);
|
||||
httpGrantTransfer.addParam(type="formfield", name="metadata[order_id]", value=orderID);
|
||||
httpGrantTransfer.addParam(type="formfield", name="metadata[type]", value="grant_owner_fee");
|
||||
httpGrantTransfer.addParam(type="header", name="Idempotency-Key", value="grant-transfer-#orderID#-#grantMetaID#");
|
||||
|
||||
grantTransferResult = httpGrantTransfer.send().getPrefix();
|
||||
grantTransferData = deserializeJSON(grantTransferResult.fileContent);
|
||||
|
||||
if (structKeyExists(grantTransferData, "id")) {
|
||||
writeLog(file="stripe_webhooks", text="Grant owner transfer #grantTransferData.id# created");
|
||||
} else {
|
||||
writeLog(file="stripe_webhooks", text="Grant owner transfer failed: #grantTransferData.error.message ?: 'unknown'#");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (any grantTransferErr) {
|
||||
writeLog(file="stripe_webhooks", text="Grant owner transfer error: #grantTransferErr.message#");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "payment_intent.payment_failed":
|
||||
paymentIntentID = eventData.id;
|
||||
orderID = val(eventData.metadata.order_id ?: 0);
|
||||
failureMessage = eventData.last_payment_error.message ?: "Payment failed";
|
||||
|
||||
auditPaymentIntentID = paymentIntentID;
|
||||
if (orderID > 0) {
|
||||
auditOrderID = orderID;
|
||||
queryExecute("
|
||||
UPDATE Orders
|
||||
SET PaymentStatus = 'failed',
|
||||
PaymentError = :failureMessage
|
||||
WHERE ID = :orderID
|
||||
AND (PaymentStatus IS NULL OR PaymentStatus = 'failed')
|
||||
", {
|
||||
orderID: orderID,
|
||||
failureMessage: failureMessage
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
writeLog(file="stripe_webhooks", text="Order #orderID# payment failed: #failureMessage#");
|
||||
}
|
||||
break;
|
||||
|
||||
case "charge.refunded":
|
||||
chargeID = eventData.id;
|
||||
paymentIntentID = eventData.payment_intent ?: "";
|
||||
refundAmount = eventData.amount_refunded / 100;
|
||||
|
||||
auditChargeID = chargeID;
|
||||
auditPaymentIntentID = paymentIntentID;
|
||||
auditAmountCents = eventData.amount_refunded;
|
||||
|
||||
if (paymentIntentID != "") {
|
||||
// FIX: Use correct column name (StripePaymentIntentID, not OrderStripePaymentIntentID)
|
||||
qOrder = queryExecute("
|
||||
SELECT ID FROM Orders
|
||||
WHERE StripePaymentIntentID = :paymentIntentID
|
||||
", { paymentIntentID: paymentIntentID }, { datasource: "payfrit" });
|
||||
|
||||
if (qOrder.recordCount > 0) {
|
||||
auditOrderID = qOrder.ID;
|
||||
queryExecute("
|
||||
UPDATE Orders
|
||||
SET PaymentStatus = 'refunded',
|
||||
RefundAmount = :refundAmount,
|
||||
RefundedOn = NOW()
|
||||
WHERE StripePaymentIntentID = :paymentIntentID
|
||||
", {
|
||||
paymentIntentID: paymentIntentID,
|
||||
refundAmount: refundAmount
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
writeLog(file="stripe_webhooks", text="Order #qOrder.ID# refunded: $#refundAmount#");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "charge.dispute.created":
|
||||
chargeID = eventData.id;
|
||||
paymentIntentID = eventData.payment_intent ?: "";
|
||||
|
||||
auditChargeID = chargeID;
|
||||
auditPaymentIntentID = paymentIntentID;
|
||||
|
||||
if (paymentIntentID != "") {
|
||||
// FIX: Use correct column name
|
||||
qOrder = queryExecute("
|
||||
SELECT ID, BusinessID FROM Orders
|
||||
WHERE StripePaymentIntentID = :paymentIntentID
|
||||
", { paymentIntentID: paymentIntentID }, { datasource: "payfrit" });
|
||||
|
||||
if (qOrder.recordCount > 0) {
|
||||
auditOrderID = qOrder.ID;
|
||||
|
||||
transaction action="begin" {
|
||||
try {
|
||||
queryExecute("
|
||||
UPDATE Orders
|
||||
SET PaymentStatus = 'disputed'
|
||||
WHERE StripePaymentIntentID = :paymentIntentID
|
||||
", { paymentIntentID: paymentIntentID }, { datasource: "payfrit" });
|
||||
|
||||
// Check if dispute task already exists (idempotency)
|
||||
qExistingTask = queryExecute("
|
||||
SELECT ID FROM Tasks
|
||||
WHERE SourceType = 'dispute' AND SourceID = :orderID
|
||||
", { orderID: qOrder.ID }, { datasource: "payfrit" });
|
||||
|
||||
if (qExistingTask.recordCount == 0) {
|
||||
queryExecute("
|
||||
INSERT INTO Tasks (BusinessID, CategoryID, Title, Details, CreatedOn, SourceType, SourceID)
|
||||
VALUES (:businessID, 4, 'Payment Dispute', 'Order ##:orderID has been disputed. Review immediately.', NOW(), 'dispute', :orderID)
|
||||
", {
|
||||
businessID: qOrder.BusinessID,
|
||||
orderID: qOrder.ID
|
||||
}, { datasource: "payfrit" });
|
||||
}
|
||||
|
||||
transaction action="commit";
|
||||
writeLog(file="stripe_webhooks", text="Dispute created for order #qOrder.ID#");
|
||||
} catch (any err) {
|
||||
transaction action="rollback";
|
||||
writeLog(file="stripe_webhooks", text="Dispute task creation failed: #err.message#");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "account.updated":
|
||||
accountID = eventData.id;
|
||||
chargesEnabled = eventData.charges_enabled ?: false;
|
||||
payoutsEnabled = eventData.payouts_enabled ?: false;
|
||||
|
||||
if (chargesEnabled && payoutsEnabled) {
|
||||
queryExecute("
|
||||
UPDATE Businesses
|
||||
SET StripeOnboardingComplete = 1
|
||||
WHERE StripeAccountID = :accountID
|
||||
", { accountID: accountID }, { datasource: "payfrit" });
|
||||
|
||||
writeLog(file="stripe_webhooks", text="Business account #accountID# is now fully active");
|
||||
}
|
||||
|
||||
// Worker accounts
|
||||
try {
|
||||
qWorkerAcct = queryExecute("
|
||||
SELECT ID FROM Users
|
||||
WHERE StripeConnectedAccountID = :accountID
|
||||
LIMIT 1
|
||||
", { accountID: accountID }, { datasource: "payfrit" });
|
||||
|
||||
if (qWorkerAcct.recordCount > 0) {
|
||||
queryExecute("
|
||||
UPDATE Users
|
||||
SET StripePayoutsEnabled = :payoutsEnabled
|
||||
WHERE StripeConnectedAccountID = :accountID
|
||||
", {
|
||||
payoutsEnabled: payoutsEnabled ? 1 : 0,
|
||||
accountID: accountID
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
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":
|
||||
// (existing checkout.session.completed handling unchanged)
|
||||
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 }, { datasource: "payfrit" });
|
||||
|
||||
writeLog(file="stripe_webhooks", text="Activation completed via payment for user #metaUserID#");
|
||||
}
|
||||
|
||||
// Tip payment handling...
|
||||
if (metaType == "tip") {
|
||||
metaTipID = val(sessionMetadata.tip_id ?: 0);
|
||||
metaWorkerID = val(sessionMetadata.worker_user_id ?: 0);
|
||||
tipPaymentIntent = eventData.payment_intent ?: "";
|
||||
|
||||
if (metaTipID > 0) {
|
||||
queryExecute("
|
||||
UPDATE Tips
|
||||
SET Status = 'paid',
|
||||
PaidOn = NOW(),
|
||||
StripePaymentIntentID = :piID
|
||||
WHERE ID = :tipID AND Status = 'pending'
|
||||
", {
|
||||
piID: tipPaymentIntent,
|
||||
tipID: metaTipID
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
writeLog(file="stripe_webhooks", text="Tip #metaTipID# paid");
|
||||
|
||||
// Transfer to worker (with idempotency key)
|
||||
if (metaWorkerID > 0) {
|
||||
qTipWorker = queryExecute("
|
||||
SELECT StripeConnectedAccountID FROM Users WHERE ID = :userID
|
||||
", { userID: metaWorkerID }, { datasource: "payfrit" });
|
||||
|
||||
tipWorkerAcct = qTipWorker.recordCount > 0 ? (qTipWorker.StripeConnectedAccountID ?: "") : "";
|
||||
|
||||
if (len(trim(tipWorkerAcct)) > 0) {
|
||||
qTipAmt = queryExecute("
|
||||
SELECT AmountCents FROM Tips WHERE ID = :tipID
|
||||
", { tipID: metaTipID }, { datasource: "payfrit" });
|
||||
|
||||
if (qTipAmt.recordCount > 0 && qTipAmt.AmountCents > 0) {
|
||||
stripeSecretKey = application.stripeSecretKey ?: "";
|
||||
httpTipTransfer = new http();
|
||||
httpTipTransfer.setMethod("POST");
|
||||
httpTipTransfer.setUrl("https://api.stripe.com/v1/transfers");
|
||||
httpTipTransfer.setUsername(stripeSecretKey);
|
||||
httpTipTransfer.setPassword("");
|
||||
httpTipTransfer.addParam(type="formfield", name="amount", value=qTipAmt.AmountCents);
|
||||
httpTipTransfer.addParam(type="formfield", name="currency", value="usd");
|
||||
httpTipTransfer.addParam(type="formfield", name="destination", value=tipWorkerAcct);
|
||||
httpTipTransfer.addParam(type="formfield", name="metadata[type]", value="tip");
|
||||
httpTipTransfer.addParam(type="formfield", name="metadata[tip_id]", value=metaTipID);
|
||||
httpTipTransfer.addParam(type="header", name="Idempotency-Key", value="tip-transfer-#metaTipID#");
|
||||
|
||||
tipTransferResult = httpTipTransfer.send().getPrefix();
|
||||
tipTransferData = deserializeJSON(tipTransferResult.fileContent);
|
||||
|
||||
if (structKeyExists(tipTransferData, "id")) {
|
||||
queryExecute("
|
||||
UPDATE Tips
|
||||
SET Status = 'transferred', StripeTransferID = :transferID
|
||||
WHERE ID = :tipID
|
||||
", {
|
||||
transferID: tipTransferData.id,
|
||||
tipID: metaTipID
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
writeLog(file="stripe_webhooks", text="Tip #metaTipID# transferred");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (any checkoutErr) {
|
||||
writeLog(file="stripe_webhooks", text="Checkout session error: #checkoutErr.message#");
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
writeLog(file="stripe_webhooks", text="Unhandled event type: #eventType#");
|
||||
}
|
||||
|
||||
// === WRITE AUDIT RECORD ===
|
||||
try {
|
||||
queryExecute("
|
||||
INSERT INTO PaymentAudit (
|
||||
OrderID, PaymentIntentID, StripeChargeID, StripeEventID,
|
||||
EventType, AmountCents, Currency, RawPayload,
|
||||
ProcessedAt, ProcessingResult, ErrorMessage
|
||||
) VALUES (
|
||||
:orderID, :piID, :chargeID, :eventID,
|
||||
:eventType, :amountCents, 'usd', :payload,
|
||||
NOW(), :result, :errorMsg
|
||||
)
|
||||
", {
|
||||
orderID: { value: auditOrderID, null: isNull(auditOrderID), cfsqltype: "cf_sql_integer" },
|
||||
piID: auditPaymentIntentID,
|
||||
chargeID: auditChargeID,
|
||||
eventID: eventID,
|
||||
eventType: eventType,
|
||||
amountCents: { value: auditAmountCents, null: isNull(auditAmountCents), cfsqltype: "cf_sql_integer" },
|
||||
payload: payload,
|
||||
result: auditResult,
|
||||
errorMsg: auditError
|
||||
}, { datasource: "payfrit" });
|
||||
} catch (any auditErr) {
|
||||
writeLog(file="stripe_webhooks", text="Audit insert failed: #auditErr.message#");
|
||||
}
|
||||
|
||||
response["OK"] = true;
|
||||
response["RECEIVED"] = true;
|
||||
if (auditResult != "success") {
|
||||
response["PROCESSING_RESULT"] = auditResult;
|
||||
}
|
||||
|
||||
} catch (any e) {
|
||||
writeLog(file="stripe_webhooks", text="FATAL ERROR: #e.message#");
|
||||
response["ERROR"] = e.message;
|
||||
}
|
||||
|
||||
writeOutput(serializeJSON(response));
|
||||
</cfscript>
|
||||
109
migrations/2026-02-15_payment_hardening.sql
Normal file
109
migrations/2026-02-15_payment_hardening.sql
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
-- ============================================================
|
||||
-- PAYMENT FLOW HARDENING MIGRATION
|
||||
-- Date: 2026-02-15
|
||||
-- Purpose: Add idempotency, audit, and integrity constraints
|
||||
-- ============================================================
|
||||
|
||||
-- 1. CREATE PAYMENTS AUDIT TABLE
|
||||
-- Stores full webhook payloads for forensic debugging
|
||||
|
||||
CREATE TABLE IF NOT EXISTS PaymentAudit (
|
||||
ID INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
OrderID INT UNSIGNED NULL,
|
||||
PaymentIntentID VARCHAR(64) NULL,
|
||||
StripeChargeID VARCHAR(64) NULL,
|
||||
StripeEventID VARCHAR(64) NOT NULL,
|
||||
EventType VARCHAR(64) NOT NULL,
|
||||
AmountCents INT UNSIGNED NULL,
|
||||
Currency CHAR(3) NULL DEFAULT 'usd',
|
||||
RawPayload MEDIUMTEXT NOT NULL,
|
||||
ProcessedAt DATETIME NULL,
|
||||
ProcessingResult ENUM('success', 'skipped_duplicate', 'skipped_already_paid', 'error') NOT NULL DEFAULT 'success',
|
||||
ErrorMessage TEXT NULL,
|
||||
CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Prevent duplicate event processing
|
||||
UNIQUE KEY uk_stripe_event (StripeEventID),
|
||||
|
||||
-- Fast lookups
|
||||
INDEX idx_order (OrderID),
|
||||
INDEX idx_payment_intent (PaymentIntentID),
|
||||
INDEX idx_created (CreatedAt)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
-- 2. ADD MISSING COLUMNS TO ORDERS TABLE
|
||||
-- (Run these only if columns don't exist)
|
||||
|
||||
-- Add StripePaymentIntentID if it doesn't exist
|
||||
SET @dbname = 'payfrit_dev';
|
||||
SET @tablename = 'Orders';
|
||||
SET @columnname = 'StripePaymentIntentID';
|
||||
SET @preparedStatement = (
|
||||
SELECT IF(
|
||||
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = @columnname) > 0,
|
||||
'SELECT 1',
|
||||
'ALTER TABLE Orders ADD COLUMN StripePaymentIntentID VARCHAR(64) NULL AFTER PaymentStatus'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @preparedStatement;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
|
||||
-- 3. ADD UNIQUE CONSTRAINT ON StripePaymentIntentID
|
||||
-- Each PaymentIntent can only pay ONE order
|
||||
|
||||
ALTER TABLE Orders
|
||||
ADD UNIQUE INDEX uk_stripe_payment_intent (StripePaymentIntentID);
|
||||
|
||||
-- Note: If this fails with "Duplicate entry", you have data integrity issues to resolve first
|
||||
|
||||
|
||||
-- 4. ADD AMOUNT VERIFICATION COLUMNS (if not present)
|
||||
-- Store the expected and actual amounts for verification
|
||||
|
||||
SET @columnname = 'ExpectedAmountCents';
|
||||
SET @preparedStatement = (
|
||||
SELECT IF(
|
||||
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = @columnname) > 0,
|
||||
'SELECT 1',
|
||||
'ALTER TABLE Orders ADD COLUMN ExpectedAmountCents INT UNSIGNED NULL AFTER StripePaymentIntentID'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @preparedStatement;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @columnname = 'ReceivedAmountCents';
|
||||
SET @preparedStatement = (
|
||||
SELECT IF(
|
||||
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = @columnname) > 0,
|
||||
'SELECT 1',
|
||||
'ALTER TABLE Orders ADD COLUMN ReceivedAmountCents INT UNSIGNED NULL AFTER ExpectedAmountCents'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @preparedStatement;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
|
||||
-- 5. ADD UNIQUE CONSTRAINT ON WorkPayoutLedgers.StripePaymentIntentID
|
||||
-- Each PaymentIntent should only be linked to ONE ledger entry
|
||||
|
||||
ALTER TABLE WorkPayoutLedgers
|
||||
ADD UNIQUE INDEX uk_ledger_payment_intent (StripePaymentIntentID);
|
||||
|
||||
|
||||
-- 6. ADD INDEX FOR FAST WEBHOOK LOOKUPS
|
||||
|
||||
ALTER TABLE Orders
|
||||
ADD INDEX idx_payment_status (PaymentStatus);
|
||||
|
||||
-- ============================================================
|
||||
-- PRODUCTION (payfrit) - Run the same on production database
|
||||
-- Replace @dbname = 'payfrit_dev' with @dbname = 'payfrit'
|
||||
-- ============================================================
|
||||
346
migrations/2026-02-15_payment_hardening_patches.md
Normal file
346
migrations/2026-02-15_payment_hardening_patches.md
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
# Payment Hardening Patches
|
||||
|
||||
## SUMMARY OF CHANGES
|
||||
|
||||
### 1. createPaymentIntent.cfm
|
||||
|
||||
**Add idempotency key and expected amount storage:**
|
||||
|
||||
```cfm
|
||||
// After line 132 (httpService.setPassword("")):
|
||||
// ADD: Idempotency key tied to OrderID (prevents duplicate PIs on client retry)
|
||||
httpService.addParam(type="header", name="Idempotency-Key", value="pi-order-#orderID#");
|
||||
```
|
||||
|
||||
```cfm
|
||||
// After line 167 (after abort on error):
|
||||
// ADD: Store expected amount and PaymentIntent ID in order for webhook verification
|
||||
queryExecute("
|
||||
UPDATE Orders
|
||||
SET ExpectedAmountCents = :expectedCents,
|
||||
StripePaymentIntentID = :piID
|
||||
WHERE ID = :orderID
|
||||
AND StripePaymentIntentID IS NULL
|
||||
", {
|
||||
expectedCents: totalAmountCents,
|
||||
piID: piData.id,
|
||||
orderID: orderID
|
||||
}, { datasource: "payfrit" });
|
||||
```
|
||||
|
||||
**Also add check for existing PaymentIntent at the start:**
|
||||
|
||||
```cfm
|
||||
// After line 81 (qOrder query):
|
||||
// ADD: Check if order already has a PaymentIntent
|
||||
if (qOrder.recordCount > 0 && len(trim(qOrder.StripePaymentIntentID ?: "")) > 0) {
|
||||
// Return existing PaymentIntent details
|
||||
response["OK"] = true;
|
||||
response["CLIENT_SECRET"] = ""; // Can't retrieve, client must use existing session
|
||||
response["PAYMENT_INTENT_ID"] = qOrder.StripePaymentIntentID;
|
||||
response["ERROR"] = "existing_payment_intent";
|
||||
response["MESSAGE"] = "Order already has a PaymentIntent. Use existing checkout session.";
|
||||
writeOutput(serializeJSON(response));
|
||||
abort;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. webhook.cfm
|
||||
|
||||
**Replace entire file with webhook.cfm.hardened** (already created)
|
||||
|
||||
Key changes:
|
||||
- Event deduplication via PaymentAudit table
|
||||
- Amount verification before marking paid
|
||||
- Atomic transactions with SELECT FOR UPDATE
|
||||
- Fixed column name (StripePaymentIntentID not OrderStripePaymentIntentID)
|
||||
- Audit logging for all events
|
||||
|
||||
---
|
||||
|
||||
### 3. orders/updateStatus.cfm
|
||||
|
||||
**Wrap task creation in transaction with order update:**
|
||||
|
||||
```cfm
|
||||
// Replace lines 54-64 and 88-157 with:
|
||||
|
||||
<cftransaction action="begin">
|
||||
<cftry>
|
||||
<!--- Update status --->
|
||||
<cfset queryExecute("
|
||||
UPDATE Orders
|
||||
SET StatusID = ?,
|
||||
LastEditedOn = ?
|
||||
WHERE ID = ?
|
||||
", [
|
||||
{ value = NewStatusID, cfsqltype = "cf_sql_integer" },
|
||||
{ value = now(), cfsqltype = "cf_sql_timestamp" },
|
||||
{ value = OrderID, cfsqltype = "cf_sql_integer" }
|
||||
], { datasource = "payfrit" })>
|
||||
|
||||
<!--- Create task if transitioning to status 3 --->
|
||||
<cfset taskCreated = false>
|
||||
<cfif NewStatusID EQ 3 AND oldStatusID NEQ 3>
|
||||
<!--- Check for existing task by OrderID --->
|
||||
<cfset qExisting = queryExecute("
|
||||
SELECT ID FROM Tasks WHERE OrderID = ? LIMIT 1
|
||||
", [ { value = OrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
|
||||
|
||||
<cfif qExisting.recordCount EQ 0>
|
||||
<!--- (existing task creation logic) --->
|
||||
<!--- ... --->
|
||||
<cfset taskCreated = true>
|
||||
</cfif>
|
||||
</cfif>
|
||||
|
||||
<cftransaction action="commit">
|
||||
<cfcatch>
|
||||
<cftransaction action="rollback">
|
||||
<cfrethrow>
|
||||
</cfcatch>
|
||||
</cftry>
|
||||
</cftransaction>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. tasks/complete.cfm
|
||||
|
||||
**Wrap all operations in single transaction:**
|
||||
|
||||
```cfm
|
||||
// After line 109 (end of rating validation):
|
||||
|
||||
<cftransaction action="begin">
|
||||
<cftry>
|
||||
<!--- All existing operations from lines 111-365 go inside this transaction --->
|
||||
|
||||
<cftransaction action="commit">
|
||||
<cfcatch>
|
||||
<cftransaction action="rollback">
|
||||
<cfset apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "transaction_failed",
|
||||
"MESSAGE": "Failed to complete task atomically",
|
||||
"DETAIL": cfcatch.message
|
||||
})>
|
||||
</cfcatch>
|
||||
</cftry>
|
||||
</cftransaction>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SQL SCHEMA CHANGES
|
||||
|
||||
Run `migrations/2026-02-15_payment_hardening.sql` on both `payfrit_dev` and `payfrit` databases.
|
||||
|
||||
Key additions:
|
||||
- `PaymentAudit` table with UNIQUE(StripeEventID)
|
||||
- `Orders.ExpectedAmountCents` column
|
||||
- `Orders.ReceivedAmountCents` column
|
||||
- UNIQUE constraint on `Orders.StripePaymentIntentID`
|
||||
- UNIQUE constraint on `WorkPayoutLedgers.StripePaymentIntentID`
|
||||
|
||||
---
|
||||
|
||||
## ORDER STATE MACHINE
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ │
|
||||
▼ │
|
||||
┌───────┐ │
|
||||
│ 0 │ Cart │
|
||||
│ cart │ │
|
||||
└───┬───┘ │
|
||||
│ submit.cfm │
|
||||
▼ │
|
||||
┌───────┐ │
|
||||
│ 1 │ Submitted (awaiting payment) │
|
||||
│ subm │◄────────────────────────────────┤
|
||||
└───┬───┘ │
|
||||
│ │
|
||||
│ ONLY webhook can transition │
|
||||
│ payment_pending → paid │
|
||||
▼ │
|
||||
┌───────┐ ┌───────┐ │
|
||||
│ 2 │────►│ 3 │ Final Prep │
|
||||
│in-prog│ │ ready │ (creates Task) │
|
||||
└───────┘ └───┬───┘ │
|
||||
│ │
|
||||
▼ │
|
||||
┌───────┐ │
|
||||
│ 4 │ Claimed │
|
||||
│claimed│ │
|
||||
└───┬───┘ │
|
||||
│ │
|
||||
▼ │
|
||||
┌───────┐ │
|
||||
│ 5 │ Delivered │
|
||||
│ done │ │
|
||||
└───────┘ │
|
||||
│
|
||||
┌───────┐ │
|
||||
│ 6 │ Cancelled ─────────────────────┘
|
||||
│cancel │ (can retry)
|
||||
└───────┘
|
||||
|
||||
┌───────┐
|
||||
│ 7 │ Deleted (abandoned cart)
|
||||
│delete │
|
||||
└───────┘
|
||||
|
||||
PAYMENT STATUS (separate from order status):
|
||||
- NULL = not yet paid
|
||||
- 'paid' = payment confirmed (ONLY set by webhook)
|
||||
- 'failed' = payment failed
|
||||
- 'refunded' = charge refunded
|
||||
- 'disputed' = dispute opened
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## INVARIANTS ENFORCED
|
||||
|
||||
| Invariant | How Enforced |
|
||||
|-----------|--------------|
|
||||
| **A) Idempotency** | |
|
||||
| PI creation uses OrderID key | `Idempotency-Key: pi-order-{OrderID}` header |
|
||||
| Webhook deduplicates events | UNIQUE(StripeEventID) in PaymentAudit |
|
||||
| No duplicate tasks | Check `SourceType='dispute' AND SourceID=OrderID` before insert |
|
||||
| | |
|
||||
| **B) Atomicity** | |
|
||||
| Payment update is atomic | `<cftransaction>` wrapper with rollback |
|
||||
| Task creation with order update | Same transaction |
|
||||
| | |
|
||||
| **C) Amount verification** | |
|
||||
| Expected stored at PI creation | `Orders.ExpectedAmountCents = totalAmountCents` |
|
||||
| Verified in webhook | `IF amountReceived != expectedAmount THEN reject` |
|
||||
| | |
|
||||
| **D) State machine** | |
|
||||
| Only webhook sets PaymentStatus='paid' | Webhook handler only, no client endpoint |
|
||||
| Client confirmation alone NOT sufficient | Client gets client_secret, webhook confirms |
|
||||
| | |
|
||||
| **E) Concurrency** | |
|
||||
| Row locking | `SELECT ... FOR UPDATE` in webhook |
|
||||
| Double-update prevention | `WHERE PaymentStatus IS NULL OR PaymentStatus NOT IN ('paid','refunded')` |
|
||||
|
||||
---
|
||||
|
||||
## TEST CHECKLIST
|
||||
|
||||
### Idempotency Tests
|
||||
|
||||
- [ ] **Replay webhook**: Send same `payment_intent.succeeded` event twice
|
||||
- Expected: Second call returns `{"OK":true,"SKIPPED":"duplicate_event"}`
|
||||
- Verify: PaymentAudit has only 1 row with that StripeEventID
|
||||
- Verify: Order not double-updated
|
||||
|
||||
- [ ] **Client retry**: Call createPaymentIntent twice for same OrderID
|
||||
- Expected: Second call returns `{"ERROR":"existing_payment_intent"}`
|
||||
- Verify: Only 1 PaymentIntent in Stripe dashboard
|
||||
|
||||
- [ ] **Concurrent webhooks**: Fire 2 webhook calls simultaneously (use curl in parallel)
|
||||
- Expected: One succeeds, one returns duplicate
|
||||
- Verify: Order has correct state
|
||||
|
||||
### Amount Verification Tests
|
||||
|
||||
- [ ] **Correct amount**: Normal payment flow
|
||||
- Verify: Order.ReceivedAmountCents matches ExpectedAmountCents
|
||||
|
||||
- [ ] **Amount mismatch**: Manually craft webhook with wrong amount
|
||||
- Expected: Webhook returns error, order NOT marked paid
|
||||
- Verify: PaymentAudit.ProcessingResult = 'error'
|
||||
|
||||
### Refund Tests
|
||||
|
||||
- [ ] **Issue refund via Stripe dashboard**
|
||||
- Expected: Order.PaymentStatus = 'refunded'
|
||||
- Expected: Order.RefundAmount populated
|
||||
- Expected: Order.RefundedOn populated
|
||||
|
||||
### Dispute Tests
|
||||
|
||||
- [ ] **Simulate dispute**
|
||||
- Expected: Order.PaymentStatus = 'disputed'
|
||||
- Expected: Task created with Title = 'Payment Dispute'
|
||||
- Verify: Only 1 task created (replay webhook to confirm)
|
||||
|
||||
### Transaction Tests
|
||||
|
||||
- [ ] **Partial failure**: Inject error after order update but before task creation
|
||||
- Expected: Both rollback, no partial state
|
||||
|
||||
---
|
||||
|
||||
## RISK SUMMARY
|
||||
|
||||
### CRITICAL RISKS (MUST FIX IMMEDIATELY)
|
||||
|
||||
1. **Column name mismatch** (refund/dispute handlers use wrong column)
|
||||
- Impact: Refunds and disputes silently fail to update orders
|
||||
- Fix: Replace `OrderStripePaymentIntentID` → `StripePaymentIntentID`
|
||||
|
||||
2. **No idempotency on PI creation**
|
||||
- Impact: Client retry creates duplicate charges
|
||||
- Fix: Add `Idempotency-Key` header
|
||||
|
||||
3. **No webhook event deduplication**
|
||||
- Impact: Replayed webhook double-processes payment
|
||||
- Fix: PaymentAudit table with UNIQUE(StripeEventID)
|
||||
|
||||
### HIGH RISKS
|
||||
|
||||
4. **No transaction wrappers**
|
||||
- Impact: Partial writes on failure
|
||||
- Fix: Wrap related operations in `<cftransaction>`
|
||||
|
||||
5. **No amount verification**
|
||||
- Impact: Any amount accepted as "paid"
|
||||
- Fix: Store ExpectedAmountCents, verify in webhook
|
||||
|
||||
6. **No unique constraint on StripePaymentIntentID**
|
||||
- Impact: Same PI could be linked to multiple orders (data corruption)
|
||||
- Fix: Add UNIQUE index
|
||||
|
||||
### MEDIUM RISKS
|
||||
|
||||
7. **No SELECT FOR UPDATE**
|
||||
- Impact: Race condition on concurrent webhook delivery
|
||||
- Fix: Add `FOR UPDATE` to order lookup in webhook
|
||||
|
||||
8. **No audit trail**
|
||||
- Impact: Cannot debug disputes or investigate issues
|
||||
- Fix: PaymentAudit table
|
||||
|
||||
---
|
||||
|
||||
## DEPLOYMENT ORDER
|
||||
|
||||
1. **Run SQL migration** on payfrit_dev first, verify
|
||||
2. **Deploy webhook.cfm.hardened** → rename to webhook.cfm
|
||||
3. **Apply patches** to createPaymentIntent.cfm
|
||||
4. **Apply patches** to updateStatus.cfm and complete.cfm
|
||||
5. **Test** with test Stripe keys
|
||||
6. **Run SQL migration** on production (payfrit)
|
||||
7. **Deploy** to production
|
||||
|
||||
---
|
||||
|
||||
## PAYFRIT $2.32 PAYOUT VERIFICATION
|
||||
|
||||
Based on the fee structure in createPaymentIntent.cfm:
|
||||
- Customer fee: 5% of subtotal
|
||||
- Business fee: 5% of subtotal
|
||||
- Payfrit receives: 10% total (application_fee_amount)
|
||||
|
||||
If Payfrit received $2.32 in application fee:
|
||||
- That represents 10% of a ~$23.20 subtotal
|
||||
- Or the order total (with fees) was approximately $27-28
|
||||
|
||||
This aligns with the code's `totalPlatformFeeCents = round((payfritCustomerFee + payfritBusinessFee) * 100)`.
|
||||
Reference in a new issue