Plugin Directory

Changeset 3651512


Ignore:
Timestamp:
08/17/2026 05:45:58 PM (2 months ago)
Author:
altoviz
Message:

Update to version 1.0.75 from GitHub

Location:
altoviz
Files:
12 edited
1 copied

Legend:

Unmodified
Added
Removed
  • altoviz/tags/1.0.75/includes/admin/class-aoz4wc-wc-admin.php

    r3561363 r3651512  
    102102                        '_wpnonce'
    103103                );
    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>';
    105105                return $actions;
    106106        }
    … …  
    120120                switch ( $type ) {
    121121                        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>';
    123123                                break;
    124124                        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>';
    126126                                break;
    127127                }
    … …  
    261261                if ( AOZ4WC()->check_requirements() && is_a( $order, 'WC_Order' ) ) {
    262262                        $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 ) );
    264264                        if ( $invoice_id ) {
    265265                                $actions['aoz-send'] = __( 'Send Altoviz 🚀 invoice', 'altoviz' );
  • altoviz/tags/1.0.75/includes/class-aoz4wc-api.php

    r3561363 r3651512  
    6060         */
    6161        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 );
    6363        }
    6464        /**
    … …  
    9595         */
    9696        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 );
    9898        }
    9999        /**
    … …  
    130130         */
    131131        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 );
    133141        }
    134142        /**
    … …  
    138146         */
    139147        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' );
    141149        }
    142150        /**
    … …  
    289297         */
    290298        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' );
    292300        }
    293301        /**
    … …  
    299307         */
    300308        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 );
    302318        }
    303319        /**
    … …  
    378394        }
    379395        /**
     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        /**
    380471         * Summary of remote_get
    381472         *
  • altoviz/tags/1.0.75/includes/class-aoz4wc-wc.php

    r3561363 r3651512  
    88
    99
     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
    1054        public function __construct() {
    1155                add_action('aoz4wc_sync_customer', array($this, 'do_sync_customer'), 10, 1);
    1256                add_action('aoz4wc_sync_product', array($this, 'do_sync_product'), 10, 1);
    1357                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);
    1459                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);
    1569                add_action('woocommerce_order_status_changed', array($this, 'woocommerce_order_status_changed'), 99, 3);
    1670                add_action('woocommerce_new_product', array($this, 'woocommerce_new_product'), 999, 2);
    … …  
    62116        }
    63117        /**
     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        /**
    64424         * Summary of profile_update
    65425         *
    … …  
    144504         *
    145505         * 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.
    147508         *
    148509         * @param mixed $order_id
    … …  
    227588                $order = is_numeric($order) ? wc_get_order($order) : $order;
    228589                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()));
    229594                        return;
    230595                }
    … …  
    282647         */
    283648        public function get_AOZ4WC_invoice_id($order) {
    284                 if (empty($order)) {
     649                if (empty($order) || ! $this->invoice_meta_is_own($order)) {
    285650                        return false;
    286651                }
    … …  
    293658         */
    294659        public function get_AOZ4WC_invoice_link($order) {
    295                 if (empty($order)) {
     660                if (empty($order) || ! $this->invoice_meta_is_own($order)) {
    296661                        return '';
    297662                }
    … …  
    304669         */
    305670        public function get_AOZ4WC_invoice_number($order) {
    306                 if (empty($order)) {
     671                if (empty($order) || ! $this->invoice_meta_is_own($order)) {
    307672                        return '';
    308673                }
    … …  
    315680         */
    316681        public function get_AOZ4WC_invoice_date($order) {
    317                 if (empty($order)) {
     682                if (empty($order) || ! $this->invoice_meta_is_own($order)) {
    318683                        return '';
    319684                }
    … …  
    542907                $aoz4wc_document_id = null;
    543908                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);
    545910                }
    546911                if ($aoz4wc_document) {
    … …  
    588953                        return false;
    589954                }
     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                }
    590959                $result = $this->do_sync_individual_order($order);
    591960                AOZ4WC()->sys_log(['do_sync_order' => $result]);
    … …  
    6311000                        return false;
    6321001                }
     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                }
    6331006                if (! AOZ4WC()->app()->is_supported_currency($order->get_currency())) {
    6341007                        return false;
    … …  
    6361009                AOZ4WC()->sys_log(array('step' => '1.1'));
    6371010                $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));
    6391015                        return false;
    6401016                }
     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) {
    6411030                AOZ4WC()->sys_log(
    6421031                        array(
    … …  
    6451034                        )
    6461035                );
    647                 $this->set_transient('order', $order_id);
    6481036                $credit      = 'shop_order_refund' === $order->get_type();
    6491037                $main_order  = $credit ? wc_get_order($order->get_parent_id()) : $order;
    … …  
    8551243                );
    8561244                // 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)) : '';
    8581246                // $data['shippingVat']     = $this->create_vat();
    8591247                // $data['taxAmount'] = 0,
    … …  
    8821270                        )
    8831271                );
    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);
    8851273                if ($aoz4wc_invoice) {
    8861274                        $aoz4wc_invoice_id = $aoz4wc_invoice['id'];
    … …  
    10571445                        $order->update_meta_data('aoz4wc_invoice_number', $aoz4wc_invoice['number']);
    10581446                        $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');
    10591451                        if ($save) {
    10601452                                $order->save();
    … …  
    11071499                }
    11081500                $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)) {
    11101504                        return false;
    11111505                }
    … …  
    15531947        }
    15541948        /**
     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        /**
    15552007         * Summary of AOZ4WC_log
    15562008         *
  • altoviz/tags/1.0.75/plugin.php

    r3561363 r3651512  
    33 * Plugin Name: Altoviz for WooCommerce
    44 * Description: Invoicing and accounting made easy for entrepreneurs.
    5  * Version: 1.0.74
     5 * Version: 1.0.75
    66 * Requires at least: 6.5
    77 * Tested up to: 7.0
    … …  
    2424}
    2525define( 'AOZ4WC_PLUGIN_FILE', __FILE__ );
    26 define( 'AOZ4WC_PLUGIN_VERSION', '1.0.74' );
     26define( 'AOZ4WC_PLUGIN_VERSION', '1.0.75' );
    2727if ( ! defined( 'AOZ4WC_PLUGIN_DIR' ) ) {
    2828        define( 'AOZ4WC_PLUGIN_DIR', __DIR__ );
    … …  
    286286                        delete_option( 'aoz4wc_plugin_activated' );
    287287                }
     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 ) );
    288307        }
    289308        /**
    … …  
    395414        }
    396415        /**
    397          * Summary of replace_subdomain
    398          *
    399          * @param mixed $subdomain
    400          * @param mixed $url
    401          * @return bool|string
     416         * 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.
    402421         */
    403422        public function replace_subdomain( $subdomain, $url = '' ) {
    … …  
    405424                        $url = home_url();
    406425                }
    407                 if ( ! empty( $subdomain ) && ( substr( $subdomain, -1 ) !== '.' ) ) {
    408                         $subdomain .= '.';
    409                 }
     426
    410427                $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'] ) ) {
    414430                        return false;
    415431                }
     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;
    416470        }
    417471        /**
  • altoviz/tags/1.0.75/readme.txt

    r3561363 r3651512  
    44Tags: invoice, invoicing, accounting
    55Requires at least: 6.5
    6 Tested up to: 6.9
     6Tested up to: 7.0
    77Requires PHP: 8.0
    8 Stable tag: 1.0.74
     8Stable tag: 1.0.75
    99License: GNU General Public License v3.0
    1010License URI: http://www.gnu.org/licenses/gpl-3.0.html
  • altoviz/tags/1.0.75/version

    r3561363 r3651512  
    1 1.0.74
     11.0.75
  • altoviz/trunk/includes/admin/class-aoz4wc-wc-admin.php

    r3561363 r3651512  
    102102                        '_wpnonce'
    103103                );
    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>';
    105105                return $actions;
    106106        }
    … …  
    120120                switch ( $type ) {
    121121                        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>';
    123123                                break;
    124124                        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>';
    126126                                break;
    127127                }
    … …  
    261261                if ( AOZ4WC()->check_requirements() && is_a( $order, 'WC_Order' ) ) {
    262262                        $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 ) );
    264264                        if ( $invoice_id ) {
    265265                                $actions['aoz-send'] = __( 'Send Altoviz 🚀 invoice', 'altoviz' );
  • altoviz/trunk/includes/class-aoz4wc-api.php

    r3561363 r3651512  
    6060         */
    6161        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 );
    6363        }
    6464        /**
    … …  
    9595         */
    9696        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 );
    9898        }
    9999        /**
    … …  
    130130         */
    131131        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 );
    133141        }
    134142        /**
    … …  
    138146         */
    139147        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' );
    141149        }
    142150        /**
    … …  
    289297         */
    290298        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' );
    292300        }
    293301        /**
    … …  
    299307         */
    300308        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 );
    302318        }
    303319        /**
    … …  
    378394        }
    379395        /**
     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        /**
    380471         * Summary of remote_get
    381472         *
  • altoviz/trunk/includes/class-aoz4wc-wc.php

    r3561363 r3651512  
    88
    99
     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
    1054        public function __construct() {
    1155                add_action('aoz4wc_sync_customer', array($this, 'do_sync_customer'), 10, 1);
    1256                add_action('aoz4wc_sync_product', array($this, 'do_sync_product'), 10, 1);
    1357                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);
    1459                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);
    1569                add_action('woocommerce_order_status_changed', array($this, 'woocommerce_order_status_changed'), 99, 3);
    1670                add_action('woocommerce_new_product', array($this, 'woocommerce_new_product'), 999, 2);
    … …  
    62116        }
    63117        /**
     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        /**
    64424         * Summary of profile_update
    65425         *
    … …  
    144504         *
    145505         * 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.
    147508         *
    148509         * @param mixed $order_id
    … …  
    227588                $order = is_numeric($order) ? wc_get_order($order) : $order;
    228589                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()));
    229594                        return;
    230595                }
    … …  
    282647         */
    283648        public function get_AOZ4WC_invoice_id($order) {
    284                 if (empty($order)) {
     649                if (empty($order) || ! $this->invoice_meta_is_own($order)) {
    285650                        return false;
    286651                }
    … …  
    293658         */
    294659        public function get_AOZ4WC_invoice_link($order) {
    295                 if (empty($order)) {
     660                if (empty($order) || ! $this->invoice_meta_is_own($order)) {
    296661                        return '';
    297662                }
    … …  
    304669         */
    305670        public function get_AOZ4WC_invoice_number($order) {
    306                 if (empty($order)) {
     671                if (empty($order) || ! $this->invoice_meta_is_own($order)) {
    307672                        return '';
    308673                }
    … …  
    315680         */
    316681        public function get_AOZ4WC_invoice_date($order) {
    317                 if (empty($order)) {
     682                if (empty($order) || ! $this->invoice_meta_is_own($order)) {
    318683                        return '';
    319684                }
    … …  
    542907                $aoz4wc_document_id = null;
    543908                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);
    545910                }
    546911                if ($aoz4wc_document) {
    … …  
    588953                        return false;
    589954                }
     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                }
    590959                $result = $this->do_sync_individual_order($order);
    591960                AOZ4WC()->sys_log(['do_sync_order' => $result]);
    … …  
    6311000                        return false;
    6321001                }
     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                }
    6331006                if (! AOZ4WC()->app()->is_supported_currency($order->get_currency())) {
    6341007                        return false;
    … …  
    6361009                AOZ4WC()->sys_log(array('step' => '1.1'));
    6371010                $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));
    6391015                        return false;
    6401016                }
     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) {
    6411030                AOZ4WC()->sys_log(
    6421031                        array(
    … …  
    6451034                        )
    6461035                );
    647                 $this->set_transient('order', $order_id);
    6481036                $credit      = 'shop_order_refund' === $order->get_type();
    6491037                $main_order  = $credit ? wc_get_order($order->get_parent_id()) : $order;
    … …  
    8551243                );
    8561244                // 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)) : '';
    8581246                // $data['shippingVat']     = $this->create_vat();
    8591247                // $data['taxAmount'] = 0,
    … …  
    8821270                        )
    8831271                );
    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);
    8851273                if ($aoz4wc_invoice) {
    8861274                        $aoz4wc_invoice_id = $aoz4wc_invoice['id'];
    … …  
    10571445                        $order->update_meta_data('aoz4wc_invoice_number', $aoz4wc_invoice['number']);
    10581446                        $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');
    10591451                        if ($save) {
    10601452                                $order->save();
    … …  
    11071499                }
    11081500                $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)) {
    11101504                        return false;
    11111505                }
    … …  
    15531947        }
    15541948        /**
     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        /**
    15552007         * Summary of AOZ4WC_log
    15562008         *
  • altoviz/trunk/plugin.php

    r3561363 r3651512  
    33 * Plugin Name: Altoviz for WooCommerce
    44 * Description: Invoicing and accounting made easy for entrepreneurs.
    5  * Version: 1.0.74
     5 * Version: 1.0.75
    66 * Requires at least: 6.5
    77 * Tested up to: 7.0
    … …  
    2424}
    2525define( 'AOZ4WC_PLUGIN_FILE', __FILE__ );
    26 define( 'AOZ4WC_PLUGIN_VERSION', '1.0.74' );
     26define( 'AOZ4WC_PLUGIN_VERSION', '1.0.75' );
    2727if ( ! defined( 'AOZ4WC_PLUGIN_DIR' ) ) {
    2828        define( 'AOZ4WC_PLUGIN_DIR', __DIR__ );
    … …  
    286286                        delete_option( 'aoz4wc_plugin_activated' );
    287287                }
     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 ) );
    288307        }
    289308        /**
    … …  
    395414        }
    396415        /**
    397          * Summary of replace_subdomain
    398          *
    399          * @param mixed $subdomain
    400          * @param mixed $url
    401          * @return bool|string
     416         * 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.
    402421         */
    403422        public function replace_subdomain( $subdomain, $url = '' ) {
    … …  
    405424                        $url = home_url();
    406425                }
    407                 if ( ! empty( $subdomain ) && ( substr( $subdomain, -1 ) !== '.' ) ) {
    408                         $subdomain .= '.';
    409                 }
     426
    410427                $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'] ) ) {
    414430                        return false;
    415431                }
     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;
    416470        }
    417471        /**
  • altoviz/trunk/readme.txt

    r3561363 r3651512  
    44Tags: invoice, invoicing, accounting
    55Requires at least: 6.5
    6 Tested up to: 6.9
     6Tested up to: 7.0
    77Requires PHP: 8.0
    8 Stable tag: 1.0.74
     8Stable tag: 1.0.75
    99License: GNU General Public License v3.0
    1010License URI: http://www.gnu.org/licenses/gpl-3.0.html
  • altoviz/trunk/version

    r3561363 r3651512  
    1 1.0.74
     11.0.75
Note: See TracChangeset for help on using the changeset viewer.