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