From 3e936728db8168560e2d48a1699283cbcf39932c Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Mon, 16 Mar 2026 13:26:17 -0700 Subject: [PATCH] Preserve payment hardening files and migrations Co-Authored-By: Claude Sonnet 4.6 --- api/stripe/webhook.cfm.hardened | 566 ++++++++++++++++++ migrations/2026-02-15_payment_hardening.sql | 109 ++++ .../2026-02-15_payment_hardening_patches.md | 346 +++++++++++ 3 files changed, 1021 insertions(+) create mode 100644 api/stripe/webhook.cfm.hardened create mode 100644 migrations/2026-02-15_payment_hardening.sql create mode 100644 migrations/2026-02-15_payment_hardening_patches.md diff --git a/api/stripe/webhook.cfm.hardened b/api/stripe/webhook.cfm.hardened new file mode 100644 index 0000000..8c045fd --- /dev/null +++ b/api/stripe/webhook.cfm.hardened @@ -0,0 +1,566 @@ + +/** + * 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)); + diff --git a/migrations/2026-02-15_payment_hardening.sql b/migrations/2026-02-15_payment_hardening.sql new file mode 100644 index 0000000..d6f3b9f --- /dev/null +++ b/migrations/2026-02-15_payment_hardening.sql @@ -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' +-- ============================================================ diff --git a/migrations/2026-02-15_payment_hardening_patches.md b/migrations/2026-02-15_payment_hardening_patches.md new file mode 100644 index 0000000..92dfff0 --- /dev/null +++ b/migrations/2026-02-15_payment_hardening_patches.md @@ -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: + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +--- + +### 4. tasks/complete.cfm + +**Wrap all operations in single transaction:** + +```cfm +// After line 109 (end of rating validation): + + + + + + + + + + + + +``` + +--- + +## 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 | `` 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 `` + +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)`.