diff --git a/WEBHOOK_MATCHING_LOG_GUIDE.md b/WEBHOOK_MATCHING_LOG_GUIDE.md new file mode 100644 index 00000000..bbf55df0 --- /dev/null +++ b/WEBHOOK_MATCHING_LOG_GUIDE.md @@ -0,0 +1,372 @@ +# Webhook Matching - Log Analysis Guide + +This guide shows you exactly what to search for in logs to trace how a payment webhook was matched to an order. + +--- + +## πŸ” **Quick Search Guide** + +### **Step 1: Find the Webhook Entry** + +Search for the **Payment ID** from the order notes: +``` +pay_u43vgdybaz5ybexffmkkytorxu +``` + +Or search for: +``` +WEBHOOK MATCHING: ========== STARTING ORDER LOOKUP ========== +``` + +--- + +## πŸ“‹ **Complete Log Sequence** + +### **1. Webhook Received (Start)** + +**Search:** `WEBHOOK MATCHING: ========== STARTING ORDER LOOKUP ==========` + +**What you'll see:** +``` +WEBHOOK MATCHING: ========== STARTING ORDER LOOKUP ========== +WEBHOOK MATCHING: Event Type: payment_captured +WEBHOOK MATCHING: Payment ID: pay_u43vgdybaz5ybexffmkkytorxu +WEBHOOK MATCHING: Session ID in metadata: ps_xxxxx (or NOT SET) +WEBHOOK MATCHING: Order ID in metadata: 12345 (or NOT SET) +``` + +**What this tells you:** +- βœ… Webhook event type +- βœ… Payment ID from webhook +- βœ… Session ID (if present) +- βœ… Order ID (if present in metadata) + +--- + +### **2. Method 1: Order ID from Metadata** + +**Search:** `WEBHOOK MATCHING: Trying METHOD 1` + +**What you'll see if Order ID exists:** +``` +WEBHOOK MATCHING: Trying METHOD 1 (Order ID from metadata): 12345 +WEBHOOK MATCHING: βœ… MATCHED BY METHOD 1 (Order ID from metadata) - Order ID: 12345 +``` + +**OR if Order ID not found:** +``` +WEBHOOK MATCHING: Trying METHOD 1 (Order ID from metadata): 12345 +WEBHOOK MATCHING: ❌ METHOD 1 FAILED - Order ID 12345 not found +``` + +**What this tells you:** +- βœ… If order_id was in webhook metadata +- βœ… If order was found by order_id +- ❌ If order_id was missing or order not found + +--- + +### **3. Method 2: Combined (Session ID + Payment ID)** + +**Search:** `WEBHOOK MATCHING: METHOD 2` or `COMBINED` + +**What you'll see if matched:** +``` +WEBHOOK MATCHING: βœ… MATCHED BY METHOD 2 (COMBINED: Session ID + Payment ID) - Order ID: 12345 +``` + +**OR if failed:** +``` +WEBHOOK MATCHING: ❌ METHOD 2 FAILED - No order found by COMBINED match (Session ID: ps_xxxxx, Payment ID: pay_xxxxx) +``` + +**What this tells you:** +- βœ… Most reliable matching method (requires BOTH session ID and payment ID) +- βœ… Order found by combined identifiers +- ❌ If either session ID or payment ID missing/doesn't match + +--- + +### **4. Method 3: Payment ID Alone** + +**Search:** `WEBHOOK MATCHING: METHOD 3` or `PAYMENT ID ALONE` + +**What you'll see if matched:** +``` +WEBHOOK MATCHING: βœ… MATCHED BY METHOD 3 (PAYMENT ID ALONE) - Order ID: 12345 +WEBHOOK MATCHING: ⚠️ WARNING: Matched by payment ID alone (less reliable than combined match) +``` + +**OR if failed:** +``` +WEBHOOK MATCHING: ❌ METHOD 3 FAILED - No order found by payment ID: pay_xxxxx +``` + +**What this tells you:** +- βœ… Fallback method (less reliable) +- βœ… Order found by payment ID only +- ⚠️ Warning that this is less reliable +- ❌ If payment ID doesn't match any order + +--- + +### **5. Order Details (If Found)** + +**Search:** `WEBHOOK MATCHING: βœ… ORDER FOUND` + +**What you'll see:** +``` +WEBHOOK MATCHING: βœ… ORDER FOUND - Order ID: 12345 +WEBHOOK MATCHING: Order Status: pending +WEBHOOK MATCHING: Order Payment Session ID: ps_xxxxx +WEBHOOK MATCHING: Order Payment ID (_cko_flow_payment_id): pay_xxxxx +WEBHOOK MATCHING: Order Payment ID (_cko_payment_id): pay_xxxxx +``` + +**What this tells you:** +- βœ… Order was successfully matched +- βœ… Current order status +- βœ… Payment session ID stored on order +- βœ… Payment IDs stored on order (both fields) + +--- + +### **6. Order Not Found** + +**Search:** `WEBHOOK MATCHING: ❌ ORDER NOT FOUND` + +**What you'll see:** +``` +WEBHOOK MATCHING: ❌ ORDER NOT FOUND - No matching order found +Flow webhook: No order found for webhook processing. Payment ID: pay_xxxxx - Will attempt to queue or process via webhook handlers +``` + +**What this tells you:** +- ❌ All 3 methods failed +- βœ… Webhook will be queued (if payment_approved or payment_captured) +- βœ… Checkout.com will retry (for other webhook types) + +--- + +### **7. Payment ID Validation** + +**Search:** `WEBHOOK MATCHING: Payment ID mismatch` or `Payment ID validation` + +**What you'll see if mismatch:** +``` +WEBHOOK MATCHING: ❌ CRITICAL ERROR - Payment ID mismatch in Flow webhook handler! +WEBHOOK MATCHING: Order ID: 12345 +WEBHOOK MATCHING: Order _cko_flow_payment_id: pay_xxxxx +WEBHOOK MATCHING: Order _cko_payment_id: pay_xxxxx +WEBHOOK MATCHING: Expected payment ID: pay_xxxxx +WEBHOOK MATCHING: Webhook payment ID: pay_different +WEBHOOK MATCHING: ❌ REJECTING WEBHOOK - Payment ID does not match order! +``` + +**OR if match:** +``` +Flow webhook: βœ… Payment ID validation passed - Order payment ID: pay_xxxxx, Webhook payment ID: pay_xxxxx +``` + +**What this tells you:** +- βœ… Payment IDs match (webhook is for correct order) +- ❌ Payment IDs don't match (webhook rejected - wrong payment) + +--- + +### **8. Duplicate Prevention** + +**Search:** `WEBHOOK: Already processed` or `WEBHOOK: βœ… Marked as processed` + +**What you'll see:** +``` +WEBHOOK: βœ… Already processed - Payment ID: pay_xxxxx, Type: payment_captured, Order: 12345 +WEBHOOK: βœ… Skipping duplicate webhook processing to prevent multiple order updates +``` + +**OR:** +``` +WEBHOOK: βœ… Marked as processed - Payment ID: pay_xxxxx, Type: payment_captured, Order: 12345 +``` + +**What this tells you:** +- βœ… Webhook already processed (duplicate prevention working) +- βœ… Webhook marked as processed (will skip duplicates) + +--- + +### **9. Webhook Processing** + +**Search:** `WEBHOOK PROCESS:` + event type (e.g., `capture_payment`, `authorize_payment`) + +**What you'll see:** +``` +=== WEBHOOK PROCESS: capture_payment START === +WEBHOOK PROCESS: Event type: payment_captured +WEBHOOK PROCESS: Order ID from metadata: 12345 +WEBHOOK PROCESS: Payment ID: pay_xxxxx +WEBHOOK PROCESS: Order loaded successfully - Order ID: 12345, Status: pending +WEBHOOK PROCESS: Order status updated to: processing +=== WEBHOOK PROCESS: capture_payment END (SUCCESS) === +``` + +**What this tells you:** +- βœ… Webhook handler executed +- βœ… Order was found and loaded +- βœ… Order status was updated +- βœ… Processing completed successfully + +--- + +## πŸ”Ž **Search Patterns for Specific Scenarios** + +### **Scenario 1: Find How Order Was Matched** + +**Search:** Payment ID + `WEBHOOK MATCHING: βœ… MATCHED BY METHOD` + +**Example:** +``` +pay_u43vgdybaz5ybexffmkkytorxu WEBHOOK MATCHING: βœ… MATCHED BY METHOD +``` + +**Result:** Shows which method (1, 2, or 3) matched the order + +--- + +### **Scenario 2: Find Why Matching Failed** + +**Search:** Payment ID + `WEBHOOK MATCHING: ❌` + +**Example:** +``` +pay_u43vgdybaz5ybexffmkkytorxu WEBHOOK MATCHING: ❌ +``` + +**Result:** Shows which methods failed and why + +--- + +### **Scenario 3: Find Payment ID Mismatch** + +**Search:** Payment ID + `Payment ID mismatch` or `REJECTING WEBHOOK` + +**Example:** +``` +pay_u43vgdybaz5ybexffmkkytorxu Payment ID mismatch +``` + +**Result:** Shows if webhook was rejected due to payment ID mismatch + +--- + +### **Scenario 4: Find Duplicate Webhook** + +**Search:** Payment ID + `Already processed` + +**Example:** +``` +pay_u43vgdybaz5ybexffmkkytorxu Already processed +``` + +**Result:** Shows if webhook was skipped as duplicate + +--- + +### **Scenario 5: Find Queued Webhook** + +**Search:** Payment ID + `WEBHOOK QUEUE` + +**Example:** +``` +pay_u43vgdybaz5ybexffmkkytorxu WEBHOOK QUEUE +``` + +**Result:** Shows if webhook was queued (order not found yet) + +--- + +## πŸ“Š **Log Analysis Checklist** + +For a specific payment, check these in order: + +1. βœ… **Webhook Received** + - Search: `WEBHOOK MATCHING: ========== STARTING ORDER LOOKUP ==========` + - Check: Payment ID, Session ID, Order ID in metadata + +2. βœ… **Matching Method Used** + - Search: `WEBHOOK MATCHING: βœ… MATCHED BY METHOD` + - Check: Which method (1, 2, or 3) matched + +3. βœ… **Order Details** + - Search: `WEBHOOK MATCHING: βœ… ORDER FOUND` + - Check: Order ID, Status, Payment IDs + +4. βœ… **Payment ID Validation** + - Search: `Payment ID validation` or `Payment ID mismatch` + - Check: If payment IDs match + +5. βœ… **Duplicate Check** + - Search: `Already processed` or `Marked as processed` + - Check: If webhook was already processed + +6. βœ… **Processing Result** + - Search: `WEBHOOK PROCESS:` + event type + - Check: If webhook was processed successfully + +--- + +## 🎯 **Example: Complete Log Trace** + +For payment `pay_u43vgdybaz5ybexffmkkytorxu`: + +``` +1. WEBHOOK MATCHING: ========== STARTING ORDER LOOKUP ========== +2. WEBHOOK MATCHING: Event Type: payment_captured +3. WEBHOOK MATCHING: Payment ID: pay_u43vgdybaz5ybexffmkkytorxu +4. WEBHOOK MATCHING: Session ID in metadata: ps_xxxxx +5. WEBHOOK MATCHING: Order ID in metadata: NOT SET +6. WEBHOOK MATCHING: ❌ METHOD 1 FAILED - Order ID not found +7. WEBHOOK MATCHING: βœ… MATCHED BY METHOD 2 (COMBINED: Session ID + Payment ID) - Order ID: 12345 +8. WEBHOOK MATCHING: βœ… ORDER FOUND - Order ID: 12345 +9. WEBHOOK MATCHING: Order Status: pending +10. Flow webhook: βœ… Payment ID validation passed +11. WEBHOOK PROCESS: capture_payment START +12. WEBHOOK PROCESS: Order status updated to: processing +13. WEBHOOK PROCESS: capture_payment END (SUCCESS) +14. WEBHOOK: βœ… Marked as processed - Payment ID: pay_u43vgdybaz5ybexffmkkytorxu +``` + +**Analysis:** +- βœ… Webhook received for `payment_captured` event +- βœ… Order ID NOT in metadata (normal for Flow checkout) +- βœ… Method 1 failed (no order_id in metadata) +- βœ… Method 2 succeeded (matched by Session ID + Payment ID) +- βœ… Payment ID validation passed +- βœ… Webhook processed successfully +- βœ… Order status updated to `processing` + +--- + +## πŸ”§ **Log File Location** + +Logs are written to: +- **WordPress Debug Log:** `wp-content/debug.log` (if `WP_DEBUG_LOG` enabled) +- **Checkout.com Plugin Logs:** Check plugin settings for log location +- **Server Error Logs:** Check your hosting provider's error logs + +--- + +## πŸ’‘ **Tips** + +1. **Use Payment ID as primary search term** - It's unique and appears in all relevant logs +2. **Search for "WEBHOOK MATCHING:"** - Shows the matching process +3. **Search for "WEBHOOK PROCESS:"** - Shows the processing result +4. **Check timestamps** - Match logs with order note timestamps +5. **Look for ❌ and βœ…** - Quick visual indicators of success/failure + +--- + +**Last Updated:** 2025-01-17 +**Version:** 5.0.0 + + diff --git a/WEBHOOK_PROCESSING_STEPS.md b/WEBHOOK_PROCESSING_STEPS.md new file mode 100644 index 00000000..99da62a8 --- /dev/null +++ b/WEBHOOK_PROCESSING_STEPS.md @@ -0,0 +1,507 @@ +# Webhook Processing Steps - Complete Guide + +This document details the exact steps and checks performed for each webhook type in the Checkout.com WooCommerce Flow integration. + +--- + +## πŸ”„ **FLOW WEBHOOK HANDLER (Entry Point)** + +**Location:** `class-wc-gateway-checkout-com-flow.php` β†’ `webhook_handler()` + +### **Step 1: Order Matching (BEFORE Event Processing)** + +The Flow webhook handler matches orders using 3 methods (in order): + +1. **Method 1: Order ID from Metadata** + - Extract `order_id` from `$data->data->metadata->order_id` + - Load order using `wc_get_order($order_id)` + - βœ… If found: Use this order + - ❌ If not found: Try Method 2 + +2. **Method 2: Combined (Payment Session ID + Payment ID)** + - Extract `cko_payment_session_id` from `$data->data->metadata->cko_payment_session_id` + - Extract `payment_id` from `$data->data->id` + - Query orders with BOTH: + - `_cko_payment_session_id` = session ID + - `_cko_flow_payment_id` = payment ID + - First try orders with status: `pending`, `failed`, `on-hold`, `processing` + - If not found, try all orders (fallback) + - βœ… If found: Use this order + - ❌ If not found: Try Method 3 + +3. **Method 3: Payment ID Alone** + - Extract `payment_id` from `$data->data->id` + - Query orders with `_cko_flow_payment_id` = payment ID + - First try orders with status: `pending`, `failed`, `on-hold`, `processing` + - If not found, try all orders (fallback) + - βœ… If found: Use this order + - ❌ If not found: Order not found + +### **Step 2: Payment ID Validation (BEFORE Event Processing)** + +**CRITICAL CHECK:** This happens BEFORE any webhook event is processed. + +- Get order's payment IDs: + - `_cko_flow_payment_id` (preferred) + - `_cko_payment_id` (fallback) +- Get webhook payment ID: `$data->data->id` +- **If order has payment ID:** + - βœ… **Match:** Continue processing + - ❌ **Mismatch:** Reject webhook (HTTP 200, but don't process) +- **If order has NO payment ID:** + - Set payment ID from webhook (first payment attempt) + - Continue processing + +### **Step 3: Event Type Routing** + +Based on `$data->type`, route to appropriate handler: + +- `payment_approved` β†’ `authorize_payment()` +- `payment_captured` β†’ `capture_payment()` +- `payment_declined` / `payment_authentication_failed` β†’ `decline_payment()` +- `payment_capture_declined` β†’ `capture_declined()` +- `payment_refunded` β†’ `refund_payment()` +- `payment_voided` β†’ `void_payment()` +- `payment_canceled` β†’ `cancel_payment()` + +### **Step 4: Mark as Processed** + +After successful processing: +- Create unique webhook ID: `{payment_id}_{event_type}` +- Add to order meta: `_cko_processed_webhook_ids` (array) +- Prevents duplicate processing + +--- + +## 1️⃣ **PAYMENT APPROVED (Authorization)** + +**Event Type:** `payment_approved` +**Handler:** `WC_Checkout_Com_Webhook::authorize_payment()` + +### **Steps:** + +1. **Extract Data** + - `order_id` from `$data->data->metadata->order_id` + - `payment_id` from `$data->data->id` + - `action_id` from `$data->data->action_id` + +2. **Validate Order ID** + - βœ… Must be numeric and not empty + - ❌ If invalid: Return `false` (webhook queued if possible) + +3. **Load Order** + - Use `self::get_wc_order($order_id)` + - ❌ If not found: Return `false` (webhook queued if possible) + +4. **Check Current State** + - Get `cko_payment_captured` meta + - Get current order status + - Get `cko_payment_authorized` meta + +5. **Status Update Logic** + - βœ… **If already captured:** + - Add note only + - Update meta: `cko_payment_authorized = true` + - Set transaction ID + - **DO NOT change status** (prevent downgrade) + - Return `true` + + - βœ… **If already authorized AND status matches configured status:** + - Add note only + - Return `true` + + - βœ… **If order status is `processing` or `completed`:** + - Add note + - Update meta: `cko_payment_authorized = true` + - Set transaction ID + - **DO NOT change status** (prevent downgrade) + - Return `true` + + - βœ… **Otherwise:** + - Set transaction ID: `action_id` + - Update meta: `_cko_payment_id = payment_id` + - Update meta: `cko_payment_authorized = true` + - Add order note + - Update status to configured status (default: `on-hold`) + - Return `true` + +6. **If Processing Failed** + - Return `false` + - Flow handler queues webhook for later processing + +--- + +## 2️⃣ **PAYMENT CAPTURED** + +**Event Type:** `payment_captured` +**Handler:** `WC_Checkout_Com_Webhook::capture_payment()` + +### **Steps:** + +1. **Extract Data** + - `order_id` from `$data->data->metadata->order_id` + - `payment_id` from `$data->data->id` + - `action_id` from `$data->data->action_id` + - `amount` from `$data->data->amount` + +2. **Validate Order ID** + - βœ… Must be numeric and not empty + - ❌ If invalid: Return `false` (webhook queued if possible) + +3. **Load Order** + - Use `self::get_wc_order($order_id)` + - ❌ If not found: Return `false` (webhook queued if possible) + +4. **Check Authorization Status** + - Get `cko_payment_authorized` meta + - βœ… **If not authorized:** Set `cko_payment_authorized = true` (capture implies authorization) + +5. **Check Capture Status** + - Get `cko_payment_captured` meta + - βœ… **If already captured:** + - Add note only + - Return `true` + +6. **Process Capture** + - Add generic capture note + - Set transaction ID: `action_id` + - Update meta: `cko_payment_captured = true` + - Compare amounts: + - If `amount < order_amount`: Partial capture note + - If `amount == order_amount`: Full capture note + - Add specific capture note + - Update status to configured status (default: `processing`) + - Return `true` + +7. **If Processing Failed** + - Return `false` + - Flow handler queues webhook for later processing + +--- + +## 3️⃣ **PAYMENT DECLINED (Failed)** + +**Event Type:** `payment_declined` or `payment_authentication_failed` +**Handler:** `WC_Checkout_Com_Webhook::decline_payment()` + +### **Steps:** + +1. **Extract Data** + - `order_id` from `$data->data->metadata->order_id` + - `payment_id` from `$data->data->id` + - `action_id` from `$data->data->action_id` + - `response_summary` from `$data->data->response_summary` + +2. **Validate Order ID** + - βœ… Must be numeric and not empty + - ❌ If invalid: Return `false` + +3. **Load Order** + - Use `self::get_wc_order($order_id)` + - ❌ If not found: Return `false` + +4. **CRITICAL: Payment ID Validation** + - Get order's `_cko_flow_payment_id` or `_cko_payment_id` + - Compare with webhook `payment_id` + - βœ… **If order has payment ID AND matches:** Continue + - βœ… **If order has NO payment ID:** Continue (first attempt) + - ❌ **If order has payment ID AND doesn't match:** + - Log error + - Return `false` (reject webhook) + +5. **Check Order Status** + - Get current order status + - βœ… **If status is `failed`:** + - Add note only + - Return `true` (prevent duplicate status change) + +6. **Process Decline** + - Create decline message with payment ID, action ID, and reason + - Add order note + - Update status to `failed` + - Return `true` + +--- + +## 4️⃣ **PAYMENT CAPTURE DECLINED (Partial Capture Failed)** + +**Event Type:** `payment_capture_declined` +**Handler:** `WC_Checkout_Com_Webhook::capture_declined()` + +### **Steps:** + +1. **Extract Data** + - `order_id` from `$data->data->metadata->order_id` + - `payment_id` from `$data->data->id` + - `action_id` from `$data->data->action_id` + - `response_summary` from `$data->data->response_summary` + +2. **Validate Order ID** + - βœ… Must be numeric and not empty + - ❌ If invalid: Return `false` + +3. **Load Order** + - Use `self::get_wc_order($order_id)` + - ❌ If not found: Return `false` + +4. **Process Capture Decline** + - Create message with payment ID, action ID, and reason + - Add order note + - **Note:** Status is NOT changed (order remains in current state) + - Return `true` + +**Note:** This webhook does NOT validate payment ID (may need to be added for consistency). + +--- + +## 5️⃣ **PAYMENT REFUNDED (Returned)** + +**Event Type:** `payment_refunded` +**Handler:** `WC_Checkout_Com_Webhook::refund_payment()` + +### **Steps:** + +1. **Extract Data** + - `order_id` from `$data->data->metadata->order_id` + - `payment_id` from `$data->data->id` + - `action_id` from `$data->data->action_id` + - `amount` from `$data->data->amount` + +2. **Validate Order ID** + - βœ… Must be numeric and not empty + - ❌ If invalid: Return `false` + +3. **Load Order** + - Use `self::get_wc_order($order_id)` + - ❌ If not found: Return `false` + +4. **CRITICAL: Payment ID Validation** + - Get order's `_cko_flow_payment_id` or `_cko_payment_id` + - Compare with webhook `payment_id` + - βœ… **If order has payment ID AND matches:** Continue + - βœ… **If order has NO payment ID:** Continue (first attempt) + - ❌ **If order has payment ID AND doesn't match:** + - Log error + - Return `false` (reject webhook) + +5. **Check Transaction ID** + - Get order's transaction ID + - βœ… **If transaction ID matches `action_id`:** + - Return `true` (already processed) + +6. **Check Refund Status** + - Get `order->get_total_refunded()` + - βœ… **If fully refunded:** + - Add note only + - Return `true` + +7. **Process Refund** + - Set transaction ID: `action_id` + - Update meta: `cko_payment_refunded = true` + - Convert amount to order currency + - Compare amounts: + - If `amount < order_amount`: **Partial refund** + - Create partial refund note + - Create WooCommerce refund record + - If `amount == order_amount`: **Full refund** + - Create full refund note + - Create WooCommerce refund record + - Add order note + - Return `true` + +--- + +## 6️⃣ **PAYMENT VOIDED** + +**Event Type:** `payment_voided` +**Handler:** `WC_Checkout_Com_Webhook::void_payment()` + +### **Steps:** + +1. **Extract Data** + - `order_id` from `$data->data->metadata->order_id` + - `payment_id` from `$data->data->id` + - `action_id` from `$data->data->action_id` + +2. **Validate Order ID** + - βœ… Must be numeric and not empty + - ❌ If invalid: Return `false` + +3. **Load Order** + - Use `self::get_wc_order($order_id)` + - ❌ If not found: Return `false` + +4. **CRITICAL: Payment ID Validation** + - Get order's `_cko_flow_payment_id` or `_cko_payment_id` + - Compare with webhook `payment_id` + - βœ… **If order has payment ID AND matches:** Continue + - βœ… **If order has NO payment ID:** Continue (first attempt) + - ❌ **If order has payment ID AND doesn't match:** + - Log error + - Return `false` (reject webhook) + +5. **Check Void Status** + - Get `cko_payment_voided` meta + - βœ… **If already voided:** + - Add note only + - Return `true` + +6. **Process Void** + - Create void message with payment ID and action ID + - Update meta: `cko_payment_voided = true` + - Add order note + - Update status to `cancelled` + - Return `true` + +--- + +## 7️⃣ **PAYMENT CANCELLED** + +**Event Type:** `payment_canceled` +**Handler:** `WC_Checkout_Com_Webhook::cancel_payment()` + +### **Steps:** + +1. **Extract Payment ID** + - `payment_id` from `$data->data->id` + +2. **Fetch Payment Details from Checkout.com API** + - Initialize Checkout SDK + - Call `getPaymentDetails($payment_id)` + - ❌ If API call fails: Return `false` + +3. **Extract Order ID** + - `order_id` from `$details['metadata']['order_id']` + - βœ… Must be numeric and not empty + - ❌ If invalid: Return `false` + +4. **Load Order** + - Use `self::get_wc_order($order_id)` + - ❌ If not found: Return `false` + +5. **CRITICAL: Payment ID Validation** + - Get order's `_cko_flow_payment_id` or `_cko_payment_id` + - Compare with webhook `payment_id` + - βœ… **If order has payment ID AND matches:** Continue + - βœ… **If order has NO payment ID:** Continue (first attempt) + - ❌ **If order has payment ID AND doesn't match:** + - Log error + - Return `false` (reject webhook) + +6. **Process Cancellation** + - Create cancellation message with payment ID + - Add order note + - Update status to `cancelled` + - Return `true` + +--- + +## πŸ”’ **SECURITY & VALIDATION SUMMARY** + +### **Payment ID Validation (Critical)** + +**Applied to:** +- βœ… `payment_declined` / `payment_authentication_failed` +- βœ… `payment_refunded` +- βœ… `payment_voided` +- βœ… `payment_canceled` +- βœ… Flow webhook handler (before event routing) + +**NOT Applied to:** +- ⚠️ `payment_approved` (relies on order_id matching) +- ⚠️ `payment_captured` (relies on order_id matching) +- ⚠️ `payment_capture_declined` (should be added) + +### **Duplicate Prevention** + +1. **Webhook Queue:** + - Checks for duplicate webhooks before queuing + - Prevents same `payment_id + webhook_type` from being queued twice + +2. **Processed Webhooks Tracking:** + - Stores `_cko_processed_webhook_ids` array on order + - Format: `{payment_id}_{event_type}` + - Prevents same webhook from being processed twice + +3. **Status Checks:** + - `authorize_payment()`: Checks if already captured/authorized + - `capture_payment()`: Checks if already captured + - `decline_payment()`: Checks if already failed + - `void_payment()`: Checks if already voided + - `refund_payment()`: Checks if transaction ID matches or fully refunded + +### **Status Protection** + +- **Prevents Downgrades:** + - `authorize_payment()` won't downgrade from `processing`/`completed` to `on-hold` + - `decline_payment()` won't change status if already `failed` + +- **Status Update Rules:** + - `authorize_payment()`: Updates to configured status (default: `on-hold`) + - `capture_payment()`: Updates to configured status (default: `processing`) + - `decline_payment()`: Updates to `failed` + - `void_payment()`: Updates to `cancelled` + - `cancel_payment()`: Updates to `cancelled` + - `capture_declined()`: **NO status change** (note only) + - `refund_payment()`: **NO status change** (WooCommerce handles refund status) + +--- + +## πŸ“ **LOGGING** + +All webhook processing includes extensive logging: + +- **Always Logged (Critical):** + - Order matching results + - Payment ID mismatches + - Order not found errors + - Invalid order IDs + +- **Debug Mode Only:** + - Full webhook data structure + - Step-by-step processing details + - Status change decisions + - Meta updates + +**Log Location:** WordPress debug log (if enabled) or Checkout.com plugin logs + +--- + +## πŸ”„ **WEBHOOK QUEUE SYSTEM** + +**When Used:** +- `payment_approved` webhook fails to process +- `payment_captured` webhook fails to process + +**How It Works:** +1. Webhook processing fails (returns `false`) +2. Flow handler checks if webhook can be queued +3. If `payment_id` exists, saves to `wp_cko_webhook_queue` table +4. Returns `true` to Checkout.com (prevents retries) +5. Queue processor runs when order is found/created +6. Processes queued webhooks in order + +**Duplicate Prevention:** +- Checks for existing unprocessed webhook with same `payment_id + webhook_type` +- Checks `_cko_processed_webhook_ids` before processing +- Marks as processed after successful processing + +--- + +## βœ… **CHECKLIST FOR EACH WEBHOOK** + +Before processing any webhook, verify: + +- [ ] Order ID is valid and numeric +- [ ] Order exists in WooCommerce +- [ ] Payment ID matches order (if order has payment ID) +- [ ] Webhook hasn't been processed before (`_cko_processed_webhook_ids`) +- [ ] Order status allows this update (no downgrades) +- [ ] All required data is present (payment_id, action_id, etc.) + +--- + +**Last Updated:** 2025-01-17 +**Version:** 5.0.0 + + diff --git a/WOOCOMMERCE_FLOW_INTEGRATION_DOCS.md b/WOOCOMMERCE_FLOW_INTEGRATION_DOCS.md new file mode 100644 index 00000000..814a0993 --- /dev/null +++ b/WOOCOMMERCE_FLOW_INTEGRATION_DOCS.md @@ -0,0 +1,526 @@ +# WooCommerce Flow Integration Documentation + +Last updated: January 2025 + +From downloading the plugin to requesting your first test payment, learn how to get started with the Checkout.com Flow integration for WooCommerce. + +## Information + +This guide assumes you have already set up WooCommerce on your WordPress instance. + +--- + +## Before you start + +You must create a public and private key to configure the integration. + +Additionally, you need a signature key to configure webhooks. + +### Create a public API key + +1. Sign in to the sandbox environment in the Dashboard. +2. Select the _Developers_ icon in the top navigation bar, and then select the _Keys_ tab. +3. Select _Create a new key_. +4. When you're prompted for which type of key to create, select _Public API key_. +5. Give the API key a description to make it easier to identify in the future. +6. Disable the _Allow any processing channel_ setting. +7. Select the processing channel you want to use for WooCommerce from the list. +8. Select _Submit_ to create the key. + +Make a note of your public API key as you'll need it for a later step. You can view your public API key at any time after creation. + +### Create a private API key + +1. Sign in to the sandbox environment in the Dashboard. +2. Select the _Developers_ icon in the top navigation bar, and then select the _Keys_ tab. +3. Select _Create a new key_. +4. When you're prompted for which type of key to create, select _Secret API key_. +5. Give the API key a description to make it easier to identify in the future. +6. Under _Scopes_, select _Default_. +7. Disable the _Allow any processing channel_ setting. +8. Select the processing channel you want to use for WooCommerce from the list. +9. Select _Create key_. +10. Copy your private API key securely. You'll need it to configure the plugin. + +### Note + +For security, you cannot view the secret API key again after you've left the _Create a new key_ page. Ensure you copy its value securely before you exit or close the window. + +### Create a webhook + +Webhooks are notifications that we send when an event occurs on your account. For example, when a payment is captured. The WooCommerce plugin uses them to update order statuses automatically. + +You can configure a webhook in your WooCommerce settings. + +**Webhook URL Format:** +``` +https://your-site.com/?wc-api=wc_checkoutcom_webhook +``` + +### Check you have no previous version of the plugin + +1. Sign in to WordPress as an administrator. +2. In the left menu, select _Plugins_. +3. Look for Checkout.com plugins. If you find one, select _Delete_, or select _Deactivate_ and then _Delete_. + +--- + +## Install the plugin + +You can install the plugin in the following ways: + +### Use the WordPress plugin directory + +1. Sign in to WordPress as an administrator. +2. In your WordPress dashboard, go to _Plugins_ > _Add New_. +3. Search for _Checkout.com Payment Gateway_. +4. Select _Install Now_. +5. After the installation completes, select _Activate Plugin_. + +### Download the plugin and install it manually + +1. Go to the WooCommerce plugin repository or download from GitHub releases. +2. Download the latest release of the plugin (`checkout-com-unified-payments-api.zip`). +3. Sign in to WordPress as an administrator. +4. In your WordPress dashboard, go to _Plugins_ > _Add New_. +5. Select _Upload Plugin_ > _Choose file_. +6. Upload your downloaded ZIP file. +7. Select _Install Now_. +8. After the installation completes, select _Activate Plugin_. + +--- + +## Configure the plugin + +1. In your WordPress dashboard, go to _WooCommerce_ > _Settings_ > _Payments_. +2. Find _Checkout.com Payment_ and select _Manage_. +3. Select _Enable Checkout.com card payments_. +4. Set the environment to _Sandbox_ (for testing) or _Live_ (for production). +5. Enter a payment option title. This is displayed to customers on your checkout page (e.g., "Credit/Debit Card"). +6. Under _Checkout mode_, select _Flow_. +7. Set _Account type_ to _NAS_ (or your account type). +8. Enter your **Secret key** and **Public key**. +9. Select _Save changes_. +10. Select _Card Settings_, configure your preferences, then select _Save changes_. +11. Select _Order Settings_, review the order status mappings, then select _Save changes_. + +### Register a webhook with the default configuration + +To check the current webhook status and register a webhook with the default configuration: + +1. In your WordPress dashboard, go to _WooCommerce_ > _Settings_ > _Payments_. +2. Find _Checkout.com Payment_ and select _Manage_. +3. Select the _Webhooks_ tab. +4. Select _Run Webhook check_ to check if a webhook is configured for the current site. + +If no webhook is configured, select _Register Webhook_. This creates a new webhook for all events listed in your Dashboard account. + +**Webhook Events Supported:** +- `payment_approved` - Payment authorized successfully +- `payment_captured` - Payment captured successfully +- `payment_declined` - Payment declined +- `payment_cancelled` - Payment cancelled +- `payment_voided` - Payment voided +- `payment_refunded` - Payment refunded + +--- + +## Test your integration + +1. Go to your shop's public URL and add a product to your cart. +2. Go to your cart then proceed to checkout. +3. Enter the required billing details. We recommend using a real email address so that you can receive the order confirmation. +4. Select the _Checkout.com Payment_ method. +5. The Flow payment form will appear. Enter the following card details: + * Number – `4242 4242 4242 4242` + * Expiry date – Any future date (e.g., `12/25`) + * CVV – `100` + * Cardholder name – Any name +6. Select the terms and conditions box. +7. Select _Place order_. + - The order will be created first (status: `Pending payment`) + - Payment will be processed through Flow + - If 3D Secure is required, you'll be redirected for authentication + - After successful payment, you'll be redirected to the order confirmation page +8. If you entered a real email address in the billing details, you'll receive an order confirmation email. +9. Sign in to your WordPress account as an administrator. +10. Select _WooCommerce_ > _Orders_ in the left menu. Your test order is displayed and has a status of `Processing`. This indicates that the payment has been successfully captured and that your webhooks are set up correctly. + +### Test Cards + +For test cards and a range of possible scenarios, see [Checkout.com Testing Documentation](https://www.checkout.com/docs/testing). + +**Common Test Cards:** +- **Success:** `4242 4242 4242 4242` +- **3D Secure Required:** `4000 0025 0000 3155` +- **Declined:** `4000 0000 0000 0002` +- **Insufficient Funds:** `4000 0000 0000 9995` + +You can now either go live as is or extend your configuration. + +--- + +## Go live + +When your testing is complete and you're ready to start accepting payments: + +1. Contact our Sales team to move to a live account. +2. Update your plugin settings: + - Change _Environment_ from _Sandbox_ to _Live_ + - Update your _Secret key_ and _Public key_ with live credentials + - Re-register your webhook URL in the live Dashboard +3. Test a small transaction to verify everything works correctly. + +--- + +## How Flow Integration Works + +The Flow integration provides a modern, secure payment experience using Checkout.com's Flow Web Components. This integration ensures reliable payment processing with comprehensive validation, webhook handling, and order management. + +### Payment Flow Overview + +The payment flow follows these steps: + +#### Step 1: Checkout Page Load +- Customer fills out billing and shipping information +- Flow payment method is selected +- Flow Web Component is initialized and mounted + +#### Step 2: Order Creation (Before Payment) +- **Why Early?** Orders are created via AJAX before payment processing begins +- This ensures the order exists in the database for webhook matching +- Order status: `Pending payment` +- Payment session ID is stored with the order + +#### Step 3: Payment Session Creation +- Payment session is created with Checkout.com API +- Session includes order details, customer information, and amount +- Payment session ID is returned and stored + +#### Step 4: Flow Component Validation +- **Client-Side Validation:** Flow component validates card details in real-time +- Card number, expiry, CVV are validated before submission +- Invalid cards are rejected before payment attempt + +#### Step 5: Payment Processing +- Customer submits payment through Flow component +- Payment is processed securely through Checkout.com +- For 3D Secure: Customer is redirected for authentication +- Payment result is returned + +#### Step 6: Webhook Processing +- Checkout.com sends webhook with payment status +- Webhook is matched to order using: + 1. **Order ID from metadata** (primary method) + 2. **Payment Session ID + Payment ID** (secondary method) + 3. **Payment ID alone** (fallback method) +- If order not found immediately, webhook is queued for later processing + +#### Step 7: Order Status Update +- Order status is automatically updated based on payment result: + * βœ… **Payment Approved** β†’ Order status: `Processing` or `On hold` (if manual capture) + * βœ… **Payment Captured** β†’ Order status: `Processing` + * ❌ **Payment Declined** β†’ Order status: `Failed` + * ⏸️ **Payment Cancelled** β†’ Order status: `Cancelled` + +### Key Features + +#### πŸ”’ Early Order Creation +Orders are created before payment processing to ensure webhooks can always find the order. This prevents webhook matching failures and allows order tracking throughout the payment process. + +#### βœ… Dual Validation System +- **Client-Side:** Flow component validates card details in real-time +- **Server-Side:** Comprehensive validation of all checkout fields before order creation + +#### 🚫 Duplicate Prevention +- Client-side lock prevents multiple simultaneous requests +- Server-side check prevents duplicate orders with same payment session ID +- Webhook queue prevents duplicate webhook processing + +#### πŸ“¬ Webhook Queue System +- Temporarily stores webhooks if order not found immediately +- Queue is processed when order becomes available +- Ensures no webhooks are lost +- Automatic retry mechanism + +#### πŸ” 3D Secure (3DS) Support +- Automatic 3DS detection +- Seamless redirect flow +- Webhook handling after 3DS return +- Prevents duplicate status updates + +#### πŸ’³ Saved Cards +- Customer can opt to save card during checkout +- Cards are tokenized securely by Checkout.com +- Saved cards appear on future checkouts +- Cards can be deleted by customer + +--- + +## Extend your configuration + +There are a number of ways you can extend your WooCommerce integration so that it suits all your business needs. + +### Add more payment methods + +#### Note +To start accepting an alternative payment method, we first need to enable it on your account. Contact your account manager or our Sales team to get started. + +We currently support the following payment methods on WooCommerce: + +* Apple Pay +* Google Pay +* PayPal +* Bancontact +* Cartes Bancaires +* EPS +* iDEAL +* Klarna Payments +* Klarna Debit Risk +* KNET +* Multibanco +* Poli +* Sofort + +#### Enable alternative payments + +1. Sign in to WordPress as an administrator. +2. In the left menu, select _WooCommerce_ > _Settings_ > _Payments_. +3. Find the payment method you want to enable (e.g., _Checkout.com - PayPal_). +4. Select _Manage_. +5. Tick _Enable Checkout.com_. +6. Enter a _Title_. This is what the customer sees on the checkout page. +7. Enter your API credentials if required. +8. Select _Save changes_. + +That's it! Your checkout page now includes the option to pay using your additional payment method(s). + +#### Apple Pay + +##### Information +Apple Pay is only supported on self-hosted instances of WordPress. + +##### Before you start +If you're located in the UAE or Saudi Arabia, contact your account manager or our Sales team to activate Apple Pay on your account. + +To get started with Apple Pay payments, you must first generate your certificate signing request and upload it to the Apple Development Center. + +Once this is done, you'll need to complete the certification process. Read our [Apple Pay guide](https://www.checkout.com/docs/payments/add-payment-methods/apple-pay) to configure your environment. + +##### Enable Apple Pay + +1. Sign in to WordPress as an administrator. +2. In the left menu, select _WooCommerce_ > _Settings_ > _Payments_. +3. Find _Checkout.com - Apple Pay_ and select _Manage_. +4. Select _Enable Checkout.com_. +5. Enter a title and description. These are displayed to customers on your checkout page. +6. Enter your merchant identifier. You can find it in the Apple Development Center. +7. Enter the absolute path to your merchant certificate and merchant certificate key. +8. Select a button type and button theme. +9. Set the button language using a two-digit ISO 639-1 code (for example, use `en` for English). +10. Select _Save changes_. + +To test Apple Pay, use the Apple Pay test cards. + +#### Google Pay + +##### Before you start +If you're located in the UAE or Saudi Arabia, contact your account manager or our Sales team to activate Google Pay on your account. + +To get started with Google Pay payments, you must register with Google Pay and choose Checkout.com as your payment processor. + +##### Enable Google Pay + +1. Sign in to WordPress as an administrator. +2. In the left menu, select _WooCommerce_ > _Settings_ > _Payments_. +3. Find _Checkout.com - Google Pay_ and select _Manage_. +4. Select _Enable Checkout.com_. +5. Enter a title and description. These are displayed to customers on your checkout page. +6. Leave the merchant identifier set to `01234567890123456789` for testing purposes. +7. To enable 3DS for Google Pay, set _Use 3D Secure_ to _Yes_. +8. Select a button style. +9. Select _Save changes_. + +### Enable 3D Secure payments + +1. Sign in to WordPress as an administrator. +2. In the left menu, select _WooCommerce_ > _Settings_ > _Payments_. +3. Find _Checkout.com Payment_ and select _Manage_. +4. Select _Card Settings_. +5. Set _Use 3D Secure_ to _Yes_. +6. Select _Save changes_. + +3D Secure payments are now enabled on your account. When a payment requires 3DS authentication, customers will be automatically redirected to their bank's authentication page. + +### Capture payments manually + +#### Enable manual captures + +1. Sign in to WordPress as an administrator. +2. In the left menu, select _WooCommerce_ > _Settings_ > _Payments_. +3. Find _Checkout.com Payment_ and select _Manage_. +4. Select _Card Settings_. +5. Set _Payment Action_ to _Authorize Only_. +6. Select _Save changes_. + +Any payments received are authorized only. You must manually capture them within seven days, or they are automatically voided. + +#### Capture a payment + +1. In the Dashboard sandbox, select _Payments_ > _Processing_ > _All Payments_. +2. Select the test payment. The _Payment details_ page opens. +3. Select _Capture payment_ in the top right. +4. Select _Capture payment_. The _Status_ column on the _Payments_ page is updated to say `CAPTURED`. +5. Sign in to WordPress as an administrator. +6. Select _WooCommerce_ > _Orders_ in the left menu. +7. Select your test order to display the order details. + +The order note confirms that your payment has been successfully captured. + +### Accept recurring payments via the WooCommerce Subscriptions extension + +With recurring payments, you can process shopper interactions for scheduled payments, such as subscription payments. + +#### Note +To use this feature, you must be using WooCommerce Subscriptions to manage subscriptions within WooCommerce. See [WooCommerce Subscriptions Store Manager Guide](https://woocommerce.com/document/subscriptions/store-manager-guide/). + +The Checkout.com WooCommerce plugin registers with payment events triggered by WooCommerce Subscriptions to support the following actions: + +* Cancellation of a subscription +* Suspension of a subscription +* Re-activation of a subscription +* Change of amount for a subscription +* Change of date for a subscription +* Management of multiple subscriptions + +### Configure order statuses + +These settings allow you to edit the order statuses in line with the status of the payment. They are automatically set to WooCommerce's default values, so be aware that editing them may cause problems with the order flow. + +To find these settings: + +1. Sign in to WordPress as an administrator. +2. Go to _WooCommerce_ > _Settings_ > _Payments_. +3. Find the Checkout.com plugin. +4. Select _Manage_ and then _Order Settings_. + +**Default Order Status Mappings:** +- **Payment Approved:** `Processing` (or `On hold` if manual capture enabled) +- **Payment Captured:** `Processing` +- **Payment Declined:** `Failed` +- **Payment Cancelled:** `Cancelled` +- **Payment Refunded:** `Refunded` + +### Saved Cards Configuration + +The Flow integration supports saving customer payment methods for future use. + +#### Enable Saved Cards + +1. Sign in to WordPress as an administrator. +2. Go to _WooCommerce_ > _Settings_ > _Payments_. +3. Find _Checkout.com Payment_ and select _Manage_. +4. Select _Card Settings_. +5. Enable _Save card for future use_ or _Enable tokenization_. +6. Select _Save changes_. + +#### Saved Cards Display Options + +You can configure how saved cards are displayed: + +- **Saved Cards First:** Saved cards appear before the new card form +- **New Payment First:** New card form appears first, saved cards below + +This can be configured in the Flow integration settings. + +--- + +## Troubleshooting + +### Flow Component Not Loading + +If the Flow payment form is not appearing: + +1. Check that Flow mode is enabled in settings +2. Verify your Public API key is correct +3. Check browser console for JavaScript errors +4. Ensure all required checkout fields are filled +5. Verify SSL certificate is valid (required for Flow) + +### Webhooks Not Processing + +If order statuses are not updating: + +1. Verify webhook URL is registered in Dashboard +2. Check webhook signature key matches +3. Review webhook logs in WordPress (if logging enabled) +4. Check that webhook events are enabled in Dashboard +5. Verify order exists before webhook arrives (early order creation should handle this) + +### Payment Session Errors + +If you see payment session errors: + +1. Verify all required billing fields are filled +2. Check email format is valid +3. Ensure amount and currency are set correctly +4. Verify API keys are correct for your environment +5. Check network connectivity to Checkout.com API + +### 3D Secure Issues + +If 3DS authentication is not working: + +1. Verify 3DS is enabled in Card Settings +2. Check that test card requires 3DS (use `4000 0025 0000 3155`) +3. Ensure redirect URLs are configured correctly +4. Check that webhook is processing after 3DS return + +--- + +## Support + +For support and integration help: + +* **Integration Support:** integration@checkout.com +* **General Support:** support@checkout.com +* **Sales:** sales@checkout.com +* **Documentation:** [Checkout.com Documentation](https://www.checkout.com/docs) + +--- + +## Requirements + +* WordPress 5.0+ +* WooCommerce 3.0+ +* PHP 7.3+ +* SSL Certificate (required for production) +* Modern browser with JavaScript enabled + +--- + +## License + +MIT License + +--- + +## Changelog + +### Version 5.0.0 + +* Initial Flow integration release +* Complete Flow Web Components integration +* Saved cards functionality +* 3D Secure support +* Webhook queue system +* Enhanced order management +* Comprehensive validation and error handling +* Duplicate prevention (orders and webhooks) +* Early order creation for reliable webhook matching + +--- + +**Checkout.com** is authorised and regulated as a Payment institution by the UK Financial Conduct Authority. + + diff --git a/checkout-com-unified-payments-api/flow-integration/assets/js/payment-session.js b/checkout-com-unified-payments-api/flow-integration/assets/js/payment-session.js index 1e87521b..12aa7187 100644 --- a/checkout-com-unified-payments-api/flow-integration/assets/js/payment-session.js +++ b/checkout-com-unified-payments-api/flow-integration/assets/js/payment-session.js @@ -227,14 +227,16 @@ var ckoFlow = { let reference = "WOO" + (cko_flow_vars.ref_session || 'default'); + // CRITICAL: Check if billing_address exists before accessing properties + const billingAddress = cartInfo["billing_address"] || {}; let email = - cartInfo["billing_address"]["email"] || + billingAddress["email"] || (document.getElementById("billing_email") ? document.getElementById("billing_email").value : ''); let family_name = - cartInfo["billing_address"]["family_name"] || + billingAddress["family_name"] || (document.getElementById("billing_last_name") ? document.getElementById("billing_last_name").value : ''); let given_name = - cartInfo["billing_address"]["given_name"] || + billingAddress["given_name"] || (document.getElementById("billing_first_name") ? document.getElementById("billing_first_name").value : ''); // CRITICAL: Validate email before proceeding - prevent API call with invalid email @@ -255,23 +257,25 @@ var ckoFlow = { // Trim email to remove whitespace email = email.trim(); let phone = - cartInfo["billing_address"]["phone"] || + billingAddress["phone"] || (document.getElementById("billing_phone") ? document.getElementById("billing_phone").value : ''); - let address1 = shippingAddress1 = cartInfo["billing_address"]["street_address"]; - let address2 = shippingAddress2 = cartInfo["billing_address"]["street_address2"]; - let city = shippingCity = cartInfo["billing_address"]["city"]; - let zip = shippingZip = cartInfo["billing_address"]["postal_code"]; - let country = shippingCountry = cartInfo["billing_address"]["country"]; + let address1 = shippingAddress1 = billingAddress["street_address"] || ''; + let address2 = shippingAddress2 = billingAddress["street_address2"] || ''; + let city = shippingCity = billingAddress["city"] || ''; + let zip = shippingZip = billingAddress["postal_code"] || ''; + let country = shippingCountry = billingAddress["country"] || ''; let shippingElement = document.getElementById("ship-to-different-address-checkbox"); if ( shippingElement?.checked ) { - shippingAddress1 = cartInfo["shipping_address"]["street_address"]; - shippingAddress2 = cartInfo["shipping_address"]["street_address2"]; - shippingCity = cartInfo["shipping_address"]["city"]; - shippingZip = cartInfo["shipping_address"]["postal_code"]; - shippingCountry = cartInfo["shipping_address"]["country"]; + // CRITICAL: Check if shipping_address exists before accessing properties + const shippingAddress = cartInfo["shipping_address"] || {}; + shippingAddress1 = shippingAddress["street_address"] || address1; + shippingAddress2 = shippingAddress["street_address2"] || address2; + shippingCity = shippingAddress["city"] || city; + shippingZip = shippingAddress["postal_code"] || zip; + shippingCountry = shippingAddress["country"] || country; } let orders = cartInfo["order_lines"]; @@ -1154,17 +1158,166 @@ var ckoFlow = { jQuery("#cko-flow-payment-id").val(paymentResponse.id); jQuery("#cko-flow-payment-type").val(paymentResponse?.type || ""); - if ( ! orderId ) { - // Trigger WooCommerce order placement on checkout page. + // Check if this is an APM payment (Alternative Payment Method) + // APM payments should submit form normally instead of redirecting + // This is because handle_3ds_return() tries to fetch payment details which may fail for APM + const paymentType = paymentResponse?.type || ""; + const isAPMPayment = paymentType && !['card', ''].includes(paymentType.toLowerCase()); + const apmTypes = ['googlepay', 'applepay', 'paypal', 'octopus', 'twint', 'klarna', 'sofort', 'ideal', 'giropay', 'bancontact', 'eps', 'p24', 'knet', 'fawry', 'qpay', 'multibanco', 'stcpay', 'alipay', 'wechatpay']; + const isKnownAPM = apmTypes.includes(paymentType.toLowerCase()); + + ckoLogger.debug('[PAYMENT COMPLETED] Payment type check:', { + paymentType: paymentType, + isAPMPayment: isAPMPayment, + isKnownAPM: isKnownAPM + }); + + // CRITICAL: Check form field for order ID (set by createOrderBeforePayment()) + // Don't use orderId from loadFlow() scope - it's only set for order-pay pages + const formOrderId = jQuery('input[name="order_id"]').val(); + const sessionOrderId = sessionStorage.getItem('cko_flow_order_id'); + const hasOrderId = formOrderId || sessionOrderId || orderId; // orderId is fallback for order-pay pages + + ckoLogger.debug('[PAYMENT COMPLETED] Order ID check:', { + formOrderId: formOrderId || 'NOT SET', + sessionOrderId: sessionOrderId || 'NOT SET', + loadFlowOrderId: orderId || 'NOT SET', + hasOrderId: !!hasOrderId + }); + + if ( ! hasOrderId ) { + // No order exists - trigger WooCommerce order placement on checkout page. + ckoLogger.debug('[PAYMENT COMPLETED] No order ID found - submitting form to create order'); + + // CRITICAL: Ensure payment session ID is in form before submitting + // This ensures payment_session_id is saved to order metadata + const existingSessionIdField = document.getElementById('cko-flow-payment-session-id'); + const existingSessionIdValue = existingSessionIdField ? existingSessionIdField.value : ''; + + if (!existingSessionIdField || !existingSessionIdValue) { + if (window.ckoAddPaymentSessionIdField) { + const added = window.ckoAddPaymentSessionIdField(); + if (added) { + ckoLogger.debug('[PAYMENT COMPLETED] Payment session ID added to form before submission (no order ID)'); + } else if (window.currentPaymentSessionId) { + // Fallback: Try to manually add + const checkoutForm = document.querySelector('form.checkout'); + if (checkoutForm) { + const manualField = document.createElement('input'); + manualField.type = 'hidden'; + manualField.id = 'cko-flow-payment-session-id'; + manualField.name = 'cko-flow-payment-session-id'; + manualField.value = window.currentPaymentSessionId; + checkoutForm.appendChild(manualField); + ckoLogger.debug('[PAYMENT COMPLETED] Payment session ID manually added (fallback)'); + } + } + } else if (window.currentPaymentSessionId) { + // Fallback: Try to manually add if function not available + const checkoutForm = document.querySelector('form.checkout'); + if (checkoutForm) { + const manualField = document.createElement('input'); + manualField.type = 'hidden'; + manualField.id = 'cko-flow-payment-session-id'; + manualField.name = 'cko-flow-payment-session-id'; + manualField.value = window.currentPaymentSessionId; + checkoutForm.appendChild(manualField); + ckoLogger.debug('[PAYMENT COMPLETED] Payment session ID manually added (no function available)'); + } + } + } + jQuery("form.checkout").submit(); } else { + // Order already exists (created via AJAX) + const orderIdToUse = formOrderId || sessionOrderId || orderId; + + // For APM payments, always submit form instead of redirecting + // This prevents payment details fetch errors in handle_3ds_return() + if (isKnownAPM || isAPMPayment) { + ckoLogger.debug('[PAYMENT COMPLETED] APM payment detected (' + paymentType + ') - submitting form instead of redirecting'); + + // CRITICAL: Ensure payment session ID is in form before submitting + // This ensures payment_session_id is saved to order metadata + const existingSessionIdField = document.getElementById('cko-flow-payment-session-id'); + const existingSessionIdValue = existingSessionIdField ? existingSessionIdField.value : ''; + + ckoLogger.debug('[PAYMENT COMPLETED] Payment session ID check:', { + hasExistingField: !!existingSessionIdField, + existingValue: existingSessionIdValue || 'EMPTY', + hasWindowFunction: !!window.ckoAddPaymentSessionIdField, + hasWindowSessionId: !!window.currentPaymentSessionId, + windowSessionId: window.currentPaymentSessionId || 'NOT SET' + }); + + // If field doesn't exist or is empty, try to add it + if (!existingSessionIdField || !existingSessionIdValue) { + if (window.ckoAddPaymentSessionIdField) { + const added = window.ckoAddPaymentSessionIdField(); + if (added) { + ckoLogger.debug('[PAYMENT COMPLETED] Payment session ID added to form before APM submission'); + } else { + ckoLogger.warn('[PAYMENT COMPLETED] Failed to add payment session ID to form - window.currentPaymentSessionId: ' + (window.currentPaymentSessionId || 'NOT SET')); + + // Fallback: Try to manually add if we have the session ID + if (window.currentPaymentSessionId) { + const checkoutForm = document.querySelector('form.checkout'); + if (checkoutForm) { + const manualField = document.createElement('input'); + manualField.type = 'hidden'; + manualField.id = 'cko-flow-payment-session-id'; + manualField.name = 'cko-flow-payment-session-id'; + manualField.value = window.currentPaymentSessionId; + checkoutForm.appendChild(manualField); + ckoLogger.debug('[PAYMENT COMPLETED] Payment session ID manually added to form (fallback)'); + } + } + } + } else { + ckoLogger.warn('[PAYMENT COMPLETED] window.ckoAddPaymentSessionIdField not available'); + + // Fallback: Try to manually add if we have the session ID + if (window.currentPaymentSessionId) { + const checkoutForm = document.querySelector('form.checkout'); + if (checkoutForm) { + const manualField = document.createElement('input'); + manualField.type = 'hidden'; + manualField.id = 'cko-flow-payment-session-id'; + manualField.name = 'cko-flow-payment-session-id'; + manualField.value = window.currentPaymentSessionId; + checkoutForm.appendChild(manualField); + ckoLogger.debug('[PAYMENT COMPLETED] Payment session ID manually added to form (fallback - no function)'); + } + } else { + ckoLogger.error('[PAYMENT COMPLETED] CRITICAL: Payment session ID cannot be added - window.currentPaymentSessionId is not set!'); + } + } + } else { + ckoLogger.debug('[PAYMENT COMPLETED] Payment session ID already in form: ' + existingSessionIdValue.substring(0, 20) + '...'); + } + + jQuery("form.checkout").submit(); + return; + } + + ckoLogger.debug('[PAYMENT COMPLETED] Order already exists (ID: ' + orderIdToUse + ') - redirecting to process payment endpoint'); + // For order-pay pages, use native DOM submit to bypass event handlers - ckoLogger.threeDS('Submitting order-pay form using native submit after payment completion'); - const orderPayForm = document.querySelector('form#order_review'); - if (orderPayForm) { - orderPayForm.submit(); + if (orderId && window.location.pathname.includes('/order-pay/')) { + ckoLogger.threeDS('Submitting order-pay form using native submit after payment completion'); + const orderPayForm = document.querySelector('form#order_review'); + if (orderPayForm) { + orderPayForm.submit(); + } else { + ckoLogger.error('ERROR: form#order_review not found!'); + } } else { - ckoLogger.error('ERROR: form#order_review not found!'); + // For regular checkout with card payments, redirect to process payment endpoint with order ID and payment ID + // This ensures process_payment() is called with the existing order + // CRITICAL: handle_3ds_return() requires cko-payment-id in GET params + const redirectUrl = window.location.origin + '/?wc-api=wc_checkoutcom_flow_process&order_id=' + orderIdToUse + '&cko-payment-id=' + paymentResponse.id; + ckoLogger.debug('[PAYMENT COMPLETED] Redirecting to process payment endpoint: ' + redirectUrl); + window.location.href = redirectUrl; } } } diff --git a/checkout-com-unified-payments-api/flow-integration/class-wc-gateway-checkout-com-flow.php b/checkout-com-unified-payments-api/flow-integration/class-wc-gateway-checkout-com-flow.php index 5d6a943f..b0ffee62 100644 --- a/checkout-com-unified-payments-api/flow-integration/class-wc-gateway-checkout-com-flow.php +++ b/checkout-com-unified-payments-api/flow-integration/class-wc-gateway-checkout-com-flow.php @@ -1472,8 +1472,12 @@ public function process_payment( $order_id ) { if ( $builder ) { $payment_details = $builder->getPaymentsClient()->getPaymentDetails( $flow_payment_id_from_post ); - if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { - WC_Checkoutcom_Utility::logger( '[PROCESS PAYMENT] [SHIPPING DEBUG] Payment details fetched successfully (early fetch)' ); + WC_Checkoutcom_Utility::logger( '[PROCESS PAYMENT] Payment details fetched successfully (early fetch) - Payment ID: ' . $flow_payment_id_from_post ); + // Log payment session ID if available in metadata + if ( isset( $payment_details['metadata']['cko_payment_session_id'] ) ) { + WC_Checkoutcom_Utility::logger( '[PROCESS PAYMENT] Payment session ID found in early fetch: ' . substr( $payment_details['metadata']['cko_payment_session_id'], 0, 20 ) . '...' ); + } else { + WC_Checkoutcom_Utility::logger( '[PROCESS PAYMENT] ⚠️ Payment session ID NOT found in early fetch metadata' ); } } } catch ( Exception $e ) { @@ -1866,7 +1870,14 @@ public function process_payment( $order_id ) { // Set transaction ID and payment ID in order meta for tracking $order->set_transaction_id( $result['action_id'] ); $order->update_meta_data( '_cko_payment_id', $flow_payment_id ); - $order->update_meta_data( '_cko_flow_payment_id', $flow_payment_id ); + + // CRITICAL: Only set _cko_flow_payment_id if not already set (prevent overwriting) + $existing_flow_payment_id = $order->get_meta( '_cko_flow_payment_id' ); + if ( empty( $existing_flow_payment_id ) ) { + $order->update_meta_data( '_cko_flow_payment_id', $flow_payment_id ); + } else { + WC_Checkoutcom_Utility::logger( '[3DS RETURN] Payment ID already exists in order (failed payment) - Order ID: ' . $order_id . ', Existing Payment ID: ' . substr( $existing_flow_payment_id, 0, 20 ) . '..., New Payment ID: ' . substr( $flow_payment_id, 0, 20 ) . '... (skipping save to prevent overwrite)' ); + } // Save addresses BEFORE marking as failed $order->save(); @@ -1913,12 +1924,56 @@ public function process_payment( $order_id ) { // Set transaction ID and payment ID in order meta $order->set_transaction_id( $result['action_id'] ); $order->update_meta_data( '_cko_payment_id', $flow_payment_id ); - $order->update_meta_data( '_cko_flow_payment_id', $flow_payment_id ); + + // CRITICAL: Only set _cko_flow_payment_id if not already set (prevent overwriting) + $existing_flow_payment_id = $order->get_meta( '_cko_flow_payment_id' ); + if ( empty( $existing_flow_payment_id ) ) { + $order->update_meta_data( '_cko_flow_payment_id', $flow_payment_id ); + } else { + WC_Checkoutcom_Utility::logger( '[3DS RETURN] Payment ID already exists in order - Order ID: ' . $order_id . ', Existing Payment ID: ' . substr( $existing_flow_payment_id, 0, 20 ) . '..., New Payment ID: ' . substr( $flow_payment_id, 0, 20 ) . '... (skipping save to prevent overwrite)' ); + } + $flow_payment_type = isset( $_POST['cko-flow-payment-type'] ) ? sanitize_text_field( $_POST['cko-flow-payment-type'] ) : 'card'; $order->update_meta_data( '_cko_flow_payment_type', $flow_payment_type ); // Store order number/reference for webhook lookup (works with Sequential Order Numbers plugins) $order->update_meta_data( '_cko_order_reference', $order->get_order_number() ); + // CRITICAL: Save payment session ID for webhook matching (METHOD 2) + // Priority: 1) POST data (from form), 2) Payment metadata (from payment_details) + // Only save if order doesn't already have a payment session ID + $existing_order_session_id = $order->get_meta( '_cko_payment_session_id' ); + + if ( empty( $existing_order_session_id ) ) { + // Order doesn't have payment session ID yet - try to get it + $payment_session_id = isset( $_POST['cko-flow-payment-session-id'] ) ? sanitize_text_field( $_POST['cko-flow-payment-session-id'] ) : ''; + if ( empty( $payment_session_id ) && isset( $payment_details['metadata']['cko_payment_session_id'] ) ) { + $payment_session_id = $payment_details['metadata']['cko_payment_session_id']; + WC_Checkoutcom_Utility::logger( '[3DS RETURN] Payment session ID retrieved from payment_details metadata: ' . substr( $payment_session_id, 0, 20 ) . '...' ); + } + + if ( ! empty( $payment_session_id ) ) { + // Check if payment_session_id already exists in another order (prevent duplicates) + $existing_orders = wc_get_orders( array( + 'meta_key' => '_cko_payment_session_id', + 'meta_value' => $payment_session_id, + 'limit' => 1, + 'exclude' => array( $order_id ), + 'return' => 'ids', + ) ); + + if ( ! empty( $existing_orders ) ) { + WC_Checkoutcom_Utility::logger( '[3DS RETURN] ❌ CRITICAL ERROR: Payment session ID already used by order: ' . $existing_orders[0] ); + } else { + $order->update_meta_data( '_cko_payment_session_id', $payment_session_id ); + WC_Checkoutcom_Utility::logger( '[3DS RETURN] βœ… Saved payment session ID to order - Order ID: ' . $order_id . ', Payment Session ID: ' . substr( $payment_session_id, 0, 20 ) . '...' ); + } + } else { + WC_Checkoutcom_Utility::logger( '[3DS RETURN] ⚠️ WARNING: Payment session ID is empty - Order ID: ' . $order_id . ', Payment ID: ' . $flow_payment_id ); + } + } else { + WC_Checkoutcom_Utility::logger( '[3DS RETURN] Payment session ID already exists in order - Order ID: ' . $order_id . ', Payment Session ID: ' . substr( $existing_order_session_id, 0, 20 ) . '... (skipping save)' ); + } + // CRITICAL: Save order immediately so webhooks can find it (especially for fast APM payments) $order->save(); WC_Checkoutcom_Utility::logger( 'Order meta saved immediately for webhook lookup (3DS return) - Order ID: ' . $order_id . ', Payment ID: ' . $flow_payment_id ); @@ -1952,27 +2007,30 @@ public function process_payment( $order_id ) { // 3. Order is already in advanced state (processing/completed) - don't downgrade $auth_status = WC_Admin_Settings::get_option( 'ckocom_order_authorised', 'on-hold' ); + // Format order amount for notes + $formatted_order_amount = wc_price( $order->get_total(), array( 'currency' => $order->get_currency() ) ); + if ( $already_captured ) { // Payment already captured - just add note, don't change status - $message = sprintf( esc_html__( 'Checkout.com Payment Authorised - using FLOW (3DS return): %s - Payment ID: %s', 'checkout-com-unified-payments-api' ), $flow_payment_type, $flow_payment_id ); + $message = sprintf( esc_html__( 'Checkout.com Payment Authorised - using FLOW (3DS return): %s - Payment ID: %s, Amount: %s', 'checkout-com-unified-payments-api' ), $flow_payment_type, $flow_payment_id, $formatted_order_amount ); $status = null; // Signal to skip status update } elseif ( $already_authorized && ( $current_status === $auth_status || in_array( $current_status, array( 'processing', 'completed' ), true ) ) ) { // Already authorized and status matches - webhook already handled it, just add note - $message = sprintf( esc_html__( 'Checkout.com Payment Authorised - using FLOW (3DS return): %s - Payment ID: %s', 'checkout-com-unified-payments-api' ), $flow_payment_type, $flow_payment_id ); + $message = sprintf( esc_html__( 'Checkout.com Payment Authorised - using FLOW (3DS return): %s - Payment ID: %s, Amount: %s', 'checkout-com-unified-payments-api' ), $flow_payment_type, $flow_payment_id, $formatted_order_amount ); $status = null; // Signal to skip status update } elseif ( in_array( $current_status, array( 'processing', 'completed' ), true ) ) { // Order already in advanced state - don't downgrade, just add note - $message = sprintf( esc_html__( 'Checkout.com Payment Authorised - using FLOW (3DS return): %s - Payment ID: %s', 'checkout-com-unified-payments-api' ), $flow_payment_type, $flow_payment_id ); + $message = sprintf( esc_html__( 'Checkout.com Payment Authorised - using FLOW (3DS return): %s - Payment ID: %s, Amount: %s', 'checkout-com-unified-payments-api' ), $flow_payment_type, $flow_payment_id, $formatted_order_amount ); $status = null; // Signal to skip status update } else { // Payment not yet processed - set status to authorized $status = $auth_status; - $message = sprintf( esc_html__( 'Checkout.com Payment Authorised - using FLOW (3DS return): %s - Payment ID: %s', 'checkout-com-unified-payments-api' ), $flow_payment_type, $flow_payment_id ); + $message = sprintf( esc_html__( 'Checkout.com Payment Authorised - using FLOW (3DS return): %s - Payment ID: %s, Amount: %s', 'checkout-com-unified-payments-api' ), $flow_payment_type, $flow_payment_id, $formatted_order_amount ); // Check if payment was flagged if ( isset( $result['risk']['flagged'] ) && $result['risk']['flagged'] ) { $status = WC_Admin_Settings::get_option( 'ckocom_order_flagged', 'flagged' ); - $message = sprintf( esc_html__( 'Checkout.com Payment Flagged (3DS return) - Payment ID: %s', 'checkout-com-unified-payments-api' ), $flow_payment_id ); + $message = sprintf( esc_html__( 'Checkout.com Payment Flagged (3DS return) - Payment ID: %s, Amount: %s', 'checkout-com-unified-payments-api' ), $flow_payment_id, $formatted_order_amount ); } } @@ -2277,7 +2335,15 @@ public function process_payment( $order_id ) { // Set transaction ID and payment ID $order->set_transaction_id( $result['action_id'] ); $order->update_meta_data( '_cko_payment_id', $result['id'] ); - $order->update_meta_data( '_cko_flow_payment_id', $result['id'] ); + + // CRITICAL: Only set _cko_flow_payment_id if not already set (prevent overwriting) + $existing_flow_payment_id = $order->get_meta( '_cko_flow_payment_id' ); + if ( empty( $existing_flow_payment_id ) ) { + $order->update_meta_data( '_cko_flow_payment_id', $result['id'] ); + } else { + WC_Checkoutcom_Utility::logger( '[PROCESS PAYMENT] Payment ID already exists in order (saved card) - Order ID: ' . $order_id . ', Existing Payment ID: ' . substr( $existing_flow_payment_id, 0, 20 ) . '..., New Payment ID: ' . substr( $result['id'], 0, 20 ) . '... (skipping save to prevent overwrite)' ); + } + $order->update_meta_data( '_cko_flow_payment_type', 'card' ); $order->update_meta_data( '_cko_order_reference', $order->get_order_number() ); @@ -2395,7 +2461,15 @@ public function process_payment( $order_id ) { // Set transaction ID and payment ID $order->set_transaction_id( $result['action_id'] ); $order->update_meta_data( '_cko_payment_id', $result['id'] ); - $order->update_meta_data( '_cko_flow_payment_id', $result['id'] ); + + // CRITICAL: Only set _cko_flow_payment_id if not already set (prevent overwriting) + $existing_flow_payment_id = $order->get_meta( '_cko_flow_payment_id' ); + if ( empty( $existing_flow_payment_id ) ) { + $order->update_meta_data( '_cko_flow_payment_id', $result['id'] ); + } else { + WC_Checkoutcom_Utility::logger( '[PROCESS PAYMENT] Payment ID already exists in order (saved card fallback 1) - Order ID: ' . $order_id . ', Existing Payment ID: ' . substr( $existing_flow_payment_id, 0, 20 ) . '..., New Payment ID: ' . substr( $result['id'], 0, 20 ) . '... (skipping save to prevent overwrite)' ); + } + $order->update_meta_data( '_cko_flow_payment_type', 'card' ); $order->update_meta_data( '_cko_order_reference', $order->get_order_number() ); @@ -2473,7 +2547,15 @@ public function process_payment( $order_id ) { // Set transaction ID and payment ID $order->set_transaction_id( $result['action_id'] ); $order->update_meta_data( '_cko_payment_id', $result['id'] ); - $order->update_meta_data( '_cko_flow_payment_id', $result['id'] ); + + // CRITICAL: Only set _cko_flow_payment_id if not already set (prevent overwriting) + $existing_flow_payment_id = $order->get_meta( '_cko_flow_payment_id' ); + if ( empty( $existing_flow_payment_id ) ) { + $order->update_meta_data( '_cko_flow_payment_id', $result['id'] ); + } else { + WC_Checkoutcom_Utility::logger( '[PROCESS PAYMENT] Payment ID already exists in order (saved card fallback 1) - Order ID: ' . $order_id . ', Existing Payment ID: ' . substr( $existing_flow_payment_id, 0, 20 ) . '..., New Payment ID: ' . substr( $result['id'], 0, 20 ) . '... (skipping save to prevent overwrite)' ); + } + $order->update_meta_data( '_cko_flow_payment_type', 'card' ); $order->update_meta_data( '_cko_order_reference', $order->get_order_number() ); @@ -2530,29 +2612,65 @@ public function process_payment( $order_id ) { } $order->update_meta_data( '_cko_payment_id', $flow_pay_id ); - $order->update_meta_data( '_cko_flow_payment_id', $flow_pay_id ); + + // CRITICAL: Only set _cko_flow_payment_id if not already set (prevent overwriting) + $existing_flow_payment_id = $order->get_meta( '_cko_flow_payment_id' ); + if ( empty( $existing_flow_payment_id ) ) { + $order->update_meta_data( '_cko_flow_payment_id', $flow_pay_id ); + } else { + WC_Checkoutcom_Utility::logger( '[PROCESS PAYMENT] Payment ID already exists in order - Order ID: ' . $order_id . ', Existing Payment ID: ' . substr( $existing_flow_payment_id, 0, 20 ) . '..., New Payment ID: ' . substr( $flow_pay_id, 0, 20 ) . '... (skipping save to prevent overwrite)' ); + } + $order->update_meta_data( '_cko_flow_payment_type', $flow_payment_type ); // Store order number/reference for webhook lookup (works with Sequential Order Numbers plugins) $order->update_meta_data( '_cko_order_reference', $order->get_order_number() ); // Store payment session ID for 3DS return lookup - // Priority: 1) POST data (from form), 2) Payment metadata (if payment already exists) + // Priority: 1) POST data (from form), 2) Already-fetched payment details, 3) Payment metadata (fetch if needed) $payment_session_id = isset( $_POST['cko-flow-payment-session-id'] ) ? sanitize_text_field( $_POST['cko-flow-payment-session-id'] ) : ''; WC_Checkoutcom_Utility::logger( 'Payment session ID from POST: ' . ( ! empty( $payment_session_id ) ? $payment_session_id : 'EMPTY' ) ); $payment_details_for_shipping = null; if ( empty( $payment_session_id ) && ! empty( $flow_pay_id ) ) { - // Try to get payment session ID from payment metadata - try { - $checkout = new Checkout_SDK(); - $builder = $checkout->get_builder(); - if ( $builder ) { - $payment_details_for_shipping = $builder->getPaymentsClient()->getPaymentDetails( $flow_pay_id ); - $payment_session_id = isset( $payment_details_for_shipping['metadata']['cko_payment_session_id'] ) ? $payment_details_for_shipping['metadata']['cko_payment_session_id'] : ''; + WC_Checkoutcom_Utility::logger( '[PROCESS PAYMENT] Payment session ID is empty, checking payment details - Payment ID: ' . $flow_pay_id ); + WC_Checkoutcom_Utility::logger( '[PROCESS PAYMENT] Has already-fetched payment_details: ' . ( ! empty( $payment_details ) ? 'YES' : 'NO' ) ); + + // CRITICAL: First try to use already-fetched payment details (from early fetch at line 1474) + // This avoids unnecessary API calls and works better for APM payments + if ( ! empty( $payment_details ) ) { + WC_Checkoutcom_Utility::logger( '[PROCESS PAYMENT] Checking already-fetched payment_details for payment session ID...' ); + if ( isset( $payment_details['metadata']['cko_payment_session_id'] ) ) { + $payment_session_id = $payment_details['metadata']['cko_payment_session_id']; + $payment_details_for_shipping = $payment_details; + WC_Checkoutcom_Utility::logger( '[PROCESS PAYMENT] βœ… Payment session ID retrieved from already-fetched payment details: ' . substr( $payment_session_id, 0, 20 ) . '...' ); + } else { + WC_Checkoutcom_Utility::logger( '[PROCESS PAYMENT] ⚠️ Payment session ID not found in already-fetched payment_details metadata' ); + if ( isset( $payment_details['metadata'] ) ) { + WC_Checkoutcom_Utility::logger( '[PROCESS PAYMENT] Available metadata keys: ' . implode( ', ', array_keys( $payment_details['metadata'] ) ) ); + } else { + WC_Checkoutcom_Utility::logger( '[PROCESS PAYMENT] ⚠️ No metadata key found in payment_details' ); + } } - } catch ( Exception $e ) { - if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { - WC_Checkoutcom_Utility::logger( '[FLOW] [SHIPPING DEBUG] Could not fetch payment details: ' . $e->getMessage() ); + } + + // Fallback: Fetch payment details if not already fetched or if payment session ID not found + if ( empty( $payment_session_id ) ) { + // Fallback: Fetch payment details if not already fetched + try { + WC_Checkoutcom_Utility::logger( '[PROCESS PAYMENT] Fetching payment details to get payment session ID - Payment ID: ' . $flow_pay_id ); + $checkout = new Checkout_SDK(); + $builder = $checkout->get_builder(); + if ( $builder ) { + $payment_details_for_shipping = $builder->getPaymentsClient()->getPaymentDetails( $flow_pay_id ); + $payment_session_id = isset( $payment_details_for_shipping['metadata']['cko_payment_session_id'] ) ? $payment_details_for_shipping['metadata']['cko_payment_session_id'] : ''; + if ( ! empty( $payment_session_id ) ) { + WC_Checkoutcom_Utility::logger( '[PROCESS PAYMENT] βœ… Payment session ID retrieved from payment metadata: ' . substr( $payment_session_id, 0, 20 ) . '...' ); + } else { + WC_Checkoutcom_Utility::logger( '[PROCESS PAYMENT] ⚠️ Payment session ID not found in payment metadata' ); + } + } + } catch ( Exception $e ) { + WC_Checkoutcom_Utility::logger( '[PROCESS PAYMENT] ⚠️ Could not fetch payment details to get payment session ID: ' . $e->getMessage() ); } } } @@ -3272,9 +3390,15 @@ public function handle_3ds_return() { $order->set_payment_method( $this->id ); $order->set_payment_method_title( $this->get_title() ); - // Save payment session ID + // Save payment session ID (only if not already set) if ( ! empty( $payment_session_id ) ) { - $order->update_meta_data( '_cko_payment_session_id', $payment_session_id ); + $existing_order_session_id = $order->get_meta( '_cko_payment_session_id' ); + if ( empty( $existing_order_session_id ) ) { + $order->update_meta_data( '_cko_payment_session_id', $payment_session_id ); + WC_Checkoutcom_Utility::logger( '[3DS RETURN] Payment session ID saved to order (from session) - Order ID: ' . $order_id . ', Payment Session ID: ' . substr( $payment_session_id, 0, 20 ) . '...' ); + } else { + WC_Checkoutcom_Utility::logger( '[3DS RETURN] Payment session ID already exists in order (from session) - Order ID: ' . $order_id . ', Existing Payment Session ID: ' . substr( $existing_order_session_id, 0, 20 ) . '..., New Payment Session ID: ' . substr( $payment_session_id, 0, 20 ) . '... (skipping save to prevent overwrite)' ); + } } // Store save card preference from GET parameter (if available in URL) @@ -3409,7 +3533,14 @@ public function handle_3ds_return() { // Save payment ID to order for reference if ( ! empty( $payment_id ) ) { $order->update_meta_data( '_cko_payment_id', $payment_id ); - $order->update_meta_data( '_cko_flow_payment_id', $payment_id ); + + // CRITICAL: Only set _cko_flow_payment_id if not already set (prevent overwriting) + $existing_flow_payment_id = $order->get_meta( '_cko_flow_payment_id' ); + if ( empty( $existing_flow_payment_id ) ) { + $order->update_meta_data( '_cko_flow_payment_id', $payment_id ); + } else { + WC_Checkoutcom_Utility::logger( '[3DS RETURN] Payment ID already exists in order (failed payment) - Order ID: ' . $order_id . ', Existing Payment ID: ' . substr( $existing_flow_payment_id, 0, 20 ) . '..., New Payment ID: ' . substr( $payment_id, 0, 20 ) . '... (skipping save to prevent overwrite)' ); + } } // Save order again after marking as failed @@ -4134,7 +4265,14 @@ private function create_minimal_order_from_payment_details( $payment_id, $paymen // Save payment IDs $order->update_meta_data( '_cko_payment_id', $payment_id ); - $order->update_meta_data( '_cko_flow_payment_id', $payment_id ); + + // CRITICAL: Only set _cko_flow_payment_id if not already set (prevent overwriting) + $existing_flow_payment_id = $order->get_meta( '_cko_flow_payment_id' ); + if ( empty( $existing_flow_payment_id ) ) { + $order->update_meta_data( '_cko_flow_payment_id', $payment_id ); + } else { + WC_Checkoutcom_Utility::logger( '[CREATE MINIMAL ORDER] Payment ID already exists in order - Order ID: ' . $order_id . ', Existing Payment ID: ' . substr( $existing_flow_payment_id, 0, 20 ) . '..., New Payment ID: ' . substr( $payment_id, 0, 20 ) . '... (skipping save to prevent overwrite)' ); + } // Mark as minimal order (created from payment details) $order->update_meta_data( '_cko_minimal_order', 'yes' ); @@ -4659,6 +4797,9 @@ function apache_request_headers() { // This ensures webhook updates correct order and prevents matching completed orders // Both identifiers must match - eliminates false positives and provides highest confidence match if ( ! $order && ! empty( $data->data->metadata->cko_payment_session_id ) && ! empty( $data->data->id ) ) { + WC_Checkoutcom_Utility::logger( 'WEBHOOK MATCHING: Trying METHOD 2 (COMBINED: Session ID + Payment ID)' ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK MATCHING: Session ID: ' . $data->data->metadata->cko_payment_session_id ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK MATCHING: Payment ID: ' . $data->data->id ); if ( $webhook_debug_enabled ) { WC_Checkoutcom_Utility::logger( 'WEBHOOK DEBUG: Looking for order by COMBINED (session ID + payment ID)' ); WC_Checkoutcom_Utility::logger( 'WEBHOOK DEBUG: Session ID: ' . $data->data->metadata->cko_payment_session_id ); @@ -4666,16 +4807,19 @@ function apache_request_headers() { } // First try to match orders that need updating (pending, failed, on-hold, processing) + // CRITICAL: Use 'compare' => '=' explicitly and ensure both meta keys exist $orders = wc_get_orders( array( 'meta_query' => array( 'relation' => 'AND', array( - 'key' => '_cko_payment_session_id', - 'value' => $data->data->metadata->cko_payment_session_id, + 'key' => '_cko_payment_session_id', + 'value' => $data->data->metadata->cko_payment_session_id, + 'compare' => '=', ), array( - 'key' => '_cko_flow_payment_id', - 'value' => $data->data->id, + 'key' => '_cko_flow_payment_id', + 'value' => $data->data->id, + 'compare' => '=', ), ), 'status' => array( 'pending', 'failed', 'on-hold', 'processing' ), // βœ… Only match orders that need updating @@ -4692,12 +4836,14 @@ function apache_request_headers() { 'meta_query' => array( 'relation' => 'AND', array( - 'key' => '_cko_payment_session_id', - 'value' => $data->data->metadata->cko_payment_session_id, + 'key' => '_cko_payment_session_id', + 'value' => $data->data->metadata->cko_payment_session_id, + 'compare' => '=', ), array( - 'key' => '_cko_flow_payment_id', - 'value' => $data->data->id, + 'key' => '_cko_flow_payment_id', + 'value' => $data->data->id, + 'compare' => '=', ), ), 'limit' => 1, @@ -4706,24 +4852,48 @@ function apache_request_headers() { } if ( ! empty( $orders ) ) { - $order = $orders[0]; - WC_Checkoutcom_Utility::logger( 'WEBHOOK MATCHING: βœ… MATCHED BY METHOD 2 (COMBINED: Session ID + Payment ID) - Order ID: ' . $order->get_id() ); - if ( $webhook_debug_enabled ) { - WC_Checkoutcom_Utility::logger( 'WEBHOOK DEBUG: βœ… Order found by COMBINED match (ID: ' . $order->get_id() . ')' ); - } + $matched_order = $orders[0]; - // Add order_id to metadata so processing functions can find it - if ( isset( $data->data->metadata ) && is_object( $data->data->metadata ) ) { - $data->data->metadata->order_id = $order->get_id(); + // CRITICAL: Validate that BOTH meta values actually match (WooCommerce meta_query can match even if one meta key doesn't exist) + $order_session_id = $matched_order->get_meta( '_cko_payment_session_id' ); + $order_payment_id = $matched_order->get_meta( '_cko_flow_payment_id' ); + $webhook_session_id = $data->data->metadata->cko_payment_session_id; + $webhook_payment_id = $data->data->id; + + // Both must exist AND match + $session_id_matches = ! empty( $order_session_id ) && $order_session_id === $webhook_session_id; + $payment_id_matches = ! empty( $order_payment_id ) && $order_payment_id === $webhook_payment_id; + + if ( $session_id_matches && $payment_id_matches ) { + // Both match - valid match + $order = $matched_order; + WC_Checkoutcom_Utility::logger( 'WEBHOOK MATCHING: βœ… MATCHED BY METHOD 2 (COMBINED: Session ID + Payment ID) - Order ID: ' . $order->get_id() ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK MATCHING: βœ… VALIDATION PASSED - Session ID matches: ' . $order_session_id . ', Payment ID matches: ' . $order_payment_id ); if ( $webhook_debug_enabled ) { - WC_Checkoutcom_Utility::logger( 'WEBHOOK DEBUG: Set metadata order_id to: ' . $order->get_id() . ' (from COMBINED match)' ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK DEBUG: βœ… Order found by COMBINED match (ID: ' . $order->get_id() . ')' ); } - } else { - // If metadata is missing or not an object, create it. - $data->data->metadata = (object) array( 'order_id' => $order->get_id() ); - if ( $webhook_debug_enabled ) { - WC_Checkoutcom_Utility::logger( 'WEBHOOK DEBUG: Created metadata object with order_id: ' . $order->get_id() . ' (from COMBINED match)' ); + + // Add order_id to metadata so processing functions can find it + if ( isset( $data->data->metadata ) && is_object( $data->data->metadata ) ) { + $data->data->metadata->order_id = $order->get_id(); + if ( $webhook_debug_enabled ) { + WC_Checkoutcom_Utility::logger( 'WEBHOOK DEBUG: Set metadata order_id to: ' . $order->get_id() . ' (from COMBINED match)' ); + } + } else { + // If metadata is missing or not an object, create it. + $data->data->metadata = (object) array( 'order_id' => $order->get_id() ); + if ( $webhook_debug_enabled ) { + WC_Checkoutcom_Utility::logger( 'WEBHOOK DEBUG: Created metadata object with order_id: ' . $order->get_id() . ' (from COMBINED match)' ); + } } + } else { + // Query matched but validation failed - reject this match + WC_Checkoutcom_Utility::logger( 'WEBHOOK MATCHING: ❌ METHOD 2 VALIDATION FAILED - Query matched Order ID: ' . $matched_order->get_id() . ' but values don\'t match!' ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK MATCHING: Order Session ID: ' . ( $order_session_id ?: 'NOT SET' ) . ', Webhook Session ID: ' . $webhook_session_id ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK MATCHING: Order Payment ID: ' . ( $order_payment_id ?: 'NOT SET' ) . ', Webhook Payment ID: ' . $webhook_payment_id ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK MATCHING: Session ID matches: ' . ( $session_id_matches ? 'YES' : 'NO' ) . ', Payment ID matches: ' . ( $payment_id_matches ? 'YES' : 'NO' ) ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK MATCHING: ❌ REJECTING INVALID MATCH - Continuing to Method 3' ); + // Don't set $order - continue to Method 3 } } else { WC_Checkoutcom_Utility::logger( 'WEBHOOK MATCHING: ❌ METHOD 2 FAILED - No order found by COMBINED match (Session ID: ' . $webhook_session_id . ', Payment ID: ' . $webhook_payment_id . ')' ); @@ -4846,22 +5016,48 @@ function apache_request_headers() { WC_Checkoutcom_Utility::logger( 'WEBHOOK DEBUG: Payment ID from webhook: ' . ($data->data->id ?? 'NULL') ); } - // For Flow payments, be more flexible with payment ID matching - // Flow payments might not have payment ID set yet, or might have different ID format - if ( $order && is_null( $payment_id ) ) { - // If no payment ID is set, try to set it from the webhook - if ( $webhook_debug_enabled ) { - WC_Checkoutcom_Utility::logger( 'Flow webhook: No payment ID found in order, setting from webhook: ' . $data->data->id ); + // CRITICAL: Validate payment ID matches order (prevent wrong webhooks from matching orders) + // This validation happens BEFORE processing webhook events + if ( $order ) { + $order_payment_id = $order->get_meta( '_cko_flow_payment_id' ); + $order_payment_id_alt = $order->get_meta( '_cko_payment_id' ); + $webhook_payment_id = $data->data->id; + + // Use Flow payment ID if available, otherwise fall back to regular payment ID + $expected_payment_id = ! empty( $order_payment_id ) ? $order_payment_id : $order_payment_id_alt; + + // CRITICAL: If order has a payment ID, it MUST match the webhook payment ID + if ( ! empty( $expected_payment_id ) && $expected_payment_id !== $webhook_payment_id ) { + // Payment ID mismatch - reject webhook BEFORE processing + WC_Checkoutcom_Utility::logger( 'WEBHOOK MATCHING: ❌ CRITICAL ERROR - Payment ID mismatch in Flow webhook handler!' ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK MATCHING: Order ID: ' . $order->get_id() ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK MATCHING: Order _cko_flow_payment_id: ' . ( $order_payment_id ?: 'NOT SET' ) ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK MATCHING: Order _cko_payment_id: ' . ( $order_payment_id_alt ?: 'NOT SET' ) ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK MATCHING: Expected payment ID: ' . $expected_payment_id ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK MATCHING: Webhook payment ID: ' . $webhook_payment_id ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK MATCHING: ❌ REJECTING WEBHOOK - Payment ID does not match order!' ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK MATCHING: This webhook is for a different payment - ignoring to prevent incorrect order updates' ); + + // Reject webhook - return HTTP 200 but don't process + $this->send_response( 200, 'Webhook payment ID does not match order payment ID' ); + return; } - $order->set_transaction_id( $data->data->id ); - $order->update_meta_data( '_cko_payment_id', $data->data->id ); - $order->update_meta_data( '_cko_flow_payment_id', $data->data->id ); - $order->save(); - $payment_id = $data->data->id; - } elseif ( $order && $payment_id !== $data->data->id ) { - // Payment ID exists but doesn't match - log but don't fail for Flow payments - if ( $webhook_debug_enabled ) { - WC_Checkoutcom_Utility::logger( 'Flow webhook: Payment ID mismatch - Order: ' . $payment_id . ', Webhook: ' . $data->data->id . ' - Continuing processing' ); + + // If order doesn't have payment ID yet, set it from webhook (for first payment attempt) + if ( empty( $expected_payment_id ) && ! empty( $webhook_payment_id ) ) { + if ( $webhook_debug_enabled ) { + WC_Checkoutcom_Utility::logger( 'Flow webhook: No payment ID found in order, setting from webhook: ' . $webhook_payment_id ); + } + $order->set_transaction_id( $webhook_payment_id ); + $order->update_meta_data( '_cko_payment_id', $webhook_payment_id ); + $order->update_meta_data( '_cko_flow_payment_id', $webhook_payment_id ); + $order->save(); + $payment_id = $webhook_payment_id; + } elseif ( ! empty( $expected_payment_id ) ) { + // Payment IDs match - continue processing + if ( $webhook_debug_enabled ) { + WC_Checkoutcom_Utility::logger( 'Flow webhook: βœ… Payment ID validation passed - Order payment ID: ' . $expected_payment_id . ', Webhook payment ID: ' . $webhook_payment_id ); + } } } elseif ( ! $order ) { // No order found - log but continue processing to allow queue system to handle it @@ -5609,19 +5805,29 @@ public function ajax_save_payment_session_id() { return; } - // Check if payment session ID already saved (avoid duplicate saves) + // CRITICAL: Check if payment session ID already saved (prevent overwriting) $existing_payment_session_id = $order->get_meta( '_cko_payment_session_id' ); - if ( ! empty( $existing_payment_session_id ) && $existing_payment_session_id === $payment_session_id ) { - // Already saved, return success - wp_send_json_success( array( - 'message' => __( 'Payment session ID already saved.', 'checkout-com-unified-payments-api' ), - 'order_id' => $order_id, - ) ); - return; + if ( ! empty( $existing_payment_session_id ) ) { + if ( $existing_payment_session_id === $payment_session_id ) { + // Already saved with same value, return success + wp_send_json_success( array( + 'message' => __( 'Payment session ID already saved.', 'checkout-com-unified-payments-api' ), + 'order_id' => $order_id, + ) ); + return; + } else { + // Different payment session ID exists - prevent overwriting + WC_Checkoutcom_Utility::logger( '[SAVE PAYMENT SESSION ID] ❌ CRITICAL ERROR: Payment session ID already exists with different value - Order ID: ' . $order_id . ', Existing: ' . substr( $existing_payment_session_id, 0, 20 ) . '..., New: ' . substr( $payment_session_id, 0, 20 ) . '... (preventing overwrite)' ); + wp_send_json_error( array( + 'message' => __( 'Payment session ID already exists with different value. Cannot overwrite.', 'checkout-com-unified-payments-api' ), + 'order_id' => $order_id, + ) ); + return; + } } - // Save payment session ID to order + // Save payment session ID to order (order doesn't have it yet) $order->update_meta_data( '_cko_payment_session_id', $payment_session_id ); $order->save(); diff --git a/checkout-com-unified-payments-api/includes/class-wc-checkout-com-webhook.php b/checkout-com-unified-payments-api/includes/class-wc-checkout-com-webhook.php index 59a2ac9a..dadf40d7 100644 --- a/checkout-com-unified-payments-api/includes/class-wc-checkout-com-webhook.php +++ b/checkout-com-unified-payments-api/includes/class-wc-checkout-com-webhook.php @@ -96,7 +96,9 @@ public static function authorize_payment( $data ) { $payment_id = $webhook_data->id; $action_id = $webhook_data->action_id; - $message = sprintf( 'Webhook received from checkout.com. Payment Authorized - Payment ID: %s, Action ID: %s', $payment_id, $action_id ); + $amount = isset( $webhook_data->amount ) ? $webhook_data->amount : 0; + $formatted_amount = $amount > 0 ? wc_price( WC_Checkoutcom_Utility::decimal_to_value( $amount, $order->get_currency() ), array( 'currency' => $order->get_currency() ) ) : ''; + $message = sprintf( 'Webhook received from checkout.com. Payment Authorized - Payment ID: %s, Action ID: %s%s', $payment_id, $action_id, $formatted_amount ? ', Amount: ' . $formatted_amount : '' ); // CRITICAL: Check if already captured FIRST (most important check) // Don't update status if already captured (even if not authorized yet) @@ -293,8 +295,9 @@ public static function capture_payment( $data ) { $amount = $webhook_data->amount; $order_amount = $order->get_total(); $order_amount_cents = WC_Checkoutcom_Utility::value_to_decimal( $order_amount, $order->get_currency() ); + $formatted_amount_for_message = wc_price( WC_Checkoutcom_Utility::decimal_to_value( $amount, $order->get_currency() ), array( 'currency' => $order->get_currency() ) ); - $message = sprintf( 'Webhook received from checkout.com Payment captured - Payment ID: %s, Action ID: %s', $payment_id, $action_id ); + $message = sprintf( 'Webhook received from checkout.com Payment captured - Payment ID: %s, Action ID: %s, Amount: %s', $payment_id, $action_id, $formatted_amount_for_message ); $already_authorized = $order->get_meta( 'cko_payment_authorized' ); @@ -313,7 +316,8 @@ public static function capture_payment( $data ) { return true; } - $order->add_order_note( sprintf( __( 'Checkout.com Payment Capture webhook received - Payment ID: %s', 'checkout-com-unified-payments-api' ), $payment_id ) ); + $formatted_amount = wc_price( WC_Checkoutcom_Utility::decimal_to_value( $amount, $order->get_currency() ), array( 'currency' => $order->get_currency() ) ); + $order->add_order_note( sprintf( __( 'Checkout.com Payment Capture webhook received - Payment ID: %s, Amount: %s', 'checkout-com-unified-payments-api' ), $payment_id, $formatted_amount ) ); // Set action id as woo transaction id. $order->set_transaction_id( $action_id ); @@ -322,13 +326,14 @@ public static function capture_payment( $data ) { // Get cko capture status configured in admin. $status = WC_Admin_Settings::get_option( 'ckocom_order_captured', 'processing' ); - /* translators: %1$s: Payment ID, %2$s: Action ID. */ - $order_message = sprintf( esc_html__( 'Checkout.com Payment Captured - Payment ID: %1$s, Action ID: %2$s', 'checkout-com-unified-payments-api' ), $payment_id, $action_id ); + $formatted_amount = wc_price( WC_Checkoutcom_Utility::decimal_to_value( $amount, $order->get_currency() ), array( 'currency' => $order->get_currency() ) ); + /* translators: %1$s: Payment ID, %2$s: Action ID, %3$s: Amount. */ + $order_message = sprintf( esc_html__( 'Checkout.com Payment Captured - Payment ID: %1$s, Action ID: %2$s, Amount: %3$s', 'checkout-com-unified-payments-api' ), $payment_id, $action_id, $formatted_amount ); // Check if webhook amount is less than order amount. if ( $amount < $order_amount_cents ) { - /* translators: %1$s: Payment ID, %2$s: Action ID. */ - $order_message = sprintf( esc_html__( 'Checkout.com Payment partially captured - Payment ID: %1$s, Action ID: %2$s', 'checkout-com-unified-payments-api' ), $payment_id, $action_id ); + /* translators: %1$s: Payment ID, %2$s: Action ID, %3$s: Amount. */ + $order_message = sprintf( esc_html__( 'Checkout.com Payment partially captured - Payment ID: %1$s, Action ID: %2$s, Amount: %3$s', 'checkout-com-unified-payments-api' ), $payment_id, $action_id, $formatted_amount ); } // add notes for the order and update status. @@ -400,11 +405,21 @@ public static function capture_declined( $data ) { WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Order loaded successfully - Order ID: ' . $order->get_id() . ', Status: ' . $order->get_status() ); } + // Get amount from webhook or use order amount as fallback + $amount = isset( $webhook_data->amount ) ? $webhook_data->amount : 0; + $formatted_amount = ''; + if ( $amount > 0 ) { + $formatted_amount = wc_price( WC_Checkoutcom_Utility::decimal_to_value( $amount, $order->get_currency() ), array( 'currency' => $order->get_currency() ) ); + } else { + // Fallback to order amount if webhook doesn't have amount + $formatted_amount = wc_price( $order->get_total(), array( 'currency' => $order->get_currency() ) ); + } + // Include Payment ID and Action ID (if available) in the order note for consistency with other webhook handlers if ( ! empty( $action_id ) ) { - $message = sprintf( 'Webhook received from checkout.com. Payment capture declined - Payment ID: %s, Action ID: %s, Reason: %s', $payment_id, $action_id, $response_summary ); + $message = sprintf( 'Webhook received from checkout.com. Payment capture declined - Payment ID: %s, Action ID: %s, Reason: %s, Amount: %s', $payment_id, $action_id, $response_summary, $formatted_amount ); } else { - $message = sprintf( 'Webhook received from checkout.com. Payment capture declined - Payment ID: %s, Reason: %s', $payment_id, $response_summary ); + $message = sprintf( 'Webhook received from checkout.com. Payment capture declined - Payment ID: %s, Reason: %s, Amount: %s', $payment_id, $response_summary, $formatted_amount ); } // Add note to order if capture declined. @@ -471,14 +486,49 @@ public static function void_payment( $data ) { WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Order loaded successfully - Order ID: ' . $order_id . ', Status: ' . $order->get_status() ); } + // CRITICAL: Validate payment ID matches order (prevent wrong webhooks from matching orders) + $payment_id = $webhook_data->id; + $order_payment_id = $order->get_meta( '_cko_flow_payment_id' ); + $order_payment_id_alt = $order->get_meta( '_cko_payment_id' ); + + // Use Flow payment ID if available, otherwise fall back to regular payment ID + $expected_payment_id = ! empty( $order_payment_id ) ? $order_payment_id : $order_payment_id_alt; + + if ( ! empty( $expected_payment_id ) && $expected_payment_id !== $payment_id ) { + // Payment ID mismatch - reject webhook + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: ❌ CRITICAL ERROR - Void webhook payment ID mismatch!' ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Order ID: ' . $order_id ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Order _cko_flow_payment_id: ' . ( $order_payment_id ?: 'NOT SET' ) ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Order _cko_payment_id: ' . ( $order_payment_id_alt ?: 'NOT SET' ) ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Expected payment ID: ' . $expected_payment_id ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Webhook payment ID: ' . $payment_id ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: ❌ REJECTING VOID WEBHOOK - Payment ID does not match order!' ); + + if ( $webhook_debug_enabled ) { + WC_Checkoutcom_Utility::logger( '=== WEBHOOK PROCESS: void_payment END (FAILED - Payment ID Mismatch) ===' ); + } + return false; // Reject webhook - wrong payment + } + + if ( $webhook_debug_enabled && ! empty( $expected_payment_id ) ) { + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: βœ… Payment ID validation passed - Order payment ID: ' . $expected_payment_id . ', Webhook payment ID: ' . $payment_id ); + } + // check if payment is already captured. $already_voided = $order->get_meta( 'cko_payment_voided' ); - $payment_id = $webhook_data->id; // Get action id from webhook data. $action_id = $webhook_data->action_id; + $amount = isset( $webhook_data->amount ) ? $webhook_data->amount : 0; + $formatted_amount = ''; + if ( $amount > 0 ) { + $formatted_amount = wc_price( WC_Checkoutcom_Utility::decimal_to_value( $amount, $order->get_currency() ), array( 'currency' => $order->get_currency() ) ); + } else { + // Fallback to order amount if webhook doesn't have amount + $formatted_amount = wc_price( $order->get_total(), array( 'currency' => $order->get_currency() ) ); + } - $message = sprintf( 'Webhook received from checkout.com. Payment voided - Payment ID: %s, Action ID: %s', $payment_id, $action_id ); + $message = sprintf( 'Webhook received from checkout.com. Payment voided - Payment ID: %s, Action ID: %s%s', $payment_id, $action_id, $formatted_amount ? ', Amount: ' . $formatted_amount : '' ); // Add note to order if captured already. if ( $already_voided ) { @@ -486,7 +536,7 @@ public static function void_payment( $data ) { return true; } - $order->add_order_note( sprintf( esc_html__( 'Checkout.com Payment Void webhook received - Payment ID: %s', 'checkout-com-unified-payments-api' ), $payment_id ) ); + $order->add_order_note( sprintf( esc_html__( 'Checkout.com Payment Void webhook received - Payment ID: %s%s', 'checkout-com-unified-payments-api' ), $payment_id, $formatted_amount ? ', Amount: ' . $formatted_amount : '' ) ); // Set action id as woo transaction id. $order->set_transaction_id( $action_id ); @@ -495,8 +545,8 @@ public static function void_payment( $data ) { // Get cko capture status configured in admin. $status = WC_Admin_Settings::get_option( 'ckocom_order_void', 'cancelled' ); - /* translators: %1$s: Payment ID, %2$s: Action ID. */ - $order_message = sprintf( esc_html__( 'Checkout.com Payment Voided - Payment ID: %1$s, Action ID: %2$s', 'checkout-com-unified-payments-api' ), $payment_id, $action_id ); + /* translators: %1$s: Payment ID, %2$s: Action ID, %3$s: Amount. */ + $order_message = sprintf( esc_html__( 'Checkout.com Payment Voided - Payment ID: %1$s, Action ID: %2$s, Amount: %3$s', 'checkout-com-unified-payments-api' ), $payment_id, $action_id, $formatted_amount ); // add notes for the order and update status. $order->add_order_note( $order_message ); @@ -567,9 +617,36 @@ public static function refund_payment( $data ) { WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Order loaded successfully - Order ID: ' . $order_id . ', Status: ' . $order->get_status() ); } + // CRITICAL: Validate payment ID matches order (prevent wrong webhooks from matching orders) + $payment_id = $webhook_data->id; + $order_payment_id = $order->get_meta( '_cko_flow_payment_id' ); + $order_payment_id_alt = $order->get_meta( '_cko_payment_id' ); + + // Use Flow payment ID if available, otherwise fall back to regular payment ID + $expected_payment_id = ! empty( $order_payment_id ) ? $order_payment_id : $order_payment_id_alt; + + if ( ! empty( $expected_payment_id ) && $expected_payment_id !== $payment_id ) { + // Payment ID mismatch - reject webhook + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: ❌ CRITICAL ERROR - Refund webhook payment ID mismatch!' ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Order ID: ' . $order_id ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Order _cko_flow_payment_id: ' . ( $order_payment_id ?: 'NOT SET' ) ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Order _cko_payment_id: ' . ( $order_payment_id_alt ?: 'NOT SET' ) ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Expected payment ID: ' . $expected_payment_id ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Webhook payment ID: ' . $payment_id ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: ❌ REJECTING REFUND WEBHOOK - Payment ID does not match order!' ); + + if ( $webhook_debug_enabled ) { + WC_Checkoutcom_Utility::logger( '=== WEBHOOK PROCESS: refund_payment END (FAILED - Payment ID Mismatch) ===' ); + } + return false; // Reject webhook - wrong payment + } + + if ( $webhook_debug_enabled && ! empty( $expected_payment_id ) ) { + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: βœ… Payment ID validation passed - Order payment ID: ' . $expected_payment_id . ', Webhook payment ID: ' . $payment_id ); + } + // check if payment is already refunded. $already_refunded = $order->get_meta( 'cko_payment_refunded' ); - $payment_id = $webhook_data->id; // Get action id from webhook data. $action_id = $webhook_data->action_id; @@ -578,7 +655,8 @@ public static function refund_payment( $data ) { $order_amount_cents = WC_Checkoutcom_Utility::value_to_decimal( $order_amount, $order->get_currency() ); $get_transaction_id = $order->get_transaction_id(); - $message = sprintf( 'Webhook received from checkout.com. Payment refunded - Payment ID: %s, Action ID: %s', $payment_id, $action_id ); + $refund_amount_formatted = wc_price( WC_Checkoutcom_Utility::decimal_to_value( $amount, $order->get_currency() ), array( 'currency' => $order->get_currency() ) ); + $message = sprintf( 'Webhook received from checkout.com. Payment refunded - Payment ID: %s, Action ID: %s, Amount: %s', $payment_id, $action_id, $refund_amount_formatted ); if ( $get_transaction_id === $action_id ) { return true; @@ -590,21 +668,19 @@ public static function refund_payment( $data ) { return true; } - $order->add_order_note( sprintf( esc_html__( 'Checkout.com Payment Refund webhook received - Payment ID: %s', 'checkout-com-unified-payments-api' ), $payment_id ) ); - // Set action id as woo transaction id. $order->set_transaction_id( $action_id ); $order->update_meta_data( 'cko_payment_refunded', true ); $refund_amount = WC_Checkoutcom_Utility::decimal_to_value( $amount, $order->get_currency() ); - /* translators: %1$s: Payment ID, %2$s: Action ID. */ - $order_message = sprintf( esc_html__( 'Checkout.com Payment Refunded - Payment ID: %1$s, Action ID: %2$s', 'checkout-com-unified-payments-api' ), $payment_id, $action_id ); + /* translators: %1$s: Payment ID, %2$s: Action ID, %3$s: Amount. */ + $order_message = sprintf( esc_html__( 'Checkout.com Payment Refunded - Payment ID: %1$s, Action ID: %2$s, Amount: %3$s', 'checkout-com-unified-payments-api' ), $payment_id, $action_id, $refund_amount_formatted ); // Check if webhook amount is less than order amount - partial refund. if ( $amount < $order_amount_cents ) { - /* translators: %1$s: Payment ID, %2$s: Action ID. */ - $order_message = sprintf( esc_html__( 'Checkout.com Payment partially refunded - Payment ID: %1$s, Action ID: %2$s', 'checkout-com-unified-payments-api' ), $payment_id, $action_id ); + /* translators: %1$s: Payment ID, %2$s: Action ID, %3$s: Amount. */ + $order_message = sprintf( esc_html__( 'Checkout.com Payment partially refunded - Payment ID: %1$s, Action ID: %2$s, Amount: %3$s', 'checkout-com-unified-payments-api' ), $payment_id, $action_id, $refund_amount_formatted ); $refund = wc_create_refund( [ @@ -617,8 +693,8 @@ public static function refund_payment( $data ) { } elseif ( $amount == $order_amount_cents ) { // PHPCS:ignore WordPress.PHP.StrictComparisons.LooseComparison // Full refund. - /* translators: %1$s: Payment ID, %2$s: Action ID. */ - $order_message = sprintf( esc_html__( 'Checkout.com Payment fully refunded - Payment ID: %1$s, Action ID: %2$s', 'checkout-com-unified-payments-api' ), $payment_id, $action_id ); + /* translators: %1$s: Payment ID, %2$s: Action ID, %3$s: Amount. */ + $order_message = sprintf( esc_html__( 'Checkout.com Payment fully refunded - Payment ID: %1$s, Action ID: %2$s, Amount: %3$s', 'checkout-com-unified-payments-api' ), $payment_id, $action_id, $refund_amount_formatted ); $refund = wc_create_refund( [ @@ -711,8 +787,43 @@ public static function cancel_payment( $data ) { WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Order loaded successfully - Order ID: ' . $order->get_id() . ', Status: ' . $order->get_status() ); } + // CRITICAL: Validate payment ID matches order (prevent wrong webhooks from matching orders) + $order_payment_id = $order->get_meta( '_cko_flow_payment_id' ); + $order_payment_id_alt = $order->get_meta( '_cko_payment_id' ); + + // Use Flow payment ID if available, otherwise fall back to regular payment ID + $expected_payment_id = ! empty( $order_payment_id ) ? $order_payment_id : $order_payment_id_alt; + + if ( ! empty( $expected_payment_id ) && $expected_payment_id !== $payment_id ) { + // Payment ID mismatch - reject webhook + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: ❌ CRITICAL ERROR - Cancel webhook payment ID mismatch!' ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Order ID: ' . $order->get_id() ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Order _cko_flow_payment_id: ' . ( $order_payment_id ?: 'NOT SET' ) ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Order _cko_payment_id: ' . ( $order_payment_id_alt ?: 'NOT SET' ) ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Expected payment ID: ' . $expected_payment_id ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Webhook payment ID: ' . $payment_id ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: ❌ REJECTING CANCEL WEBHOOK - Payment ID does not match order!' ); + + if ( $webhook_debug_enabled ) { + WC_Checkoutcom_Utility::logger( '=== WEBHOOK PROCESS: cancel_payment END (FAILED - Payment ID Mismatch) ===' ); + } + return false; // Reject webhook - wrong payment + } + + if ( $webhook_debug_enabled && ! empty( $expected_payment_id ) ) { + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: βœ… Payment ID validation passed - Order payment ID: ' . $expected_payment_id . ', Webhook payment ID: ' . $payment_id ); + } + $status = 'wc-cancelled'; - $message = 'Webhook received from checkout.com. Payment cancelled'; + $amount = isset( $webhook_data->amount ) ? $webhook_data->amount : 0; + $formatted_amount = ''; + if ( $amount > 0 ) { + $formatted_amount = wc_price( WC_Checkoutcom_Utility::decimal_to_value( $amount, $order->get_currency() ), array( 'currency' => $order->get_currency() ) ); + } else { + // Fallback to order amount if webhook doesn't have amount + $formatted_amount = wc_price( $order->get_total(), array( 'currency' => $order->get_currency() ) ); + } + $message = sprintf( 'Webhook received from checkout.com. Payment cancelled%s', $formatted_amount ? ' - Amount: ' . $formatted_amount : '' ); // Add notes for the order and update status. $order->add_order_note( $message ); @@ -804,12 +915,63 @@ public static function decline_payment( $data ) { WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Order loaded successfully - Order ID: ' . $order->get_id() . ', Status: ' . $order->get_status() ); } + // CRITICAL: Validate payment ID matches order (prevent wrong webhooks from matching orders) + $order_payment_id = $order->get_meta( '_cko_flow_payment_id' ); + $order_payment_id_alt = $order->get_meta( '_cko_payment_id' ); + + // Use Flow payment ID if available, otherwise fall back to regular payment ID + $expected_payment_id = ! empty( $order_payment_id ) ? $order_payment_id : $order_payment_id_alt; + + // CRITICAL: If order has a payment ID, it MUST match the webhook payment ID + // If order doesn't have payment ID yet, this webhook might be for a different payment attempt + // For decline webhooks, we should be more strict - only process if payment IDs match OR order has no payment ID set + if ( ! empty( $expected_payment_id ) && $expected_payment_id !== $payment_id ) { + // Payment ID mismatch - reject webhook + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: ❌ CRITICAL ERROR - Decline webhook payment ID mismatch!' ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Order ID: ' . $order->get_id() ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Order _cko_flow_payment_id: ' . ( $order_payment_id ?: 'NOT SET' ) ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Order _cko_payment_id: ' . ( $order_payment_id_alt ?: 'NOT SET' ) ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Expected payment ID: ' . $expected_payment_id ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: Webhook payment ID: ' . $payment_id ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: ❌ REJECTING DECLINE WEBHOOK - Payment ID does not match order!' ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: This webhook is for a different payment attempt - ignoring to prevent incorrect order updates' ); + + if ( $webhook_debug_enabled ) { + WC_Checkoutcom_Utility::logger( '=== WEBHOOK PROCESS: decline_payment END (FAILED - Payment ID Mismatch) ===' ); + } + return false; // Reject webhook - wrong payment + } + + // If order doesn't have payment ID yet, this might be the first payment attempt for this order + // But we should still validate - if order_id in metadata matches, it's likely correct + // However, if multiple payments have same order_id, we can't distinguish them + // For now, we'll allow it but log a warning + if ( empty( $expected_payment_id ) ) { + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: ⚠️ Order has no payment ID set yet - Processing decline webhook' ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: ⚠️ Order ID: ' . $order->get_id() . ', Webhook Payment ID: ' . $payment_id ); + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: ⚠️ This could be a different payment attempt - verify order_id in metadata is correct' ); + } + + if ( $webhook_debug_enabled && ! empty( $expected_payment_id ) ) { + WC_Checkoutcom_Utility::logger( 'WEBHOOK PROCESS: βœ… Payment ID validation passed - Order payment ID: ' . $expected_payment_id . ', Webhook payment ID: ' . $payment_id ); + } + $status = 'wc-failed'; + // Get amount from webhook or use order amount as fallback + $amount = isset( $webhook_data->amount ) ? $webhook_data->amount : 0; + $formatted_amount = ''; + if ( $amount > 0 ) { + $formatted_amount = wc_price( WC_Checkoutcom_Utility::decimal_to_value( $amount, $order->get_currency() ), array( 'currency' => $order->get_currency() ) ); + } else { + // Fallback to order amount if webhook doesn't have amount + $formatted_amount = wc_price( $order->get_total(), array( 'currency' => $order->get_currency() ) ); + } + // Include Payment ID and Action ID (if available) in the order note for consistency with other webhook handlers if ( ! empty( $action_id ) ) { - $message = sprintf( 'Webhook received from checkout.com. Payment declined - Payment ID: %s, Action ID: %s, Reason: %s', $payment_id, $action_id, $response_summary ); + $message = sprintf( 'Webhook received from checkout.com. Payment declined - Payment ID: %s, Action ID: %s, Reason: %s, Amount: %s', $payment_id, $action_id, $response_summary, $formatted_amount ); } else { - $message = sprintf( 'Webhook received from checkout.com. Payment declined - Payment ID: %s, Reason: %s', $payment_id, $response_summary ); + $message = sprintf( 'Webhook received from checkout.com. Payment declined - Payment ID: %s, Reason: %s, Amount: %s', $payment_id, $response_summary, $formatted_amount ); } // Add notes for the order and update status. diff --git a/checkout-com-unified-payments-api/readme.txt b/checkout-com-unified-payments-api/readme.txt index c5df73cd..5a892f50 100644 --- a/checkout-com-unified-payments-api/readme.txt +++ b/checkout-com-unified-payments-api/readme.txt @@ -2,7 +2,7 @@ Contributors: checkoutintegration Tags: checkout, payments, credit card, payment gateway, apple pay, google pay, payment request Requires at least: 5.0 -Stable tag: 5.0.0_beta +Stable tag: 5.0.0 Requires PHP: 7.3 Tested up to: 6.7.0 License: GPLv2 or later