Changeset 3442556
- Timestamp:
- 01/19/2026 01:06:20 PM (2 months ago)
- Location:
- storecontrl-wp-connection/trunk
- Files:
-
- 6 edited
-
includes/admin/class-storecontrl-wp-connection-admin.php (modified) (3 diffs)
-
includes/api/class-storecontrl-web-api.php (modified) (1 diff)
-
includes/class-storecontrl-wp-connection.php (modified) (1 diff)
-
includes/woocommerce/class-storecontrl-woocommerce-functions.php (modified) (4 diffs)
-
readme.txt (modified) (2 diffs)
-
storecontrl-wp-connection.php (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
storecontrl-wp-connection/trunk/includes/admin/class-storecontrl-wp-connection-admin.php
r3350913 r3442556 222 222 // Add-Ons section 223 223 add_settings_field('storecontrl_creditcheques', __('Spaarpunten', 'storecontrl-wp-connection'), array( $this, 'display_storecontrl_creditcheques_element' ), 'storecontrl_addons_options', 'addons_section'); 224 add_settings_field('storecontrl_points_per_purchase', __('Euro(s) = Spaarpunt', 'storecontrl-wp-connection'), array( $this, 'display_storecontrl_points_per_purchase_element' ), 'storecontrl_addons_options', 'addons_section'); 224 225 225 226 register_setting('storecontrl_api_options', 'storecontrl_api_url'); … … 297 298 298 299 register_setting('storecontrl_addons_options', 'storecontrl_creditcheques'); 300 register_setting('storecontrl_addons_options', 'storecontrl_points_per_purchase'); 299 301 } 300 302 … … 1282 1284 <?php 1283 1285 } 1286 public function display_storecontrl_points_per_purchase_element(){ 1287 ?> 1288 <input name="storecontrl_points_per_purchase" value="<?php echo get_option( 'storecontrl_points_per_purchase' ) ?? 1; ?>" type="number"> X euro voor 1 spaarpunt 1289 <div class="info"><span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>Vul hierboven in voor hoeveel euro er aangekocht moet worden om een spaarpunt te verzamelen. Standaard is dit € 1 = 1 spaarpunt. </div> 1290 <?php 1291 } 1292 1284 1293 1285 1294 public function resend_new_order_to_storecontrl() { -
storecontrl-wp-connection/trunk/includes/api/class-storecontrl-web-api.php
r3434321 r3442556 303 303 } 304 304 305 public function storecontrl_new_order( $data ) { 306 307 // Post data to api url 308 $request_url = '/Order/NewOrder'; 309 $args = array( 310 'content_type' => 'application/xml', 311 'has_sessionId' => false, 312 'post_fields' => $data 313 ); 314 $results = $this->curl_request( $request_url, 'POST', $args ); 315 316 return $results; 317 } 305 public function storecontrl_new_order($data) { 306 307 // Forceer XML string 308 if ($data instanceof SimpleXMLElement) { 309 $data = $data->asXML(); 310 } elseif (is_object($data) && method_exists($data, '__toString')) { 311 $data = (string)$data; 312 } 313 314 if (!is_string($data) || trim($data) === '') { 315 // Belangrijk: voorkom dat curl_request met lege payload gaat schieten 316 $logging = new StoreContrl_WP_Connection_Logging(); 317 $logging->log_file_write('NewOrder | Payload empty/invalid before curl_request'); 318 return null; 319 } 320 321 $request_url = '/Order/NewOrder'; 322 $args = array( 323 // Let op: ik zou dit liever in headers zetten, maar ik laat je huidige contract intact 324 'content_type' => 'application/xml; charset=utf-8', 325 'has_sessionId' => false, 326 'post_fields' => $data, 327 ); 328 329 $results = $this->curl_request($request_url, 'POST', $args); 330 331 return $results; 332 } 318 333 319 334 public function storecontrl_cancel_order( $order_id ) { -
storecontrl-wp-connection/trunk/includes/class-storecontrl-wp-connection.php
r3435714 r3442556 66 66 $this->loader->add_action( 'wp_ajax_check_storecontrl_credit_cheque', $woocommerce, 'check_storecontrl_credit_cheque' ); 67 67 $this->loader->add_action( 'wp_ajax_nopriv_check_storecontrl_credit_cheque', $woocommerce, 'check_storecontrl_credit_cheque' ); 68 $this->loader->add_action( 'woocommerce_order_status_changed', $woocommerce, 'create_storecontrl_new_order' ); 68 add_action( 'woocommerce_order_status_changed', array( $woocommerce, 'create_storecontrl_new_order'), 10, 4 ); 69 add_action('woocommerce_payment_complete', array( $woocommerce, 'create_storecontrl_new_order'), 10, 1); 69 70 $this->loader->add_action( 'add_meta_boxes', $woocommerce, 'register_plugin_metaboxes' ); 70 71 -
storecontrl-wp-connection/trunk/includes/woocommerce/class-storecontrl-woocommerce-functions.php
r3435411 r3442556 10 10 add_action('admin_notices', array($this, 'show_bulk_succes_message')); 11 11 12 add_action('init', function () { 13 add_rewrite_endpoint('spaarpunten', EP_ROOT | EP_PAGES); 14 }); 15 16 add_filter('woocommerce_get_query_vars', function ($vars) { 17 $vars['spaarpunten'] = 'spaarpunten'; 18 return $vars; 19 }); 20 21 add_filter('woocommerce_account_menu_items', array($this, 'sc_woocommerce_account_menu_items'), 10, 1); 22 add_action('woocommerce_account_spaarpunten_endpoint', array($this, 'woocommerce_account_spaarpunten_endpoint')); 12 $storecontrl_creditcheques = get_option( 'storecontrl_creditcheques'); 13 if( isset($storecontrl_creditcheques) && $storecontrl_creditcheques == '1' ) { 14 add_action('init', function () { 15 add_rewrite_endpoint('spaarpunten', EP_ROOT | EP_PAGES); 16 }); 17 18 add_filter('woocommerce_get_query_vars', function ($vars) { 19 $vars['spaarpunten'] = 'spaarpunten'; 20 return $vars; 21 }); 22 23 add_filter('woocommerce_account_menu_items', array($this, 'sc_woocommerce_account_menu_items'), 10, 1); 24 add_action('woocommerce_account_spaarpunten_endpoint', array($this, 'woocommerce_account_spaarpunten_endpoint')); 25 } 23 26 } 24 27 25 28 public function sc_woocommerce_account_menu_items($items) { 26 // Plaats bijvoorbeeld vóór "Uitloggen"27 29 $logout = $items['customer-logout'] ?? null; 28 30 if ($logout !== null) unset($items['customer-logout']); … … 40 42 echo '<h2>Spaarpunten</h2>'; 41 43 42 if (empty($email)) { 43 echo '<p>Geen e-mailadres gevonden.</p>'; 44 return; 45 } 46 47 $web_api = new StoreContrl_Web_Api(); 48 $GetCustomerDiscount = $web_api->GetCustomerDiscount($email); 49 50 echo '<pre>'; 51 print_r($GetCustomerDiscount); 52 echo '</pre>'; 53 exit; 54 55 echo '<p>Totaal spaarpunten voor <strong>' . esc_html($email) . '</strong>:</p>'; 56 echo '<p style="font-size:24px; font-weight:700;">' . esc_html('TODO') . '</p>'; 44 if( !empty($email)) { 45 $web_api = new StoreContrl_Web_Api(); 46 $GetCustomerDiscount = $web_api->GetCustomerDiscount($email); 47 48 $pointsPerPurchase = get_option( 'storecontrl_points_per_purchase' ) ?? 1; 49 ?> 50 51 <!-- Rewards / punten widget --> 52 <div class="rewards-card" role="region" aria-label="Gespaarde punten"> 53 <div class="rewards-left" aria-hidden="true"> 54 <div class="trophy">🏆</div> 55 </div> 56 57 <div class="rewards-main"> 58 <div class="rewards-top"> 59 <div> 60 <div class="rewards-title">Jouw punten</div> 61 <div class="rewards-sub">Spaar voor extra kortingen met elke aankoop</div> 62 </div> 63 64 <div class="rewards-points"> 65 <span class="points-number" id="points"><?php echo $GetCustomerDiscount['available_credits']; ?></span> 66 <span class="points-label">punten</span> 67 </div> 68 </div> 69 70 <div class="rewards-foot"> 71 <span class="pill">🛒 Elke besteding van <strong id="pointsPerPurchase">€ <?php echo $pointsPerPurchase; ?></strong> = 1 spaarpunt</span> 72 </div> 73 </div> 74 </div> 75 76 <style> 77 .rewards-card{ 78 display:flex; gap:16px; align-items:stretch; 79 padding:16px; border:1px solid #e8e8ef; border-radius:16px; 80 background: linear-gradient(135deg, #ffffff, #faf7ff); 81 max-width: 520px; 82 box-shadow: 0 8px 24px rgba(0,0,0,0.06); 83 font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; 84 } 85 .rewards-left{ 86 display:flex; align-items:center; justify-content:center; 87 width:64px; border-radius:14px; 88 background: radial-gradient(circle at 30% 30%, #fff4d6, #ffe7b3); 89 border:1px solid #ffe0a0; 90 } 91 .trophy{ font-size:34px; transform: translateY(-1px); } 92 .rewards-main{ flex:1; min-width:0; } 93 .rewards-top{ display:flex; justify-content:space-between; gap:12px; align-items:flex-start; } 94 .rewards-title{ font-size:14px; font-weight:700; letter-spacing:0.2px; } 95 .rewards-sub{ font-size:12px; color:#6a6a7a; margin-top:2px; } 96 .rewards-points{ text-align:right; white-space:nowrap; } 97 .points-number{ font-size:26px; font-weight:800; line-height:1; } 98 .points-label{ font-size:12px; color:#6a6a7a; margin-left:6px; } 99 .progress-wrap{ margin-top:12px; } 100 .rewards-foot{ 101 display:flex; justify-content:space-between; gap:12px; align-items:center; 102 margin-top:12px; flex-wrap:wrap; 103 } 104 .pill{ 105 display:inline-flex; align-items:center; gap:8px; 106 font-size:12px; color:#2f2f3a; 107 padding:6px 10px; border-radius:999px; 108 background:#f2efff; border:1px solid #e6e0ff; 109 } 110 .rewards-link{ 111 font-size:12px; text-decoration:none; font-weight:700; 112 color:#5b21b6; 113 } 114 .rewards-link:hover{ text-decoration:underline; } 115 </style> 116 117 <script> 118 const points = <?php echo $GetCustomerDiscount['available_credits']; ?>; 119 const pointsPerPurchase = 10; 120 121 const pct = Math.min(100, Math.round((points / nextTarget) * 100)); 122 123 document.getElementById('points').textContent = points; 124 document.getElementById('pointsPerPurchase').textContent = pointsPerPurchase; 125 </script> 126 127 <?php 128 } 57 129 } 58 130 … … 251 323 } 252 324 253 public function create_storecontrl_new_order( $order_id, $response = false ) { 254 255 // Check if "New order" option is enabled 256 $storecontrl_wc_new_order = get_option( 'storecontrl_wc_new_order' ); 257 258 $web_api = new StoreContrl_Web_Api(); 259 $logging = new StoreContrl_WP_Connection_Logging(); 260 261 if( isset($storecontrl_wc_new_order) && $storecontrl_wc_new_order == '1' ) { 262 263 global $woocommerce; 264 265 $order = wc_get_order( $order_id ); 266 267 $logging->log_file_write('NewOrder | WebOrder status changed to: '.$order->get_status()); 268 269 $order_data = $order->get_data(); 270 271 if( $order->has_status( 'cancelled' )) { 272 $order->update_meta_data('order_returned_successfully_to_storecontrl', '0'); 273 $web_api->storecontrl_cancel_order($order_id); 274 } 275 276 // Check ones more if order is paid and completed 277 if( $order->has_status( 'processing' ) || $order->has_status( 'completed' )) { 278 279 $data = array(); 280 281 $customer_exist = false; 282 283 // Convert date to XML DateTime 284 $order_date = $order->get_date_created(); 285 $timestamp = strtotime($order_date); 286 $order_date = date('c', $timestamp); 287 $order_date = strtok($order_date, '+'); 288 289 // Basic order data 290 $data['internet_order_id'] = $order_data['id']; 291 292 // Compatibility for plugin "Aangepaste bestelnummers voor WooCommerce" 293 $custom_order_number = $order->get_meta('_alg_wc_full_custom_order_number'); 294 if( isset($custom_order_number) && !empty($custom_order_number) ){ 295 $data['internet_order_id'] = $custom_order_number; 296 } 297 298 $custom_order_number = $order->get_meta('_order_number'); 299 if( isset($custom_order_number) && !empty($custom_order_number) ){ 300 $data['internet_order_id'] = $custom_order_number; 301 } 302 303 // If customer exist 304 if ($customer_exist) { 305 306 $data['customer_email'] = $order_data['billing']['email']; 307 $data['billing_address_id'] = ''; 308 $data['deliver_address_id'] = ''; 309 } 310 else { 311 312 // Customer 313 $data['customer']['name'] = str_replace(array('/', '&', '-'), ' ',$order_data['billing']['first_name']); 314 $data['customer']['surname'] = str_replace(array('/', '&', '-'), ' ',$order_data['billing']['last_name']); 315 $data['customer']['phone_number'] = $order_data['billing']['phone']; 316 $data['customer']['email'] = $order_data['billing']['email']; 317 $data['customer']['sex'] = 'unknown'; 318 $data['customer']['customer_type_id'] = get_option("storecontrl_wc_customer_type"); 319 320 // Billing 321 $data['customer']['billing_address']['name'] = str_replace(array('/', '&', '-'), ' ',$order_data['billing']['first_name']); 322 $data['customer']['billing_address']['surname'] = str_replace(array('/', '&', '-'), ' ',$order_data['billing']['last_name']); 323 324 // Check for housenumber 325 if (isset($order_data['billing']['address_2']) && !empty($order_data['billing']['address_2'])) { 326 $housenumber = $order_data['billing']['address_2']; 327 } 328 else { 329 330 if (!empty(preg_replace("/[^0-9]/", "", $order_data['billing']['address_1']))) { 331 $housenumber = preg_replace("/[^0-9]/", "", $order_data['billing']['address_1']); 332 } 333 else{ 334 $housenumber = ''; 335 } 336 } 337 $data['customer']['billing_address']['street'] = str_replace($housenumber, '', $order_data['billing']['address_1']); 338 $data['customer']['billing_address']['housenumber'] = $housenumber; 339 $data['customer']['billing_address']['zipcode'] = $order_data['billing']['postcode']; 340 $data['customer']['billing_address']['city'] = $order_data['billing']['city']; 341 342 // Check if country is already a code and not a full name 343 if (strlen($order_data['billing']['country']) == 2) { 344 $data['customer']['billing_address']['country_code'] = $order_data['billing']['country']; 345 } else { 346 $country_name = $order_data['billing']['country']; 347 $data['customer']['billing_address']['country_code'] = $this->get_country_code($country_name); 348 } 349 350 // Shipping 351 if (!empty($order_data['shipping']['first_name'])) { 352 $data['customer']['deliver_address']['name'] = str_replace(array('/', '&', '-'), ' ',$order_data['shipping']['first_name']); 353 } else { 354 $data['customer']['deliver_address']['name'] = str_replace(array('/', '&', '-'), ' ',$order_data['billing']['first_name']); 355 } 356 357 if (!empty($order_data['shipping']['last_name'])) { 358 $data['customer']['deliver_address']['surname'] = str_replace(array('/', '&', '-'), ' ',$order_data['shipping']['last_name']); 359 } else { 360 $data['customer']['deliver_address']['surname'] = str_replace(array('/', '&', '-'), ' ',$order_data['billing']['last_name']); 361 } 362 363 if (!empty($order_data['shipping']['address_1'])) { 364 $data['customer']['deliver_address']['street'] = $order_data['shipping']['address_1']; 365 } else { 366 $data['customer']['deliver_address']['street'] = $order_data['billing']['address_1']; 367 } 368 369 if (!empty($order_data['shipping']['address_2'])) { 370 $housenumber = $order_data['shipping']['address_2']; 371 } 372 else { 373 374 if (isset($order_data['shipping']['address_1']) && !empty($order_data['shipping']['address_1'])) { 375 if (!empty(preg_replace("/[^0-9]/", "", $order_data['shipping']['address_1']))) { 376 $housenumber = preg_replace("/[^0-9]/", "", $order_data['shipping']['address_1']); 377 } 378 } else { 379 if (isset($order_data['billing']['address_2']) && !empty($order_data['billing']['address_2'])) { 380 $housenumber = $order_data['billing']['address_2']; 381 } else { 382 if (!empty(preg_replace("/[^0-9]/", "", $order_data['billing']['address_1']))) { 383 $housenumber = preg_replace("/[^0-9]/", "", $order_data['billing']['address_1']); 384 } 385 } 386 } 387 } 388 $data['customer']['deliver_address']['street'] = str_replace($housenumber, '', $data['customer']['deliver_address']['street']); 389 $data['customer']['deliver_address']['housenumber'] = $housenumber; 390 391 if (!empty($order_data['shipping']['postcode'])) { 392 $data['customer']['deliver_address']['zipcode'] = $order_data['shipping']['postcode']; 393 } else { 394 $data['customer']['deliver_address']['zipcode'] = $order_data['billing']['postcode']; 395 } 396 397 if (!empty($order_data['shipping']['city'])) { 398 $data['customer']['deliver_address']['city'] = $order_data['shipping']['city']; 399 } else { 400 $data['customer']['deliver_address']['city'] = $order_data['billing']['city']; 401 } 402 403 if (!empty($order_data['shipping']['country'])) { 404 405 // Check if country is already a code and not a full name 406 if (strlen($order_data['shipping']['country']) == 2) { 407 $data['customer']['deliver_address']['country_code'] = $order_data['shipping']['country']; 408 } else { 409 $country_name = $order_data['shipping']['country']; 410 $data['customer']['deliver_address']['country_code'] = $this->get_country_code($country_name); 411 } 412 } else { 413 414 // Check if country is already a code and not a full name 415 if (strlen($order_data['billing']['country']) == 2) { 416 $data['customer']['deliver_address']['country_code'] = $order_data['billing']['country']; 417 } else { 418 $country_name = $order_data['billing']['country']; 419 $data['customer']['deliver_address']['country_code'] = $this->get_country_code($country_name); 420 } 421 } 422 } 423 424 $data['orderdate'] = $order_date; 425 426 if (isset($order_data['shipping_total']) && !empty($order_data['shipping_total'])) { 427 $shipping_cost = number_format((float)$order_data['shipping_total'], 2, '.', ''); 428 $order_total = $order_data['total']; 325 /** 326 * Push new order to StoreContrl. 327 * 328 * Compatible with: 329 * - woocommerce_order_status_changed ($order_id, $old_status, $new_status, $order) 330 * - woocommerce_payment_complete ($order_id) 331 * - manual/AJAX call (set $is_ajax = true and call with $order_id) 332 */ 333 public function create_storecontrl_new_order($order_id, $old_status = null, $new_status = null, $order = null) { 334 335 $web_api = new StoreContrl_Web_Api(); 336 $logging = new StoreContrl_WP_Connection_Logging(); 337 338 // Feature toggle 339 $storecontrl_wc_new_order = get_option('storecontrl_wc_new_order'); 340 if (empty($storecontrl_wc_new_order) || $storecontrl_wc_new_order !== '1') { 341 return; 342 } 343 344 // Detect AJAX/manual context (adjust if you have your own AJAX entrypoint) 345 $is_ajax = (defined('DOING_AJAX') && DOING_AJAX); 346 347 // Ensure WC_Order 348 $order = ($order instanceof WC_Order) ? $order : wc_get_order($order_id); 349 if (!$order instanceof WC_Order) { 350 $logging->log_file_write("NewOrder | Order not found for ID: {$order_id}"); 351 if ($is_ajax) { 352 wp_send_json(array('Status' => 'Error', 'Message' => 'Order not found')); 353 } 354 return; 355 } 356 357 // Log trigger context 358 $old_status = is_string($old_status) ? $old_status : ''; 359 $new_status = is_string($new_status) ? $new_status : ''; 360 $logging->log_file_write( 361 "NewOrder | Trigger for order {$order_id} | status={$order->get_status()} | change={$old_status}->{$new_status}" 362 ); 363 364 // Cancelled => notify StoreContrl cancel 365 if ($order->has_status('cancelled')) { 366 $order->update_meta_data('order_returned_successfully_to_storecontrl', '0'); 367 $order->save(); 368 $web_api->storecontrl_cancel_order($order_id); 369 370 if ($is_ajax) { 371 wp_send_json(array('Status' => 'Success', 'Message' => 'Order cancelled sent')); 372 } 373 return; 374 } 375 376 // Only push when paid-ish state 377 if (!$order->has_status('processing') && !$order->has_status('completed')) { 378 // If you only want to push on payment_complete, you can just return here. 379 return; 380 } 381 382 // Idempotency: prevent double push 383 if ($order->get_meta('_storecontrl_pushed')) { 384 $logging->log_file_write("NewOrder | Already pushed, skipping: {$order_id}"); 385 if ($is_ajax) { 386 wp_send_json(array('Status' => 'Success', 'Message' => 'Already pushed')); 387 } 388 return; 389 } 390 391 $order_data = $order->get_data(); 392 $data = array(); 393 $customer_exist = false; // keep your existing logic 394 395 // Convert date to XML DateTime 396 $order_date = $order->get_date_created(); 397 $timestamp = $order_date ? $order_date->getTimestamp() : time(); 398 $order_date = date('c', $timestamp); 399 $order_date = strtok($order_date, '+'); 400 401 // Basic order id (with custom order number compatibility) 402 $data['internet_order_id'] = $order_data['id']; 403 404 $custom_order_number = $order->get_meta('_alg_wc_full_custom_order_number'); 405 if (!empty($custom_order_number)) { 406 $data['internet_order_id'] = $custom_order_number; 407 } 408 $custom_order_number = $order->get_meta('_order_number'); 409 if (!empty($custom_order_number)) { 410 $data['internet_order_id'] = $custom_order_number; 411 } 412 413 // Customer data 414 if ($customer_exist) { 415 $data['customer_email'] = $order_data['billing']['email']; 416 $data['billing_address_id'] = ''; 417 $data['deliver_address_id'] = ''; 418 } else { 419 420 $data['customer']['name'] = str_replace(array('/', '&', '-'), ' ', $order_data['billing']['first_name']); 421 $data['customer']['surname'] = str_replace(array('/', '&', '-'), ' ', $order_data['billing']['last_name']); 422 $data['customer']['phone_number'] = $order_data['billing']['phone']; 423 $data['customer']['email'] = $order_data['billing']['email']; 424 $data['customer']['sex'] = 'unknown'; 425 $data['customer']['customer_type_id'] = get_option("storecontrl_wc_customer_type"); 426 427 // Billing 428 $data['customer']['billing_address']['name'] = str_replace(array('/', '&', '-'), ' ', $order_data['billing']['first_name']); 429 $data['customer']['billing_address']['surname'] = str_replace(array('/', '&', '-'), ' ', $order_data['billing']['last_name']); 430 431 // House number billing 432 if (!empty($order_data['billing']['address_2'])) { 433 $housenumber = $order_data['billing']['address_2']; 434 } else { 435 $housenumber = !empty(preg_replace("/[^0-9]/", "", $order_data['billing']['address_1'])) 436 ? preg_replace("/[^0-9]/", "", $order_data['billing']['address_1']) 437 : ''; 438 } 439 $data['customer']['billing_address']['street'] = str_replace($housenumber, '', $order_data['billing']['address_1']); 440 $data['customer']['billing_address']['housenumber'] = $housenumber; 441 $data['customer']['billing_address']['zipcode'] = $order_data['billing']['postcode']; 442 $data['customer']['billing_address']['city'] = $order_data['billing']['city']; 443 444 if (strlen($order_data['billing']['country']) == 2) { 445 $data['customer']['billing_address']['country_code'] = $order_data['billing']['country']; 446 } else { 447 $data['customer']['billing_address']['country_code'] = $this->get_country_code($order_data['billing']['country']); 448 } 449 450 // Shipping 451 $data['customer']['deliver_address']['name'] = 452 !empty($order_data['shipping']['first_name']) 453 ? str_replace(array('/', '&', '-'), ' ', $order_data['shipping']['first_name']) 454 : str_replace(array('/', '&', '-'), ' ', $order_data['billing']['first_name']); 455 456 $data['customer']['deliver_address']['surname'] = 457 !empty($order_data['shipping']['last_name']) 458 ? str_replace(array('/', '&', '-'), ' ', $order_data['shipping']['last_name']) 459 : str_replace(array('/', '&', '-'), ' ', $order_data['billing']['last_name']); 460 461 $data['customer']['deliver_address']['street'] = 462 !empty($order_data['shipping']['address_1']) 463 ? $order_data['shipping']['address_1'] 464 : $order_data['billing']['address_1']; 465 466 // House number shipping 467 if (!empty($order_data['shipping']['address_2'])) { 468 $housenumber = $order_data['shipping']['address_2']; 469 } else { 470 if (!empty($order_data['shipping']['address_1']) && !empty(preg_replace("/[^0-9]/", "", $order_data['shipping']['address_1']))) { 471 $housenumber = preg_replace("/[^0-9]/", "", $order_data['shipping']['address_1']); 472 } elseif (!empty($order_data['billing']['address_2'])) { 473 $housenumber = $order_data['billing']['address_2']; 429 474 } else { 430 $shipping_cost = 0; 431 $order_total = $order_data['total']; 432 } 433 $data['order_total'] = number_format((float)$order_total, 2, '.', ''); 434 435 if( isset($order_data['customer_note']) && !empty($order_data['customer_note']) ){ 436 $data['comments'] = $order_data['customer_note']; 437 } 438 439 $sc_couponcode = get_post_meta($order_data['id'], 'sc_couponcode', true); 440 $sc_coupondiscount = get_post_meta($order_data['id'], 'sc_coupondiscount', true); 441 if( isset($sc_couponcode) && !empty($sc_couponcode) && isset($sc_coupondiscount) && !empty($sc_coupondiscount) ){ 442 $data['couponcode'] = $sc_couponcode; 443 $data['coupondiscount'] = number_format($sc_coupondiscount, 2, '.', ''); 444 } 445 446 447 if( isset($order_data['fee_lines']) && !empty($order_data['fee_lines']) ){ 448 foreach( $order_data['fee_lines'] as $fee_line ){ 449 $fee_name = $fee_line->get_name(); 450 $fee_name = strtolower($fee_name); 451 $fee_total = $fee_line->get_total(); 452 $fee_total_tax = $fee_line->get_total_tax(); 453 $fee_amount = $fee_total + $fee_total_tax; 454 455 if( in_array($fee_name, array('bulk discount', 'bulkkorting')) ){ 456 $data['coupondiscount'] = abs(number_format($fee_amount, 2, '.', '')); 457 } 458 459 if( 460 strpos($fee_name, 'bezorging') !== false 461 || 462 strpos($fee_name, 'levering') !== false 463 || 464 strpos($fee_name, 'inpakken') !== false 465 || 466 strpos($fee_name, 'toeslag') !== false 467 || 468 strpos($fee_name, 'kaartje') !== false 469 ){ 470 $shipping_cost += $fee_total; 471 } 472 473 if( $fee_name == 'gateway fee' || $fee_name == 'gatewaykosten' ){ 474 $shipping_cost += $fee_amount; 475 } 476 } 477 } 478 479 if( isset($data['coupondiscount']) ){ 480 if (!$data['coupondiscount'] || is_null($data['coupondiscount']) && empty($data['coupondiscount'])) { 481 unset($data['coupondiscount']); 482 unset($data['couponcode']); 483 } 484 } 485 486 if( isset($order_data['shipping_tax']) && $order_data['shipping_tax'] != 0 ){ 487 $shipping_cost = (float)$shipping_cost + (float)$order_data['shipping_tax']; 488 } 489 $data['shipping_cost'] = $shipping_cost; 490 491 $shipping_methods = $order->get_shipping_methods(); 492 if( isset($shipping_methods) && !empty($shipping_methods) ){ 493 foreach ($shipping_methods as $shipping_method) { 494 $order_shipping_method = strtok($shipping_method->get_method_id(), ':'); 495 $value = get_option("storecontrl_wc_shipping_method_" . $order_shipping_method); 496 $data['shipping_method'] = (int)$value; 497 } 498 } 499 500 // Set default if exist and no other method apply 501 $storecontrl_wc_shipping_method_default = get_option( "storecontrl_wc_shipping_method_default" ); 502 if( isset($storecontrl_wc_shipping_method_default) && !empty($storecontrl_wc_shipping_method_default) && empty($data['shipping_method']) ){ 503 $data['shipping_method'] = $storecontrl_wc_shipping_method_default; 504 } 505 506 // Set StoreContrl payment method id 507 $wc_payment_gateways = $woocommerce->payment_gateways->payment_gateways(); 508 $order_payment_method = $order->get_payment_method(); 509 foreach ($wc_payment_gateways as $key => $wc_payment_gateway) { 510 if ($wc_payment_gateway->id === $order_payment_method) { 511 $data['payment_methods']['payment_method']['payment_id'] = get_option("storecontrl_wc_payment_method_" . $key); 512 $data['payment_methods']['payment_method']['partial_amount'] = number_format((float)$order_data['total'], 2, '.', ''); 513 } 514 } 515 516 // Set default if exist and no other method apply 517 $storecontrl_wc_payment_method_default = get_option( "storecontrl_wc_payment_method_default" ); 518 if( isset($storecontrl_wc_payment_method_default) && !empty($storecontrl_wc_payment_method_default) && empty($data['payment_methods']['payment_method']['payment_id']) ){ 519 $data['payment_methods']['payment_method']['payment_id'] = $storecontrl_wc_payment_method_default; 520 $data['payment_methods']['payment_method']['partial_amount'] = number_format((float)$order_data['total'], 2, '.', ''); 521 } 522 523 $regular_order_total = 0; 524 $discount_amount = 0; 525 $order_products = $order->get_items(); 526 foreach ($order_products as $order_product) { 527 528 $order_detail = array(); 529 530 $product_data = $order_product->get_data(); 531 532 // If variation ID not exist get it by title 533 if( !isset($product_data['variation_id']) || empty($product_data['variation_id']) ){ 534 $args = array( 535 "post_type" => "product_variation", 536 "post_parent" => $product_data['product_id'], 537 "s" => $product_data['name'] 538 ); 539 $variation_post = get_posts( $args ); 540 541 if( isset($variation_post[0]->ID) ){ 542 $product_data['variation_id'] = $variation_post[0]->ID; 543 } 544 } 545 546 $storecontrl_size_id = (int)get_post_meta($product_data['variation_id'], 'storecontrl_size_id', true); 547 $retail_price = (float)get_post_meta($product_data['variation_id'], '_regular_price', true); 548 $retail_price = number_format($retail_price, 2, '.', ''); 549 550 if( empty($storecontrl_size_id) ){ 551 $data['order_total'] = ($data['order_total'] - $retail_price); 552 $data['payment_methods']['payment_method']['partial_amount'] = ($data['payment_methods']['payment_method']['partial_amount'] - $retail_price); 553 continue; 554 } 555 556 $order_detail['size_id'] = $storecontrl_size_id; 557 $order_detail['count'] = $product_data['quantity']; 558 $order_detail['retail_price'] = $retail_price; 559 560 if( isset($product_data['total_tax']) && $product_data['total_tax'] != 0 ){ 561 $selling_price = $product_data['total'] + $product_data['total_tax']; 562 } 563 else{ 564 $selling_price = $product_data['total']; 565 } 566 567 $selling_price = number_format(round($selling_price, 2) / $product_data['quantity'], 3, '.', ''); 568 569 // Check if dfifference is lower than 1 cent 570 if( $retail_price != $selling_price ){ 571 $difference_between = abs($retail_price - $selling_price ); 572 if( $difference_between <= 0.01 ){ 573 $selling_price = $retail_price; 574 } 575 } 576 $order_detail['selling_price'] = $selling_price; 577 578 if( isset($data['coupondiscount']) && !empty($data['coupondiscount']) ){ 579 $order_detail['selling_price'] = $order_detail['retail_price']; 580 } 581 582 $regular_order_total += number_format($retail_price * $product_data['quantity'], 3, '.', ''); 583 $product_discount = $retail_price - $selling_price; 584 $discount_amount += number_format($product_discount * $product_data['quantity'], 3, '.', ''); 585 586 $data['order_details'][] = $order_detail; 587 } 588 589 if( $discount_amount > 0 ){ 590 $data['discount_amount'] = $discount_amount; 591 $data['discount_percentage'] = number_format(($discount_amount * 100) / $regular_order_total, 2, '.', ''); 592 } 593 elseif( isset($order_data['discount_total']) && !empty($order_data['discount_total']) ){ 594 $discount_amount = $order_data['discount_total'] + $order_data['discount_tax']; 595 $data['discount_amount'] = number_format($discount_amount, 2, '.', ''); 596 } 597 598 599 $logging = new StoreContrl_WP_Connection_Logging(); 600 if( !isset($data['order_details']) || empty($data['order_details']) ){ 601 $logging->log_file_write('NewOrder | Order has no StoreContrl products'); 602 603 // AJAX call 604 if( $response ){ 605 return 'NewOrder | Order has no StoreContrl products'; 606 } 607 } 608 609 // De korting kan nooit hoger zijn dan het order totaal! 610 if( $data['order_total'] == '0.00' && isset($data['coupondiscount']) && isset($data['discount_amount']) ){ 611 $data['coupondiscount'] = $data['discount_amount']; 612 } 613 614 // Convert data array to xml 615 $functions = new StoreContrl_WP_Connection_Functions(); 616 $xml_data = $functions->array_to_xml($data, new SimpleXMLElement('<order/>'), 'order_detail'); 617 618 // Save order and customer in StoreContrl 619 $status = 'Error'; 620 $results = $web_api->storecontrl_new_order($xml_data); 621 622 // Check for already used discount rules 623 $message = (string)$results; 624 if( $message == 'Ordertotal does not match the sum of the detailtotals or paymentmethods, order is refused' ){ 625 unset($data['couponcode']); 626 unset($data['coupondiscount']); 627 unset($data['discount_amount']); 628 unset($data['discount_percentage']); 629 $xml_data = $functions->array_to_xml($data, new SimpleXMLElement('<order/>'), 'order_detail'); 630 $results = $web_api->storecontrl_new_order($xml_data); 631 } 632 633 if ($results == 'Order processed') { 634 $message = 'Order processed: ' . $order_data['id']; 635 update_post_meta($order_id, 'order_returned_successfully_to_storecontrl', '1'); 636 $order->update_meta_data('order_returned_successfully_to_storecontrl', '1'); 637 $status = 'Success'; 638 } elseif ($results == 'OrderId already exists') { 639 update_post_meta($order_id, 'order_returned_successfully_to_storecontrl', '1'); 640 $order->update_meta_data('order_returned_successfully_to_storecontrl', '1'); 641 $message = 'OrderId already exists: ' . $order_data['id']; 642 $status = 'Success'; 643 } 644 475 $housenumber = !empty(preg_replace("/[^0-9]/", "", $order_data['billing']['address_1'])) 476 ? preg_replace("/[^0-9]/", "", $order_data['billing']['address_1']) 477 : ''; 478 } 479 } 480 481 $data['customer']['deliver_address']['street'] = str_replace($housenumber, '', $data['customer']['deliver_address']['street']); 482 $data['customer']['deliver_address']['housenumber'] = $housenumber; 483 484 $data['customer']['deliver_address']['zipcode'] = 485 !empty($order_data['shipping']['postcode']) ? $order_data['shipping']['postcode'] : $order_data['billing']['postcode']; 486 487 $data['customer']['deliver_address']['city'] = 488 !empty($order_data['shipping']['city']) ? $order_data['shipping']['city'] : $order_data['billing']['city']; 489 490 $ship_country = !empty($order_data['shipping']['country']) ? $order_data['shipping']['country'] : $order_data['billing']['country']; 491 if (strlen($ship_country) == 2) { 492 $data['customer']['deliver_address']['country_code'] = $ship_country; 493 } else { 494 $data['customer']['deliver_address']['country_code'] = $this->get_country_code($ship_country); 495 } 496 } 497 498 $data['orderdate'] = $order_date; 499 500 // Totals / shipping 501 $shipping_cost = !empty($order_data['shipping_total']) ? number_format((float)$order_data['shipping_total'], 2, '.', '') : 0; 502 $order_total = $order_data['total']; 503 504 $data['order_total'] = number_format((float)$order_total, 2, '.', ''); 505 506 if (!empty($order_data['customer_note'])) { 507 $data['comments'] = $order_data['customer_note']; 508 } 509 510 // HPOS-friendly coupon meta 511 $sc_couponcode = $order->get_meta('sc_couponcode'); 512 $sc_coupondiscount = $order->get_meta('sc_coupondiscount'); 513 if (!empty($sc_couponcode) && !empty($sc_coupondiscount)) { 514 $data['couponcode'] = $sc_couponcode; 515 $data['coupondiscount'] = number_format((float)$sc_coupondiscount, 2, '.', ''); 516 } 517 518 // Fees 519 if (!empty($order_data['fee_lines'])) { 520 foreach ($order_data['fee_lines'] as $fee_line) { 521 $fee_name = strtolower($fee_line->get_name()); 522 $fee_total = (float)$fee_line->get_total(); 523 $fee_total_tax = (float)$fee_line->get_total_tax(); 524 $fee_amount = $fee_total + $fee_total_tax; 525 526 if (in_array($fee_name, array('bulk discount', 'bulkkorting'), true)) { 527 $data['coupondiscount'] = abs(number_format($fee_amount, 2, '.', '')); 528 } 529 530 if ( 531 strpos($fee_name, 'bezorging') !== false || 532 strpos($fee_name, 'levering') !== false || 533 strpos($fee_name, 'inpakken') !== false || 534 strpos($fee_name, 'toeslag') !== false || 535 strpos($fee_name, 'kaartje') !== false 536 ) { 537 $shipping_cost += $fee_total; 538 } 539 540 if ($fee_name === 'gateway fee' || $fee_name === 'gatewaykosten') { 541 $shipping_cost += $fee_amount; 542 } 543 } 544 } 545 546 // Remove empty coupon fields 547 if (isset($data['coupondiscount']) && (empty($data['coupondiscount']) || is_null($data['coupondiscount']))) { 548 unset($data['coupondiscount'], $data['couponcode']); 549 } 550 551 if (!empty($order_data['shipping_tax']) && (float)$order_data['shipping_tax'] != 0) { 552 $shipping_cost = (float)$shipping_cost + (float)$order_data['shipping_tax']; 553 } 554 $data['shipping_cost'] = $shipping_cost; 555 556 // Shipping method mapping 557 $shipping_methods = $order->get_shipping_methods(); 558 if (!empty($shipping_methods)) { 559 foreach ($shipping_methods as $shipping_method) { 560 $order_shipping_method = strtok($shipping_method->get_method_id(), ':'); 561 $value = get_option("storecontrl_wc_shipping_method_" . $order_shipping_method); 562 $data['shipping_method'] = (int)$value; 563 } 564 } 565 566 // Default shipping method 567 $default_ship = get_option("storecontrl_wc_shipping_method_default"); 568 if (!empty($default_ship) && empty($data['shipping_method'])) { 569 $data['shipping_method'] = (int)$default_ship; 570 } 571 572 // Payment method mapping 573 $wc_payment_gateways = WC()->payment_gateways() ? WC()->payment_gateways->payment_gateways() : array(); 574 $order_payment_method = $order->get_payment_method(); 575 foreach ($wc_payment_gateways as $key => $wc_payment_gateway) { 576 if ($wc_payment_gateway->id === $order_payment_method) { 577 $data['payment_methods']['payment_method']['payment_id'] = get_option("storecontrl_wc_payment_method_" . $key); 578 $data['payment_methods']['payment_method']['partial_amount'] = number_format((float)$order_data['total'], 2, '.', ''); 579 } 580 } 581 582 // Default payment method 583 $default_pay = get_option("storecontrl_wc_payment_method_default"); 584 if (!empty($default_pay) && empty($data['payment_methods']['payment_method']['payment_id'])) { 585 $data['payment_methods']['payment_method']['payment_id'] = (int)$default_pay; 586 $data['payment_methods']['payment_method']['partial_amount'] = number_format((float)$order_data['total'], 2, '.', ''); 587 } 588 589 // Order details 590 $regular_order_total = 0; 591 $discount_amount = 0; 592 593 foreach ($order->get_items() as $order_product) { 594 $order_detail = array(); 595 $product_data = $order_product->get_data(); 596 597 // Variation id fallback (your existing logic) 598 if (empty($product_data['variation_id'])) { 599 $args = array( 600 "post_type" => "product_variation", 601 "post_parent" => $product_data['product_id'], 602 "s" => $product_data['name'] 603 ); 604 $variation_post = get_posts($args); 605 if (!empty($variation_post[0]->ID)) { 606 $product_data['variation_id'] = $variation_post[0]->ID; 607 } 608 } 609 610 $storecontrl_size_id = (int)get_post_meta($product_data['variation_id'], 'storecontrl_size_id', true); 611 $retail_price = (float)get_post_meta($product_data['variation_id'], '_regular_price', true); 612 $retail_price = number_format($retail_price, 2, '.', ''); 613 614 if (empty($storecontrl_size_id)) { 615 // remove non-StoreContrl product from totals 616 $data['order_total'] = ($data['order_total'] - $retail_price); 617 $data['payment_methods']['payment_method']['partial_amount'] = ($data['payment_methods']['payment_method']['partial_amount'] - $retail_price); 618 continue; 619 } 620 621 $order_detail['size_id'] = $storecontrl_size_id; 622 $order_detail['count'] = $product_data['quantity']; 623 $order_detail['retail_price'] = $retail_price; 624 625 $selling_price = (!empty($product_data['total_tax']) && (float)$product_data['total_tax'] != 0) 626 ? ((float)$product_data['total'] + (float)$product_data['total_tax']) 627 : (float)$product_data['total']; 628 629 $selling_price = number_format(round($selling_price, 2) / max(1, (int)$product_data['quantity']), 3, '.', ''); 630 631 if ($retail_price != $selling_price) { 632 $difference_between = abs($retail_price - $selling_price); 633 if ($difference_between <= 0.01) { 634 $selling_price = $retail_price; 635 } 636 } 637 $order_detail['selling_price'] = $selling_price; 638 639 if (!empty($data['coupondiscount'])) { 640 $order_detail['selling_price'] = $order_detail['retail_price']; 641 } 642 643 $regular_order_total += (float)number_format((float)$retail_price * (int)$product_data['quantity'], 3, '.', ''); 644 $product_discount = (float)$retail_price - (float)$selling_price; 645 $discount_amount += (float)number_format($product_discount * (int)$product_data['quantity'], 3, '.', ''); 646 647 $data['order_details'][] = $order_detail; 648 } 649 650 if ($discount_amount > 0 && $regular_order_total > 0) { 651 $data['discount_amount'] = $discount_amount; 652 $data['discount_percentage'] = number_format(($discount_amount * 100) / $regular_order_total, 2, '.', ''); 653 } elseif (!empty($order_data['discount_total'])) { 654 $discount_amount = (float)$order_data['discount_total'] + (float)$order_data['discount_tax']; 655 $data['discount_amount'] = number_format($discount_amount, 2, '.', ''); 656 } 657 658 // ---- Readiness guards (the key fix) ---- 659 $missing = array(); 660 661 if (empty($data['order_details'])) { 662 $missing[] = 'order_details'; 663 } 664 if (empty($data['shipping_method'])) { 665 $missing[] = 'shipping_method'; 666 } 667 if (empty($data['payment_methods']['payment_method']['payment_id'])) { 668 $missing[] = 'payment_id'; 669 } 670 671 if (!empty($missing)) { 672 $logging->log_file_write('NewOrder | Not ready (' . implode(',', $missing) . ') -> retry scheduled'); 673 674 // prevent infinite spam: max 3 tries 675 $tries = (int)$order->get_meta('_storecontrl_retry_tries'); 676 if ($tries < 3) { 677 $order->update_meta_data('_storecontrl_retry_tries', $tries + 1); 645 678 $order->save(); 646 $logging->log_file_write('NewOrder | ' . $message); 647 648 // AJAX call 649 if( $response ){ 650 wp_send_json( array( 651 'Status' => $status, 652 'Message' => $results, 653 )); 654 } 655 } 656 } 657 } 679 680 if (!wp_next_scheduled('storecontrl_retry_new_order', array($order_id))) { 681 wp_schedule_single_event(time() + 60, 'storecontrl_retry_new_order', array($order_id)); 682 } 683 } else { 684 $logging->log_file_write('NewOrder | Retry limit reached, giving up'); 685 } 686 687 if ($is_ajax) { 688 wp_send_json(array('Status' => 'Error', 'Message' => 'Not ready: ' . implode(',', $missing))); 689 } 690 return; 691 } 692 693 // Discount sanity 694 if ($data['order_total'] == '0.00' && isset($data['coupondiscount']) && isset($data['discount_amount'])) { 695 $data['coupondiscount'] = $data['discount_amount']; 696 } 697 698 // Convert to XML 699 $functions = new StoreContrl_WP_Connection_Functions(); 700 $xml_data = $functions->array_to_xml($data, new SimpleXMLElement('<order/>'), 'order_detail'); 701 702 // Send to StoreContrl 703 $status = 'Error'; 704 $results = $web_api->storecontrl_new_order($xml_data); 705 706 // Handle known mismatch case 707 $message = (string)$results; 708 if ($message === 'Ordertotal does not match the sum of the detailtotals or paymentmethods, order is refused') { 709 unset($data['couponcode'], $data['coupondiscount'], $data['discount_amount'], $data['discount_percentage']); 710 $xml_data = $functions->array_to_xml($data, new SimpleXMLElement('<order/>'), 'order_detail'); 711 $results = $web_api->storecontrl_new_order($xml_data); 712 $message = (string)$results; 713 } 714 715 if ($results === 'Order processed') { 716 $message = 'Order processed: ' . $order_data['id']; 717 $order->update_meta_data('order_returned_successfully_to_storecontrl', '1'); 718 $order->update_meta_data('_storecontrl_pushed', time()); 719 $status = 'Success'; 720 } elseif ($results === 'OrderId already exists') { 721 $message = 'OrderId already exists: ' . $order_data['id']; 722 $order->update_meta_data('order_returned_successfully_to_storecontrl', '1'); 723 $order->update_meta_data('_storecontrl_pushed', time()); 724 $status = 'Success'; 725 } 726 727 $order->save(); 728 $logging->log_file_write('NewOrder | ' . $message); 729 730 if ($is_ajax) { 731 wp_send_json(array( 732 'Status' => $status, 733 'Message' => (string)$results, 734 )); 735 } 736 } 737 658 738 659 739 public function get_country_code( $country_name ){ … … 986 1066 } 987 1067 988 public function check_storecontrl_gift_voucher( ) {989 990 $credit_cheque = str_replace(' ', '', $_POST['credit_cheque']);991 992 $credit_cheque = '123312213';993 994 // Get data from resulting URL995 $request_url = '/GiftCard/'.$credit_cheque.'/Balance';996 $args = array(997 'content_type' => 'application/json',998 'has_sessionId' => false999 );1000 1001 $web_api = new StoreContrl_Web_Api();1002 $results = $web_api->curl_request( $request_url, 'GET', $args );1003 1004 if( $_SERVER['REMOTE_ADDR'] == '62.45.35.198' || $_SERVER['REMOTE_ADDR'] == '2001:4c3c:ec00:fc00:49f5:2843:c652:73ca' ){1005 echo '<pre>';1006 print_r($request_url);1007 echo '</pre>';1008 echo '<pre>';1009 print_r($results);1010 echo '</pre>';1011 exit;1012 }1013 1014 if( isset($results) && $results != 'Invalid ChequeCode or CreditCheque inactive' ) {1015 wp_send_json_success($results);1016 }1017 else {1018 wp_send_json_error($results);1019 }1020 }1021 1022 1068 public function add_storecontrl_cart_fee( $cart ){ 1023 1069 $storecontrl_creditcheques = get_option( 'storecontrl_creditcheques'); -
storecontrl-wp-connection/trunk/readme.txt
r3435950 r3442556 5 5 Requires at least: 6.6.0 6 6 Tested up to: 6.8.3 7 Stable tag: 4.2. 57 Stable tag: 4.2.6 8 8 Requires PHP: 8.0 9 9 License: GPLv2 or later … … 93 93 == Changelog == 94 94 95 = 4.2.6 = 96 * Nieuwe versie voor de spaarpunten add-on 97 95 98 = 4.2.5 = 96 99 * Bugfix with new added products and variations processing -
storecontrl-wp-connection/trunk/storecontrl-wp-connection.php
r3435950 r3442556 4 4 Plugin URI: http://www.arture.nl/storecontrl 5 5 Description: The Wordpress plugin for connecting Woocommerce with StoreContrl Cloud. With the synchronizing cronjobs your products will be automatically processed, images added, and the categories set. Every 5 minutes all stock changes are processed. We provide a up-to-date plugin, easy setup and always the best support. 6 Version: 4.2. 56 Version: 4.2.6 7 7 Requires Plugins: woocommerce 8 8 Author: Arture
Note: See TracChangeset
for help on using the changeset viewer.