Plugin Directory

Changeset 3772638


Ignore:
Timestamp:
10/29/2026 10:26:33 AM (3 days ago)
Author:
mariusmonek
Message:
  • Fixed: Include basket product descriptions in payment requests for express checkout payments.
  • Security: generate an idempotency token per order and reuse it for any subsequent requests if the payment was successful.
  • Fixed: Ensure the webhook handler ignores payment events that are not associated with WooCommerce.
  • The webhook handler updates the WooCommerce order status based on the event type.
  • Include the WooCommerce order ID in the payment reference.
Location:
monek-checkout/trunk
Files:
14 edited

Legend:

Unmodified
Added
Removed
  • monek-checkout/trunk/Blocks/Monek_Blocks.php

    r3718819 r3772638  
    44
    55use Automattic\WooCommerce\Blocks\Payments\Integrations\AbstractPaymentMethodType;
     6use Monek\Checkout\Application\Checkout\CurrencyFormatter;
     7use Monek\Checkout\Application\Checkout\PaymentPayloadBuilder;
     8use Monek\Checkout\Application\Checkout\StoreContext;
    69
    710if (! defined('ABSPATH')) {
    … …  
    5053        $sdkHandle = 'monek-checkout-sdk';
    5154        if (! wp_script_is($sdkHandle, 'registered')) {
    52             wp_register_script($sdkHandle, 'https://checkout-js.monek.com/monek-checkout.iife.js', [], null, true);
     55            wp_register_script($sdkHandle, MONEK_CHECKOUT_SDK_URL, [], null, true);
    5356        }
    5457
    … …  
    123126        $restNonce = function_exists('wp_create_nonce') ? wp_create_nonce('wp_rest') : '';
    124127
    125         return [
     128        $basketDescription = $this->get_basket_description();
     129
     130        $data = [
    126131            'title' => $gateway ? $gateway->get_title() : __('Monek Checkout', 'monek-checkout'),
    127132            // 'description' => $gateway ? $gateway->get_description() : __('Secure payment powered by Monek.', 'monek-checkout'),
    … …  
    155160            'customTheme' => $stylingConfiguration['customTheme'] ?? null,
    156161        ];
     162
     163        if ($basketDescription) {
     164            $data['basketDescription'] = $basketDescription;
     165        }
     166
     167        return $data;
     168    }
     169
     170    /**
     171     * Builds a base64 encoded basket details from the WooCommerce cart.
     172     * The express payment has no associated WooCommerce order to get basket details from.
     173     */
     174    private function get_basket_description(): ?string
     175    {
     176        if (! function_exists('WC') || ! WC()->cart || WC()->cart->is_empty()) {
     177            return null;
     178        }
     179
     180        $paymentPayloadBuilder = new PaymentPayloadBuilder(new CurrencyFormatter(), new StoreContext());
     181
     182        return $paymentPayloadBuilder->buildBasketFromCart(WC()->cart);
    157183    }
    158184
  • monek-checkout/trunk/Monek-Checkout.php

    r3721045 r3772638  
    55 * Author: Monek Ltd
    66 * Author URI: https://www.monek.com
    7  * Version: 4.3.4
     7 * Version: 4.4.0
    88 * Text Domain: monek-checkout
    99 * Requires Plugins: woocommerce
    … …  
    4444if (! defined('MONEK_PLUGIN_URL')) {
    4545    define('MONEK_PLUGIN_URL', plugin_dir_url(__FILE__));
     46}
     47
     48if (! defined('MONEK_CHECKOUT_SDK_URL')) {
     49    define('MONEK_CHECKOUT_SDK_URL', 'https://checkout-js.monek.com/monek-checkout.iife.js');
    4650}
    4751
  • monek-checkout/trunk/MonekCheckoutGateway.php

    r3721045 r3772638  
    3838    private CurrencyFormatter $currencyFormatter;
    3939    private StoreContext $storeContext;
     40    private PaymentPayloadBuilder $paymentPayloadBuilder;
    4041    private string $publishable_key;
    4142    private string $secret_key;
    … …  
    6768        $this->storeContext = new StoreContext();
    6869        $paymentPayloadBuilder = new PaymentPayloadBuilder($this->currencyFormatter, $this->storeContext);
     70        $this->paymentPayloadBuilder = $paymentPayloadBuilder;
    6971        $paymentProcessor = new PaymentProcessor(
    7072            (string) $this->publishable_key,
    … …  
    8183            $this->currencyFormatter
    8284        );
    83         $this->standardCheckoutHandler = new StandardCheckoutHandler($paymentProcessor, $this->logger);
     85        $this->standardCheckoutHandler = new StandardCheckoutHandler($paymentProcessor, $this->logger, $this->storeContext);
    8486
    8587        add_action('woocommerce_update_options_payment_gateways_' . $this->id, [$this, 'process_admin_options']);
    … …  
    139141            'svix_signing_secret' => [
    140142                'title' => __('Webhook URL key', 'monek-checkout'),
    141                 'type' => 'text',
     143                'type' => 'password',
    142144                'description' => __('Paste the signing secret for your svix endpoint.', 'monek-checkout'),
    143145                'default' => '',
    … …  
    440442        wp_register_script(
    441443            $sdkHandle,
    442             'https://checkout-js.monek.com/monek-checkout.iife.js',
     444            MONEK_CHECKOUT_SDK_URL,
    443445            [],
    444446            monek_get_plugin_version(),
    … …  
    479481        }
    480482
     483        $basketDescription = $this->buildBasketDescription($payPageOrder);
     484
    481485        $settings = [
    482486            'gatewayId' => $this->id,
     487            'pluginVersion' => monek_get_plugin_version(),
    483488            'publishableKey' => $this->publishable_key,
    484489            'showExpress' => ('yes' === $this->show_express),
    … …  
    504509        }
    505510
     511        if ($basketDescription) {
     512            $settings['basketDescription'] = $basketDescription;
     513        }
     514
    506515        $stylingConfiguration = $this->getStylingConfiguration();
    507516        $settings['themeMode'] = $stylingConfiguration['themeMode'];
    … …  
    577586            'state' => $order->get_billing_state(),
    578587        ];
     588    }
     589
     590    /**
     591     * Builds a base64 encoded basket description for the SDK.
     592     * Get the basket description based on the current order or cart (for ApplePay).
     593     */
     594    private function buildBasketDescription(?WC_Order $payPageOrder): ?string
     595    {
     596        if ($payPageOrder) {
     597            return $this->paymentPayloadBuilder->buildBasketFromOrder($payPageOrder);
     598        }
     599
     600        if (function_exists('WC') && WC()->cart && ! WC()->cart->is_empty()) {
     601            return $this->paymentPayloadBuilder->buildBasketFromCart(WC()->cart);
     602        }
     603
     604        return null;
    579605    }
    580606
  • monek-checkout/trunk/assets/js/monek-embedded-checkout.js

    r3627118 r3772638  
    248248    const version = configuration.pluginVersion || '';
    249249    const versionPrefix = version ? `${version.replace(/\./g, '')}-` : '';
    250    
     250
    251251    try {
    252252      if (windowObject.crypto?.randomUUID) {
    253         return `MNK-wc${versionPrefix}${windowObject.crypto.randomUUID()}`;
     253        return `MNK-wc-${versionPrefix}${windowObject.crypto.randomUUID()}`;
    254254      }
    255255    } catch (error) {
    … …  
    259259    }
    260260
    261     return `MNK-wc${versionPrefix}${Date.now()}-${Math.random().toString(36).slice(2)}`;
     261    return `MNK-wc-${versionPrefix}${Date.now()}-${Math.random().toString(36).slice(2)}`;
    262262  }
    263263
    … …  
    492492    const callbacks = {
    493493      getAmount: () => ({ minor: getOrderTotalMinor(), currency: configuration.currencyNumeric || '826' }),
    494       getDescription: () => configuration.orderDescription || 'Order',
     494      getDescription: () => configuration.basketDescription || configuration.orderDescription || 'Order',
    495495      getCardholderDetails: buildCardholderDetails,
    496496      onPaymentAuthorised: verifyExpressPayment,
  • monek-checkout/trunk/changelog.txt

    r3721045 r3772638  
    11*** Monek Checkout Changelog ***
     2
     32026-10-29 version 4.4.0
     4* Fixed - Include basket product descriptions in payment requests for express checkout payments.
     5* Security - Generate an idempotency token per order and reuse it for any subsequent requests if the payment was successful.
     6* Fixed - Ensure the webhook handler ignores payment events that are not associated with WooCommerce.
     7* Fixed - The webhook handler updates the WooCommerce order status based on the event type.
     8* Added - Include the WooCommerce order ID in the payment reference.
    29
    3102026-09-30 version 4.3.4
  • monek-checkout/trunk/readme.txt

    r3722609 r3772638  
    55Tested up to: 7.1
    66Requires PHP: 8.3
    7 Stable tag: 4.3.4
     7Stable tag: 4.4.0
    88License: GPLv3
    99License URI: https://www.gnu.org/licenses/gpl-3.0.html
    … …  
    7272== Changelog ==
    7373
     74= 4.4.0 =
     75* Fixed: Include basket product descriptions in payment requests for express checkout payments.
     76* Security: generate an idempotency token per order and reuse it for any subsequent requests if the payment was successful.
     77* Fixed: Ensure the webhook handler ignores payment events that are not associated with WooCommerce.
     78* The webhook handler updates the WooCommerce order status based on the event type.
     79* Include the WooCommerce order ID in the payment reference.
     80
    7481= 4.3.4 =
    7582* Restore PHP 8.2 compatibility.
  • monek-checkout/trunk/src/Application/Checkout/CurrencyFormatter.php

    r3721045 r3772638  
    1313
    1414        return (int) round($amountFloat * pow(10, $decimals));
     15    }
     16
     17    public function toMajorUnits(string $amount, string $currencyCode): string
     18    {
     19        $decimals = $this->getDecimals($currencyCode);
     20
     21        if ($decimals === 0) {
     22            return $amount;
     23        }
     24
     25        $amount = trim($amount);
     26        $negative = str_starts_with($amount, '-');
     27        $digits = ltrim($negative ? substr($amount, 1) : $amount, '0');
     28        $digits = $digits === '' ? '0' : $digits;
     29
     30        $digits = str_pad($digits, $decimals + 1, '0', STR_PAD_LEFT);
     31
     32        $major = substr($digits, 0, -$decimals);
     33        $fraction = substr($digits, -$decimals);
     34
     35        return ($negative ? '-' : '') . $major . '.' . $fraction;
    1536    }
    1637
  • monek-checkout/trunk/src/Application/Checkout/ExpressCheckoutHandler.php

    r3718819 r3772638  
    121121            $order->add_order_note('Express payment verified.');
    122122        }
     123
     124        $tokenTransactionId = $this->verifier->readTokenTransactionId($claims);
     125        if ($tokenTransactionId !== '') {
     126            $order->update_meta_data('_express_token_transaction_id', $tokenTransactionId);
     127            $order->add_order_note(sprintf('Express token transaction verified (txn: %s).', $tokenTransactionId));
     128        }
    123129    }
    124130
  • monek-checkout/trunk/src/Application/Checkout/ExpressPaymentVerifier.php

    r3721045 r3772638  
    116116    }
    117117
     118    public function readTokenTransactionId(array $claims): string
     119    {
     120        return $this->readClaim($claims, ['tokenTransactionId']);
     121    }
     122
    118123    public function readMinorAmount(array $claims): ?int
    119124    {
  • monek-checkout/trunk/src/Application/Checkout/PaymentPayloadBuilder.php

    r3564408 r3772638  
    6262            }),
    6363            'storeCardDetails' => false,
    64             'idempotencyToken' => $this->storeContext->generateIdempotencyToken((int) $order->get_id()),
     64            'idempotencyToken' => $this->storeContext->resolveIdempotencyToken($order),
    6565            'source' => 'EmbeddedCheckout',
    6666            'url' => home_url(),
    67             'basketDescription' => $this->createBasketDetails($order),
     67            'basketDescription' => $this->buildBasketFromOrder($order),
    6868            'paymentReference' => $paymentReference,
    6969            'originId' => $this->storeContext->buildOriginId(),
    … …  
    8989    }
    9090
    91     private function createBasketDetails(WC_Order $order): string
     91    public function buildBasketFromOrder(WC_Order $order): string
    9292    {
    9393        $items = [];
    … …  
    104104            $line_tax     = (float) $item->get_total_tax();
    105105
    106             // Avoid division by zero
    107             $unit_price = $quantity > 0 ? $line_total / $quantity : 0;
     106            $items[] = $this->buildBasketItem(
     107                $product->get_sku() ?: (string) $product->get_id(),
     108                $item->get_name(),
     109                (int) $quantity,
     110                $line_total,
     111                $line_tax
     112            );
     113        }
    108114
    109             // Tax rate calculation (approx)
    110             $tax_rate = 0;
    111             if ( $line_total > 0 && $line_tax > 0 ) {
    112                 $tax_rate = round( ( $line_tax / $line_total ) * 100, 2 );
     115        return $this->assembleBasket($items);
     116    }
     117
     118    /**
     119     * Builds the basket structure from the WooCommerce cart. This is used for express payment flow that is initiated before a WooCommerce order exists.
     120     */
     121    public function buildBasketFromCart(\WC_Cart $cart): string
     122    {
     123        $items = [];
     124
     125        foreach ( $cart->get_cart() as $cartItem ) {
     126            $product = $cartItem['data'] ?? null;
     127            if ( ! $product ) {
     128                continue;
    113129            }
    114130
    115             $items[] = [
    116                 'sku'            => $product->get_sku() ?: (string) $product->get_id(),
    117                 'commodityCode'  => '',
    118                 'description'    => $item->get_name(),
    119                 'quantity'       => (int) $quantity,
    120                 'unitPrice'      => round( $unit_price, 2 ),
    121                 'unitOfMeasure'  => '',
    122                 'total'          => round( $line_total, 2 ),
    123                 'taxRate'        => $tax_rate,
    124                 'taxAmount'      => round( $line_tax, 2 ),
    125             ];
     131            $quantity   = (int) ($cartItem['quantity'] ?? 0);
     132            $line_total = (float) ($cartItem['line_total'] ?? 0);
     133            $line_tax   = (float) ($cartItem['line_tax'] ?? 0);
     134
     135            $items[] = $this->buildBasketItem(
     136                $product->get_sku() ?: (string) $product->get_id(),
     137                $product->get_name(),
     138                $quantity,
     139                $line_total,
     140                $line_tax
     141            );
    126142        }
    127143
     144        return $this->assembleBasket($items);
     145    }
     146
     147    private function buildBasketItem(string $sku, string $description, int $quantity, float $lineTotal, float $lineTax): array
     148    {
     149        // Avoid division by zero
     150        $unitPrice = $quantity > 0 ? $lineTotal / $quantity : 0;
     151
     152        // Tax rate calculation (approx)
     153        $taxRate = 0;
     154        if ( $lineTotal > 0 && $lineTax > 0 ) {
     155            $taxRate = round( ( $lineTax / $lineTotal ) * 100, 2 );
     156        }
     157
     158        return [
     159            'sku'            => $sku,
     160            'commodityCode'  => '',
     161            'description'    => $description,
     162            'quantity'       => $quantity,
     163            'unitPrice'      => round( $unitPrice, 2 ),
     164            'unitOfMeasure'  => '',
     165            'total'          => round( $lineTotal, 2 ),
     166            'taxRate'        => $taxRate,
     167            'taxAmount'      => round( $lineTax, 2 ),
     168        ];
     169    }
     170
     171    private function assembleBasket(array $items): string
     172    {
    128173        $basket = [
    129174            'taxType'   => 0,
  • monek-checkout/trunk/src/Application/Checkout/PaymentProcessor.php

    r3557730 r3772638  
    9494        $authCode = $decodedBody['AuthCode'] ?? $decodedBody['authCode'] ?? null;
    9595        $errorCode = $decodedBody['ErrorCode'] ?? $decodedBody['errorCode'] ?? null;
     96        $transactionId = $decodedBody['TransactionId'] ?? $decodedBody['transactionId'] ?? null;
    9697
    9798        $isSuccessful = in_array((string) $result, ['Success'], true);
    … …  
    102103            'auth_code' => $authCode ? (string) $authCode : null,
    103104            'error_code' => $errorCode ? (string) $errorCode : null,
     105            'transactionId' => is_scalar($transactionId) ? (string) $transactionId : '',
    104106            'raw' => is_array($decodedBody) ? $decodedBody : null,
    105107        ];
  • monek-checkout/trunk/src/Application/Checkout/StandardCheckoutHandler.php

    r3718819 r3772638  
    1212    private PaymentProcessor $paymentProcessor;
    1313    private Logger $logger;
     14    private StoreContext $storeContext;
    1415
    15     public function __construct(PaymentProcessor $paymentProcessor, Logger $logger)
     16    public function __construct(PaymentProcessor $paymentProcessor, Logger $logger, StoreContext $storeContext)
    1617    {
    1718        $this->paymentProcessor = $paymentProcessor;
    1819        $this->logger = $logger;
     20        $this->storeContext = $storeContext;
    1921    }
    2022
    … …  
    6466        $this->assertRequiredDataPresent($request);
    6567
     68        // Generate a WooCommerce style payment ref using the order ID, or fall back to the payment reference provided.
     69        $wooCommerceOrderReference = $order->get_id() ? 'WOO-ORDER-' . $order->get_id() : $request->getPaymentReference();
     70
    6671        $response = $this->paymentProcessor->process(
    6772            $order,
    … …  
    6974            $request->getSessionIdentifier(),
    7075            $request->getExpiry(),
    71             $request->getPaymentReference()
     76            $wooCommerceOrderReference
    7277        );
    7378
    … …  
    7681        }
    7782
     83        $order->update_meta_data('_monek_payment_reference', $wooCommerceOrderReference);
     84        $order->update_meta_data('_monek_transaction_id', $response['transactionId'] ?? '');
     85
     86        $order->add_order_note(sprintf('Payment Reference: %s', $wooCommerceOrderReference));
     87
    7888        if (! $response['success']) {
     89            $this->storeContext->clearIdempotencyToken($order);
    7990            $message = $response['message'] ?: __('Payment failed. Please try again.', 'monek-checkout');
    8091            throw new \Exception(esc_html($message));
    … …  
    8495        $order->update_meta_data('_monek_session', $request->getSessionIdentifier());
    8596        $order->update_meta_data('_monek_result', $response['raw'] ? wp_json_encode($response['raw']) : '');
    86         $order->update_meta_data('_monek_payment_reference', $request->getPaymentReference());
    8797        $order->payment_complete();
    8898        $order->save();
  • monek-checkout/trunk/src/Application/Checkout/StoreContext.php

    r3721045 r3772638  
    77    private const DEFAULT_COUNTRY_CODE = '826';
    88    private const PARTIAL_ORIGIN_ID = 'a6c921f4-8e00-4b11-99f4-';
     9    private const IDEMPOTENCY_META_KEY = '_monek_idempotency_token';
    910
    1011    public function getNumericCountryCode(): string
    … …  
    2930    }
    3031
    31     public function generateIdempotencyToken(int $orderId): string
     32    public function resolveIdempotencyToken(\WC_Order $order): string
    3233    {
     34        $existing = (string) $order->get_meta(self::IDEMPOTENCY_META_KEY);
     35        if ($existing !== '') {
     36            return $existing;
     37        }
     38
    3339        $version = monek_get_plugin_version();
    34         return 'wc-' . str_replace('.', '', $version) . $orderId . '-' . wp_generate_uuid4();
     40        $token   = 'wc-' . str_replace('.', '', $version) . '-' . $order->get_id() . '-' . wp_generate_uuid4();
     41        $order->update_meta_data(self::IDEMPOTENCY_META_KEY, $token);
     42        $order->save_meta_data();
     43        return $token;
     44    }
     45
     46    public function clearIdempotencyToken(\WC_Order $order): void
     47    {
     48        $order->delete_meta_data(self::IDEMPOTENCY_META_KEY);
     49        $order->save_meta_data();
    3550    }
    3651
  • monek-checkout/trunk/src/Infrastructure/WordPress/Webhook/WebhookRouteRegistrar.php

    r3721045 r3772638  
    33namespace Monek\Checkout\Infrastructure\WordPress\Webhook;
    44
     5use Monek\Checkout\Application\Checkout\CurrencyFormatter;
    56use WP_REST_Request;
    67use WP_REST_Response;
    … …  
    1718    // Delay in ms between order lookup retries
    1819    private const ORDER_LOOKUP_RETRY_DELAY_MICROSECONDS = 250000;
     20
     21    private const PAYMENT_REFERENCE_META_KEY = '_monek_payment_reference';
     22    private const TRANSACTION_ID_META_KEY = '_monek_transaction_id';
     23    private const LAST_SVIX_ID_META_KEY = '_monek_last_svix_id';
     24    private const REFUND_AMOUNTS_META_KEY = '_monek_refund_amounts';
    1925
    2026    public function register(): void
    … …  
    5157        }
    5258
    53         $paymentReference = $this->extractPaymentReference($body);
    54         if ($paymentReference === '') {
    55             $this->log('webhook: missing paymentReference', ['body' => $body], 'warning');
    56             return new WP_REST_Response(['ok' => false, 'error' => 'missing_reference'], 400);
    57         }
    58 
    59         $orderId = $this->locateOrderByPaymentReferenceWithRetry($paymentReference);
    60         if (! $orderId) {
    61             $this->log('webhook: order not found', ['reference' => $paymentReference], 'warning');
    62             return new WP_REST_Response([
    63                 'ok' => true,
    64                 'error' => 'order_not_found',
    65                 'reference' => $paymentReference,
    66             ], 200);
     59        $eventType = $this->extractEventType($body);
     60        if ($eventType === '') {
     61            $this->log('webhook: missing event type - event ignored', ['fields' => array_keys($body)], 'warning');
     62            return new WP_REST_Response(['ok' => true, 'status' => 'ignored', 'reason' => 'missing_event_type'], 200);
     63        }
     64
     65        $orderId = $this->resolveOrderId($body, $eventType);
     66        if ($orderId instanceof WP_REST_Response) {
     67            return $orderId;
    6768        }
    6869
    … …  
    7677        }
    7778
     79        // Svix redelivers on timeouts/non-2xx; skip events we have already processed
     80        $svixId = $this->getHeaderValue($request, 'svix-id');
     81        if ($svixId !== '' && $order->get_meta(self::LAST_SVIX_ID_META_KEY) === $svixId) {
     82            $this->log('webhook: duplicate delivery ignored', ['svix_id' => $svixId, 'order_id' => $orderId]);
     83
     84            return new WP_REST_Response(['ok' => true, 'status' => 'duplicate', 'order_id' => $orderId], 200);
     85        }
     86
     87        if ($svixId !== '') {
     88            $order->update_meta_data(self::LAST_SVIX_ID_META_KEY, $svixId);
     89        }
    7890        $order->update_meta_data('_monek_last_webhook', wp_json_encode($body));
    7991
    80         $targetStatus = 'payment-confirmed';
    81 
    82         $transactionId = $this->extractTransactionId($body);
    83 
    84         if ($order->get_status() !== $targetStatus) {
     92        return $this->handleSvixEventType($order, $body, $eventType);
     93    }
     94
     95    // Reversals and refunds reference the original transaction id (the payment reference can change on refund)
     96    // Everything else references the payment reference
     97    private function resolveOrderId(array $body, string $eventType)
     98    {
     99        // Event types look like "reversal.success" or "refund.success"
     100        $isParentLookup = strpos($eventType, 'reversal') === 0 || strpos($eventType, 'refund') === 0;
     101
     102        if ($isParentLookup) {
     103            $keys = ['ParentReference', 'parentReference', 'parent_reference'];
     104            $metaKey = self::TRANSACTION_ID_META_KEY;
     105            $missing = 'missing_parent_reference';
     106        } else {
     107            $keys = ['PaymentReference', 'paymentReference', 'payment_reference'];
     108            $metaKey = self::PAYMENT_REFERENCE_META_KEY;
     109            $missing = 'missing_payment_reference';
     110        }
     111
     112        $reference = $this->extractPaymentValue($body, $keys);
     113        if ($reference === '') {
     114            $this->log('webhook: ' . $missing . ' - event ignored', ['event_type' => $eventType, 'fields' => array_keys($body)], 'warning');
     115
     116            return new WP_REST_Response(['ok' => true, 'status' => 'ignored', 'reason' => $missing], 200);
     117        }
     118
     119        // Only sales can race the checkout finalisation; a reversal/refund's original transaction already exists
     120        $orderId = $this->locateOrderByMetaKeyWithRetry($reference, $metaKey, $isParentLookup ? 1 : self::ORDER_LOOKUP_MAX_ATTEMPTS);
     121        if (! $orderId) {
     122            $this->log('webhook: order not found', ['reference' => $reference, 'event_type' => $eventType], 'warning');
     123
     124            // 404 so Webhook service retries later in case the order is saved after the webhook event
     125            return new WP_REST_Response([
     126                'ok' => false,
     127                'error' => 'order_not_found',
     128                'reference' => $reference,
     129            ], 404);
     130        }
     131
     132        return $orderId;
     133    }
     134
     135    // Maps a Svix event type "operation.result" to a WooCommerce order status
     136    private function handleSvixEventType($order, array $body, string $eventType): WP_REST_Response
     137    {
     138        $parts = explode('.', $eventType, 2);
     139        $operation = $parts[0];
     140        $result = $parts[1] ?? '';
     141
     142        $targetStatus = null;
     143        // Statuses a failed/referred sale must never overwrite
     144        $protectedStatuses = ['payment-confirmed', 'processing', 'completed', 'refunded', 'cancelled'];
     145
     146        $message = $this->extractMessage($body);
     147        $transactionId = $this->extractPaymentValue($body, ['CrossReference', 'crossReference', 'TxId', 'txId']);
     148
     149        if ($operation === 'sale' && $result === 'success') {
     150            // Guard against late/out-of-order deliveries reviving a refunded or cancelled order
     151            $targetStatus = in_array($order->get_status(), ['refunded', 'cancelled'], true) ? null : 'payment-confirmed';
     152        } elseif ($operation === 'sale' && ($result === 'decline' || $result === 'error')) {
     153            $targetStatus = in_array($order->get_status(), $protectedStatuses, true) ? null : 'failed';
     154        } elseif ($operation === 'sale' && $result === 'refer') {
     155            $targetStatus = in_array($order->get_status(), $protectedStatuses, true) ? null : 'on-hold';
     156        } elseif ($operation === 'refund' && $result === 'success') {
     157            $targetStatus = $this->isFullRefund($order, $body, $transactionId) ? 'refunded' : null;
     158        } elseif ($operation === 'reversal' && $result === 'success') {
     159            $targetStatus = 'cancelled';
     160        }
     161
     162        if ($targetStatus === null) {
     163            $order->add_order_note(sprintf(
     164                'Monek webhook %s received; order status left unchanged (%s).%s',
     165                $eventType,
     166                $order->get_status(),
     167                $message !== '' ? ' Message: ' . $message : ''
     168            ));
     169            $status = 'unchanged';
     170        } elseif ($order->get_status() === $targetStatus) {
     171            $order->add_order_note(sprintf('Monek webhook %s received (already %s).', $eventType, $targetStatus));
     172            $status = 'unchanged';
     173        } else {
    85174            $order->update_status(
    86175                $targetStatus,
    87                 sprintf('Webhook set status to %s (ref: %s).', $targetStatus, $paymentReference)
     176                sprintf(
     177                    'Monek webhook %s set status to %s.%s',
     178                    $eventType,
     179                    $targetStatus,
     180                    $message !== '' ? ' Message: ' . $message : ''
     181                )
    88182            );
    89183
    90184            if ($transactionId !== '') {
    91                 $order->add_order_note(sprintf('Monek Transaction ID: %s', $transactionId));
    92             }
    93         } else {
    94             $order->add_order_note(sprintf('Webhook ping received (already %s). Ref: %s', $targetStatus, $paymentReference));
     185
     186                $labels = [
     187                    'cancelled' => 'Monek reversal Transaction ID',
     188                    'refunded'  => 'Monek refund Transaction ID',
     189                ];
     190                $label = isset($labels[$targetStatus]) ? $labels[$targetStatus] : 'Monek Transaction ID';
     191
     192                $order->add_order_note(sprintf('%s: %s', $label, $transactionId));
     193            }
     194            $status = 'updated';
    95195        }
    96196
    97197        $order->save();
    98198
    99         $this->log('webhook: status updated', [
    100             'reference' => $paymentReference,
    101             'order_id' => $orderId,
    102             'status' => $order->get_status(),
     199        $this->log('webhook: svix event handled', [
     200            'order_id' => $order->get_id(),
     201            'transaction_id' => $transactionId,
     202            'event_type' => $eventType,
     203            'result' => $status,
     204            'order_status' => $order->get_status(),
    103205        ]);
    104206
     207        // Always 200 so svix does not retry non-confirming events
    105208        return new WP_REST_Response([
    106209            'ok' => true,
    107             'reference' => $paymentReference,
    108             'order_id' => $orderId,
    109             'status' => $order->get_status(),
     210            'status' => $status,
     211            'event_type' => $eventType,
     212            'order_id' => $order->get_id(),
     213            'transaction_id' => $transactionId,
     214            'order_status' => $order->get_status(),
     215            'message' => $message
    110216        ], 200);
    111217    }
    112218
    113     private function extractTransactionId(array $body): string
    114     {
    115         if (isset($body['crossReference'])) {
    116             return (string) $body['crossReference'];
    117         }
    118 
    119         if (isset($body['Data']['CrossReference'])) {
    120             return (string) $body['Data']['CrossReference'];
    121         }
    122 
    123         if (isset($body['TxId'])) {
    124             return (string) $body['TxId'];
    125         }
    126 
    127         if (isset($body['txId'])) {
    128             return (string) $body['txId'];
    129         }
    130 
    131         return '';
    132     }
    133 
    134     private function extractPaymentReference(array $body): string
    135     {
    136         if (isset($body['paymentReference'])) {
    137             return (string) $body['paymentReference'];
    138         }
    139 
    140         if (isset($body['Data']['PaymentReference'])) {
    141             return (string) $body['Data']['PaymentReference'];
    142         }
    143 
    144         if (isset($body['reference'])) {
    145             return (string) $body['reference'];
     219    private function extractEventType(array $body): string
     220    {
     221        foreach (['EventType', 'eventType'] as $key) {
     222            $value = $body[$key] ?? null;
     223            if (is_scalar($value) && trim((string) $value) !== '') {
     224                return strtolower(trim((string) $value));
     225            }
     226        }
     227
     228        // No event type:  fall back to ResponseCode ("00" = approved, anything else = declined)
     229        $responseCode = $this->extractResponseCode($body);
     230        if ($responseCode === '') {
     231            return '';
     232        }
     233
     234        return $this->inferOperation($body) . ($responseCode === '00' ? '.success' : '.decline');
     235    }
     236
     237    // Infers sale/refund/reversal from MessageType
     238    private function inferOperation(array $body): string
     239    {
     240        $messageType = $body['Data']['MessageType'] ?? $body['messageType'] ?? '';
     241        $messageType = is_scalar($messageType) ? strtoupper((string) $messageType) : '';
     242
     243        if (strpos($messageType, 'REFUND') !== false) {
     244            return 'refund';
     245        }
     246
     247        if (strpos($messageType, 'REVERSAL') !== false || strpos($messageType, 'VOID') !== false) {
     248            return 'reversal';
     249        }
     250
     251        return 'sale';
     252    }
     253
     254    private function isFullRefund($order, array $body, string $transactionId): bool
     255    {
     256        $amount = $body['Data']['Amount'] ?? $body['amount'] ?? null;
     257        if (! is_scalar($amount) || ! is_numeric($amount)) {
     258            return false;
     259        }
     260
     261        $orderMinor = (new CurrencyFormatter())->toMinorUnits($order->get_total(), (string) $order->get_currency());
     262
     263        $totalRefunded = $this->saveRefundAmount($order, (int) $amount, $transactionId);
     264
     265        $fullRefund = $totalRefunded >= $orderMinor;
     266
     267        if (! $fullRefund) {
     268            $formatter = new CurrencyFormatter();
     269            $currency = (string) $order->get_currency();
     270            $eventMajor = $formatter->toMajorUnits((string) $amount, $currency);
     271            $totalMajor = $formatter->toMajorUnits((string) $totalRefunded, $currency);
     272
     273            $order->add_order_note(sprintf(
     274                'Partial refund of %s (total refunded: %s); Monek refund Transaction ID: %s',
     275                $eventMajor,
     276                $totalMajor,
     277                $transactionId !== '' ? $transactionId : 'NONE'
     278            ));
     279        }
     280
     281        return $fullRefund;
     282    }
     283
     284    // Stores the refund amount (minor units) in order meta and returns the total refunded
     285    // Keyed by transaction ID so redelivered webhooks are not counted twice
     286    private function saveRefundAmount($order, int $amountMinor, string $transactionId): int
     287    {
     288        $refunds = $order->get_meta(self::REFUND_AMOUNTS_META_KEY, true);
     289        $refunds = is_array($refunds) ? $refunds : [];
     290
     291        if ($transactionId !== '') {
     292            $refunds[$transactionId] = $amountMinor;
     293        } else {
     294            $refunds[] = $amountMinor;
     295        }
     296
     297        $order->update_meta_data(self::REFUND_AMOUNTS_META_KEY, $refunds);
     298
     299        return (int) array_sum(array_map('intval', $refunds));
     300    }
     301
     302    private function extractResponseCode(array $body): string
     303    {
     304        $code = $body['Data']['ResponseCode'] ?? $body['responseCode'] ?? '';
     305
     306        return is_scalar($code) ? trim((string) $code) : '';
     307    }
     308
     309    private function extractMessage(array $body): string
     310    {
     311        $message = $body['Data']['Message'] ?? $body['message'] ?? '';
     312
     313        return is_scalar($message) ? trim((string) $message) : '';
     314    }
     315
     316    private function extractPaymentValue(array $body, array $keys): string
     317    {
     318        $data = isset($body['Data']) && is_array($body['Data']) ? $body['Data'] : [];
     319
     320        foreach ([$body, $data] as $source) {
     321            foreach ($keys as $key) {
     322                if (isset($source[$key]) && is_scalar($source[$key]) && trim((string) $source[$key]) !== '') {
     323                    return trim((string) $source[$key]);
     324                }
     325            }
    146326        }
    147327
    … …  
    155335     * the async webhook delivery).
    156336     */
    157     private function locateOrderByPaymentReferenceWithRetry(string $paymentReference): ?int
    158     {
    159         for ($attempt = 1; $attempt <= self::ORDER_LOOKUP_MAX_ATTEMPTS; $attempt++) {
    160             $orderId = $this->locateOrderByPaymentReference($paymentReference);
     337    private function locateOrderByMetaKeyWithRetry(string $ref, string $metaKey, int $maxAttempts = self::ORDER_LOOKUP_MAX_ATTEMPTS): ?int
     338    {
     339        for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
     340
     341            $orderId = $this->locateOrderByReference($ref, $metaKey);
    161342            if ($orderId) {
    162343                if ($attempt > 1) {
    163344                    $this->log('webhook: order found after retry', [
    164                         'reference' => $paymentReference,
     345                        'reference' => $ref,
    165346                        'attempt' => $attempt,
    166347                    ]);
    … …  
    170351            }
    171352
    172             if ($attempt < self::ORDER_LOOKUP_MAX_ATTEMPTS) {
     353            if ($attempt < $maxAttempts) {
    173354                usleep(self::ORDER_LOOKUP_RETRY_DELAY_MICROSECONDS);
    174355            }
    … …  
    178359    }
    179360
    180     private function locateOrderByPaymentReference(string $paymentReference): ?int
     361    private function locateOrderByReference(string $ref, string $metaKey): ?int
    181362    {
    182363        if (! function_exists('wc_get_orders')) {
    … …  
    187368            'limit' => 1,
    188369            'return' => 'ids',
    189             'meta_key' => '_monek_payment_reference',
    190             'meta_value' => $paymentReference,
     370            'meta_key' => $metaKey,
     371            'meta_value' => $ref,
    191372            'orderby' => 'date',
    192373            'order' => 'DESC',
Note: See TracChangeset for help on using the changeset viewer.