Changeset 3651512
- Timestamp:
- 08/17/2026 05:45:58 PM (2 months ago)
- Location:
- altoviz
- Files:
-
- 12 edited
- 1 copied
-
tags/1.0.75 (copied) (copied from altoviz/trunk )
-
tags/1.0.75/includes/admin/class-aoz4wc-wc-admin.php (modified) (3 diffs)
-
tags/1.0.75/includes/class-aoz4wc-api.php (modified) (7 diffs)
-
tags/1.0.75/includes/class-aoz4wc-wc.php (modified) (18 diffs)
-
tags/1.0.75/plugin.php (modified) (5 diffs)
-
tags/1.0.75/readme.txt (modified) (1 diff)
-
tags/1.0.75/version (modified) (1 diff)
-
trunk/includes/admin/class-aoz4wc-wc-admin.php (modified) (3 diffs)
-
trunk/includes/class-aoz4wc-api.php (modified) (7 diffs)
-
trunk/includes/class-aoz4wc-wc.php (modified) (18 diffs)
-
trunk/plugin.php (modified) (5 diffs)
-
trunk/readme.txt (modified) (1 diff)
-
trunk/version (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
altoviz/tags/1.0.75/includes/admin/class-aoz4wc-wc-admin.php
r3561363 r3651512 102 102 '_wpnonce' 103 103 ); 104 $actions['aoz-update-user'] = '<a href="' . $link . '" title="' . __( 'Update customer in the Altoviz app', 'altoviz' ) . '">' .__( 'Update Altoviz 🚀 customer', 'altoviz' ) . '</a>';104 $actions['aoz-update-user'] = '<a href="' . esc_url( $link ) . '" title="' . esc_attr__( 'Update customer in the Altoviz app', 'altoviz' ) . '">' . esc_html__( 'Update Altoviz 🚀 customer', 'altoviz' ) . '</a>'; 105 105 return $actions; 106 106 } … … 120 120 switch ( $type ) { 121 121 case 'product': 122 $actions['aoz-update'] = '<a href="' . $link . '" title="' . __( 'Update product in the Altoviz app', 'altoviz' ) . '">' .__( 'Update Altoviz 🚀 product', 'altoviz' ) . '</a>';122 $actions['aoz-update'] = '<a href="' . esc_url( $link ) . '" title="' . esc_attr__( 'Update product in the Altoviz app', 'altoviz' ) . '">' . esc_html__( 'Update Altoviz 🚀 product', 'altoviz' ) . '</a>'; 123 123 break; 124 124 case 'shop_order': 125 $actions['aoz-update'] = '<a href="' . $link . '" title="' . __( 'Update invoice in the Altoviz app', 'altoviz' ) . '">' .__( 'Update Altoviz 🚀 invoice', 'altoviz' ) . '</a>';125 $actions['aoz-update'] = '<a href="' . esc_url( $link ) . '" title="' . esc_attr__( 'Update invoice in the Altoviz app', 'altoviz' ) . '">' . esc_html__( 'Update Altoviz 🚀 invoice', 'altoviz' ) . '</a>'; 126 126 break; 127 127 } … … 261 261 if ( AOZ4WC()->check_requirements() && is_a( $order, 'WC_Order' ) ) { 262 262 $actions['aoz-update'] = __( 'Update Altoviz 🚀 invoice', 'altoviz' ); 263 $invoice_id = intval( $order->get_meta( 'aoz4wc_invoice_id', true) );263 $invoice_id = intval( AOZ4WC()->wc()->get_AOZ4WC_invoice_id( $order ) ); 264 264 if ( $invoice_id ) { 265 265 $actions['aoz-send'] = __( 'Send Altoviz 🚀 invoice', 'altoviz' ); -
altoviz/tags/1.0.75/includes/class-aoz4wc-api.php
r3561363 r3651512 60 60 */ 61 61 public function customers_find( $email, $internal_id, $single = false ) { 62 return $this-> remote_get( '/v1/customers/find?email=' . $email . '&internalid=' .$internal_id, $single );62 return $this->find( '/v1/customers/find', array( 'email' => $email ), $internal_id, $single ); 63 63 } 64 64 /** … … 95 95 */ 96 96 public function products_find( $number, $internal_id, $single = false ) { 97 return $this-> remote_get( '/v1/products/find?number=' . $number . '&internalid=' .$internal_id, $single );97 return $this->find( '/v1/products/find', array( 'number' => $number ), $internal_id, $single ); 98 98 } 99 99 /** … … 130 130 */ 131 131 public function sales_credits_find( $number, $internal_id, $single = false ) { 132 return $this->remote_get( '/v1/salecredits/find?number=' . $number . '&internalid=' . $internal_id, $single ); 132 return $this->find( '/v1/salecredits/find', array( 'number' => $number ), $internal_id, $single ); 133 } 134 /** 135 * Summary of sales_credits_get 136 * 137 * @param mixed $id 138 */ 139 public function sales_credits_get( $id ) { 140 return $this->remote_get( '/v1/salecredits/' . $id ); 133 141 } 134 142 /** … … 138 146 */ 139 147 public function sales_credits_finalize( $id ) { 140 return $this->remote_post( '/v1/salecredits/finalize/' . $id );148 return $this->remote_post( '/v1/salecredits/finalize/' . $id . '?dateAdjustment=MinimumDate' ); 141 149 } 142 150 /** … … 289 297 */ 290 298 public function sales_invoices_finalize( $id ) { 291 return $this->remote_post( '/v1/saleinvoices/finalize/' . $id );299 return $this->remote_post( '/v1/saleinvoices/finalize/' . $id . '?dateAdjustment=MinimumDate' ); 292 300 } 293 301 /** … … 299 307 */ 300 308 public function sales_invoices_find( $number, $internal_id, $single = false ) { 301 return $this->remote_get( '/v1/saleinvoices/find?number=' . $number . '&internalid=' . $internal_id, $single ); 309 return $this->find( '/v1/saleinvoices/find', array( 'number' => $number ), $internal_id, $single ); 310 } 311 /** 312 * Summary of sales_invoices_get 313 * 314 * @param mixed $id 315 */ 316 public function sales_invoices_get( $id ) { 317 return $this->remote_get( '/v1/saleinvoices/' . $id ); 302 318 } 303 319 /** … … 378 394 } 379 395 /** 396 * Look an entity up by its internal id and return only what actually carries that id. 397 * 398 * The endpoints are not all equally strict — products and contacts match with ILIKE, where 399 * the underscore of the "wc_" prefix is a single character wildcard, so "wc_123" also 400 * matches "wcX123". Filtering here makes every lookup exact regardless of the endpoint, 401 * and an ambiguous answer is refused rather than guessed at. 402 * 403 * @param mixed $path 404 * @param mixed $params 405 * @param mixed $internal_id 406 * @param mixed $single 407 */ 408 private function find( $path, $params, $internal_id, $single = false ) { 409 $internal_id = is_string( $internal_id ) ? trim( $internal_id ) : $internal_id; 410 if ( empty( $internal_id ) ) { 411 // An empty internal id is never a legitimate lookup: it matches every entity that 412 // has none, and the caller would then adopt an arbitrary one. 413 AOZ4WC()->log( sprintf( 'Refused a lookup on %s with an empty internal id', $path ), 'error' ); 414 return false; 415 } 416 $url = $path . '?' . http_build_query( array_merge( $params, array( 'internalid' => $internal_id ) ) ); 417 $result = $this->remote_get( $url ); 418 return $this->pick( $result, $internal_id, $single, $url ); 419 } 420 /** 421 * Keep only the entities whose internal id is exactly the one that was asked for. 422 * 423 * @param mixed $result 424 * @param mixed $internal_id 425 * @param mixed $single 426 * @param mixed $url 427 */ 428 private function pick( $result, $internal_id, $single, $url = '' ) { 429 if ( ! is_array( $result ) ) { 430 return false; 431 } 432 $matches = array(); 433 foreach ( $result as $entity ) { 434 if ( ! is_array( $entity ) || ! isset( $entity['internalId'] ) ) { 435 continue; 436 } 437 if ( (string) $entity['internalId'] !== (string) $internal_id ) { 438 continue; 439 } 440 if ( ! empty( $entity['isCancelled'] ) ) { 441 continue; 442 } 443 $matches[] = $entity; 444 } 445 if ( ! $single ) { 446 return count( $matches ) ? $matches : false; 447 } 448 if ( 1 === count( $matches ) ) { 449 return reset( $matches ); 450 } 451 if ( count( $matches ) > 1 ) { 452 // Picking one at random is how an order ends up attached to somebody else's 453 // document. Refuse, and make the duplicate visible so it can be resolved. 454 $ids = implode( ', ', array_map( function ( $entity ) { 455 return $entity['id'] ?? '?'; 456 }, $matches ) ); 457 AOZ4WC()->log( 458 sprintf( 459 // translators: %1$s internal id, %2$s list of Altoviz ids 460 __( 'Several Altoviz entities share the internal id %1$s (ids: %2$s). Resolve the duplicate in Altoviz 🚀 before synchronizing again.', 'altoviz' ), 461 $internal_id, 462 $ids 463 ), 464 'error' 465 ); 466 AOZ4WC()->sys_log( array( 'source' => 'API', 'url' => $url, 'ambiguous_internal_id' => $internal_id, 'ids' => $ids ) ); 467 } 468 return false; 469 } 470 /** 380 471 * Summary of remote_get 381 472 * -
altoviz/tags/1.0.75/includes/class-aoz4wc-wc.php
r3561363 r3651512 8 8 9 9 10 /** 11 * Order meta keys owned by this plugin. Kept in one place because they have to be 12 * stripped wholesale whenever WooCommerce copies meta from one order to another. 13 */ 14 const ORDER_META_KEYS = array( 15 'aoz4wc_customer_id', 16 'aoz4wc_customer_number', 17 'aoz4wc_invoice_date', 18 'aoz4wc_invoice_id', 19 'aoz4wc_invoice_internal_id', 20 'aoz4wc_invoice_kind', 21 'aoz4wc_invoice_number', 22 'aoz4wc_invoice_public_link', 23 'aoz4wc_invoice_sent', 24 ); 25 26 /** 27 * Keys written by earlier generations of this plugin. Sites bridge them to the current ones 28 * with the aoz4wc_order_invoice_* filters, so a stale one is just as capable of pointing an 29 * order at somebody else's invoice — it has to be recognized and removed alongside the rest. 30 * 31 * Meta keys are case sensitive, hence both spellings. 32 */ 33 const LEGACY_ORDER_META_KEYS = array( 34 'AOZ4WC_invoice_date', 35 'AOZ4WC_invoice_id', 36 'AOZ4WC_invoice_number', 37 'AOZ4WC_invoice_public_link', 38 '_altoviz_invoice_date', 39 '_altoviz_invoice_id', 40 '_altoviz_invoice_link', 41 '_altoviz_invoice_number', 42 'aoz_cancelled_reason', 43 'aoz_customer_id', 44 'aoz_customer_number', 45 'aoz_internal', 46 'aoz_invoice_date', 47 'aoz_invoice_id', 48 'aoz_invoice_link', 49 'aoz_invoice_number', 50 'aoz_invoice_public_link', 51 'aoz_invoice_sent', 52 ); 53 10 54 public function __construct() { 11 55 add_action('aoz4wc_sync_customer', array($this, 'do_sync_customer'), 10, 1); 12 56 add_action('aoz4wc_sync_product', array($this, 'do_sync_product'), 10, 1); 13 57 add_action('aoz4wc_sync_order', array($this, 'do_sync_order'), 10, 1); 58 add_action('aoz4wc_backfill_invoice_meta', array($this, 'do_backfill_invoice_meta'), 10, 1); 14 59 add_action('profile_update', array($this, 'profile_update'), 10, 3); 60 // A renewal / resubscribe order is built by copying the subscription's meta, and a 61 // subscription may still carry invoice meta written by an older version of this plugin 62 // which used to synchronize subscriptions as if they were orders. Copying it would make 63 // the new order point at the subscription's invoice, so strip it at every copy point. 64 add_filter('wcs_subscription_meta', array($this, 'wcs_strip_invoice_meta'), 10, 1); 65 add_filter('wcs_renewal_order_meta', array($this, 'wcs_strip_invoice_meta'), 10, 1); 66 add_filter('wcs_resubscribe_order_meta', array($this, 'wcs_strip_invoice_meta'), 10, 1); 67 // Catch-all for order duplication plugins and importers, which copy meta the same way. 68 add_action('woocommerce_new_order', array($this, 'clear_inherited_invoice_meta'), 1, 2); 15 69 add_action('woocommerce_order_status_changed', array($this, 'woocommerce_order_status_changed'), 99, 3); 16 70 add_action('woocommerce_new_product', array($this, 'woocommerce_new_product'), 999, 2); … … 62 116 } 63 117 /** 118 * Only real orders and refunds get an Altoviz document. Subscriptions are WC_Order 119 * subclasses, so they reach the sync entry points like any other order — an older version 120 * of this plugin synchronized them and left invoice meta behind on them. 121 * 122 * @param mixed $order 123 * @return bool 124 */ 125 public function is_syncable_order($order) { 126 return is_a($order, 'WC_Abstract_Order') 127 && in_array($order->get_type(), array('shop_order', 'shop_order_refund'), true); 128 } 129 /** 130 * Whether the invoice meta stored on an order actually describes that order. 131 * 132 * Meta is copied verbatim by WooCommerce Subscriptions renewals, order duplication tools 133 * and importers, so its presence alone proves nothing. Only a stamped internal id that 134 * matches the order's own does. Returns 'own', 'foreign' or 'unverified' — the last one 135 * meaning the meta predates the stamp and has not been checked against the API yet. 136 * 137 * @param mixed $order 138 * @return string 139 */ 140 public function invoice_meta_ownership($order) { 141 if (! is_a($order, 'WC_Abstract_Order') || ! $this->get_stored_invoice_id($order)) { 142 return 'foreign'; 143 } 144 $stamped = $order->get_meta('aoz4wc_invoice_internal_id', true); 145 if (empty($stamped)) { 146 // Either written before the stamp existed, or inherited from a subscription under one 147 // of the legacy keys. Indistinguishable locally, so it takes an API check to settle. 148 return 'unverified'; 149 } 150 return $stamped === $this->format_internal_id($order->get_id()) ? 'own' : 'foreign'; 151 } 152 /** 153 * The document id stored on an order, whatever key it is under. 154 * 155 * Deliberately goes through the aoz4wc_order_invoice_id filter without the ownership gate, 156 * so that references left by older versions of the plugin — which sites bridge with that 157 * filter — are seen here rather than mistaken for an order that has no document at all. 158 * 159 * @param mixed $order 160 * @return mixed 161 */ 162 public function get_stored_invoice_id($order) { 163 if (! is_a($order, 'WC_Abstract_Order')) { 164 return false; 165 } 166 return apply_filters('aoz4wc_order_invoice_id', $order->get_meta('aoz4wc_invoice_id', true), $order); 167 } 168 /** 169 * Whether a meta key holds an Altoviz reference, under any generation of this plugin. 170 * 171 * @param mixed $key 172 * @return bool 173 */ 174 public function is_owned_meta_key($key) { 175 $key = (string) $key; 176 // Matched by prefix rather than by name: the legacy list cannot be assumed complete, and 177 // a key that slips through is one that keeps pointing an order at the wrong document. 178 return 0 === stripos($key, 'aoz4wc_') 179 || 0 === stripos($key, 'aoz_') 180 || 0 === stripos($key, '_altoviz_'); 181 } 182 /** 183 * Summary of invoice_meta_is_own 184 * 185 * @param mixed $order 186 * @return bool 187 */ 188 public function invoice_meta_is_own($order) { 189 return 'own' === $this->invoice_meta_ownership($order); 190 } 191 /** 192 * Drop this plugin's meta from the set WooCommerce Subscriptions is about to copy onto a 193 * subscription, a renewal order or a resubscribe order. 194 * 195 * The filtered value is a list of rows shaped like array('meta_key' => ..., 'meta_value' => ...). 196 * 197 * @param mixed $meta 198 * @return array 199 */ 200 public function wcs_strip_invoice_meta($meta) { 201 if (! is_array($meta)) { 202 return $meta; 203 } 204 return array_values( 205 array_filter( 206 $meta, 207 function ($row) { 208 $key = is_array($row) ? ( $row['meta_key'] ?? '' ) : ( is_object($row) ? ( $row->meta_key ?? '' ) : '' ); 209 return ! $this->is_owned_meta_key($key); 210 } 211 ) 212 ); 213 } 214 /** 215 * Remove invoice meta that a freshly created order inherited from another one. 216 * 217 * A brand new order cannot legitimately own an Altoviz document yet, so anything present 218 * at this point was copied. 219 * 220 * @param mixed $order_id 221 * @param mixed $order 222 * @return void 223 */ 224 public function clear_inherited_invoice_meta($order_id, $order = null) { 225 $order = is_a($order, 'WC_Abstract_Order') ? $order : wc_get_order($order_id); 226 $inherited = $order ? $this->get_stored_invoice_id($order) : false; 227 if (! $inherited) { 228 return; 229 } 230 AOZ4WC()->log( 231 sprintf( 232 // translators: %1$s order id, %2$s invoice id 233 __('Removed Altoviz invoice #%2$s inherited by the new Woocommerce order #%1$s', 'altoviz'), 234 $order_id, 235 $inherited 236 ), 237 'warning' 238 ); 239 $this->delete_order_meta($order, true); 240 } 241 /** 242 * Summary of delete_order_meta 243 * 244 * @param mixed $order 245 * @param mixed $save 246 * @return void 247 */ 248 public function delete_order_meta($order, $save = true) { 249 if (! is_a($order, 'WC_Abstract_Order')) { 250 return; 251 } 252 // The legacy keys have to go too: leaving them behind lets the compatibility filters 253 // resurrect the very reference that was just removed. 254 foreach (array_merge(self::ORDER_META_KEYS, self::LEGACY_ORDER_META_KEYS) as $key) { 255 $order->delete_meta_data($key); 256 } 257 // Then sweep whatever else this plugin has ever written, so a key missing from the lists 258 // above is still removed rather than left behind to resurface. 259 foreach ($order->get_meta_data() as $meta) { 260 $key = is_object($meta) ? ( $meta->key ?? '' ) : ''; 261 if ($key && $this->is_owned_meta_key($key)) { 262 $order->delete_meta_data($key); 263 } 264 } 265 if ($save) { 266 $order->save(); 267 } 268 } 269 /** 270 * Resolve the Altoviz document belonging to an order. 271 * 272 * Prefers the id already stored on the order, but never trusts it blindly: the document is 273 * fetched and its internal id compared to the order's. Meta that turns out to describe 274 * another order — copied by a subscription renewal, an order duplication tool or an import 275 * — is reported and removed instead of being used. 276 * 277 * @param mixed $order 278 * @param mixed $credit 279 * @return array|false 280 */ 281 public function resolve_order_document($order, $credit = false) { 282 if (! is_a($order, 'WC_Abstract_Order')) { 283 return false; 284 } 285 $internal_id = $this->format_internal_id($order->get_id()); 286 $stored_id = intval($this->get_stored_invoice_id($order)); 287 $stored_kind = $order->get_meta('aoz4wc_invoice_kind', true); 288 if ($stored_id && ( empty($stored_kind) || ( $credit ? 'credit' : 'invoice' ) === $stored_kind )) { 289 $document = $credit 290 ? AOZ4WC()->api()->sales_credits_get($stored_id) 291 : AOZ4WC()->api()->sales_invoices_get($stored_id); 292 if (is_array($document) && isset($document['internalId'])) { 293 if ((string) $document['internalId'] === (string) $internal_id) { 294 return $document; 295 } 296 AOZ4WC()->log( 297 sprintf( 298 // translators: %1$s order id, %2$s Altoviz document id, %3$s the internal id the document really carries 299 __('The Altoviz 🚀 document #%2$s stored on the Woocommerce order #%1$s belongs to %3$s. The stale reference has been removed.', 'altoviz'), 300 $order->get_id(), 301 $stored_id, 302 $document['internalId'] 303 ), 304 'error' 305 ); 306 $this->delete_order_meta($order, true); 307 } 308 } 309 return AOZ4WC()->api()->sales_documents_find('', $internal_id, true, $credit); 310 } 311 /** 312 * One-off maintenance pass over orders whose invoice reference predates the internal id 313 * stamp, including the ones left by earlier versions of this plugin. 314 * 315 * Until an order has been through here its reference stays hidden, because an inherited one 316 * cannot be told apart from a legitimate one without asking the API. So this walks every 317 * order rather than only the ones that look suspicious, and pages by an offset over an 318 * unfiltered, stably ordered set — a query filtered on the very meta being written would 319 * shift underneath its own pagination and skip orders. 320 * 321 * Orders without a reference cost nothing, so the batch can be large: the only per-order 322 * expense is one API call for those that actually carry one. 323 * 324 * @param mixed $page 325 * @return void 326 */ 327 public function do_backfill_invoice_meta($page = 1) { 328 $page = max(1, intval($page)); 329 $batch = max(1, intval(apply_filters('aoz4wc_backfill_batch_size', 100))); 330 if (1 === $page) { 331 $this->purge_subscription_invoice_meta(); 332 } 333 $orders = wc_get_orders( 334 array( 335 'limit' => $batch, 336 'page' => $page, 337 'type' => 'shop_order', 338 'orderby' => 'ID', 339 'order' => 'ASC', 340 'status' => array_keys(wc_get_order_statuses()), 341 ) 342 ); 343 if (empty($orders)) { 344 update_option('aoz4wc_invoice_meta_backfill', 'done'); 345 $checked = intval(get_option('aoz4wc_invoice_meta_backfill_count', 0)); 346 AOZ4WC()->log( 347 sprintf( 348 // translators: %s number of orders 349 __('Altoviz 🚀 invoice references checked. %s order(s) were confirmed and stamped.', 'altoviz'), 350 $checked 351 ), 352 'information' 353 ); 354 return; 355 } 356 $verified = 0; 357 foreach ($orders as $order) { 358 if ($this->verify_order_invoice_meta($order)) { 359 ++$verified; 360 } 361 } 362 if ($verified) { 363 update_option('aoz4wc_invoice_meta_backfill_count', intval(get_option('aoz4wc_invoice_meta_backfill_count', 0)) + $verified); 364 } 365 AOZ4WC()->sys_log(array('aoz4wc_backfill_invoice_meta' => 'batch', 'page' => $page, 'verified' => $verified)); 366 AOZ4WC()->schedule('aoz4wc_backfill_invoice_meta', array($page + 1)); 367 } 368 /** 369 * Confirm that an order's unstamped invoice meta really describes that order, and stamp it. 370 * 371 * Nothing is deleted here on an inconclusive answer: an unreachable API must not be able to 372 * wipe correct references. Meta that cannot be confirmed simply stays unverified, and the 373 * read side keeps it hidden. 374 * 375 * @param mixed $order 376 * @return bool 377 */ 378 public function verify_order_invoice_meta($order) { 379 if ('unverified' !== $this->invoice_meta_ownership($order)) { 380 return false; 381 } 382 $document = $this->resolve_order_document($order, 'shop_order_refund' === $order->get_type()); 383 if ($document) { 384 $this->update_order_meta($order, $document, true); 385 return true; 386 } 387 return false; 388 } 389 /** 390 * Remove invoice meta left on subscriptions by an older version of this plugin, which used 391 * to synchronize them as orders. 392 * 393 * This is the source that renewal orders copy from, so it has to be cleared or every future 394 * renewal inherits it again. The Altoviz documents themselves are accounting records and 395 * are left untouched — only the Woocommerce side references are removed. 396 * 397 * @return void 398 */ 399 public function purge_subscription_invoice_meta() { 400 if (! function_exists('wcs_get_subscriptions')) { 401 return; 402 } 403 $subscriptions = wcs_get_subscriptions(array('subscriptions_per_page' => -1)); 404 $purged = 0; 405 foreach ($subscriptions as $subscription) { 406 if (! $this->get_stored_invoice_id($subscription)) { 407 continue; 408 } 409 $this->delete_order_meta($subscription, true); 410 ++$purged; 411 } 412 if ($purged) { 413 AOZ4WC()->log( 414 sprintf( 415 // translators: %s number of subscriptions 416 __('Removed stale Altoviz 🚀 invoice references from %s subscription(s). Subscriptions are not invoiced; their renewal orders are.', 'altoviz'), 417 $purged 418 ), 419 'warning' 420 ); 421 } 422 } 423 /** 64 424 * Summary of profile_update 65 425 * … … 144 504 * 145 505 * Legacy handler intentionally left in place for backward compatibility. 146 * It is currently not registered in __construct(). 506 * This method is not registered: the woocommerce_new_order hook is bound to 507 * clear_inherited_invoice_meta() instead, which does not synchronize anything. 147 508 * 148 509 * @param mixed $order_id … … 227 588 $order = is_numeric($order) ? wc_get_order($order) : $order; 228 589 if (! $order) { 590 return; 591 } 592 if (! $this->is_syncable_order($order)) { 593 AOZ4WC()->sys_log(array('AOZ Sync order action skipped', 'type' => $order->get_type(), 'id' => $order->get_id())); 229 594 return; 230 595 } … … 282 647 */ 283 648 public function get_AOZ4WC_invoice_id($order) { 284 if (empty($order) ) {649 if (empty($order) || ! $this->invoice_meta_is_own($order)) { 285 650 return false; 286 651 } … … 293 658 */ 294 659 public function get_AOZ4WC_invoice_link($order) { 295 if (empty($order) ) {660 if (empty($order) || ! $this->invoice_meta_is_own($order)) { 296 661 return ''; 297 662 } … … 304 669 */ 305 670 public function get_AOZ4WC_invoice_number($order) { 306 if (empty($order) ) {671 if (empty($order) || ! $this->invoice_meta_is_own($order)) { 307 672 return ''; 308 673 } … … 315 680 */ 316 681 public function get_AOZ4WC_invoice_date($order) { 317 if (empty($order) ) {682 if (empty($order) || ! $this->invoice_meta_is_own($order)) { 318 683 return ''; 319 684 } … … 542 907 $aoz4wc_document_id = null; 543 908 if (! $aoz4wc_document) { 544 $aoz4wc_document = AOZ4WC()->api()->sales_documents_find('', $this->format_internal_id($order_id), true, $credit);909 $aoz4wc_document = $this->resolve_order_document($order, $credit); 545 910 } 546 911 if ($aoz4wc_document) { … … 588 953 return false; 589 954 } 955 if (! $this->is_syncable_order($order)) { 956 AOZ4WC()->sys_log(array('do_sync_order skipped', 'type' => $order->get_type(), 'id' => $order->get_id())); 957 return false; 958 } 590 959 $result = $this->do_sync_individual_order($order); 591 960 AOZ4WC()->sys_log(['do_sync_order' => $result]); … … 631 1000 return false; 632 1001 } 1002 if (! $this->is_syncable_order($order)) { 1003 AOZ4WC()->sys_log(array('do_sync_individual_order skipped', 'type' => $order->get_type(), 'id' => $order->get_id())); 1004 return false; 1005 } 633 1006 if (! AOZ4WC()->app()->is_supported_currency($order->get_currency())) { 634 1007 return false; … … 636 1009 AOZ4WC()->sys_log(array('step' => '1.1')); 637 1010 $order_id = $order->get_id(); 638 if ($this->get_transient('order', $order_id)) { 1011 // The lock has to span the whole lookup and creation, otherwise two concurrent 1012 // synchronizations both find nothing and both create a document for the same order. 1013 if (! $this->acquire_lock('order', $order_id)) { 1014 AOZ4WC()->sys_log(array('do_sync_individual_order' => 'already running', 'order_id' => $order_id)); 639 1015 return false; 640 1016 } 1017 try { 1018 return $this->sync_individual_order($order, $order_id); 1019 } finally { 1020 $this->release_lock('order', $order_id); 1021 } 1022 } 1023 /** 1024 * Summary of sync_individual_order 1025 * 1026 * @param mixed $order 1027 * @param mixed $order_id 1028 */ 1029 private function sync_individual_order($order, $order_id) { 641 1030 AOZ4WC()->sys_log( 642 1031 array( … … 645 1034 ) 646 1035 ); 647 $this->set_transient('order', $order_id);648 1036 $credit = 'shop_order_refund' === $order->get_type(); 649 1037 $main_order = $credit ? wc_get_order($order->get_parent_id()) : $order; … … 855 1243 ); 856 1244 // translators: %1$s invoice number 857 $data['subject'] = $credit ? sprintf(__('Refund of the invoice #%1$s', 'altoviz'), $ main_order->get_meta('aoz4wc_invoice_number', true)) : '';1245 $data['subject'] = $credit ? sprintf(__('Refund of the invoice #%1$s', 'altoviz'), $this->get_AOZ4WC_invoice_number($main_order)) : ''; 858 1246 // $data['shippingVat'] = $this->create_vat(); 859 1247 // $data['taxAmount'] = 0, … … 882 1270 ) 883 1271 ); 884 $aoz4wc_invoice = AOZ4WC()->api()->sales_documents_find('', $this->format_internal_id($order_id), true, $credit);1272 $aoz4wc_invoice = $this->resolve_order_document($order, $credit); 885 1273 if ($aoz4wc_invoice) { 886 1274 $aoz4wc_invoice_id = $aoz4wc_invoice['id']; … … 1057 1445 $order->update_meta_data('aoz4wc_invoice_number', $aoz4wc_invoice['number']); 1058 1446 $order->update_meta_data('aoz4wc_invoice_public_link', $aoz4wc_invoice['publicLink']); 1447 // Stamp which order this document was resolved for, so that a copy of this meta 1448 // landing on another order can be recognized as not belonging to it. 1449 $order->update_meta_data('aoz4wc_invoice_internal_id', $this->format_internal_id($order->get_id())); 1450 $order->update_meta_data('aoz4wc_invoice_kind', 'shop_order_refund' === $order->get_type() ? 'credit' : 'invoice'); 1059 1451 if ($save) { 1060 1452 $order->save(); … … 1107 1499 } 1108 1500 $product_id = $product->get_id(); 1109 if (get_transient('product', $product_id)) { 1501 // Was calling WordPress' global get_transient() with a literal 'product' key, which this 1502 // plugin never sets, so the guard never fired. 1503 if ($this->get_transient('product', $product_id)) { 1110 1504 return false; 1111 1505 } … … 1553 1947 } 1554 1948 /** 1949 * Summary of get_lock_name 1950 * 1951 * @param mixed $name 1952 * @param mixed $id 1953 * @return string 1954 */ 1955 private function get_lock_name($name, $id) { 1956 return 'aoz4wc_lock_' . $name . '_' . $id; 1957 } 1958 /** 1959 * Take an exclusive lock, or report that somebody else already holds it. 1960 * 1961 * The insert is what makes this atomic: option_name is unique, so exactly one concurrent 1962 * request can create the row. add_option() cannot be used for this — it looks the option up 1963 * before inserting, which leaves a window where two requests both believe they won. 1964 * 1965 * @param mixed $name 1966 * @param mixed $id 1967 * @return bool 1968 */ 1969 private function acquire_lock($name, $id) { 1970 global $wpdb; 1971 $option = $this->get_lock_name($name, $id); 1972 $ttl = max(1, intval(apply_filters('aoz4wc_sync_lock_ttl', 60, $name, $id))); 1973 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching 1974 $inserted = $wpdb->query( 1975 $wpdb->prepare( 1976 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", 1977 $option, 1978 time() 1979 ) 1980 ); 1981 wp_cache_delete($option, 'options'); 1982 wp_cache_delete('notoptions', 'options'); 1983 if ($inserted) { 1984 return true; 1985 } 1986 // Nobody releases the lock when a request is killed mid-synchronization, so a lock older 1987 // than the timeout is assumed abandoned and taken over. 1988 $since = intval(get_option($option, 0)); 1989 if ($since > 0 && ( time() - $since ) > $ttl) { 1990 AOZ4WC()->sys_log(array('stale lock taken over', 'lock' => $option, 'age' => time() - $since)); 1991 $this->release_lock($name, $id); 1992 return $this->acquire_lock($name, $id); 1993 } 1994 return false; 1995 } 1996 /** 1997 * Summary of release_lock 1998 * 1999 * @param mixed $name 2000 * @param mixed $id 2001 * @return void 2002 */ 2003 private function release_lock($name, $id) { 2004 delete_option($this->get_lock_name($name, $id)); 2005 } 2006 /** 1555 2007 * Summary of AOZ4WC_log 1556 2008 * -
altoviz/tags/1.0.75/plugin.php
r3561363 r3651512 3 3 * Plugin Name: Altoviz for WooCommerce 4 4 * Description: Invoicing and accounting made easy for entrepreneurs. 5 * Version: 1.0.7 45 * Version: 1.0.75 6 6 * Requires at least: 6.5 7 7 * Tested up to: 7.0 … … 24 24 } 25 25 define( 'AOZ4WC_PLUGIN_FILE', __FILE__ ); 26 define( 'AOZ4WC_PLUGIN_VERSION', '1.0.7 4' );26 define( 'AOZ4WC_PLUGIN_VERSION', '1.0.75' ); 27 27 if ( ! defined( 'AOZ4WC_PLUGIN_DIR' ) ) { 28 28 define( 'AOZ4WC_PLUGIN_DIR', __DIR__ ); … … 286 286 delete_option( 'aoz4wc_plugin_activated' ); 287 287 } 288 $this->maybe_backfill_invoice_meta(); 289 } 290 /** 291 * Invoice references stored on orders before this version carry no internal id stamp, so 292 * they cannot be told apart from references copied onto an order by a subscription renewal 293 * or an order duplication. Schedule a one-off pass that checks and stamps them. 294 * 295 * @return void 296 */ 297 private function maybe_backfill_invoice_meta() { 298 if ( ! $this->check_requirements() ) { 299 return; 300 } 301 $state = get_option( 'aoz4wc_invoice_meta_backfill', '' ); 302 if ( 'done' === $state || 'scheduled' === $state ) { 303 return; 304 } 305 update_option( 'aoz4wc_invoice_meta_backfill', 'scheduled' ); 306 $this->schedule( 'aoz4wc_backfill_invoice_meta', array( 1 ) ); 288 307 } 289 308 /** … … 395 414 } 396 415 /** 397 * Summary of replace_subdomain398 * 399 * @param mixed $subdomain400 * @param mixed $url401 * @return bool|string416 * Replace the subdomain of a URL, or add one if there isn't one. 417 * 418 * @param string $subdomain Subdomain to set, e.g. 'shop'. Empty string removes it. 419 * @param string $url URL to work on. Defaults to home_url(). 420 * @return string|false Rebuilt URL, or false if the host can't be handled. 402 421 */ 403 422 public function replace_subdomain( $subdomain, $url = '' ) { … … 405 424 $url = home_url(); 406 425 } 407 if ( ! empty( $subdomain ) && ( substr( $subdomain, -1 ) !== '.' ) ) { 408 $subdomain .= '.'; 409 } 426 410 427 $parts = wp_parse_url( $url ); 411 if ( preg_match( '/[a-z0-9\-]{1,63}\.[a-z\.]{2,12}$/', $parts['host'], $domain_tld ) ) { 412 return $parts['scheme'] . '://' . $subdomain . $domain_tld[0]; 413 } else { 428 429 if ( empty( $parts['host'] ) ) { 414 430 return false; 415 431 } 432 433 $host = strtolower( trim( $parts['host'], '.' ) ); 434 435 // Nothing to replace on IPs or single-label hosts such as "localhost". 436 if ( filter_var( $host, FILTER_VALIDATE_IP ) || false === strpos( $host, '.' ) ) { 437 return false; 438 } 439 440 $labels = explode( '.', $host ); 441 $count = count( $labels ); 442 $keep = 2; 443 444 // Rough handling of two-part suffixes: example.co.uk, example.com.au… 445 $second_level = array( 'co', 'com', 'net', 'org', 'gov', 'edu', 'ac', 'ne', 'or', 'sch' ); 446 if ( $count > 2 && 2 === strlen( $labels[ $count - 1 ] ) && in_array( $labels[ $count - 2 ], $second_level, true ) ) { 447 $keep = 3; 448 } 449 450 $domain = implode( '.', array_slice( $labels, -$keep ) ); 451 $subdomain = trim( (string) $subdomain, " \t\n\r\0\x0B." ); 452 $new_host = ( '' === $subdomain ) ? $domain : $subdomain . '.' . $domain; 453 454 // Rebuild, keeping credentials, port, path, query and fragment. 455 $rebuilt = isset( $parts['scheme'] ) ? $parts['scheme'] . '://' : '//'; 456 457 if ( isset( $parts['user'] ) ) { 458 $rebuilt .= $parts['user']; 459 $rebuilt .= isset( $parts['pass'] ) ? ':' . $parts['pass'] : ''; 460 $rebuilt .= '@'; 461 } 462 463 $rebuilt .= $new_host; 464 $rebuilt .= isset( $parts['port'] ) ? ':' . $parts['port'] : ''; 465 $rebuilt .= isset( $parts['path'] ) ? $parts['path'] : ''; 466 $rebuilt .= isset( $parts['query'] ) ? '?' . $parts['query'] : ''; 467 $rebuilt .= isset( $parts['fragment'] ) ? '#' . $parts['fragment'] : ''; 468 469 return $rebuilt; 416 470 } 417 471 /** -
altoviz/tags/1.0.75/readme.txt
r3561363 r3651512 4 4 Tags: invoice, invoicing, accounting 5 5 Requires at least: 6.5 6 Tested up to: 6.96 Tested up to: 7.0 7 7 Requires PHP: 8.0 8 Stable tag: 1.0.7 48 Stable tag: 1.0.75 9 9 License: GNU General Public License v3.0 10 10 License URI: http://www.gnu.org/licenses/gpl-3.0.html -
altoviz/tags/1.0.75/version
r3561363 r3651512 1 1.0.7 41 1.0.75 -
altoviz/trunk/includes/admin/class-aoz4wc-wc-admin.php
r3561363 r3651512 102 102 '_wpnonce' 103 103 ); 104 $actions['aoz-update-user'] = '<a href="' . $link . '" title="' . __( 'Update customer in the Altoviz app', 'altoviz' ) . '">' .__( 'Update Altoviz 🚀 customer', 'altoviz' ) . '</a>';104 $actions['aoz-update-user'] = '<a href="' . esc_url( $link ) . '" title="' . esc_attr__( 'Update customer in the Altoviz app', 'altoviz' ) . '">' . esc_html__( 'Update Altoviz 🚀 customer', 'altoviz' ) . '</a>'; 105 105 return $actions; 106 106 } … … 120 120 switch ( $type ) { 121 121 case 'product': 122 $actions['aoz-update'] = '<a href="' . $link . '" title="' . __( 'Update product in the Altoviz app', 'altoviz' ) . '">' .__( 'Update Altoviz 🚀 product', 'altoviz' ) . '</a>';122 $actions['aoz-update'] = '<a href="' . esc_url( $link ) . '" title="' . esc_attr__( 'Update product in the Altoviz app', 'altoviz' ) . '">' . esc_html__( 'Update Altoviz 🚀 product', 'altoviz' ) . '</a>'; 123 123 break; 124 124 case 'shop_order': 125 $actions['aoz-update'] = '<a href="' . $link . '" title="' . __( 'Update invoice in the Altoviz app', 'altoviz' ) . '">' .__( 'Update Altoviz 🚀 invoice', 'altoviz' ) . '</a>';125 $actions['aoz-update'] = '<a href="' . esc_url( $link ) . '" title="' . esc_attr__( 'Update invoice in the Altoviz app', 'altoviz' ) . '">' . esc_html__( 'Update Altoviz 🚀 invoice', 'altoviz' ) . '</a>'; 126 126 break; 127 127 } … … 261 261 if ( AOZ4WC()->check_requirements() && is_a( $order, 'WC_Order' ) ) { 262 262 $actions['aoz-update'] = __( 'Update Altoviz 🚀 invoice', 'altoviz' ); 263 $invoice_id = intval( $order->get_meta( 'aoz4wc_invoice_id', true) );263 $invoice_id = intval( AOZ4WC()->wc()->get_AOZ4WC_invoice_id( $order ) ); 264 264 if ( $invoice_id ) { 265 265 $actions['aoz-send'] = __( 'Send Altoviz 🚀 invoice', 'altoviz' ); -
altoviz/trunk/includes/class-aoz4wc-api.php
r3561363 r3651512 60 60 */ 61 61 public function customers_find( $email, $internal_id, $single = false ) { 62 return $this-> remote_get( '/v1/customers/find?email=' . $email . '&internalid=' .$internal_id, $single );62 return $this->find( '/v1/customers/find', array( 'email' => $email ), $internal_id, $single ); 63 63 } 64 64 /** … … 95 95 */ 96 96 public function products_find( $number, $internal_id, $single = false ) { 97 return $this-> remote_get( '/v1/products/find?number=' . $number . '&internalid=' .$internal_id, $single );97 return $this->find( '/v1/products/find', array( 'number' => $number ), $internal_id, $single ); 98 98 } 99 99 /** … … 130 130 */ 131 131 public function sales_credits_find( $number, $internal_id, $single = false ) { 132 return $this->remote_get( '/v1/salecredits/find?number=' . $number . '&internalid=' . $internal_id, $single ); 132 return $this->find( '/v1/salecredits/find', array( 'number' => $number ), $internal_id, $single ); 133 } 134 /** 135 * Summary of sales_credits_get 136 * 137 * @param mixed $id 138 */ 139 public function sales_credits_get( $id ) { 140 return $this->remote_get( '/v1/salecredits/' . $id ); 133 141 } 134 142 /** … … 138 146 */ 139 147 public function sales_credits_finalize( $id ) { 140 return $this->remote_post( '/v1/salecredits/finalize/' . $id );148 return $this->remote_post( '/v1/salecredits/finalize/' . $id . '?dateAdjustment=MinimumDate' ); 141 149 } 142 150 /** … … 289 297 */ 290 298 public function sales_invoices_finalize( $id ) { 291 return $this->remote_post( '/v1/saleinvoices/finalize/' . $id );299 return $this->remote_post( '/v1/saleinvoices/finalize/' . $id . '?dateAdjustment=MinimumDate' ); 292 300 } 293 301 /** … … 299 307 */ 300 308 public function sales_invoices_find( $number, $internal_id, $single = false ) { 301 return $this->remote_get( '/v1/saleinvoices/find?number=' . $number . '&internalid=' . $internal_id, $single ); 309 return $this->find( '/v1/saleinvoices/find', array( 'number' => $number ), $internal_id, $single ); 310 } 311 /** 312 * Summary of sales_invoices_get 313 * 314 * @param mixed $id 315 */ 316 public function sales_invoices_get( $id ) { 317 return $this->remote_get( '/v1/saleinvoices/' . $id ); 302 318 } 303 319 /** … … 378 394 } 379 395 /** 396 * Look an entity up by its internal id and return only what actually carries that id. 397 * 398 * The endpoints are not all equally strict — products and contacts match with ILIKE, where 399 * the underscore of the "wc_" prefix is a single character wildcard, so "wc_123" also 400 * matches "wcX123". Filtering here makes every lookup exact regardless of the endpoint, 401 * and an ambiguous answer is refused rather than guessed at. 402 * 403 * @param mixed $path 404 * @param mixed $params 405 * @param mixed $internal_id 406 * @param mixed $single 407 */ 408 private function find( $path, $params, $internal_id, $single = false ) { 409 $internal_id = is_string( $internal_id ) ? trim( $internal_id ) : $internal_id; 410 if ( empty( $internal_id ) ) { 411 // An empty internal id is never a legitimate lookup: it matches every entity that 412 // has none, and the caller would then adopt an arbitrary one. 413 AOZ4WC()->log( sprintf( 'Refused a lookup on %s with an empty internal id', $path ), 'error' ); 414 return false; 415 } 416 $url = $path . '?' . http_build_query( array_merge( $params, array( 'internalid' => $internal_id ) ) ); 417 $result = $this->remote_get( $url ); 418 return $this->pick( $result, $internal_id, $single, $url ); 419 } 420 /** 421 * Keep only the entities whose internal id is exactly the one that was asked for. 422 * 423 * @param mixed $result 424 * @param mixed $internal_id 425 * @param mixed $single 426 * @param mixed $url 427 */ 428 private function pick( $result, $internal_id, $single, $url = '' ) { 429 if ( ! is_array( $result ) ) { 430 return false; 431 } 432 $matches = array(); 433 foreach ( $result as $entity ) { 434 if ( ! is_array( $entity ) || ! isset( $entity['internalId'] ) ) { 435 continue; 436 } 437 if ( (string) $entity['internalId'] !== (string) $internal_id ) { 438 continue; 439 } 440 if ( ! empty( $entity['isCancelled'] ) ) { 441 continue; 442 } 443 $matches[] = $entity; 444 } 445 if ( ! $single ) { 446 return count( $matches ) ? $matches : false; 447 } 448 if ( 1 === count( $matches ) ) { 449 return reset( $matches ); 450 } 451 if ( count( $matches ) > 1 ) { 452 // Picking one at random is how an order ends up attached to somebody else's 453 // document. Refuse, and make the duplicate visible so it can be resolved. 454 $ids = implode( ', ', array_map( function ( $entity ) { 455 return $entity['id'] ?? '?'; 456 }, $matches ) ); 457 AOZ4WC()->log( 458 sprintf( 459 // translators: %1$s internal id, %2$s list of Altoviz ids 460 __( 'Several Altoviz entities share the internal id %1$s (ids: %2$s). Resolve the duplicate in Altoviz 🚀 before synchronizing again.', 'altoviz' ), 461 $internal_id, 462 $ids 463 ), 464 'error' 465 ); 466 AOZ4WC()->sys_log( array( 'source' => 'API', 'url' => $url, 'ambiguous_internal_id' => $internal_id, 'ids' => $ids ) ); 467 } 468 return false; 469 } 470 /** 380 471 * Summary of remote_get 381 472 * -
altoviz/trunk/includes/class-aoz4wc-wc.php
r3561363 r3651512 8 8 9 9 10 /** 11 * Order meta keys owned by this plugin. Kept in one place because they have to be 12 * stripped wholesale whenever WooCommerce copies meta from one order to another. 13 */ 14 const ORDER_META_KEYS = array( 15 'aoz4wc_customer_id', 16 'aoz4wc_customer_number', 17 'aoz4wc_invoice_date', 18 'aoz4wc_invoice_id', 19 'aoz4wc_invoice_internal_id', 20 'aoz4wc_invoice_kind', 21 'aoz4wc_invoice_number', 22 'aoz4wc_invoice_public_link', 23 'aoz4wc_invoice_sent', 24 ); 25 26 /** 27 * Keys written by earlier generations of this plugin. Sites bridge them to the current ones 28 * with the aoz4wc_order_invoice_* filters, so a stale one is just as capable of pointing an 29 * order at somebody else's invoice — it has to be recognized and removed alongside the rest. 30 * 31 * Meta keys are case sensitive, hence both spellings. 32 */ 33 const LEGACY_ORDER_META_KEYS = array( 34 'AOZ4WC_invoice_date', 35 'AOZ4WC_invoice_id', 36 'AOZ4WC_invoice_number', 37 'AOZ4WC_invoice_public_link', 38 '_altoviz_invoice_date', 39 '_altoviz_invoice_id', 40 '_altoviz_invoice_link', 41 '_altoviz_invoice_number', 42 'aoz_cancelled_reason', 43 'aoz_customer_id', 44 'aoz_customer_number', 45 'aoz_internal', 46 'aoz_invoice_date', 47 'aoz_invoice_id', 48 'aoz_invoice_link', 49 'aoz_invoice_number', 50 'aoz_invoice_public_link', 51 'aoz_invoice_sent', 52 ); 53 10 54 public function __construct() { 11 55 add_action('aoz4wc_sync_customer', array($this, 'do_sync_customer'), 10, 1); 12 56 add_action('aoz4wc_sync_product', array($this, 'do_sync_product'), 10, 1); 13 57 add_action('aoz4wc_sync_order', array($this, 'do_sync_order'), 10, 1); 58 add_action('aoz4wc_backfill_invoice_meta', array($this, 'do_backfill_invoice_meta'), 10, 1); 14 59 add_action('profile_update', array($this, 'profile_update'), 10, 3); 60 // A renewal / resubscribe order is built by copying the subscription's meta, and a 61 // subscription may still carry invoice meta written by an older version of this plugin 62 // which used to synchronize subscriptions as if they were orders. Copying it would make 63 // the new order point at the subscription's invoice, so strip it at every copy point. 64 add_filter('wcs_subscription_meta', array($this, 'wcs_strip_invoice_meta'), 10, 1); 65 add_filter('wcs_renewal_order_meta', array($this, 'wcs_strip_invoice_meta'), 10, 1); 66 add_filter('wcs_resubscribe_order_meta', array($this, 'wcs_strip_invoice_meta'), 10, 1); 67 // Catch-all for order duplication plugins and importers, which copy meta the same way. 68 add_action('woocommerce_new_order', array($this, 'clear_inherited_invoice_meta'), 1, 2); 15 69 add_action('woocommerce_order_status_changed', array($this, 'woocommerce_order_status_changed'), 99, 3); 16 70 add_action('woocommerce_new_product', array($this, 'woocommerce_new_product'), 999, 2); … … 62 116 } 63 117 /** 118 * Only real orders and refunds get an Altoviz document. Subscriptions are WC_Order 119 * subclasses, so they reach the sync entry points like any other order — an older version 120 * of this plugin synchronized them and left invoice meta behind on them. 121 * 122 * @param mixed $order 123 * @return bool 124 */ 125 public function is_syncable_order($order) { 126 return is_a($order, 'WC_Abstract_Order') 127 && in_array($order->get_type(), array('shop_order', 'shop_order_refund'), true); 128 } 129 /** 130 * Whether the invoice meta stored on an order actually describes that order. 131 * 132 * Meta is copied verbatim by WooCommerce Subscriptions renewals, order duplication tools 133 * and importers, so its presence alone proves nothing. Only a stamped internal id that 134 * matches the order's own does. Returns 'own', 'foreign' or 'unverified' — the last one 135 * meaning the meta predates the stamp and has not been checked against the API yet. 136 * 137 * @param mixed $order 138 * @return string 139 */ 140 public function invoice_meta_ownership($order) { 141 if (! is_a($order, 'WC_Abstract_Order') || ! $this->get_stored_invoice_id($order)) { 142 return 'foreign'; 143 } 144 $stamped = $order->get_meta('aoz4wc_invoice_internal_id', true); 145 if (empty($stamped)) { 146 // Either written before the stamp existed, or inherited from a subscription under one 147 // of the legacy keys. Indistinguishable locally, so it takes an API check to settle. 148 return 'unverified'; 149 } 150 return $stamped === $this->format_internal_id($order->get_id()) ? 'own' : 'foreign'; 151 } 152 /** 153 * The document id stored on an order, whatever key it is under. 154 * 155 * Deliberately goes through the aoz4wc_order_invoice_id filter without the ownership gate, 156 * so that references left by older versions of the plugin — which sites bridge with that 157 * filter — are seen here rather than mistaken for an order that has no document at all. 158 * 159 * @param mixed $order 160 * @return mixed 161 */ 162 public function get_stored_invoice_id($order) { 163 if (! is_a($order, 'WC_Abstract_Order')) { 164 return false; 165 } 166 return apply_filters('aoz4wc_order_invoice_id', $order->get_meta('aoz4wc_invoice_id', true), $order); 167 } 168 /** 169 * Whether a meta key holds an Altoviz reference, under any generation of this plugin. 170 * 171 * @param mixed $key 172 * @return bool 173 */ 174 public function is_owned_meta_key($key) { 175 $key = (string) $key; 176 // Matched by prefix rather than by name: the legacy list cannot be assumed complete, and 177 // a key that slips through is one that keeps pointing an order at the wrong document. 178 return 0 === stripos($key, 'aoz4wc_') 179 || 0 === stripos($key, 'aoz_') 180 || 0 === stripos($key, '_altoviz_'); 181 } 182 /** 183 * Summary of invoice_meta_is_own 184 * 185 * @param mixed $order 186 * @return bool 187 */ 188 public function invoice_meta_is_own($order) { 189 return 'own' === $this->invoice_meta_ownership($order); 190 } 191 /** 192 * Drop this plugin's meta from the set WooCommerce Subscriptions is about to copy onto a 193 * subscription, a renewal order or a resubscribe order. 194 * 195 * The filtered value is a list of rows shaped like array('meta_key' => ..., 'meta_value' => ...). 196 * 197 * @param mixed $meta 198 * @return array 199 */ 200 public function wcs_strip_invoice_meta($meta) { 201 if (! is_array($meta)) { 202 return $meta; 203 } 204 return array_values( 205 array_filter( 206 $meta, 207 function ($row) { 208 $key = is_array($row) ? ( $row['meta_key'] ?? '' ) : ( is_object($row) ? ( $row->meta_key ?? '' ) : '' ); 209 return ! $this->is_owned_meta_key($key); 210 } 211 ) 212 ); 213 } 214 /** 215 * Remove invoice meta that a freshly created order inherited from another one. 216 * 217 * A brand new order cannot legitimately own an Altoviz document yet, so anything present 218 * at this point was copied. 219 * 220 * @param mixed $order_id 221 * @param mixed $order 222 * @return void 223 */ 224 public function clear_inherited_invoice_meta($order_id, $order = null) { 225 $order = is_a($order, 'WC_Abstract_Order') ? $order : wc_get_order($order_id); 226 $inherited = $order ? $this->get_stored_invoice_id($order) : false; 227 if (! $inherited) { 228 return; 229 } 230 AOZ4WC()->log( 231 sprintf( 232 // translators: %1$s order id, %2$s invoice id 233 __('Removed Altoviz invoice #%2$s inherited by the new Woocommerce order #%1$s', 'altoviz'), 234 $order_id, 235 $inherited 236 ), 237 'warning' 238 ); 239 $this->delete_order_meta($order, true); 240 } 241 /** 242 * Summary of delete_order_meta 243 * 244 * @param mixed $order 245 * @param mixed $save 246 * @return void 247 */ 248 public function delete_order_meta($order, $save = true) { 249 if (! is_a($order, 'WC_Abstract_Order')) { 250 return; 251 } 252 // The legacy keys have to go too: leaving them behind lets the compatibility filters 253 // resurrect the very reference that was just removed. 254 foreach (array_merge(self::ORDER_META_KEYS, self::LEGACY_ORDER_META_KEYS) as $key) { 255 $order->delete_meta_data($key); 256 } 257 // Then sweep whatever else this plugin has ever written, so a key missing from the lists 258 // above is still removed rather than left behind to resurface. 259 foreach ($order->get_meta_data() as $meta) { 260 $key = is_object($meta) ? ( $meta->key ?? '' ) : ''; 261 if ($key && $this->is_owned_meta_key($key)) { 262 $order->delete_meta_data($key); 263 } 264 } 265 if ($save) { 266 $order->save(); 267 } 268 } 269 /** 270 * Resolve the Altoviz document belonging to an order. 271 * 272 * Prefers the id already stored on the order, but never trusts it blindly: the document is 273 * fetched and its internal id compared to the order's. Meta that turns out to describe 274 * another order — copied by a subscription renewal, an order duplication tool or an import 275 * — is reported and removed instead of being used. 276 * 277 * @param mixed $order 278 * @param mixed $credit 279 * @return array|false 280 */ 281 public function resolve_order_document($order, $credit = false) { 282 if (! is_a($order, 'WC_Abstract_Order')) { 283 return false; 284 } 285 $internal_id = $this->format_internal_id($order->get_id()); 286 $stored_id = intval($this->get_stored_invoice_id($order)); 287 $stored_kind = $order->get_meta('aoz4wc_invoice_kind', true); 288 if ($stored_id && ( empty($stored_kind) || ( $credit ? 'credit' : 'invoice' ) === $stored_kind )) { 289 $document = $credit 290 ? AOZ4WC()->api()->sales_credits_get($stored_id) 291 : AOZ4WC()->api()->sales_invoices_get($stored_id); 292 if (is_array($document) && isset($document['internalId'])) { 293 if ((string) $document['internalId'] === (string) $internal_id) { 294 return $document; 295 } 296 AOZ4WC()->log( 297 sprintf( 298 // translators: %1$s order id, %2$s Altoviz document id, %3$s the internal id the document really carries 299 __('The Altoviz 🚀 document #%2$s stored on the Woocommerce order #%1$s belongs to %3$s. The stale reference has been removed.', 'altoviz'), 300 $order->get_id(), 301 $stored_id, 302 $document['internalId'] 303 ), 304 'error' 305 ); 306 $this->delete_order_meta($order, true); 307 } 308 } 309 return AOZ4WC()->api()->sales_documents_find('', $internal_id, true, $credit); 310 } 311 /** 312 * One-off maintenance pass over orders whose invoice reference predates the internal id 313 * stamp, including the ones left by earlier versions of this plugin. 314 * 315 * Until an order has been through here its reference stays hidden, because an inherited one 316 * cannot be told apart from a legitimate one without asking the API. So this walks every 317 * order rather than only the ones that look suspicious, and pages by an offset over an 318 * unfiltered, stably ordered set — a query filtered on the very meta being written would 319 * shift underneath its own pagination and skip orders. 320 * 321 * Orders without a reference cost nothing, so the batch can be large: the only per-order 322 * expense is one API call for those that actually carry one. 323 * 324 * @param mixed $page 325 * @return void 326 */ 327 public function do_backfill_invoice_meta($page = 1) { 328 $page = max(1, intval($page)); 329 $batch = max(1, intval(apply_filters('aoz4wc_backfill_batch_size', 100))); 330 if (1 === $page) { 331 $this->purge_subscription_invoice_meta(); 332 } 333 $orders = wc_get_orders( 334 array( 335 'limit' => $batch, 336 'page' => $page, 337 'type' => 'shop_order', 338 'orderby' => 'ID', 339 'order' => 'ASC', 340 'status' => array_keys(wc_get_order_statuses()), 341 ) 342 ); 343 if (empty($orders)) { 344 update_option('aoz4wc_invoice_meta_backfill', 'done'); 345 $checked = intval(get_option('aoz4wc_invoice_meta_backfill_count', 0)); 346 AOZ4WC()->log( 347 sprintf( 348 // translators: %s number of orders 349 __('Altoviz 🚀 invoice references checked. %s order(s) were confirmed and stamped.', 'altoviz'), 350 $checked 351 ), 352 'information' 353 ); 354 return; 355 } 356 $verified = 0; 357 foreach ($orders as $order) { 358 if ($this->verify_order_invoice_meta($order)) { 359 ++$verified; 360 } 361 } 362 if ($verified) { 363 update_option('aoz4wc_invoice_meta_backfill_count', intval(get_option('aoz4wc_invoice_meta_backfill_count', 0)) + $verified); 364 } 365 AOZ4WC()->sys_log(array('aoz4wc_backfill_invoice_meta' => 'batch', 'page' => $page, 'verified' => $verified)); 366 AOZ4WC()->schedule('aoz4wc_backfill_invoice_meta', array($page + 1)); 367 } 368 /** 369 * Confirm that an order's unstamped invoice meta really describes that order, and stamp it. 370 * 371 * Nothing is deleted here on an inconclusive answer: an unreachable API must not be able to 372 * wipe correct references. Meta that cannot be confirmed simply stays unverified, and the 373 * read side keeps it hidden. 374 * 375 * @param mixed $order 376 * @return bool 377 */ 378 public function verify_order_invoice_meta($order) { 379 if ('unverified' !== $this->invoice_meta_ownership($order)) { 380 return false; 381 } 382 $document = $this->resolve_order_document($order, 'shop_order_refund' === $order->get_type()); 383 if ($document) { 384 $this->update_order_meta($order, $document, true); 385 return true; 386 } 387 return false; 388 } 389 /** 390 * Remove invoice meta left on subscriptions by an older version of this plugin, which used 391 * to synchronize them as orders. 392 * 393 * This is the source that renewal orders copy from, so it has to be cleared or every future 394 * renewal inherits it again. The Altoviz documents themselves are accounting records and 395 * are left untouched — only the Woocommerce side references are removed. 396 * 397 * @return void 398 */ 399 public function purge_subscription_invoice_meta() { 400 if (! function_exists('wcs_get_subscriptions')) { 401 return; 402 } 403 $subscriptions = wcs_get_subscriptions(array('subscriptions_per_page' => -1)); 404 $purged = 0; 405 foreach ($subscriptions as $subscription) { 406 if (! $this->get_stored_invoice_id($subscription)) { 407 continue; 408 } 409 $this->delete_order_meta($subscription, true); 410 ++$purged; 411 } 412 if ($purged) { 413 AOZ4WC()->log( 414 sprintf( 415 // translators: %s number of subscriptions 416 __('Removed stale Altoviz 🚀 invoice references from %s subscription(s). Subscriptions are not invoiced; their renewal orders are.', 'altoviz'), 417 $purged 418 ), 419 'warning' 420 ); 421 } 422 } 423 /** 64 424 * Summary of profile_update 65 425 * … … 144 504 * 145 505 * Legacy handler intentionally left in place for backward compatibility. 146 * It is currently not registered in __construct(). 506 * This method is not registered: the woocommerce_new_order hook is bound to 507 * clear_inherited_invoice_meta() instead, which does not synchronize anything. 147 508 * 148 509 * @param mixed $order_id … … 227 588 $order = is_numeric($order) ? wc_get_order($order) : $order; 228 589 if (! $order) { 590 return; 591 } 592 if (! $this->is_syncable_order($order)) { 593 AOZ4WC()->sys_log(array('AOZ Sync order action skipped', 'type' => $order->get_type(), 'id' => $order->get_id())); 229 594 return; 230 595 } … … 282 647 */ 283 648 public function get_AOZ4WC_invoice_id($order) { 284 if (empty($order) ) {649 if (empty($order) || ! $this->invoice_meta_is_own($order)) { 285 650 return false; 286 651 } … … 293 658 */ 294 659 public function get_AOZ4WC_invoice_link($order) { 295 if (empty($order) ) {660 if (empty($order) || ! $this->invoice_meta_is_own($order)) { 296 661 return ''; 297 662 } … … 304 669 */ 305 670 public function get_AOZ4WC_invoice_number($order) { 306 if (empty($order) ) {671 if (empty($order) || ! $this->invoice_meta_is_own($order)) { 307 672 return ''; 308 673 } … … 315 680 */ 316 681 public function get_AOZ4WC_invoice_date($order) { 317 if (empty($order) ) {682 if (empty($order) || ! $this->invoice_meta_is_own($order)) { 318 683 return ''; 319 684 } … … 542 907 $aoz4wc_document_id = null; 543 908 if (! $aoz4wc_document) { 544 $aoz4wc_document = AOZ4WC()->api()->sales_documents_find('', $this->format_internal_id($order_id), true, $credit);909 $aoz4wc_document = $this->resolve_order_document($order, $credit); 545 910 } 546 911 if ($aoz4wc_document) { … … 588 953 return false; 589 954 } 955 if (! $this->is_syncable_order($order)) { 956 AOZ4WC()->sys_log(array('do_sync_order skipped', 'type' => $order->get_type(), 'id' => $order->get_id())); 957 return false; 958 } 590 959 $result = $this->do_sync_individual_order($order); 591 960 AOZ4WC()->sys_log(['do_sync_order' => $result]); … … 631 1000 return false; 632 1001 } 1002 if (! $this->is_syncable_order($order)) { 1003 AOZ4WC()->sys_log(array('do_sync_individual_order skipped', 'type' => $order->get_type(), 'id' => $order->get_id())); 1004 return false; 1005 } 633 1006 if (! AOZ4WC()->app()->is_supported_currency($order->get_currency())) { 634 1007 return false; … … 636 1009 AOZ4WC()->sys_log(array('step' => '1.1')); 637 1010 $order_id = $order->get_id(); 638 if ($this->get_transient('order', $order_id)) { 1011 // The lock has to span the whole lookup and creation, otherwise two concurrent 1012 // synchronizations both find nothing and both create a document for the same order. 1013 if (! $this->acquire_lock('order', $order_id)) { 1014 AOZ4WC()->sys_log(array('do_sync_individual_order' => 'already running', 'order_id' => $order_id)); 639 1015 return false; 640 1016 } 1017 try { 1018 return $this->sync_individual_order($order, $order_id); 1019 } finally { 1020 $this->release_lock('order', $order_id); 1021 } 1022 } 1023 /** 1024 * Summary of sync_individual_order 1025 * 1026 * @param mixed $order 1027 * @param mixed $order_id 1028 */ 1029 private function sync_individual_order($order, $order_id) { 641 1030 AOZ4WC()->sys_log( 642 1031 array( … … 645 1034 ) 646 1035 ); 647 $this->set_transient('order', $order_id);648 1036 $credit = 'shop_order_refund' === $order->get_type(); 649 1037 $main_order = $credit ? wc_get_order($order->get_parent_id()) : $order; … … 855 1243 ); 856 1244 // translators: %1$s invoice number 857 $data['subject'] = $credit ? sprintf(__('Refund of the invoice #%1$s', 'altoviz'), $ main_order->get_meta('aoz4wc_invoice_number', true)) : '';1245 $data['subject'] = $credit ? sprintf(__('Refund of the invoice #%1$s', 'altoviz'), $this->get_AOZ4WC_invoice_number($main_order)) : ''; 858 1246 // $data['shippingVat'] = $this->create_vat(); 859 1247 // $data['taxAmount'] = 0, … … 882 1270 ) 883 1271 ); 884 $aoz4wc_invoice = AOZ4WC()->api()->sales_documents_find('', $this->format_internal_id($order_id), true, $credit);1272 $aoz4wc_invoice = $this->resolve_order_document($order, $credit); 885 1273 if ($aoz4wc_invoice) { 886 1274 $aoz4wc_invoice_id = $aoz4wc_invoice['id']; … … 1057 1445 $order->update_meta_data('aoz4wc_invoice_number', $aoz4wc_invoice['number']); 1058 1446 $order->update_meta_data('aoz4wc_invoice_public_link', $aoz4wc_invoice['publicLink']); 1447 // Stamp which order this document was resolved for, so that a copy of this meta 1448 // landing on another order can be recognized as not belonging to it. 1449 $order->update_meta_data('aoz4wc_invoice_internal_id', $this->format_internal_id($order->get_id())); 1450 $order->update_meta_data('aoz4wc_invoice_kind', 'shop_order_refund' === $order->get_type() ? 'credit' : 'invoice'); 1059 1451 if ($save) { 1060 1452 $order->save(); … … 1107 1499 } 1108 1500 $product_id = $product->get_id(); 1109 if (get_transient('product', $product_id)) { 1501 // Was calling WordPress' global get_transient() with a literal 'product' key, which this 1502 // plugin never sets, so the guard never fired. 1503 if ($this->get_transient('product', $product_id)) { 1110 1504 return false; 1111 1505 } … … 1553 1947 } 1554 1948 /** 1949 * Summary of get_lock_name 1950 * 1951 * @param mixed $name 1952 * @param mixed $id 1953 * @return string 1954 */ 1955 private function get_lock_name($name, $id) { 1956 return 'aoz4wc_lock_' . $name . '_' . $id; 1957 } 1958 /** 1959 * Take an exclusive lock, or report that somebody else already holds it. 1960 * 1961 * The insert is what makes this atomic: option_name is unique, so exactly one concurrent 1962 * request can create the row. add_option() cannot be used for this — it looks the option up 1963 * before inserting, which leaves a window where two requests both believe they won. 1964 * 1965 * @param mixed $name 1966 * @param mixed $id 1967 * @return bool 1968 */ 1969 private function acquire_lock($name, $id) { 1970 global $wpdb; 1971 $option = $this->get_lock_name($name, $id); 1972 $ttl = max(1, intval(apply_filters('aoz4wc_sync_lock_ttl', 60, $name, $id))); 1973 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching 1974 $inserted = $wpdb->query( 1975 $wpdb->prepare( 1976 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", 1977 $option, 1978 time() 1979 ) 1980 ); 1981 wp_cache_delete($option, 'options'); 1982 wp_cache_delete('notoptions', 'options'); 1983 if ($inserted) { 1984 return true; 1985 } 1986 // Nobody releases the lock when a request is killed mid-synchronization, so a lock older 1987 // than the timeout is assumed abandoned and taken over. 1988 $since = intval(get_option($option, 0)); 1989 if ($since > 0 && ( time() - $since ) > $ttl) { 1990 AOZ4WC()->sys_log(array('stale lock taken over', 'lock' => $option, 'age' => time() - $since)); 1991 $this->release_lock($name, $id); 1992 return $this->acquire_lock($name, $id); 1993 } 1994 return false; 1995 } 1996 /** 1997 * Summary of release_lock 1998 * 1999 * @param mixed $name 2000 * @param mixed $id 2001 * @return void 2002 */ 2003 private function release_lock($name, $id) { 2004 delete_option($this->get_lock_name($name, $id)); 2005 } 2006 /** 1555 2007 * Summary of AOZ4WC_log 1556 2008 * -
altoviz/trunk/plugin.php
r3561363 r3651512 3 3 * Plugin Name: Altoviz for WooCommerce 4 4 * Description: Invoicing and accounting made easy for entrepreneurs. 5 * Version: 1.0.7 45 * Version: 1.0.75 6 6 * Requires at least: 6.5 7 7 * Tested up to: 7.0 … … 24 24 } 25 25 define( 'AOZ4WC_PLUGIN_FILE', __FILE__ ); 26 define( 'AOZ4WC_PLUGIN_VERSION', '1.0.7 4' );26 define( 'AOZ4WC_PLUGIN_VERSION', '1.0.75' ); 27 27 if ( ! defined( 'AOZ4WC_PLUGIN_DIR' ) ) { 28 28 define( 'AOZ4WC_PLUGIN_DIR', __DIR__ ); … … 286 286 delete_option( 'aoz4wc_plugin_activated' ); 287 287 } 288 $this->maybe_backfill_invoice_meta(); 289 } 290 /** 291 * Invoice references stored on orders before this version carry no internal id stamp, so 292 * they cannot be told apart from references copied onto an order by a subscription renewal 293 * or an order duplication. Schedule a one-off pass that checks and stamps them. 294 * 295 * @return void 296 */ 297 private function maybe_backfill_invoice_meta() { 298 if ( ! $this->check_requirements() ) { 299 return; 300 } 301 $state = get_option( 'aoz4wc_invoice_meta_backfill', '' ); 302 if ( 'done' === $state || 'scheduled' === $state ) { 303 return; 304 } 305 update_option( 'aoz4wc_invoice_meta_backfill', 'scheduled' ); 306 $this->schedule( 'aoz4wc_backfill_invoice_meta', array( 1 ) ); 288 307 } 289 308 /** … … 395 414 } 396 415 /** 397 * Summary of replace_subdomain398 * 399 * @param mixed $subdomain400 * @param mixed $url401 * @return bool|string416 * Replace the subdomain of a URL, or add one if there isn't one. 417 * 418 * @param string $subdomain Subdomain to set, e.g. 'shop'. Empty string removes it. 419 * @param string $url URL to work on. Defaults to home_url(). 420 * @return string|false Rebuilt URL, or false if the host can't be handled. 402 421 */ 403 422 public function replace_subdomain( $subdomain, $url = '' ) { … … 405 424 $url = home_url(); 406 425 } 407 if ( ! empty( $subdomain ) && ( substr( $subdomain, -1 ) !== '.' ) ) { 408 $subdomain .= '.'; 409 } 426 410 427 $parts = wp_parse_url( $url ); 411 if ( preg_match( '/[a-z0-9\-]{1,63}\.[a-z\.]{2,12}$/', $parts['host'], $domain_tld ) ) { 412 return $parts['scheme'] . '://' . $subdomain . $domain_tld[0]; 413 } else { 428 429 if ( empty( $parts['host'] ) ) { 414 430 return false; 415 431 } 432 433 $host = strtolower( trim( $parts['host'], '.' ) ); 434 435 // Nothing to replace on IPs or single-label hosts such as "localhost". 436 if ( filter_var( $host, FILTER_VALIDATE_IP ) || false === strpos( $host, '.' ) ) { 437 return false; 438 } 439 440 $labels = explode( '.', $host ); 441 $count = count( $labels ); 442 $keep = 2; 443 444 // Rough handling of two-part suffixes: example.co.uk, example.com.au… 445 $second_level = array( 'co', 'com', 'net', 'org', 'gov', 'edu', 'ac', 'ne', 'or', 'sch' ); 446 if ( $count > 2 && 2 === strlen( $labels[ $count - 1 ] ) && in_array( $labels[ $count - 2 ], $second_level, true ) ) { 447 $keep = 3; 448 } 449 450 $domain = implode( '.', array_slice( $labels, -$keep ) ); 451 $subdomain = trim( (string) $subdomain, " \t\n\r\0\x0B." ); 452 $new_host = ( '' === $subdomain ) ? $domain : $subdomain . '.' . $domain; 453 454 // Rebuild, keeping credentials, port, path, query and fragment. 455 $rebuilt = isset( $parts['scheme'] ) ? $parts['scheme'] . '://' : '//'; 456 457 if ( isset( $parts['user'] ) ) { 458 $rebuilt .= $parts['user']; 459 $rebuilt .= isset( $parts['pass'] ) ? ':' . $parts['pass'] : ''; 460 $rebuilt .= '@'; 461 } 462 463 $rebuilt .= $new_host; 464 $rebuilt .= isset( $parts['port'] ) ? ':' . $parts['port'] : ''; 465 $rebuilt .= isset( $parts['path'] ) ? $parts['path'] : ''; 466 $rebuilt .= isset( $parts['query'] ) ? '?' . $parts['query'] : ''; 467 $rebuilt .= isset( $parts['fragment'] ) ? '#' . $parts['fragment'] : ''; 468 469 return $rebuilt; 416 470 } 417 471 /** -
altoviz/trunk/readme.txt
r3561363 r3651512 4 4 Tags: invoice, invoicing, accounting 5 5 Requires at least: 6.5 6 Tested up to: 6.96 Tested up to: 7.0 7 7 Requires PHP: 8.0 8 Stable tag: 1.0.7 48 Stable tag: 1.0.75 9 9 License: GNU General Public License v3.0 10 10 License URI: http://www.gnu.org/licenses/gpl-3.0.html -
altoviz/trunk/version
r3561363 r3651512 1 1.0.7 41 1.0.75
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)