Preserve payment hardening files and migrations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-03-16 13:26:17 -07:00
parent fc43d2af9c
commit 3e936728db
3 changed files with 1021 additions and 0 deletions

View 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>

View 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'
-- ============================================================

View 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)`.