Changeset 3772638
- Timestamp:
- 10/29/2026 10:26:33 AM (3 days ago)
- Location:
- monek-checkout/trunk
- Files:
-
- 14 edited
-
Blocks/Monek_Blocks.php (modified) (4 diffs)
-
Monek-Checkout.php (modified) (2 diffs)
-
MonekCheckoutGateway.php (modified) (8 diffs)
-
assets/js/monek-embedded-checkout.js (modified) (3 diffs)
-
changelog.txt (modified) (1 diff)
-
readme.txt (modified) (2 diffs)
-
src/Application/Checkout/CurrencyFormatter.php (modified) (1 diff)
-
src/Application/Checkout/ExpressCheckoutHandler.php (modified) (1 diff)
-
src/Application/Checkout/ExpressPaymentVerifier.php (modified) (1 diff)
-
src/Application/Checkout/PaymentPayloadBuilder.php (modified) (3 diffs)
-
src/Application/Checkout/PaymentProcessor.php (modified) (2 diffs)
-
src/Application/Checkout/StandardCheckoutHandler.php (modified) (5 diffs)
-
src/Application/Checkout/StoreContext.php (modified) (2 diffs)
-
src/Infrastructure/WordPress/Webhook/WebhookRouteRegistrar.php (modified) (8 diffs)
Legend:
- Unmodified
- Added
- Removed
-
monek-checkout/trunk/Blocks/Monek_Blocks.php
r3718819 r3772638 4 4 5 5 use Automattic\WooCommerce\Blocks\Payments\Integrations\AbstractPaymentMethodType; 6 use Monek\Checkout\Application\Checkout\CurrencyFormatter; 7 use Monek\Checkout\Application\Checkout\PaymentPayloadBuilder; 8 use Monek\Checkout\Application\Checkout\StoreContext; 6 9 7 10 if (! defined('ABSPATH')) { … … 50 53 $sdkHandle = 'monek-checkout-sdk'; 51 54 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); 53 56 } 54 57 … … 123 126 $restNonce = function_exists('wp_create_nonce') ? wp_create_nonce('wp_rest') : ''; 124 127 125 return [ 128 $basketDescription = $this->get_basket_description(); 129 130 $data = [ 126 131 'title' => $gateway ? $gateway->get_title() : __('Monek Checkout', 'monek-checkout'), 127 132 // 'description' => $gateway ? $gateway->get_description() : __('Secure payment powered by Monek.', 'monek-checkout'), … … 155 160 'customTheme' => $stylingConfiguration['customTheme'] ?? null, 156 161 ]; 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); 157 183 } 158 184 -
monek-checkout/trunk/Monek-Checkout.php
r3721045 r3772638 5 5 * Author: Monek Ltd 6 6 * Author URI: https://www.monek.com 7 * Version: 4. 3.47 * Version: 4.4.0 8 8 * Text Domain: monek-checkout 9 9 * Requires Plugins: woocommerce … … 44 44 if (! defined('MONEK_PLUGIN_URL')) { 45 45 define('MONEK_PLUGIN_URL', plugin_dir_url(__FILE__)); 46 } 47 48 if (! defined('MONEK_CHECKOUT_SDK_URL')) { 49 define('MONEK_CHECKOUT_SDK_URL', 'https://checkout-js.monek.com/monek-checkout.iife.js'); 46 50 } 47 51 -
monek-checkout/trunk/MonekCheckoutGateway.php
r3721045 r3772638 38 38 private CurrencyFormatter $currencyFormatter; 39 39 private StoreContext $storeContext; 40 private PaymentPayloadBuilder $paymentPayloadBuilder; 40 41 private string $publishable_key; 41 42 private string $secret_key; … … 67 68 $this->storeContext = new StoreContext(); 68 69 $paymentPayloadBuilder = new PaymentPayloadBuilder($this->currencyFormatter, $this->storeContext); 70 $this->paymentPayloadBuilder = $paymentPayloadBuilder; 69 71 $paymentProcessor = new PaymentProcessor( 70 72 (string) $this->publishable_key, … … 81 83 $this->currencyFormatter 82 84 ); 83 $this->standardCheckoutHandler = new StandardCheckoutHandler($paymentProcessor, $this->logger );85 $this->standardCheckoutHandler = new StandardCheckoutHandler($paymentProcessor, $this->logger, $this->storeContext); 84 86 85 87 add_action('woocommerce_update_options_payment_gateways_' . $this->id, [$this, 'process_admin_options']); … … 139 141 'svix_signing_secret' => [ 140 142 'title' => __('Webhook URL key', 'monek-checkout'), 141 'type' => ' text',143 'type' => 'password', 142 144 'description' => __('Paste the signing secret for your svix endpoint.', 'monek-checkout'), 143 145 'default' => '', … … 440 442 wp_register_script( 441 443 $sdkHandle, 442 'https://checkout-js.monek.com/monek-checkout.iife.js',444 MONEK_CHECKOUT_SDK_URL, 443 445 [], 444 446 monek_get_plugin_version(), … … 479 481 } 480 482 483 $basketDescription = $this->buildBasketDescription($payPageOrder); 484 481 485 $settings = [ 482 486 'gatewayId' => $this->id, 487 'pluginVersion' => monek_get_plugin_version(), 483 488 'publishableKey' => $this->publishable_key, 484 489 'showExpress' => ('yes' === $this->show_express), … … 504 509 } 505 510 511 if ($basketDescription) { 512 $settings['basketDescription'] = $basketDescription; 513 } 514 506 515 $stylingConfiguration = $this->getStylingConfiguration(); 507 516 $settings['themeMode'] = $stylingConfiguration['themeMode']; … … 577 586 'state' => $order->get_billing_state(), 578 587 ]; 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; 579 605 } 580 606 -
monek-checkout/trunk/assets/js/monek-embedded-checkout.js
r3627118 r3772638 248 248 const version = configuration.pluginVersion || ''; 249 249 const versionPrefix = version ? `${version.replace(/\./g, '')}-` : ''; 250 250 251 251 try { 252 252 if (windowObject.crypto?.randomUUID) { 253 return `MNK-wc ${versionPrefix}${windowObject.crypto.randomUUID()}`;253 return `MNK-wc-${versionPrefix}${windowObject.crypto.randomUUID()}`; 254 254 } 255 255 } catch (error) { … … 259 259 } 260 260 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)}`; 262 262 } 263 263 … … 492 492 const callbacks = { 493 493 getAmount: () => ({ minor: getOrderTotalMinor(), currency: configuration.currencyNumeric || '826' }), 494 getDescription: () => configuration. orderDescription || 'Order',494 getDescription: () => configuration.basketDescription || configuration.orderDescription || 'Order', 495 495 getCardholderDetails: buildCardholderDetails, 496 496 onPaymentAuthorised: verifyExpressPayment, -
monek-checkout/trunk/changelog.txt
r3721045 r3772638 1 1 *** Monek Checkout Changelog *** 2 3 2026-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. 2 9 3 10 2026-09-30 version 4.3.4 -
monek-checkout/trunk/readme.txt
r3722609 r3772638 5 5 Tested up to: 7.1 6 6 Requires PHP: 8.3 7 Stable tag: 4. 3.47 Stable tag: 4.4.0 8 8 License: GPLv3 9 9 License URI: https://www.gnu.org/licenses/gpl-3.0.html … … 72 72 == Changelog == 73 73 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 74 81 = 4.3.4 = 75 82 * Restore PHP 8.2 compatibility. -
monek-checkout/trunk/src/Application/Checkout/CurrencyFormatter.php
r3721045 r3772638 13 13 14 14 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; 15 36 } 16 37 -
monek-checkout/trunk/src/Application/Checkout/ExpressCheckoutHandler.php
r3718819 r3772638 121 121 $order->add_order_note('Express payment verified.'); 122 122 } 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 } 123 129 } 124 130 -
monek-checkout/trunk/src/Application/Checkout/ExpressPaymentVerifier.php
r3721045 r3772638 116 116 } 117 117 118 public function readTokenTransactionId(array $claims): string 119 { 120 return $this->readClaim($claims, ['tokenTransactionId']); 121 } 122 118 123 public function readMinorAmount(array $claims): ?int 119 124 { -
monek-checkout/trunk/src/Application/Checkout/PaymentPayloadBuilder.php
r3564408 r3772638 62 62 }), 63 63 'storeCardDetails' => false, 64 'idempotencyToken' => $this->storeContext-> generateIdempotencyToken((int) $order->get_id()),64 'idempotencyToken' => $this->storeContext->resolveIdempotencyToken($order), 65 65 'source' => 'EmbeddedCheckout', 66 66 'url' => home_url(), 67 'basketDescription' => $this-> createBasketDetails($order),67 'basketDescription' => $this->buildBasketFromOrder($order), 68 68 'paymentReference' => $paymentReference, 69 69 'originId' => $this->storeContext->buildOriginId(), … … 89 89 } 90 90 91 p rivate function createBasketDetails(WC_Order $order): string91 public function buildBasketFromOrder(WC_Order $order): string 92 92 { 93 93 $items = []; … … 104 104 $line_tax = (float) $item->get_total_tax(); 105 105 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 } 108 114 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; 113 129 } 114 130 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 ); 126 142 } 127 143 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 { 128 173 $basket = [ 129 174 'taxType' => 0, -
monek-checkout/trunk/src/Application/Checkout/PaymentProcessor.php
r3557730 r3772638 94 94 $authCode = $decodedBody['AuthCode'] ?? $decodedBody['authCode'] ?? null; 95 95 $errorCode = $decodedBody['ErrorCode'] ?? $decodedBody['errorCode'] ?? null; 96 $transactionId = $decodedBody['TransactionId'] ?? $decodedBody['transactionId'] ?? null; 96 97 97 98 $isSuccessful = in_array((string) $result, ['Success'], true); … … 102 103 'auth_code' => $authCode ? (string) $authCode : null, 103 104 'error_code' => $errorCode ? (string) $errorCode : null, 105 'transactionId' => is_scalar($transactionId) ? (string) $transactionId : '', 104 106 'raw' => is_array($decodedBody) ? $decodedBody : null, 105 107 ]; -
monek-checkout/trunk/src/Application/Checkout/StandardCheckoutHandler.php
r3718819 r3772638 12 12 private PaymentProcessor $paymentProcessor; 13 13 private Logger $logger; 14 private StoreContext $storeContext; 14 15 15 public function __construct(PaymentProcessor $paymentProcessor, Logger $logger )16 public function __construct(PaymentProcessor $paymentProcessor, Logger $logger, StoreContext $storeContext) 16 17 { 17 18 $this->paymentProcessor = $paymentProcessor; 18 19 $this->logger = $logger; 20 $this->storeContext = $storeContext; 19 21 } 20 22 … … 64 66 $this->assertRequiredDataPresent($request); 65 67 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 66 71 $response = $this->paymentProcessor->process( 67 72 $order, … … 69 74 $request->getSessionIdentifier(), 70 75 $request->getExpiry(), 71 $ request->getPaymentReference()76 $wooCommerceOrderReference 72 77 ); 73 78 … … 76 81 } 77 82 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 78 88 if (! $response['success']) { 89 $this->storeContext->clearIdempotencyToken($order); 79 90 $message = $response['message'] ?: __('Payment failed. Please try again.', 'monek-checkout'); 80 91 throw new \Exception(esc_html($message)); … … 84 95 $order->update_meta_data('_monek_session', $request->getSessionIdentifier()); 85 96 $order->update_meta_data('_monek_result', $response['raw'] ? wp_json_encode($response['raw']) : ''); 86 $order->update_meta_data('_monek_payment_reference', $request->getPaymentReference());87 97 $order->payment_complete(); 88 98 $order->save(); -
monek-checkout/trunk/src/Application/Checkout/StoreContext.php
r3721045 r3772638 7 7 private const DEFAULT_COUNTRY_CODE = '826'; 8 8 private const PARTIAL_ORIGIN_ID = 'a6c921f4-8e00-4b11-99f4-'; 9 private const IDEMPOTENCY_META_KEY = '_monek_idempotency_token'; 9 10 10 11 public function getNumericCountryCode(): string … … 29 30 } 30 31 31 public function generateIdempotencyToken(int $orderId): string32 public function resolveIdempotencyToken(\WC_Order $order): string 32 33 { 34 $existing = (string) $order->get_meta(self::IDEMPOTENCY_META_KEY); 35 if ($existing !== '') { 36 return $existing; 37 } 38 33 39 $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(); 35 50 } 36 51 -
monek-checkout/trunk/src/Infrastructure/WordPress/Webhook/WebhookRouteRegistrar.php
r3721045 r3772638 3 3 namespace Monek\Checkout\Infrastructure\WordPress\Webhook; 4 4 5 use Monek\Checkout\Application\Checkout\CurrencyFormatter; 5 6 use WP_REST_Request; 6 7 use WP_REST_Response; … … 17 18 // Delay in ms between order lookup retries 18 19 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'; 19 25 20 26 public function register(): void … … 51 57 } 52 58 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; 67 68 } 68 69 … … 76 77 } 77 78 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 } 78 90 $order->update_meta_data('_monek_last_webhook', wp_json_encode($body)); 79 91 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 { 85 174 $order->update_status( 86 175 $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 ) 88 182 ); 89 183 90 184 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'; 95 195 } 96 196 97 197 $order->save(); 98 198 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(), 103 205 ]); 104 206 207 // Always 200 so svix does not retry non-confirming events 105 208 return new WP_REST_Response([ 106 209 '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 110 216 ], 200); 111 217 } 112 218 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 } 146 326 } 147 327 … … 155 335 * the async webhook delivery). 156 336 */ 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); 161 342 if ($orderId) { 162 343 if ($attempt > 1) { 163 344 $this->log('webhook: order found after retry', [ 164 'reference' => $ paymentReference,345 'reference' => $ref, 165 346 'attempt' => $attempt, 166 347 ]); … … 170 351 } 171 352 172 if ($attempt < self::ORDER_LOOKUP_MAX_ATTEMPTS) {353 if ($attempt < $maxAttempts) { 173 354 usleep(self::ORDER_LOOKUP_RETRY_DELAY_MICROSECONDS); 174 355 } … … 178 359 } 179 360 180 private function locateOrderBy PaymentReference(string $paymentReference): ?int361 private function locateOrderByReference(string $ref, string $metaKey): ?int 181 362 { 182 363 if (! function_exists('wc_get_orders')) { … … 187 368 'limit' => 1, 188 369 'return' => 'ids', 189 'meta_key' => '_monek_payment_reference',190 'meta_value' => $ paymentReference,370 'meta_key' => $metaKey, 371 'meta_value' => $ref, 191 372 'orderby' => 'date', 192 373 'order' => 'DESC',
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)