Plugin Directory

Changeset 3734166


Ignore:
Timestamp:
10/08/2026 09:50:25 AM (2 weeks ago)
Author:
ukto
Message:

Release 1.8.0 - customer matching by identity, filtered customer lookup, personal data masked in logs, hardened SIRET lookup, per-action nonces, safe PDF download, scoped uninstall

Location:
ukto-invoice-sync-for-pennylane
Files:
28 edited
1 copied

Legend:

Unmodified
Added
Removed
  • ukto-invoice-sync-for-pennylane/tags/1.8.0/assets/js/isfp-siret.js

    r3681339 r3734166  
    1313
    1414        var timer = null;
     15        var i18n  = window.pwSiret.i18n || {};
    1516
    1617        // Setter natif : indispensable pour que React (Checkout Blocks) voie la valeur.
    … …  
    7273                        }
    7374                }
    74                 showStatus( input, '✓ ' + ( data.company || 'Entreprise trouvée' ) + ( data.city ? ' — ' + data.city : '' ), true );
     75                showStatus( input, '✓ ' + ( data.company || i18n.found ) + ( data.city ? ' — ' + data.city : '' ), true );
    7576        }
    7677
    … …  
    8788                clearTimeout( timer );
    8889                timer = setTimeout( function () {
    89                         showStatus( input, 'Recherche de l’entreprise…', true );
    90                         fetch( window.pwSiret.ajaxUrl + '?action=isfp_siret_lookup&nonce=' + window.pwSiret.nonce + '&siret=' + digits )
     90                        showStatus( input, i18n.searching, true );
     91                        fetch( window.pwSiret.ajaxUrl + '?action=isfp_siret_lookup&nonce=' + encodeURIComponent( window.pwSiret.nonce ) + '&siret=' + digits )
    9192                                .then( function ( response ) { return response.json(); } )
    9293                                .then( function ( result ) {
    … …  
    9495                                                fill( input, result.data );
    9596                                        } else {
    96                                                 showStatus( input, 'SIRET introuvable — vérifiez le numéro.', false );
     97                                                // Le serveur dit pourquoi : limite atteinte, service en panne…
     98                                                var message = result && result.data && result.data.message;
     99                                                showStatus( input, message || i18n.notFound, false );
    97100                                        }
    98101                                } )
    99102                                .catch( function () {
    100                                         showStatus( input, 'Vérification indisponible pour le moment.', false );
     103                                        showStatus( input, i18n.unavailable, false );
    101104                                } );
    102105                }, 400 );
  • ukto-invoice-sync-for-pennylane/tags/1.8.0/includes/class-isfp-api-client.php

    r3681339 r3734166  
    124124                 */
    125125                if ( 'GET' !== $method && ISFP_Settings::dry_run() ) {
    126                         ISFP_Logger::info( sprintf( '[TEST MODE] Blocked %s %s %s', $method, $path, $body ? wp_json_encode( $body ) : '' ) );
     126                        ISFP_Logger::info( sprintf( '[TEST MODE] Blocked %s %s %s', $method, $path, $body ? wp_json_encode( ISFP_Logger::redact( $body ) ) : '' ) );
    127127                        return new WP_Error(
    128128                                'isfp_dry_run',
    … …  
    147147                }
    148148
    149                 ISFP_Logger::debug( sprintf( 'API %s %s %s', $method, $path, $body ? wp_json_encode( $body ) : '' ) );
     149                ISFP_Logger::debug( sprintf( 'API %s %s %s', $method, $path, $body ? wp_json_encode( ISFP_Logger::redact( $body ) ) : '' ) );
    150150
    151151                $response = wp_remote_request( self::BASE_URL . $path, $args );
  • ukto-invoice-sync-for-pennylane/tags/1.8.0/includes/class-isfp-customer-sync.php

    r3681339 r3734166  
    66 *
    77 * Anti-doublons :
    8  *  1. ID Pennylane déjà stocké en user meta (ou meta de commande pour les invités).
    9  *  2. Sinon recherche par email côté Pennylane.
    10  *  3. Sinon création (company si SIRET/TVA, individual sinon).
     8 *  0. Tiers déjà rattaché à la commande.
     9 *  1. Tiers du profil client, s'il a été retenu pour la même identité.
     10 *  2. Sinon recherche côté Pennylane : même e-mail, même type, mêmes identifiants.
     11 *  3. Sinon création (company si SIRET/TVA/raison sociale, individual sinon).
    1112 */
    1213class ISFP_Customer_Sync {
    … …  
    1415        const USER_META  = '_pennylane_customer_id';
    1516        const ORDER_META = '_pennylane_customer_id';
     17
     18        /**
     19         * Identité sous laquelle le tiers du profil client a été retenu.
     20         *
     21         * Avant 1.8.0, le tiers mémorisé sur le compte WordPress servait à vie :
     22         * un client qui achetait d'abord en particulier puis en entreprise, avec
     23         * SIRET et numéro de TVA, voyait sa facture B2B rattachée à sa fiche de
     24         * particulier — sans le numéro de TVA de l'acheteur, mention obligatoire
     25         * d'une livraison intracommunautaire exonérée.
     26         */
     27        const USER_IDENTITY_META = '_pennylane_customer_identity';
    1628
    1729        /**
    … …  
    2335         */
    2436        public static function ensure_customer( $order, ISFP_Api_Client $client ) {
    25                 // 1. Déjà connu ?
    26                 $known = self::known_customer_id( $order );
     37                // 0. Cette commande-ci est déjà rattachée (reprise après une erreur).
     38                $on_order = (string) $order->get_meta( self::ORDER_META );
     39                if ( '' !== $on_order ) {
     40                        return $on_order;
     41                }
     42
     43                // 1. Tiers du profil client, s'il correspond à la même identité.
     44                $known = self::known_for_user( $order );
    2745                if ( $known ) {
    2846                        /**
    … …  
    3755                }
    3856
    39                 // 2. Recherche par email.
    40                 $email = $order->get_billing_email();
    41                 $found = self::find_by_email( $email, $client );
     57                // 2. Recherche côté Pennylane : e-mail, type et identité.
     58                $found = self::find_customer( $order, $client );
    4259                if ( is_wp_error( $found ) ) {
    4360                        return $found;
    … …  
    84101        }
    85102
    86         private static function known_customer_id( $order ) {
     103        /**
     104         * Identité de l'acheteur d'une commande : « individual », ou l'entreprise
     105         * avec ses identifiants. Deux commandes de même identité peuvent partager
     106         * un tiers Pennylane ; deux identités différentes, jamais.
     107         */
     108        private static function identity( $order ) {
     109                if ( ! self::is_company_order( $order ) ) {
     110                        return 'individual';
     111                }
     112                return implode( '|', array(
     113                        'company',
     114                        ISFP_Vat_Mapper::normalize_vat( $order->get_meta( '_billing_vat_number' ) ),
     115                        ISFP_Vat_Mapper::normalize_siret( $order->get_meta( '_billing_siret' ) ),
     116                        function_exists( 'mb_strtolower' ) ? mb_strtolower( trim( (string) $order->get_billing_company() ) ) : strtolower( trim( (string) $order->get_billing_company() ) ),
     117                ) );
     118        }
     119
     120        /**
     121         * Tiers mémorisé sur le compte WordPress, s'il vaut pour cette commande.
     122         *
     123         * Mémorisé avant 1.8.0 (sans identité) : on ne s'y fie que pour une
     124         * commande de particulier. Pour une entreprise, on refait la recherche,
     125         * qui retrouvera la bonne fiche si elle existe.
     126         */
     127        private static function known_for_user( $order ) {
    87128                $user_id = $order->get_customer_id();
    88                 if ( $user_id ) {
    89                         $id = get_user_meta( $user_id, self::USER_META, true );
    90                         if ( $id ) {
    91                                 return $id;
    92                         }
    93                 }
    94                 return (string) $order->get_meta( self::ORDER_META );
     129                if ( ! $user_id ) {
     130                        return '';
     131                }
     132                $id = (string) get_user_meta( $user_id, self::USER_META, true );
     133                if ( '' === $id ) {
     134                        return '';
     135                }
     136                $retenue = (string) get_user_meta( $user_id, self::USER_IDENTITY_META, true );
     137                $ici     = self::identity( $order );
     138                if ( '' === $retenue ) {
     139                        return 'individual' === $ici ? $id : '';
     140                }
     141                return $retenue === $ici ? $id : '';
    95142        }
    96143
    … …  
    101148                if ( $user_id ) {
    102149                        update_user_meta( $user_id, self::USER_META, $customer_id );
    103                 }
    104         }
    105 
    106         /**
    107          * Recherche d'un tiers par email. Le format de filtre v2 est à valider —
    108          * en attendant on parcourt la première page filtrée côté serveur si possible,
    109          * sinon on matche côté plugin.
    110          */
    111         /**
    112          * Recherche d'un tiers par email, sur TOUT le compte : la liste est paginée
    113          * par curseur, donc s'arrêter à la première page (100 tiers) recréait un
    114          * doublon à chaque commande dès que le compte dépassait 100 clients.
    115          * Sortie anticipée au premier match ; plafond de pages en garde-fou.
    116          */
    117         private static function find_by_email( $email, ISFP_Api_Client $client ) {
    118                 if ( ! $email ) {
     150                        update_user_meta( $user_id, self::USER_IDENTITY_META, self::identity( $order ) );
     151                }
     152        }
     153
     154        /**
     155         * Recherche du tiers d'une commande dans Pennylane.
     156         *
     157         * Une seule requête filtrée par e-mail et par type, au lieu de parcourir
     158         * tout le compte page par page (jusqu'à 50 appels, et un doublon créé
     159         * au-delà de 5 000 tiers). Deux replis vers ce parcours historique :
     160         *  - Pennylane refuse le filtre (HTTP 400) ;
     161         *  - Pennylane l'ignore et renvoie des tiers qui n'ont pas cet e-mail —
     162         *    s'en tenir à cette page ferait alors créer un doublon.
     163         *
     164         * @return string|null|WP_Error ID du tiers, null si absent.
     165         */
     166        private static function find_customer( $order, ISFP_Api_Client $client ) {
     167                $saisi = trim( (string) $order->get_billing_email() );
     168                $email = strtolower( $saisi );
     169                if ( '' === $email ) {
    119170                        return null;
    120171                }
    121 
    122                 $needle    = strtolower( $email );
     172                $is_company = self::is_company_order( $order );
     173
     174                // Les deux graphies : rien ne garantit que Pennylane compare sans tenir
     175                // compte de la casse, et la fiche porte l'adresse telle que saisie.
     176                $filtre = array(
     177                        array( 'field' => 'emails', 'operator' => 'in', 'value' => array_values( array_unique( array( $saisi, $email ) ) ) ),
     178                        array( 'field' => 'customer_type', 'operator' => 'eq', 'value' => $is_company ? 'company' : 'individual' ),
     179                );
     180                $response = $client->list_customers(
     181                        apply_filters( 'isfp_customer_search_query', array( 'limit' => 100, 'filter' => wp_json_encode( $filtre ) ), $email )
     182                );
     183
     184                if ( is_wp_error( $response ) ) {
     185                        $data = $response->get_error_data();
     186                        if ( ! is_array( $data ) || 400 !== (int) ( isset( $data['status'] ) ? $data['status'] : 0 ) ) {
     187                                return $response; // Panne réelle : à signaler, pas à contourner.
     188                        }
     189                        ISFP_Logger::info( 'Customer search: filter rejected by Pennylane, falling back to a full scan.' );
     190                        return self::scan( $order, $email, $is_company, $client );
     191                }
     192
     193                $items = isset( $response['items'] ) && is_array( $response['items'] ) ? $response['items'] : array();
     194                foreach ( $items as $item ) {
     195                        if ( ! self::has_email( $item, $email ) ) {
     196                                ISFP_Logger::info( 'Customer search: filter ignored by Pennylane, falling back to a full scan.' );
     197                                return self::scan( $order, $email, $is_company, $client );
     198                        }
     199                }
     200                return self::pick( $items, $order, $email, $is_company );
     201        }
     202
     203        /**
     204         * Parcours historique de tout le compte, page par page (curseur). Sortie
     205         * anticipée au premier tiers qui convient ; plafond de pages en garde-fou.
     206         */
     207        private static function scan( $order, $email, $is_company, ISFP_Api_Client $client ) {
    123208                $cursor    = null;
    124209                $max_pages = (int) apply_filters( 'isfp_customer_search_max_pages', 50 ); // 50 × 100 = 5 000 tiers.
    … …  
    129214                                $query['cursor'] = $cursor;
    130215                        }
    131                         $response = $client->list_customers( apply_filters( 'isfp_customer_search_query', $query, $email ) );
     216                        $response = $client->list_customers( $query );
    132217                        if ( is_wp_error( $response ) ) {
    133218                                return $response;
    134219                        }
    135 
    136                         $items = isset( $response['items'] ) ? $response['items'] : array();
    137                         foreach ( $items as $item ) {
    138                                 $emails = array();
    139                                 if ( ! empty( $item['emails'] ) && is_array( $item['emails'] ) ) {
    140                                         $emails = $item['emails'];
    141                                 } elseif ( ! empty( $item['email'] ) ) {
    142                                         $emails = array( $item['email'] );
    143                                 }
    144                                 if ( in_array( $needle, array_map( 'strtolower', $emails ), true ) ) {
    145                                         return self::extract_id( $item );
    146                                 }
    147                         }
    148 
     220                        $items = isset( $response['items'] ) && is_array( $response['items'] ) ? $response['items'] : array();
     221                        $found = self::pick( $items, $order, $email, $is_company );
     222                        if ( $found ) {
     223                                return $found;
     224                        }
    149225                        $cursor = isset( $response['next_cursor'] ) ? $response['next_cursor'] : null;
    150226                        if ( ! $cursor || empty( $items ) ) {
    151                                 break;
    152                         }
    153                 }
     227                                return null;
     228                        }
     229                }
     230                ISFP_Logger::error( sprintf( 'Customer search stopped after %d pages without a match for order #%d: a new customer is created, check for a duplicate in Pennylane.', $max_pages, $order->get_id() ) );
    154231                return null;
     232        }
     233
     234        private static function has_email( $item, $email ) {
     235                $emails = array();
     236                if ( ! empty( $item['emails'] ) && is_array( $item['emails'] ) ) {
     237                        $emails = $item['emails'];
     238                } elseif ( ! empty( $item['email'] ) ) {
     239                        $emails = array( $item['email'] );
     240                }
     241                return in_array( $email, array_map( 'strtolower', array_map( 'trim', $emails ) ), true );
     242        }
     243
     244        /**
     245         * Choisit, parmi des tiers, celui qui convient à la commande.
     246         *
     247         * Même e-mail ET même type. Pour une entreprise, un numéro de TVA ou un
     248         * SIRET différent exclut le tiers — le même cabinet comptable peut tenir
     249         * l'adresse de plusieurs sociétés ; à identifiant égal, il est préféré.
     250         */
     251        private static function pick( array $items, $order, $email, $is_company ) {
     252                $type = $is_company ? 'company' : 'individual';
     253                $vat  = ISFP_Vat_Mapper::normalize_vat( $order->get_meta( '_billing_vat_number' ) );
     254                $sir  = ISFP_Vat_Mapper::normalize_siret( $order->get_meta( '_billing_siret' ) );
     255
     256                $repli = '';
     257                foreach ( $items as $item ) {
     258                        if ( ! self::has_email( $item, $email ) ) {
     259                                continue;
     260                        }
     261                        if ( ! empty( $item['customer_type'] ) && $type !== $item['customer_type'] ) {
     262                                continue;
     263                        }
     264                        if ( ! $is_company ) {
     265                                return self::extract_id( $item );
     266                        }
     267                        $sa_tva   = ISFP_Vat_Mapper::normalize_vat( isset( $item['vat_number'] ) ? $item['vat_number'] : '' );
     268                        $son_sir  = ISFP_Vat_Mapper::normalize_siret( isset( $item['reg_no'] ) ? $item['reg_no'] : '' );
     269                        if ( ( $vat && $sa_tva && $vat !== $sa_tva ) || ( $sir && $son_sir && $sir !== $son_sir ) ) {
     270                                continue; // Une autre entreprise.
     271                        }
     272                        if ( ( $vat && $vat === $sa_tva ) || ( $sir && $sir === $son_sir ) ) {
     273                                return self::extract_id( $item );
     274                        }
     275                        if ( '' === $repli ) {
     276                                $repli = self::extract_id( $item );
     277                        }
     278                }
     279                return '' !== $repli ? $repli : null;
    155280        }
    156281
  • ukto-invoice-sync-for-pennylane/tags/1.8.0/includes/class-isfp-invoice-service.php

    r3733469 r3734166  
    8686                        $customer_preview = ISFP_Customer_Sync::preview_payload( $order );
    8787                        $invoice_preview  = self::build_invoice_payload( $order, '«dry-run»' );
    88                         ISFP_Logger::info( sprintf( '[TEST MODE] Order #%d — customer payload: %s', $order->get_id(), wp_json_encode( $customer_preview, JSON_UNESCAPED_UNICODE ) ) );
    89                         ISFP_Logger::info( sprintf( '[TEST MODE] Order #%d — invoice payload: %s', $order->get_id(), wp_json_encode( $invoice_preview, JSON_UNESCAPED_UNICODE ) ) );
     88                        ISFP_Logger::info( sprintf( '[TEST MODE] Order #%d — customer payload: %s', $order->get_id(), wp_json_encode( ISFP_Logger::redact( $customer_preview ), JSON_UNESCAPED_UNICODE ) ) );
     89                        ISFP_Logger::info( sprintf( '[TEST MODE] Order #%d — invoice payload: %s', $order->get_id(), wp_json_encode( ISFP_Logger::redact( $invoice_preview ), JSON_UNESCAPED_UNICODE ) ) );
    9090                        $order->add_order_note( __( 'Pennylane [TEST MODE]: invoice simulated — payloads written to the logs, nothing sent to Pennylane.', 'ukto-invoice-sync-for-pennylane' ) );
    9191                        return true;
  • ukto-invoice-sync-for-pennylane/tags/1.8.0/includes/class-isfp-logger.php

    r3681339 r3734166  
    33
    44/**
    5  * Wrapper WC_Logger — source unique, secrets masqués.
     5 * Wrapper WC_Logger — source unique, secrets et données personnelles masqués.
    66 * Consultation : WooCommerce → État → Journaux → pennylane-*.log
     7 *
     8 * Les journaux sont lus par des personnes qui n'ont pas à connaître les
     9 * clients (prestataire, hébergeur, support) et conservés des semaines. Avant
     10 * 1.8.0, le mode test — actif par défaut — y écrivait nom, adresse, e-mail et
     11 * téléphone de chaque client, et la Pro les adresses de chaque envoi.
     12 * Désormais : adresses e-mail masquées dans tout message, champs personnels
     13 * masqués dans les charges utiles (voir redact()). Pour un débogage ponctuel,
     14 * le filtre `isfp_log_personal_data` rétablit les valeurs complètes.
    715 */
    816class ISFP_Logger {
    … …  
    1018        const SOURCE = 'pennylane';
    1119
     20        /** Clés dont la valeur identifie une personne, dans les charges utiles. */
     21        const PERSONAL_KEYS = array( 'first_name', 'last_name', 'name', 'recipient', 'phone', 'address', 'emails', 'email' );
     22
    1223        public static function log( $level, $message, array $context = array() ) {
    1324                $logger  = wc_get_logger();
    1425                $message = self::mask_secrets( $message );
     26                if ( ! self::personal_data_allowed() ) {
     27                        $message = self::mask_emails( $message );
     28                }
    1529                $logger->log( $level, $message, array_merge( array( 'source' => self::SOURCE ), $context ) );
    1630        }
    … …  
    2943
    3044        /**
     45         * Charge utile prête à journaliser : valeurs personnelles masquées, le reste
     46         * (montants, taux, références) intact — c'est ce que le mode test sert à
     47         * vérifier.
     48         *
     49         * @param mixed $data
     50         * @return mixed
     51         */
     52        public static function redact( $data ) {
     53                if ( ! is_array( $data ) || self::personal_data_allowed() ) {
     54                        return $data;
     55                }
     56                foreach ( $data as $key => $value ) {
     57                        if ( is_string( $key ) && in_array( $key, self::PERSONAL_KEYS, true ) ) {
     58                                $data[ $key ] = self::mask_value( $value );
     59                        } elseif ( is_array( $value ) ) {
     60                                $data[ $key ] = self::redact( $value );
     61                        }
     62                }
     63                return $data;
     64        }
     65
     66        private static function mask_value( $value ) {
     67                if ( is_array( $value ) ) {
     68                        return array_map( array( __CLASS__, 'mask_value' ), $value );
     69                }
     70                $value = (string) $value;
     71                if ( '' === $value ) {
     72                        return '';
     73                }
     74                return function_exists( 'mb_substr' ) ? mb_substr( $value, 0, 1 ) . '***' : substr( $value, 0, 1 ) . '***';
     75        }
     76
     77        /** « marie.dupont@example.fr » → « m***@example.fr ». */
     78        private static function mask_emails( $message ) {
     79                return preg_replace( '/([A-Za-z0-9])[A-Za-z0-9._%+\-]*@([A-Za-z0-9\-]+(?:\.[A-Za-z0-9\-]+)*\.[A-Za-z]{2,})/', '$1***@$2', (string) $message );
     80        }
     81
     82        private static function personal_data_allowed() {
     83                /**
     84                 * Journaliser les données personnelles en clair (débogage ponctuel).
     85                 *
     86                 * @param bool $allowed false par défaut.
     87                 */
     88                return (bool) apply_filters( 'isfp_log_personal_data', false );
     89        }
     90
     91        /**
    3192         * Masque le token API partout où il pourrait apparaître (headers, payloads dumpés).
    3293         */
  • ukto-invoice-sync-for-pennylane/tags/1.8.0/includes/class-isfp-my-account.php

    r3681339 r3734166  
    4545                }
    4646
    47                 $response = wp_remote_get( $pdf_url, array( 'timeout' => 30 ) );
    48                 if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
     47                $body = self::fetch_pdf( $pdf_url );
     48                if ( '' === $body ) {
    4949                        wp_die( esc_html__( 'The download is unavailable at the moment.', 'ukto-invoice-sync-for-pennylane' ), '', array( 'response' => 502 ) );
    5050                }
    … …  
    5757                header( 'Content-Type: application/pdf' );
    5858                header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
    59                 echo wp_remote_retrieve_body( $response ); // phpcs:ignore WordPress.Security.EscapeOutput -- flux binaire PDF.
     59                echo $body; // phpcs:ignore WordPress.Security.EscapeOutput -- flux binaire PDF.
    6060                exit;
    6161        }
     62
     63        /**
     64         * Contenu du PDF, ou chaîne vide.
     65         *
     66         * wp_safe_remote_get : jamais d'adresse interne ni de port exotique, même
     67         * si l'URL renvoyée par l'API devait un jour en désigner une. Le corps doit
     68         * être un PDF (signature `%PDF-` dans les 1 024 premiers octets, comme le
     69         * tolère la norme) : une page d'erreur HTML de l'hébergeur ne doit pas
     70         * arriver chez le client sous le nom « facture.pdf ».
     71         */
     72        private static function fetch_pdf( $url ) {
     73                $response = wp_safe_remote_get( $url, array(
     74                        'timeout'             => 30,
     75                        'limit_response_size' => 20 * MB_IN_BYTES,
     76                ) );
     77                if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
     78                        return '';
     79                }
     80                $body = (string) wp_remote_retrieve_body( $response );
     81                return false === strpos( substr( $body, 0, 1024 ), '%PDF-' ) ? '' : $body;
     82        }
    6283}
  • ukto-invoice-sync-for-pennylane/tags/1.8.0/includes/class-isfp-order-metabox.php

    r3733469 r3734166  
    113113                foreach ( $actions as $action => $conf ) {
    114114                        list( $label, $primary ) = $conf;
    115                         $url = wp_nonce_url(
    116                                 admin_url( sprintf( 'admin-post.php?action=isfp_order_action&isfp_do=%s&order_id=%d', $action, $order->get_id() ) ),
    117                                 'isfp_order_action_' . $order->get_id()
    118                         );
     115                        $url = self::action_url( $action, $order );
    119116                        printf(
    120117                                '<a class="button %s" href="%s">%s</a>',
    … …  
    138135                                );
    139136                        } else {
    140                                 $url = wp_nonce_url(
    141                                         admin_url( sprintf( 'admin-post.php?action=isfp_order_action&isfp_do=get_pdf&order_id=%d', $order->get_id() ) ),
    142                                         'isfp_order_action_' . $order->get_id()
    143                                 );
     137                                $url = self::action_url( 'get_pdf', $order );
    144138                                printf(
    145139                                        '<a class="button" href="%s">%s</a>',
    … …  
    221215        const NOTICE_TRANSIENT = 'isfp_action_notice_';
    222216
     217        /**
     218         * Lien d'une action de la commande, signé pour CETTE action.
     219         *
     220         * Avant 1.8.0, un seul nonce couvrait toutes les actions de la commande :
     221         * le lien « Obtenir le PDF » valait aussi pour « Marquer payée » ou
     222         * « Envoyer par e-mail ».
     223         */
     224        public static function action_url( $action, $order ) {
     225                $action = sanitize_key( $action );
     226                return wp_nonce_url(
     227                        admin_url( sprintf( 'admin-post.php?action=isfp_order_action&isfp_do=%s&order_id=%d', $action, $order->get_id() ) ),
     228                        self::nonce_action( $action, $order->get_id() )
     229                );
     230        }
     231
     232        private static function nonce_action( $action, $order_id ) {
     233                return 'isfp_order_action_' . $action . '_' . (int) $order_id;
     234        }
     235
    223236        public static function handle_action() {
    224237                $order_id = isset( $_GET['order_id'] ) ? (int) $_GET['order_id'] : 0;
    225238                $do       = isset( $_GET['isfp_do'] ) ? sanitize_key( $_GET['isfp_do'] ) : '';
    226239
    227                 if ( ! current_user_can( 'manage_woocommerce' ) || ! check_admin_referer( 'isfp_order_action_' . $order_id ) ) {
     240                if ( ! current_user_can( 'manage_woocommerce' ) || ! check_admin_referer( self::nonce_action( $do, $order_id ) ) ) {
    228241                        wp_die( esc_html__( 'You are not allowed to do that.', 'ukto-invoice-sync-for-pennylane' ) );
    229242                }
    … …  
    246259                                $pdf    = $target ? ISFP_Invoice_Service::get_pdf_url( $target ) : '';
    247260                                if ( $pdf ) {
     261                                        // Rien de l'administration ne part chez l'hébergeur du PDF,
     262                                        // pas même l'origine que la politique par défaut laisse passer.
     263                                        header( 'Referrer-Policy: no-referrer' );
    248264                                        wp_redirect( $pdf ); // phpcs:ignore WordPress.Security.SafeRedirect -- URL externe Pennylane, voulue.
    249265                                        exit;
  • ukto-invoice-sync-for-pennylane/tags/1.8.0/includes/class-isfp-siret-autocomplete.php

    r3713298 r3734166  
    2727                        'ajaxUrl' => admin_url( 'admin-ajax.php' ),
    2828                        'nonce'   => wp_create_nonce( 'isfp_siret' ),
     29                        'i18n'    => array(
     30                                'searching'   => __( 'Looking up the company…', 'ukto-invoice-sync-for-pennylane' ),
     31                                'found'       => __( 'Company found', 'ukto-invoice-sync-for-pennylane' ),
     32                                'notFound'    => __( 'SIRET not found — please check the number.', 'ukto-invoice-sync-for-pennylane' ),
     33                                'unavailable' => __( 'Verification unavailable for now.', 'ukto-invoice-sync-for-pennylane' ),
     34                        ),
    2935                ) );
    3036        }
    … …  
    3339                check_ajax_referer( 'isfp_siret', 'nonce' );
    3440
    35                 /**
    36                  * Rate limit par IP : ce point d'entrée est ouvert aux visiteurs anonymes
    37                  * (nopriv) et le nonce déconnecté, partagé et valable des heures, ne
    38                  * limite rien. Sans plafond, un robot pouvait utiliser la boutique comme
    39                  * relais vers l'API data.gouv (bannissement de l'IP du site) tout en
    40                  * créant un transient par SIRET tenté (gonflement illimité de wp_options).
    41                  */
    42                 $ip     = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
    43                 $rl_key = 'isfp_siret_rl_' . md5( $ip );
    44                 $count  = (int) get_transient( $rl_key );
    45                 if ( $count >= 10 ) {
    46                         wp_send_json_error( array( 'message' => __( 'Too many lookups — please wait a minute.', 'ukto-invoice-sync-for-pennylane' ) ), 429 );
     41                // Le nonce reste valable jusqu'à 24 h : le script publié avant une
     42                // désactivation pouvait encore interroger l'API pendant ce temps.
     43                if ( ! ISFP_Settings::siret_autofill() ) {
     44                        wp_send_json_error( array( 'message' => __( 'SIRET lookup is disabled.', 'ukto-invoice-sync-for-pennylane' ) ), 403 );
    4745                }
    48                 set_transient( $rl_key, $count + 1, MINUTE_IN_SECONDS );
    4946
    5047                $siret = isset( $_GET['siret'] ) ? preg_replace( '/\D/', '', sanitize_text_field( wp_unslash( $_GET['siret'] ) ) ) : '';
    … …  
    5350                }
    5451
     52                // Une réponse en cache ne coûte rien : elle n'entre pas dans la limite.
    5553                $cache_key = 'isfp_siret_' . $siret;
    5654                $cached    = get_transient( $cache_key );
    5755                if ( is_array( $cached ) ) {
    5856                        empty( $cached ) ? wp_send_json_error( array( 'message' => __( 'Company not found.', 'ukto-invoice-sync-for-pennylane' ) ) ) : wp_send_json_success( $cached );
     57                }
     58
     59                if ( ! self::within_rate_limit() ) {
     60                        wp_send_json_error( array( 'message' => __( 'Too many lookups — please wait a minute.', 'ukto-invoice-sync-for-pennylane' ) ), 429 );
    5961                }
    6062
    … …  
    120122        }
    121123
     124        /**
     125         * Limite de débit des recherches qui partent vers l'API.
     126         *
     127         * Ce point d'entrée est ouvert aux visiteurs anonymes (nopriv) et le nonce
     128         * déconnecté, partagé et valable des heures, ne limite rien. Sans plafond,
     129         * un robot pouvait utiliser la boutique comme relais vers l'API data.gouv
     130         * (bannissement de l'IP du site) tout en créant un transient par SIRET
     131         * tenté (gonflement de wp_options).
     132         *
     133         * Deux compteurs par minute : un par visiteur, un pour toute la boutique.
     134         * Derrière un CDN ou un proxy inverse, REMOTE_ADDR est celle du proxy et
     135         * tous les visiteurs partageraient le premier : le filtre
     136         * `isfp_siret_client_ip` permet de lire l'en-tête de confiance du proxy
     137         * (jamais lu d'office : sans proxy, il serait falsifiable).
     138         */
     139        private static function within_rate_limit() {
     140                $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
     141                $ip = (string) apply_filters( 'isfp_siret_client_ip', $ip );
     142
     143                $limites = array(
     144                        'isfp_siret_rl_' . md5( $ip ) => (int) apply_filters( 'isfp_siret_rate_limit', 10 ),
     145                        'isfp_siret_rl_site'          => (int) apply_filters( 'isfp_siret_site_rate_limit', 120 ),
     146                );
     147                foreach ( $limites as $cle => $plafond ) {
     148                        if ( (int) get_transient( $cle ) >= $plafond ) {
     149                                return false;
     150                        }
     151                }
     152                foreach ( array_keys( $limites ) as $cle ) {
     153                        set_transient( $cle, (int) get_transient( $cle ) + 1, MINUTE_IN_SECONDS );
     154                }
     155                return true;
     156        }
     157
    122158        /** "HALVEN SAS" → "Halven Sas" est moche : on capitalise mais on garde les sigles courts. */
    123159        private static function title_case( $value ) {
  • ukto-invoice-sync-for-pennylane/tags/1.8.0/languages/ukto-invoice-sync-for-pennylane-fr_FR.po

    r3734078 r3734166  
    3737msgstr "Ce n° de TVA intracommunautaire n’est pas reconnu par le VIES (service européen). Veuillez le corriger ou le retirer pour commander avec TVA."
    3838
    39 #: includes/class-isfp-customer-sync.php:66
     39#: includes/class-isfp-customer-sync.php:83
    4040msgid "Pennylane response contained no customer ID: %s"
    4141msgstr "La réponse de Pennylane ne contient aucun ID de client : %s"
    … …  
    117117msgstr "Générer la facture"
    118118
    119 #: includes/class-isfp-order-metabox.php:137
     119#: includes/class-isfp-order-metabox.php:134
    120120msgid "Download PDF"
    121121msgstr "Télécharger le PDF"
    122122
    123 #: includes/class-isfp-order-metabox.php:147
     123#: includes/class-isfp-order-metabox.php:141
    124124msgid "Get PDF"
    125125msgstr "Récupérer le PDF"
    126126
    127 #: includes/class-isfp-order-metabox.php:182
     127#: includes/class-isfp-order-metabox.php:176
    128128msgid "E-invoicing: submitted"
    129129msgstr "Facture électronique : déposée"
    130130
    131 #: includes/class-isfp-order-metabox.php:183
     131#: includes/class-isfp-order-metabox.php:177
    132132msgid "E-invoicing: sent"
    133133msgstr "Facture électronique : transmise"
    134134
    135 #: includes/class-isfp-order-metabox.php:184
     135#: includes/class-isfp-order-metabox.php:178
    136136msgid "E-invoicing: accepted"
    137137msgstr "Facture électronique : acceptée"
    138138
    139 #: includes/class-isfp-order-metabox.php:185
     139#: includes/class-isfp-order-metabox.php:179
    140140msgid "E-invoicing: approved"
    141141msgstr "Facture électronique : approuvée"
    142142
    143 #: includes/class-isfp-order-metabox.php:186
     143#: includes/class-isfp-order-metabox.php:180
    144144msgid "E-invoicing: collected"
    145145msgstr "Facture électronique : encaissée"
    146146
    147 #: includes/class-isfp-order-metabox.php:187
     147#: includes/class-isfp-order-metabox.php:181
    148148msgid "E-invoicing: partially collected"
    149149msgstr "Facture électronique : partiellement encaissée"
    150150
    151 #: includes/class-isfp-order-metabox.php:188
     151#: includes/class-isfp-order-metabox.php:182
    152152msgid "E-invoicing: disputed"
    153153msgstr "Facture électronique : en litige"
    154154
    155 #: includes/class-isfp-order-metabox.php:189
     155#: includes/class-isfp-order-metabox.php:183
    156156msgid "E-invoicing: refused by the platform"
    157157msgstr "Facture électronique : refusée par la plateforme"
    158158
    159 #: includes/class-isfp-order-metabox.php:190
     159#: includes/class-isfp-order-metabox.php:184
    160160msgid "E-invoicing: rejected by the platform"
    161161msgstr "Facture électronique : rejetée par la plateforme"
    162162
    163 #: includes/class-isfp-order-metabox.php:196
     163#: includes/class-isfp-order-metabox.php:190
    164164msgid "E-invoicing: %s"
    165165msgstr "Facture électronique : %s"
    166166
    167 #: includes/class-isfp-order-metabox.php:209
     167#: includes/class-isfp-order-metabox.php:203
    168168msgid "Factur-X file"
    169169msgstr "Fichier Factur-X"
    170170
    171 #: includes/class-isfp-order-metabox.php:210
     171#: includes/class-isfp-order-metabox.php:204
    172172msgid "Not a Factur-X file"
    173173msgstr "Fichier non Factur-X"
    174174
    175 #: includes/class-isfp-order-metabox.php:228
     175#: includes/class-isfp-order-metabox.php:241
    176176msgid "You are not allowed to do that."
    177177msgstr "Action non autorisée."
    178178
    179 #: includes/class-isfp-order-metabox.php:237
     179#: includes/class-isfp-order-metabox.php:250
    180180msgid "Invoice created in Pennylane."
    181181msgstr "Facture créée dans Pennylane."
    182182
    183 #: includes/class-isfp-order-metabox.php:251
     183#: includes/class-isfp-order-metabox.php:267
    184184msgid "The PDF is not available yet — Pennylane generates it asynchronously. Try again in a few minutes."
    185185msgstr "Le PDF n’est pas encore disponible — Pennylane le génère de façon asynchrone. Réessayez dans quelques minutes."
    186186
    187 #: includes/class-isfp-order-metabox.php:265
     187#: includes/class-isfp-order-metabox.php:281
    188188msgid "Unknown action."
    189189msgstr "Action inconnue."
    190190
    191 #: includes/class-isfp-order-metabox.php:274
     191#: includes/class-isfp-order-metabox.php:290
    192192msgid "Done."
    193193msgstr "Action effectuée."
    194194
    195 #: includes/class-isfp-order-metabox.php:333
     195#: includes/class-isfp-order-metabox.php:349
    196196msgid "Invoiced"
    197197msgstr "Facturée"
    198198
    199 #: includes/class-isfp-order-metabox.php:335
     199#: includes/class-isfp-order-metabox.php:351
    200200msgid "Error"
    201201msgstr "Erreur"
    202202
    203 #: includes/class-isfp-order-metabox.php:337
     203#: includes/class-isfp-order-metabox.php:353
    204204msgid "Draft"
    205205msgstr "Brouillon"
    … …  
    449449msgstr "Désactiver"
    450450
    451 #: includes/class-isfp-siret-autocomplete.php:46
     451#: includes/class-isfp-siret-autocomplete.php:30
     452msgid "Looking up the company…"
     453msgstr "Recherche de l’entreprise…"
     454
     455#: includes/class-isfp-siret-autocomplete.php:31
     456msgid "Company found"
     457msgstr "Entreprise trouvée"
     458
     459#: includes/class-isfp-siret-autocomplete.php:32
     460msgid "SIRET not found — please check the number."
     461msgstr "SIRET introuvable — vérifiez le numéro."
     462
     463#: includes/class-isfp-siret-autocomplete.php:33
     464msgid "Verification unavailable for now."
     465msgstr "Vérification indisponible pour le moment."
     466
     467#: includes/class-isfp-siret-autocomplete.php:44
     468msgid "SIRET lookup is disabled."
     469msgstr "La recherche par SIRET est désactivée."
     470
     471#: includes/class-isfp-siret-autocomplete.php:49
     472msgid "Invalid SIRET."
     473msgstr "SIRET invalide."
     474
     475#: includes/class-isfp-siret-autocomplete.php:56 includes/class-isfp-siret-autocomplete.php:79
     476msgid "Company not found."
     477msgstr "Entreprise introuvable."
     478
     479#: includes/class-isfp-siret-autocomplete.php:60
    452480msgid "Too many lookups — please wait a minute."
    453481msgstr "Trop de recherches — veuillez patienter une minute."
    454482
    455 #: includes/class-isfp-siret-autocomplete.php:52
    456 msgid "Invalid SIRET."
    457 msgstr "SIRET invalide."
    458 
    459 #: includes/class-isfp-siret-autocomplete.php:58 includes/class-isfp-siret-autocomplete.php:77
    460 msgid "Company not found."
    461 msgstr "Entreprise introuvable."
    462 
    463 #: includes/class-isfp-siret-autocomplete.php:67
     483#: includes/class-isfp-siret-autocomplete.php:69
    464484msgid "Lookup service unavailable."
    465485msgstr "Service de recherche indisponible."
  • ukto-invoice-sync-for-pennylane/tags/1.8.0/languages/ukto-invoice-sync-for-pennylane.pot

    r3734078 r3734166  
    3838msgstr ""
    3939
    40 #: includes/class-isfp-customer-sync.php:66
     40#: includes/class-isfp-customer-sync.php:83
    4141msgid "Pennylane response contained no customer ID: %s"
    4242msgstr ""
    … …  
    118118msgstr ""
    119119
    120 #: includes/class-isfp-order-metabox.php:137
     120#: includes/class-isfp-order-metabox.php:134
    121121msgid "Download PDF"
    122122msgstr ""
    123123
    124 #: includes/class-isfp-order-metabox.php:147
     124#: includes/class-isfp-order-metabox.php:141
    125125msgid "Get PDF"
    126126msgstr ""
    127127
     128#: includes/class-isfp-order-metabox.php:176
     129msgid "E-invoicing: submitted"
     130msgstr ""
     131
     132#: includes/class-isfp-order-metabox.php:177
     133msgid "E-invoicing: sent"
     134msgstr ""
     135
     136#: includes/class-isfp-order-metabox.php:178
     137msgid "E-invoicing: accepted"
     138msgstr ""
     139
     140#: includes/class-isfp-order-metabox.php:179
     141msgid "E-invoicing: approved"
     142msgstr ""
     143
     144#: includes/class-isfp-order-metabox.php:180
     145msgid "E-invoicing: collected"
     146msgstr ""
     147
     148#: includes/class-isfp-order-metabox.php:181
     149msgid "E-invoicing: partially collected"
     150msgstr ""
     151
    128152#: includes/class-isfp-order-metabox.php:182
    129 msgid "E-invoicing: submitted"
     153msgid "E-invoicing: disputed"
    130154msgstr ""
    131155
    132156#: includes/class-isfp-order-metabox.php:183
    133 msgid "E-invoicing: sent"
     157msgid "E-invoicing: refused by the platform"
    134158msgstr ""
    135159
    136160#: includes/class-isfp-order-metabox.php:184
    137 msgid "E-invoicing: accepted"
    138 msgstr ""
    139 
    140 #: includes/class-isfp-order-metabox.php:185
    141 msgid "E-invoicing: approved"
    142 msgstr ""
    143 
    144 #: includes/class-isfp-order-metabox.php:186
    145 msgid "E-invoicing: collected"
    146 msgstr ""
    147 
    148 #: includes/class-isfp-order-metabox.php:187
    149 msgid "E-invoicing: partially collected"
    150 msgstr ""
    151 
    152 #: includes/class-isfp-order-metabox.php:188
    153 msgid "E-invoicing: disputed"
    154 msgstr ""
    155 
    156 #: includes/class-isfp-order-metabox.php:189
    157 msgid "E-invoicing: refused by the platform"
     161msgid "E-invoicing: rejected by the platform"
    158162msgstr ""
    159163
    160164#: includes/class-isfp-order-metabox.php:190
    161 msgid "E-invoicing: rejected by the platform"
    162 msgstr ""
    163 
    164 #: includes/class-isfp-order-metabox.php:196
    165165msgid "E-invoicing: %s"
    166166msgstr ""
    167167
    168 #: includes/class-isfp-order-metabox.php:209
     168#: includes/class-isfp-order-metabox.php:203
    169169msgid "Factur-X file"
    170170msgstr ""
    171171
    172 #: includes/class-isfp-order-metabox.php:210
     172#: includes/class-isfp-order-metabox.php:204
    173173msgid "Not a Factur-X file"
    174174msgstr ""
    175175
    176 #: includes/class-isfp-order-metabox.php:228
     176#: includes/class-isfp-order-metabox.php:241
    177177msgid "You are not allowed to do that."
    178178msgstr ""
    179179
    180 #: includes/class-isfp-order-metabox.php:237
     180#: includes/class-isfp-order-metabox.php:250
    181181msgid "Invoice created in Pennylane."
    182182msgstr ""
    183183
    184 #: includes/class-isfp-order-metabox.php:251
     184#: includes/class-isfp-order-metabox.php:267
    185185msgid "The PDF is not available yet — Pennylane generates it asynchronously. Try again in a few minutes."
    186186msgstr ""
    187187
    188 #: includes/class-isfp-order-metabox.php:265
     188#: includes/class-isfp-order-metabox.php:281
    189189msgid "Unknown action."
    190190msgstr ""
    191191
    192 #: includes/class-isfp-order-metabox.php:274
     192#: includes/class-isfp-order-metabox.php:290
    193193msgid "Done."
    194194msgstr ""
    195195
    196 #: includes/class-isfp-order-metabox.php:333
     196#: includes/class-isfp-order-metabox.php:349
    197197msgid "Invoiced"
    198198msgstr ""
    199199
    200 #: includes/class-isfp-order-metabox.php:335
     200#: includes/class-isfp-order-metabox.php:351
    201201msgid "Error"
    202202msgstr ""
    203203
    204 #: includes/class-isfp-order-metabox.php:337
     204#: includes/class-isfp-order-metabox.php:353
    205205msgid "Draft"
    206206msgstr ""
    … …  
    450450msgstr ""
    451451
    452 #: includes/class-isfp-siret-autocomplete.php:46
     452#: includes/class-isfp-siret-autocomplete.php:30
     453msgid "Looking up the company…"
     454msgstr ""
     455
     456#: includes/class-isfp-siret-autocomplete.php:31
     457msgid "Company found"
     458msgstr ""
     459
     460#: includes/class-isfp-siret-autocomplete.php:32
     461msgid "SIRET not found — please check the number."
     462msgstr ""
     463
     464#: includes/class-isfp-siret-autocomplete.php:33
     465msgid "Verification unavailable for now."
     466msgstr ""
     467
     468#: includes/class-isfp-siret-autocomplete.php:44
     469msgid "SIRET lookup is disabled."
     470msgstr ""
     471
     472#: includes/class-isfp-siret-autocomplete.php:49
     473msgid "Invalid SIRET."
     474msgstr ""
     475
     476#: includes/class-isfp-siret-autocomplete.php:56 includes/class-isfp-siret-autocomplete.php:79
     477msgid "Company not found."
     478msgstr ""
     479
     480#: includes/class-isfp-siret-autocomplete.php:60
    453481msgid "Too many lookups — please wait a minute."
    454482msgstr ""
    455483
    456 #: includes/class-isfp-siret-autocomplete.php:52
    457 msgid "Invalid SIRET."
    458 msgstr ""
    459 
    460 #: includes/class-isfp-siret-autocomplete.php:58 includes/class-isfp-siret-autocomplete.php:77
    461 msgid "Company not found."
    462 msgstr ""
    463 
    464 #: includes/class-isfp-siret-autocomplete.php:67
     484#: includes/class-isfp-siret-autocomplete.php:69
    465485msgid "Lookup service unavailable."
    466486msgstr ""
  • ukto-invoice-sync-for-pennylane/tags/1.8.0/readme.txt

    r3734078 r3734166  
    66Requires PHP: 7.4
    77Requires Plugins: woocommerce
    8 Stable tag: 1.7.9
     8Stable tag: 1.8.0
    99License: GPLv2 or later
    1010License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    142142Yes. With test mode enabled, no write request reaches Pennylane: the API client blocks them
    143143centrally and payloads are only written to the WooCommerce logs so you can review them before
    144 going live.
     144going live — with customer names, addresses, phone numbers and e-mail addresses masked.
    145145
    146146= Where are the logs? =
    147147
    148148WooCommerce → Status → Logs, source "pennylane". Every API error is logged with the full
    149 response, and every order keeps its own note trail.
     149response, and every order keeps its own note trail. Customers' personal data is masked in the
     150logs; to log it in full while debugging, return true from the `isfp_log_personal_data` filter.
    150151
    151152= Can invoices be created automatically? =
    … …  
    164165
    165166== Changelog ==
     167
     168= 1.8.0 =
     169* **Customers buying first as individuals, then as businesses, now get a business invoice on a
     170  business record.** The Pennylane customer remembered on a WordPress account used to be reused
     171  for every later order: a B2B invoice could land on the buyer's individual record, without their
     172  VAT number — a mandatory mention on a VAT-exempt intra-Community invoice. The remembered record
     173  is now only reused for the same identity (individual, or the same company, VAT and SIRET
     174  numbers); otherwise the right record is looked up or created.
     175* Customer lookup in Pennylane is now a single filtered request (e-mail and customer type) instead
     176  of scanning the whole account page by page — up to 50 calls, and a duplicate customer beyond
     177  5,000. If Pennylane rejects or ignores the filter, the previous full scan is used. Two companies
     178  sharing one e-mail address (an accounting firm, for instance) are told apart by their VAT and
     179  SIRET numbers.
     180* **Personal data is no longer written to the logs.** Test mode — on by default — logged each
     181  customer's name, address, phone number and e-mail address. They are now masked (amounts, VAT
     182  rates and references stay readable). The `isfp_log_personal_data` filter restores full values
     183  for one-off debugging.
     184* SIRET lookup at checkout: the setting is now checked on the server side (the endpoint kept
     185  answering for up to 24 hours after the option was turned off); cached answers no longer count
     186  towards the rate limit; a store-wide ceiling is added, and the `isfp_siret_client_ip` filter lets
     187  stores behind a CDN or reverse proxy rate-limit per visitor. Its messages can now be translated
     188  and the server's explanation is shown to the customer.
     189* Order actions: each action link is now signed for that action only (one nonce used to cover
     190  every action of an order).
     191* Customer invoice download (My account): fetched with `wp_safe_remote_get`, size-capped, and only
     192  served when the file really is a PDF — a hosting error page is no longer sent as "invoice.pdf".
     193* Uninstalling the plugin no longer deletes the Pro extension's settings, and now removes all of
     194  its own options (B2B exemption, OSS, the pre-1.7 API token, caches and locks).
    166195
    167196= 1.7.9 =
    … …  
    259288== Upgrade Notice ==
    260289
     290= 1.8.0 =
     291Business orders from customers who previously bought as individuals now go to a business record in Pennylane. Personal data is no longer written to the logs.
     292
    261293= 1.7.9 =
    262294The B2B VAT exemption now requires VIES validation. If you use the exemption without VIES, enable VIES in the Invoicing tab after updating.
  • ukto-invoice-sync-for-pennylane/tags/1.8.0/ukto-invoice-sync-for-pennylane.php

    r3734078 r3734166  
    44 * Plugin URI:        https://ukto.fr/pennylane.php
    55 * Description:       Send your WooCommerce orders to Pennylane as compliant invoices: one-click invoicing from the order screen, full VAT/OSS mapping, EU VAT number validation (VIES) and SIRET at checkout, invoice PDF in My Account.
    6  * Version:           1.7.9
     6 * Version:           1.8.0
    77 * Requires at least: 5.8
    88 * Requires PHP:      7.4
    … …  
    2020defined( 'ABSPATH' ) || exit;
    2121
    22 define( 'ISFP_VERSION', '1.7.9' ); // Doit égaler l'en-tête Version — vérifié par build.ps1.
     22define( 'ISFP_VERSION', '1.8.0' ); // Doit égaler l'en-tête Version — vérifié par build.ps1.
    2323
    2424/**
  • ukto-invoice-sync-for-pennylane/tags/1.8.0/uninstall.php

    r3681339 r3734166  
    88 *  - les métadonnées client et produit, pour la même raison.
    99 *
    10  * Le token API est supprimé : c'est un secret, il n'a rien à faire en base
    11  * une fois le plugin retiré.
     10 * Seules les options de CETTE extension sont supprimées. Avant 1.8.0, la liste
     11 * mêlait les réglages de l'extension Pro (déclencheurs, modèles d'e-mail,
     12 * alertes) : désinstaller la gratuite pour la réinstaller effaçait la
     13 * configuration payante. C'est la désinstallation de la Pro qui s'en charge.
     14 *
     15 * Le jeton API est supprimé, sous son nom actuel comme sous l'ancien
     16 * (`pw_api_token`, d'avant le changement de préfixe) : c'est un secret, il n'a
     17 * rien à faire en base une fois le plugin retiré.
    1218 *
    1319 * @package InvoiceSyncForPennylane
    … …  
    1622defined( 'WP_UNINSTALL_PLUGIN' ) || exit;
    1723
    18 $isfp_options = array(
    19         'isfp_api_token',
    20         'isfp_trigger_statuses',
    21         'isfp_paid_statuses',
    22         'isfp_auto_invoice',
    23         'isfp_auto_send_email',
    24         'isfp_auto_credit_notes',
    25         'isfp_auto_mark_paid',
    26         'isfp_dry_run',
    27         'isfp_alerts_enabled',
    28         'isfp_alert_email',
    29         'isfp_alert_queue',
    30         'isfp_weekly_report',
    31         'isfp_vies_enabled',
    32         'isfp_vies_block',
    33         'isfp_siret_autofill',
    34         'isfp_email_delay',
    35         'isfp_email_mode',
    36         'isfp_email_subject',
    37         'isfp_email_body',
    38         'isfp_cn_email_subject',
    39         'isfp_cn_email_body',
    40         'isfp_invoice_notes',
    41         'isfp_language_mode',
     24// Réglages de l'extension gratuite (voir ISFP_Settings::register_settings()).
     25$isfp_keys = array(
     26        'api_token',
     27        'dry_run',
     28        'vies_enabled',
     29        'vies_block',
     30        'b2b_exempt',
     31        'siret_autofill',
     32        'oss_registered',
     33        'language_mode',
     34        'invoice_notes',
    4235);
     36foreach ( $isfp_keys as $isfp_key ) {
     37        delete_option( 'isfp_' . $isfp_key );
     38        delete_option( 'pw_' . $isfp_key ); // Ancien préfixe, encore présent si la migration a eu lieu.
     39}
     40delete_option( 'isfp_prefix_migrated' );
    4341
    44 // Variantes par langue (isfp_email_subject_fr, isfp_email_body_en…). Requête
    45 // directe assumée : les noms d'options sont dynamiques (suffixe de langue),
    46 // et à la désinstallation il n'y a rien à mettre en cache.
     42// Transients et verrous à nom dynamique : cache VIES et SIRET, limites de
     43// débit, messages après action, verrous de facturation. Requête directe
     44// assumée : aucune API WordPress ne liste des options par motif, et à la
     45// désinstallation il n'y a rien à mettre en cache.
    4746global $wpdb;
    4847// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    49 $isfp_localized = $wpdb->get_col(
    50         "SELECT option_name FROM {$wpdb->options}
    51          WHERE option_name LIKE 'pw\\_email\\_subject\\_%'
    52             OR option_name LIKE 'pw\\_email\\_body\\_%'
    53             OR option_name LIKE 'pw\\_cn\\_email\\_subject\\_%'
    54             OR option_name LIKE 'pw\\_cn\\_email\\_body\\_%'"
     48$wpdb->query(
     49        "DELETE FROM {$wpdb->options}
     50         WHERE option_name LIKE '\\_transient\\_isfp\\_vies\\_%'
     51            OR option_name LIKE '\\_transient\\_timeout\\_isfp\\_vies\\_%'
     52            OR option_name LIKE '\\_transient\\_isfp\\_siret\\_%'
     53            OR option_name LIKE '\\_transient\\_timeout\\_isfp\\_siret\\_%'
     54            OR option_name LIKE '\\_transient\\_isfp\\_action\\_notice\\_%'
     55            OR option_name LIKE '\\_transient\\_timeout\\_isfp\\_action\\_notice\\_%'
     56            OR option_name LIKE '\\_isfp\\_lock\\_%'"
    5557);
    56 foreach ( array_merge( $isfp_options, (array) $isfp_localized ) as $isfp_option ) {
    57         delete_option( $isfp_option );
    58 }
    59 
    60 // Transients laissés par le plugin.
    61 delete_transient( 'isfp_products_cache' );
    62 delete_transient( 'isfp_alert_throttle' );
  • ukto-invoice-sync-for-pennylane/trunk/assets/js/isfp-siret.js

    r3681339 r3734166  
    1313
    1414        var timer = null;
     15        var i18n  = window.pwSiret.i18n || {};
    1516
    1617        // Setter natif : indispensable pour que React (Checkout Blocks) voie la valeur.
    … …  
    7273                        }
    7374                }
    74                 showStatus( input, '✓ ' + ( data.company || 'Entreprise trouvée' ) + ( data.city ? ' — ' + data.city : '' ), true );
     75                showStatus( input, '✓ ' + ( data.company || i18n.found ) + ( data.city ? ' — ' + data.city : '' ), true );
    7576        }
    7677
    … …  
    8788                clearTimeout( timer );
    8889                timer = setTimeout( function () {
    89                         showStatus( input, 'Recherche de l’entreprise…', true );
    90                         fetch( window.pwSiret.ajaxUrl + '?action=isfp_siret_lookup&nonce=' + window.pwSiret.nonce + '&siret=' + digits )
     90                        showStatus( input, i18n.searching, true );
     91                        fetch( window.pwSiret.ajaxUrl + '?action=isfp_siret_lookup&nonce=' + encodeURIComponent( window.pwSiret.nonce ) + '&siret=' + digits )
    9192                                .then( function ( response ) { return response.json(); } )
    9293                                .then( function ( result ) {
    … …  
    9495                                                fill( input, result.data );
    9596                                        } else {
    96                                                 showStatus( input, 'SIRET introuvable — vérifiez le numéro.', false );
     97                                                // Le serveur dit pourquoi : limite atteinte, service en panne…
     98                                                var message = result && result.data && result.data.message;
     99                                                showStatus( input, message || i18n.notFound, false );
    97100                                        }
    98101                                } )
    99102                                .catch( function () {
    100                                         showStatus( input, 'Vérification indisponible pour le moment.', false );
     103                                        showStatus( input, i18n.unavailable, false );
    101104                                } );
    102105                }, 400 );
  • ukto-invoice-sync-for-pennylane/trunk/includes/class-isfp-api-client.php

    r3681339 r3734166  
    124124                 */
    125125                if ( 'GET' !== $method && ISFP_Settings::dry_run() ) {
    126                         ISFP_Logger::info( sprintf( '[TEST MODE] Blocked %s %s %s', $method, $path, $body ? wp_json_encode( $body ) : '' ) );
     126                        ISFP_Logger::info( sprintf( '[TEST MODE] Blocked %s %s %s', $method, $path, $body ? wp_json_encode( ISFP_Logger::redact( $body ) ) : '' ) );
    127127                        return new WP_Error(
    128128                                'isfp_dry_run',
    … …  
    147147                }
    148148
    149                 ISFP_Logger::debug( sprintf( 'API %s %s %s', $method, $path, $body ? wp_json_encode( $body ) : '' ) );
     149                ISFP_Logger::debug( sprintf( 'API %s %s %s', $method, $path, $body ? wp_json_encode( ISFP_Logger::redact( $body ) ) : '' ) );
    150150
    151151                $response = wp_remote_request( self::BASE_URL . $path, $args );
  • ukto-invoice-sync-for-pennylane/trunk/includes/class-isfp-customer-sync.php

    r3681339 r3734166  
    66 *
    77 * Anti-doublons :
    8  *  1. ID Pennylane déjà stocké en user meta (ou meta de commande pour les invités).
    9  *  2. Sinon recherche par email côté Pennylane.
    10  *  3. Sinon création (company si SIRET/TVA, individual sinon).
     8 *  0. Tiers déjà rattaché à la commande.
     9 *  1. Tiers du profil client, s'il a été retenu pour la même identité.
     10 *  2. Sinon recherche côté Pennylane : même e-mail, même type, mêmes identifiants.
     11 *  3. Sinon création (company si SIRET/TVA/raison sociale, individual sinon).
    1112 */
    1213class ISFP_Customer_Sync {
    … …  
    1415        const USER_META  = '_pennylane_customer_id';
    1516        const ORDER_META = '_pennylane_customer_id';
     17
     18        /**
     19         * Identité sous laquelle le tiers du profil client a été retenu.
     20         *
     21         * Avant 1.8.0, le tiers mémorisé sur le compte WordPress servait à vie :
     22         * un client qui achetait d'abord en particulier puis en entreprise, avec
     23         * SIRET et numéro de TVA, voyait sa facture B2B rattachée à sa fiche de
     24         * particulier — sans le numéro de TVA de l'acheteur, mention obligatoire
     25         * d'une livraison intracommunautaire exonérée.
     26         */
     27        const USER_IDENTITY_META = '_pennylane_customer_identity';
    1628
    1729        /**
    … …  
    2335         */
    2436        public static function ensure_customer( $order, ISFP_Api_Client $client ) {
    25                 // 1. Déjà connu ?
    26                 $known = self::known_customer_id( $order );
     37                // 0. Cette commande-ci est déjà rattachée (reprise après une erreur).
     38                $on_order = (string) $order->get_meta( self::ORDER_META );
     39                if ( '' !== $on_order ) {
     40                        return $on_order;
     41                }
     42
     43                // 1. Tiers du profil client, s'il correspond à la même identité.
     44                $known = self::known_for_user( $order );
    2745                if ( $known ) {
    2846                        /**
    … …  
    3755                }
    3856
    39                 // 2. Recherche par email.
    40                 $email = $order->get_billing_email();
    41                 $found = self::find_by_email( $email, $client );
     57                // 2. Recherche côté Pennylane : e-mail, type et identité.
     58                $found = self::find_customer( $order, $client );
    4259                if ( is_wp_error( $found ) ) {
    4360                        return $found;
    … …  
    84101        }
    85102
    86         private static function known_customer_id( $order ) {
     103        /**
     104         * Identité de l'acheteur d'une commande : « individual », ou l'entreprise
     105         * avec ses identifiants. Deux commandes de même identité peuvent partager
     106         * un tiers Pennylane ; deux identités différentes, jamais.
     107         */
     108        private static function identity( $order ) {
     109                if ( ! self::is_company_order( $order ) ) {
     110                        return 'individual';
     111                }
     112                return implode( '|', array(
     113                        'company',
     114                        ISFP_Vat_Mapper::normalize_vat( $order->get_meta( '_billing_vat_number' ) ),
     115                        ISFP_Vat_Mapper::normalize_siret( $order->get_meta( '_billing_siret' ) ),
     116                        function_exists( 'mb_strtolower' ) ? mb_strtolower( trim( (string) $order->get_billing_company() ) ) : strtolower( trim( (string) $order->get_billing_company() ) ),
     117                ) );
     118        }
     119
     120        /**
     121         * Tiers mémorisé sur le compte WordPress, s'il vaut pour cette commande.
     122         *
     123         * Mémorisé avant 1.8.0 (sans identité) : on ne s'y fie que pour une
     124         * commande de particulier. Pour une entreprise, on refait la recherche,
     125         * qui retrouvera la bonne fiche si elle existe.
     126         */
     127        private static function known_for_user( $order ) {
    87128                $user_id = $order->get_customer_id();
    88                 if ( $user_id ) {
    89                         $id = get_user_meta( $user_id, self::USER_META, true );
    90                         if ( $id ) {
    91                                 return $id;
    92                         }
    93                 }
    94                 return (string) $order->get_meta( self::ORDER_META );
     129                if ( ! $user_id ) {
     130                        return '';
     131                }
     132                $id = (string) get_user_meta( $user_id, self::USER_META, true );
     133                if ( '' === $id ) {
     134                        return '';
     135                }
     136                $retenue = (string) get_user_meta( $user_id, self::USER_IDENTITY_META, true );
     137                $ici     = self::identity( $order );
     138                if ( '' === $retenue ) {
     139                        return 'individual' === $ici ? $id : '';
     140                }
     141                return $retenue === $ici ? $id : '';
    95142        }
    96143
    … …  
    101148                if ( $user_id ) {
    102149                        update_user_meta( $user_id, self::USER_META, $customer_id );
    103                 }
    104         }
    105 
    106         /**
    107          * Recherche d'un tiers par email. Le format de filtre v2 est à valider —
    108          * en attendant on parcourt la première page filtrée côté serveur si possible,
    109          * sinon on matche côté plugin.
    110          */
    111         /**
    112          * Recherche d'un tiers par email, sur TOUT le compte : la liste est paginée
    113          * par curseur, donc s'arrêter à la première page (100 tiers) recréait un
    114          * doublon à chaque commande dès que le compte dépassait 100 clients.
    115          * Sortie anticipée au premier match ; plafond de pages en garde-fou.
    116          */
    117         private static function find_by_email( $email, ISFP_Api_Client $client ) {
    118                 if ( ! $email ) {
     150                        update_user_meta( $user_id, self::USER_IDENTITY_META, self::identity( $order ) );
     151                }
     152        }
     153
     154        /**
     155         * Recherche du tiers d'une commande dans Pennylane.
     156         *
     157         * Une seule requête filtrée par e-mail et par type, au lieu de parcourir
     158         * tout le compte page par page (jusqu'à 50 appels, et un doublon créé
     159         * au-delà de 5 000 tiers). Deux replis vers ce parcours historique :
     160         *  - Pennylane refuse le filtre (HTTP 400) ;
     161         *  - Pennylane l'ignore et renvoie des tiers qui n'ont pas cet e-mail —
     162         *    s'en tenir à cette page ferait alors créer un doublon.
     163         *
     164         * @return string|null|WP_Error ID du tiers, null si absent.
     165         */
     166        private static function find_customer( $order, ISFP_Api_Client $client ) {
     167                $saisi = trim( (string) $order->get_billing_email() );
     168                $email = strtolower( $saisi );
     169                if ( '' === $email ) {
    119170                        return null;
    120171                }
    121 
    122                 $needle    = strtolower( $email );
     172                $is_company = self::is_company_order( $order );
     173
     174                // Les deux graphies : rien ne garantit que Pennylane compare sans tenir
     175                // compte de la casse, et la fiche porte l'adresse telle que saisie.
     176                $filtre = array(
     177                        array( 'field' => 'emails', 'operator' => 'in', 'value' => array_values( array_unique( array( $saisi, $email ) ) ) ),
     178                        array( 'field' => 'customer_type', 'operator' => 'eq', 'value' => $is_company ? 'company' : 'individual' ),
     179                );
     180                $response = $client->list_customers(
     181                        apply_filters( 'isfp_customer_search_query', array( 'limit' => 100, 'filter' => wp_json_encode( $filtre ) ), $email )
     182                );
     183
     184                if ( is_wp_error( $response ) ) {
     185                        $data = $response->get_error_data();
     186                        if ( ! is_array( $data ) || 400 !== (int) ( isset( $data['status'] ) ? $data['status'] : 0 ) ) {
     187                                return $response; // Panne réelle : à signaler, pas à contourner.
     188                        }
     189                        ISFP_Logger::info( 'Customer search: filter rejected by Pennylane, falling back to a full scan.' );
     190                        return self::scan( $order, $email, $is_company, $client );
     191                }
     192
     193                $items = isset( $response['items'] ) && is_array( $response['items'] ) ? $response['items'] : array();
     194                foreach ( $items as $item ) {
     195                        if ( ! self::has_email( $item, $email ) ) {
     196                                ISFP_Logger::info( 'Customer search: filter ignored by Pennylane, falling back to a full scan.' );
     197                                return self::scan( $order, $email, $is_company, $client );
     198                        }
     199                }
     200                return self::pick( $items, $order, $email, $is_company );
     201        }
     202
     203        /**
     204         * Parcours historique de tout le compte, page par page (curseur). Sortie
     205         * anticipée au premier tiers qui convient ; plafond de pages en garde-fou.
     206         */
     207        private static function scan( $order, $email, $is_company, ISFP_Api_Client $client ) {
    123208                $cursor    = null;
    124209                $max_pages = (int) apply_filters( 'isfp_customer_search_max_pages', 50 ); // 50 × 100 = 5 000 tiers.
    … …  
    129214                                $query['cursor'] = $cursor;
    130215                        }
    131                         $response = $client->list_customers( apply_filters( 'isfp_customer_search_query', $query, $email ) );
     216                        $response = $client->list_customers( $query );
    132217                        if ( is_wp_error( $response ) ) {
    133218                                return $response;
    134219                        }
    135 
    136                         $items = isset( $response['items'] ) ? $response['items'] : array();
    137                         foreach ( $items as $item ) {
    138                                 $emails = array();
    139                                 if ( ! empty( $item['emails'] ) && is_array( $item['emails'] ) ) {
    140                                         $emails = $item['emails'];
    141                                 } elseif ( ! empty( $item['email'] ) ) {
    142                                         $emails = array( $item['email'] );
    143                                 }
    144                                 if ( in_array( $needle, array_map( 'strtolower', $emails ), true ) ) {
    145                                         return self::extract_id( $item );
    146                                 }
    147                         }
    148 
     220                        $items = isset( $response['items'] ) && is_array( $response['items'] ) ? $response['items'] : array();
     221                        $found = self::pick( $items, $order, $email, $is_company );
     222                        if ( $found ) {
     223                                return $found;
     224                        }
    149225                        $cursor = isset( $response['next_cursor'] ) ? $response['next_cursor'] : null;
    150226                        if ( ! $cursor || empty( $items ) ) {
    151                                 break;
    152                         }
    153                 }
     227                                return null;
     228                        }
     229                }
     230                ISFP_Logger::error( sprintf( 'Customer search stopped after %d pages without a match for order #%d: a new customer is created, check for a duplicate in Pennylane.', $max_pages, $order->get_id() ) );
    154231                return null;
     232        }
     233
     234        private static function has_email( $item, $email ) {
     235                $emails = array();
     236                if ( ! empty( $item['emails'] ) && is_array( $item['emails'] ) ) {
     237                        $emails = $item['emails'];
     238                } elseif ( ! empty( $item['email'] ) ) {
     239                        $emails = array( $item['email'] );
     240                }
     241                return in_array( $email, array_map( 'strtolower', array_map( 'trim', $emails ) ), true );
     242        }
     243
     244        /**
     245         * Choisit, parmi des tiers, celui qui convient à la commande.
     246         *
     247         * Même e-mail ET même type. Pour une entreprise, un numéro de TVA ou un
     248         * SIRET différent exclut le tiers — le même cabinet comptable peut tenir
     249         * l'adresse de plusieurs sociétés ; à identifiant égal, il est préféré.
     250         */
     251        private static function pick( array $items, $order, $email, $is_company ) {
     252                $type = $is_company ? 'company' : 'individual';
     253                $vat  = ISFP_Vat_Mapper::normalize_vat( $order->get_meta( '_billing_vat_number' ) );
     254                $sir  = ISFP_Vat_Mapper::normalize_siret( $order->get_meta( '_billing_siret' ) );
     255
     256                $repli = '';
     257                foreach ( $items as $item ) {
     258                        if ( ! self::has_email( $item, $email ) ) {
     259                                continue;
     260                        }
     261                        if ( ! empty( $item['customer_type'] ) && $type !== $item['customer_type'] ) {
     262                                continue;
     263                        }
     264                        if ( ! $is_company ) {
     265                                return self::extract_id( $item );
     266                        }
     267                        $sa_tva   = ISFP_Vat_Mapper::normalize_vat( isset( $item['vat_number'] ) ? $item['vat_number'] : '' );
     268                        $son_sir  = ISFP_Vat_Mapper::normalize_siret( isset( $item['reg_no'] ) ? $item['reg_no'] : '' );
     269                        if ( ( $vat && $sa_tva && $vat !== $sa_tva ) || ( $sir && $son_sir && $sir !== $son_sir ) ) {
     270                                continue; // Une autre entreprise.
     271                        }
     272                        if ( ( $vat && $vat === $sa_tva ) || ( $sir && $sir === $son_sir ) ) {
     273                                return self::extract_id( $item );
     274                        }
     275                        if ( '' === $repli ) {
     276                                $repli = self::extract_id( $item );
     277                        }
     278                }
     279                return '' !== $repli ? $repli : null;
    155280        }
    156281
  • ukto-invoice-sync-for-pennylane/trunk/includes/class-isfp-invoice-service.php

    r3733469 r3734166  
    8686                        $customer_preview = ISFP_Customer_Sync::preview_payload( $order );
    8787                        $invoice_preview  = self::build_invoice_payload( $order, '«dry-run»' );
    88                         ISFP_Logger::info( sprintf( '[TEST MODE] Order #%d — customer payload: %s', $order->get_id(), wp_json_encode( $customer_preview, JSON_UNESCAPED_UNICODE ) ) );
    89                         ISFP_Logger::info( sprintf( '[TEST MODE] Order #%d — invoice payload: %s', $order->get_id(), wp_json_encode( $invoice_preview, JSON_UNESCAPED_UNICODE ) ) );
     88                        ISFP_Logger::info( sprintf( '[TEST MODE] Order #%d — customer payload: %s', $order->get_id(), wp_json_encode( ISFP_Logger::redact( $customer_preview ), JSON_UNESCAPED_UNICODE ) ) );
     89                        ISFP_Logger::info( sprintf( '[TEST MODE] Order #%d — invoice payload: %s', $order->get_id(), wp_json_encode( ISFP_Logger::redact( $invoice_preview ), JSON_UNESCAPED_UNICODE ) ) );
    9090                        $order->add_order_note( __( 'Pennylane [TEST MODE]: invoice simulated — payloads written to the logs, nothing sent to Pennylane.', 'ukto-invoice-sync-for-pennylane' ) );
    9191                        return true;
  • ukto-invoice-sync-for-pennylane/trunk/includes/class-isfp-logger.php

    r3681339 r3734166  
    33
    44/**
    5  * Wrapper WC_Logger — source unique, secrets masqués.
     5 * Wrapper WC_Logger — source unique, secrets et données personnelles masqués.
    66 * Consultation : WooCommerce → État → Journaux → pennylane-*.log
     7 *
     8 * Les journaux sont lus par des personnes qui n'ont pas à connaître les
     9 * clients (prestataire, hébergeur, support) et conservés des semaines. Avant
     10 * 1.8.0, le mode test — actif par défaut — y écrivait nom, adresse, e-mail et
     11 * téléphone de chaque client, et la Pro les adresses de chaque envoi.
     12 * Désormais : adresses e-mail masquées dans tout message, champs personnels
     13 * masqués dans les charges utiles (voir redact()). Pour un débogage ponctuel,
     14 * le filtre `isfp_log_personal_data` rétablit les valeurs complètes.
    715 */
    816class ISFP_Logger {
    … …  
    1018        const SOURCE = 'pennylane';
    1119
     20        /** Clés dont la valeur identifie une personne, dans les charges utiles. */
     21        const PERSONAL_KEYS = array( 'first_name', 'last_name', 'name', 'recipient', 'phone', 'address', 'emails', 'email' );
     22
    1223        public static function log( $level, $message, array $context = array() ) {
    1324                $logger  = wc_get_logger();
    1425                $message = self::mask_secrets( $message );
     26                if ( ! self::personal_data_allowed() ) {
     27                        $message = self::mask_emails( $message );
     28                }
    1529                $logger->log( $level, $message, array_merge( array( 'source' => self::SOURCE ), $context ) );
    1630        }
    … …  
    2943
    3044        /**
     45         * Charge utile prête à journaliser : valeurs personnelles masquées, le reste
     46         * (montants, taux, références) intact — c'est ce que le mode test sert à
     47         * vérifier.
     48         *
     49         * @param mixed $data
     50         * @return mixed
     51         */
     52        public static function redact( $data ) {
     53                if ( ! is_array( $data ) || self::personal_data_allowed() ) {
     54                        return $data;
     55                }
     56                foreach ( $data as $key => $value ) {
     57                        if ( is_string( $key ) && in_array( $key, self::PERSONAL_KEYS, true ) ) {
     58                                $data[ $key ] = self::mask_value( $value );
     59                        } elseif ( is_array( $value ) ) {
     60                                $data[ $key ] = self::redact( $value );
     61                        }
     62                }
     63                return $data;
     64        }
     65
     66        private static function mask_value( $value ) {
     67                if ( is_array( $value ) ) {
     68                        return array_map( array( __CLASS__, 'mask_value' ), $value );
     69                }
     70                $value = (string) $value;
     71                if ( '' === $value ) {
     72                        return '';
     73                }
     74                return function_exists( 'mb_substr' ) ? mb_substr( $value, 0, 1 ) . '***' : substr( $value, 0, 1 ) . '***';
     75        }
     76
     77        /** « marie.dupont@example.fr » → « m***@example.fr ». */
     78        private static function mask_emails( $message ) {
     79                return preg_replace( '/([A-Za-z0-9])[A-Za-z0-9._%+\-]*@([A-Za-z0-9\-]+(?:\.[A-Za-z0-9\-]+)*\.[A-Za-z]{2,})/', '$1***@$2', (string) $message );
     80        }
     81
     82        private static function personal_data_allowed() {
     83                /**
     84                 * Journaliser les données personnelles en clair (débogage ponctuel).
     85                 *
     86                 * @param bool $allowed false par défaut.
     87                 */
     88                return (bool) apply_filters( 'isfp_log_personal_data', false );
     89        }
     90
     91        /**
    3192         * Masque le token API partout où il pourrait apparaître (headers, payloads dumpés).
    3293         */
  • ukto-invoice-sync-for-pennylane/trunk/includes/class-isfp-my-account.php

    r3681339 r3734166  
    4545                }
    4646
    47                 $response = wp_remote_get( $pdf_url, array( 'timeout' => 30 ) );
    48                 if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
     47                $body = self::fetch_pdf( $pdf_url );
     48                if ( '' === $body ) {
    4949                        wp_die( esc_html__( 'The download is unavailable at the moment.', 'ukto-invoice-sync-for-pennylane' ), '', array( 'response' => 502 ) );
    5050                }
    … …  
    5757                header( 'Content-Type: application/pdf' );
    5858                header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
    59                 echo wp_remote_retrieve_body( $response ); // phpcs:ignore WordPress.Security.EscapeOutput -- flux binaire PDF.
     59                echo $body; // phpcs:ignore WordPress.Security.EscapeOutput -- flux binaire PDF.
    6060                exit;
    6161        }
     62
     63        /**
     64         * Contenu du PDF, ou chaîne vide.
     65         *
     66         * wp_safe_remote_get : jamais d'adresse interne ni de port exotique, même
     67         * si l'URL renvoyée par l'API devait un jour en désigner une. Le corps doit
     68         * être un PDF (signature `%PDF-` dans les 1 024 premiers octets, comme le
     69         * tolère la norme) : une page d'erreur HTML de l'hébergeur ne doit pas
     70         * arriver chez le client sous le nom « facture.pdf ».
     71         */
     72        private static function fetch_pdf( $url ) {
     73                $response = wp_safe_remote_get( $url, array(
     74                        'timeout'             => 30,
     75                        'limit_response_size' => 20 * MB_IN_BYTES,
     76                ) );
     77                if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
     78                        return '';
     79                }
     80                $body = (string) wp_remote_retrieve_body( $response );
     81                return false === strpos( substr( $body, 0, 1024 ), '%PDF-' ) ? '' : $body;
     82        }
    6283}
  • ukto-invoice-sync-for-pennylane/trunk/includes/class-isfp-order-metabox.php

    r3733469 r3734166  
    113113                foreach ( $actions as $action => $conf ) {
    114114                        list( $label, $primary ) = $conf;
    115                         $url = wp_nonce_url(
    116                                 admin_url( sprintf( 'admin-post.php?action=isfp_order_action&isfp_do=%s&order_id=%d', $action, $order->get_id() ) ),
    117                                 'isfp_order_action_' . $order->get_id()
    118                         );
     115                        $url = self::action_url( $action, $order );
    119116                        printf(
    120117                                '<a class="button %s" href="%s">%s</a>',
    … …  
    138135                                );
    139136                        } else {
    140                                 $url = wp_nonce_url(
    141                                         admin_url( sprintf( 'admin-post.php?action=isfp_order_action&isfp_do=get_pdf&order_id=%d', $order->get_id() ) ),
    142                                         'isfp_order_action_' . $order->get_id()
    143                                 );
     137                                $url = self::action_url( 'get_pdf', $order );
    144138                                printf(
    145139                                        '<a class="button" href="%s">%s</a>',
    … …  
    221215        const NOTICE_TRANSIENT = 'isfp_action_notice_';
    222216
     217        /**
     218         * Lien d'une action de la commande, signé pour CETTE action.
     219         *
     220         * Avant 1.8.0, un seul nonce couvrait toutes les actions de la commande :
     221         * le lien « Obtenir le PDF » valait aussi pour « Marquer payée » ou
     222         * « Envoyer par e-mail ».
     223         */
     224        public static function action_url( $action, $order ) {
     225                $action = sanitize_key( $action );
     226                return wp_nonce_url(
     227                        admin_url( sprintf( 'admin-post.php?action=isfp_order_action&isfp_do=%s&order_id=%d', $action, $order->get_id() ) ),
     228                        self::nonce_action( $action, $order->get_id() )
     229                );
     230        }
     231
     232        private static function nonce_action( $action, $order_id ) {
     233                return 'isfp_order_action_' . $action . '_' . (int) $order_id;
     234        }
     235
    223236        public static function handle_action() {
    224237                $order_id = isset( $_GET['order_id'] ) ? (int) $_GET['order_id'] : 0;
    225238                $do       = isset( $_GET['isfp_do'] ) ? sanitize_key( $_GET['isfp_do'] ) : '';
    226239
    227                 if ( ! current_user_can( 'manage_woocommerce' ) || ! check_admin_referer( 'isfp_order_action_' . $order_id ) ) {
     240                if ( ! current_user_can( 'manage_woocommerce' ) || ! check_admin_referer( self::nonce_action( $do, $order_id ) ) ) {
    228241                        wp_die( esc_html__( 'You are not allowed to do that.', 'ukto-invoice-sync-for-pennylane' ) );
    229242                }
    … …  
    246259                                $pdf    = $target ? ISFP_Invoice_Service::get_pdf_url( $target ) : '';
    247260                                if ( $pdf ) {
     261                                        // Rien de l'administration ne part chez l'hébergeur du PDF,
     262                                        // pas même l'origine que la politique par défaut laisse passer.
     263                                        header( 'Referrer-Policy: no-referrer' );
    248264                                        wp_redirect( $pdf ); // phpcs:ignore WordPress.Security.SafeRedirect -- URL externe Pennylane, voulue.
    249265                                        exit;
  • ukto-invoice-sync-for-pennylane/trunk/includes/class-isfp-siret-autocomplete.php

    r3713298 r3734166  
    2727                        'ajaxUrl' => admin_url( 'admin-ajax.php' ),
    2828                        'nonce'   => wp_create_nonce( 'isfp_siret' ),
     29                        'i18n'    => array(
     30                                'searching'   => __( 'Looking up the company…', 'ukto-invoice-sync-for-pennylane' ),
     31                                'found'       => __( 'Company found', 'ukto-invoice-sync-for-pennylane' ),
     32                                'notFound'    => __( 'SIRET not found — please check the number.', 'ukto-invoice-sync-for-pennylane' ),
     33                                'unavailable' => __( 'Verification unavailable for now.', 'ukto-invoice-sync-for-pennylane' ),
     34                        ),
    2935                ) );
    3036        }
    … …  
    3339                check_ajax_referer( 'isfp_siret', 'nonce' );
    3440
    35                 /**
    36                  * Rate limit par IP : ce point d'entrée est ouvert aux visiteurs anonymes
    37                  * (nopriv) et le nonce déconnecté, partagé et valable des heures, ne
    38                  * limite rien. Sans plafond, un robot pouvait utiliser la boutique comme
    39                  * relais vers l'API data.gouv (bannissement de l'IP du site) tout en
    40                  * créant un transient par SIRET tenté (gonflement illimité de wp_options).
    41                  */
    42                 $ip     = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
    43                 $rl_key = 'isfp_siret_rl_' . md5( $ip );
    44                 $count  = (int) get_transient( $rl_key );
    45                 if ( $count >= 10 ) {
    46                         wp_send_json_error( array( 'message' => __( 'Too many lookups — please wait a minute.', 'ukto-invoice-sync-for-pennylane' ) ), 429 );
     41                // Le nonce reste valable jusqu'à 24 h : le script publié avant une
     42                // désactivation pouvait encore interroger l'API pendant ce temps.
     43                if ( ! ISFP_Settings::siret_autofill() ) {
     44                        wp_send_json_error( array( 'message' => __( 'SIRET lookup is disabled.', 'ukto-invoice-sync-for-pennylane' ) ), 403 );
    4745                }
    48                 set_transient( $rl_key, $count + 1, MINUTE_IN_SECONDS );
    4946
    5047                $siret = isset( $_GET['siret'] ) ? preg_replace( '/\D/', '', sanitize_text_field( wp_unslash( $_GET['siret'] ) ) ) : '';
    … …  
    5350                }
    5451
     52                // Une réponse en cache ne coûte rien : elle n'entre pas dans la limite.
    5553                $cache_key = 'isfp_siret_' . $siret;
    5654                $cached    = get_transient( $cache_key );
    5755                if ( is_array( $cached ) ) {
    5856                        empty( $cached ) ? wp_send_json_error( array( 'message' => __( 'Company not found.', 'ukto-invoice-sync-for-pennylane' ) ) ) : wp_send_json_success( $cached );
     57                }
     58
     59                if ( ! self::within_rate_limit() ) {
     60                        wp_send_json_error( array( 'message' => __( 'Too many lookups — please wait a minute.', 'ukto-invoice-sync-for-pennylane' ) ), 429 );
    5961                }
    6062
    … …  
    120122        }
    121123
     124        /**
     125         * Limite de débit des recherches qui partent vers l'API.
     126         *
     127         * Ce point d'entrée est ouvert aux visiteurs anonymes (nopriv) et le nonce
     128         * déconnecté, partagé et valable des heures, ne limite rien. Sans plafond,
     129         * un robot pouvait utiliser la boutique comme relais vers l'API data.gouv
     130         * (bannissement de l'IP du site) tout en créant un transient par SIRET
     131         * tenté (gonflement de wp_options).
     132         *
     133         * Deux compteurs par minute : un par visiteur, un pour toute la boutique.
     134         * Derrière un CDN ou un proxy inverse, REMOTE_ADDR est celle du proxy et
     135         * tous les visiteurs partageraient le premier : le filtre
     136         * `isfp_siret_client_ip` permet de lire l'en-tête de confiance du proxy
     137         * (jamais lu d'office : sans proxy, il serait falsifiable).
     138         */
     139        private static function within_rate_limit() {
     140                $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
     141                $ip = (string) apply_filters( 'isfp_siret_client_ip', $ip );
     142
     143                $limites = array(
     144                        'isfp_siret_rl_' . md5( $ip ) => (int) apply_filters( 'isfp_siret_rate_limit', 10 ),
     145                        'isfp_siret_rl_site'          => (int) apply_filters( 'isfp_siret_site_rate_limit', 120 ),
     146                );
     147                foreach ( $limites as $cle => $plafond ) {
     148                        if ( (int) get_transient( $cle ) >= $plafond ) {
     149                                return false;
     150                        }
     151                }
     152                foreach ( array_keys( $limites ) as $cle ) {
     153                        set_transient( $cle, (int) get_transient( $cle ) + 1, MINUTE_IN_SECONDS );
     154                }
     155                return true;
     156        }
     157
    122158        /** "HALVEN SAS" → "Halven Sas" est moche : on capitalise mais on garde les sigles courts. */
    123159        private static function title_case( $value ) {
  • ukto-invoice-sync-for-pennylane/trunk/languages/ukto-invoice-sync-for-pennylane-fr_FR.po

    r3734078 r3734166  
    3737msgstr "Ce n° de TVA intracommunautaire n’est pas reconnu par le VIES (service européen). Veuillez le corriger ou le retirer pour commander avec TVA."
    3838
    39 #: includes/class-isfp-customer-sync.php:66
     39#: includes/class-isfp-customer-sync.php:83
    4040msgid "Pennylane response contained no customer ID: %s"
    4141msgstr "La réponse de Pennylane ne contient aucun ID de client : %s"
    … …  
    117117msgstr "Générer la facture"
    118118
    119 #: includes/class-isfp-order-metabox.php:137
     119#: includes/class-isfp-order-metabox.php:134
    120120msgid "Download PDF"
    121121msgstr "Télécharger le PDF"
    122122
    123 #: includes/class-isfp-order-metabox.php:147
     123#: includes/class-isfp-order-metabox.php:141
    124124msgid "Get PDF"
    125125msgstr "Récupérer le PDF"
    126126
    127 #: includes/class-isfp-order-metabox.php:182
     127#: includes/class-isfp-order-metabox.php:176
    128128msgid "E-invoicing: submitted"
    129129msgstr "Facture électronique : déposée"
    130130
    131 #: includes/class-isfp-order-metabox.php:183
     131#: includes/class-isfp-order-metabox.php:177
    132132msgid "E-invoicing: sent"
    133133msgstr "Facture électronique : transmise"
    134134
    135 #: includes/class-isfp-order-metabox.php:184
     135#: includes/class-isfp-order-metabox.php:178
    136136msgid "E-invoicing: accepted"
    137137msgstr "Facture électronique : acceptée"
    138138
    139 #: includes/class-isfp-order-metabox.php:185
     139#: includes/class-isfp-order-metabox.php:179
    140140msgid "E-invoicing: approved"
    141141msgstr "Facture électronique : approuvée"
    142142
    143 #: includes/class-isfp-order-metabox.php:186
     143#: includes/class-isfp-order-metabox.php:180
    144144msgid "E-invoicing: collected"
    145145msgstr "Facture électronique : encaissée"
    146146
    147 #: includes/class-isfp-order-metabox.php:187
     147#: includes/class-isfp-order-metabox.php:181
    148148msgid "E-invoicing: partially collected"
    149149msgstr "Facture électronique : partiellement encaissée"
    150150
    151 #: includes/class-isfp-order-metabox.php:188
     151#: includes/class-isfp-order-metabox.php:182
    152152msgid "E-invoicing: disputed"
    153153msgstr "Facture électronique : en litige"
    154154
    155 #: includes/class-isfp-order-metabox.php:189
     155#: includes/class-isfp-order-metabox.php:183
    156156msgid "E-invoicing: refused by the platform"
    157157msgstr "Facture électronique : refusée par la plateforme"
    158158
    159 #: includes/class-isfp-order-metabox.php:190
     159#: includes/class-isfp-order-metabox.php:184
    160160msgid "E-invoicing: rejected by the platform"
    161161msgstr "Facture électronique : rejetée par la plateforme"
    162162
    163 #: includes/class-isfp-order-metabox.php:196
     163#: includes/class-isfp-order-metabox.php:190
    164164msgid "E-invoicing: %s"
    165165msgstr "Facture électronique : %s"
    166166
    167 #: includes/class-isfp-order-metabox.php:209
     167#: includes/class-isfp-order-metabox.php:203
    168168msgid "Factur-X file"
    169169msgstr "Fichier Factur-X"
    170170
    171 #: includes/class-isfp-order-metabox.php:210
     171#: includes/class-isfp-order-metabox.php:204
    172172msgid "Not a Factur-X file"
    173173msgstr "Fichier non Factur-X"
    174174
    175 #: includes/class-isfp-order-metabox.php:228
     175#: includes/class-isfp-order-metabox.php:241
    176176msgid "You are not allowed to do that."
    177177msgstr "Action non autorisée."
    178178
    179 #: includes/class-isfp-order-metabox.php:237
     179#: includes/class-isfp-order-metabox.php:250
    180180msgid "Invoice created in Pennylane."
    181181msgstr "Facture créée dans Pennylane."
    182182
    183 #: includes/class-isfp-order-metabox.php:251
     183#: includes/class-isfp-order-metabox.php:267
    184184msgid "The PDF is not available yet — Pennylane generates it asynchronously. Try again in a few minutes."
    185185msgstr "Le PDF n’est pas encore disponible — Pennylane le génère de façon asynchrone. Réessayez dans quelques minutes."
    186186
    187 #: includes/class-isfp-order-metabox.php:265
     187#: includes/class-isfp-order-metabox.php:281
    188188msgid "Unknown action."
    189189msgstr "Action inconnue."
    190190
    191 #: includes/class-isfp-order-metabox.php:274
     191#: includes/class-isfp-order-metabox.php:290
    192192msgid "Done."
    193193msgstr "Action effectuée."
    194194
    195 #: includes/class-isfp-order-metabox.php:333
     195#: includes/class-isfp-order-metabox.php:349
    196196msgid "Invoiced"
    197197msgstr "Facturée"
    198198
    199 #: includes/class-isfp-order-metabox.php:335
     199#: includes/class-isfp-order-metabox.php:351
    200200msgid "Error"
    201201msgstr "Erreur"
    202202
    203 #: includes/class-isfp-order-metabox.php:337
     203#: includes/class-isfp-order-metabox.php:353
    204204msgid "Draft"
    205205msgstr "Brouillon"
    … …  
    449449msgstr "Désactiver"
    450450
    451 #: includes/class-isfp-siret-autocomplete.php:46
     451#: includes/class-isfp-siret-autocomplete.php:30
     452msgid "Looking up the company…"
     453msgstr "Recherche de l’entreprise…"
     454
     455#: includes/class-isfp-siret-autocomplete.php:31
     456msgid "Company found"
     457msgstr "Entreprise trouvée"
     458
     459#: includes/class-isfp-siret-autocomplete.php:32
     460msgid "SIRET not found — please check the number."
     461msgstr "SIRET introuvable — vérifiez le numéro."
     462
     463#: includes/class-isfp-siret-autocomplete.php:33
     464msgid "Verification unavailable for now."
     465msgstr "Vérification indisponible pour le moment."
     466
     467#: includes/class-isfp-siret-autocomplete.php:44
     468msgid "SIRET lookup is disabled."
     469msgstr "La recherche par SIRET est désactivée."
     470
     471#: includes/class-isfp-siret-autocomplete.php:49
     472msgid "Invalid SIRET."
     473msgstr "SIRET invalide."
     474
     475#: includes/class-isfp-siret-autocomplete.php:56 includes/class-isfp-siret-autocomplete.php:79
     476msgid "Company not found."
     477msgstr "Entreprise introuvable."
     478
     479#: includes/class-isfp-siret-autocomplete.php:60
    452480msgid "Too many lookups — please wait a minute."
    453481msgstr "Trop de recherches — veuillez patienter une minute."
    454482
    455 #: includes/class-isfp-siret-autocomplete.php:52
    456 msgid "Invalid SIRET."
    457 msgstr "SIRET invalide."
    458 
    459 #: includes/class-isfp-siret-autocomplete.php:58 includes/class-isfp-siret-autocomplete.php:77
    460 msgid "Company not found."
    461 msgstr "Entreprise introuvable."
    462 
    463 #: includes/class-isfp-siret-autocomplete.php:67
     483#: includes/class-isfp-siret-autocomplete.php:69
    464484msgid "Lookup service unavailable."
    465485msgstr "Service de recherche indisponible."
  • ukto-invoice-sync-for-pennylane/trunk/languages/ukto-invoice-sync-for-pennylane.pot

    r3734078 r3734166  
    3838msgstr ""
    3939
    40 #: includes/class-isfp-customer-sync.php:66
     40#: includes/class-isfp-customer-sync.php:83
    4141msgid "Pennylane response contained no customer ID: %s"
    4242msgstr ""
    … …  
    118118msgstr ""
    119119
    120 #: includes/class-isfp-order-metabox.php:137
     120#: includes/class-isfp-order-metabox.php:134
    121121msgid "Download PDF"
    122122msgstr ""
    123123
    124 #: includes/class-isfp-order-metabox.php:147
     124#: includes/class-isfp-order-metabox.php:141
    125125msgid "Get PDF"
    126126msgstr ""
    127127
     128#: includes/class-isfp-order-metabox.php:176
     129msgid "E-invoicing: submitted"
     130msgstr ""
     131
     132#: includes/class-isfp-order-metabox.php:177
     133msgid "E-invoicing: sent"
     134msgstr ""
     135
     136#: includes/class-isfp-order-metabox.php:178
     137msgid "E-invoicing: accepted"
     138msgstr ""
     139
     140#: includes/class-isfp-order-metabox.php:179
     141msgid "E-invoicing: approved"
     142msgstr ""
     143
     144#: includes/class-isfp-order-metabox.php:180
     145msgid "E-invoicing: collected"
     146msgstr ""
     147
     148#: includes/class-isfp-order-metabox.php:181
     149msgid "E-invoicing: partially collected"
     150msgstr ""
     151
    128152#: includes/class-isfp-order-metabox.php:182
    129 msgid "E-invoicing: submitted"
     153msgid "E-invoicing: disputed"
    130154msgstr ""
    131155
    132156#: includes/class-isfp-order-metabox.php:183
    133 msgid "E-invoicing: sent"
     157msgid "E-invoicing: refused by the platform"
    134158msgstr ""
    135159
    136160#: includes/class-isfp-order-metabox.php:184
    137 msgid "E-invoicing: accepted"
    138 msgstr ""
    139 
    140 #: includes/class-isfp-order-metabox.php:185
    141 msgid "E-invoicing: approved"
    142 msgstr ""
    143 
    144 #: includes/class-isfp-order-metabox.php:186
    145 msgid "E-invoicing: collected"
    146 msgstr ""
    147 
    148 #: includes/class-isfp-order-metabox.php:187
    149 msgid "E-invoicing: partially collected"
    150 msgstr ""
    151 
    152 #: includes/class-isfp-order-metabox.php:188
    153 msgid "E-invoicing: disputed"
    154 msgstr ""
    155 
    156 #: includes/class-isfp-order-metabox.php:189
    157 msgid "E-invoicing: refused by the platform"
     161msgid "E-invoicing: rejected by the platform"
    158162msgstr ""
    159163
    160164#: includes/class-isfp-order-metabox.php:190
    161 msgid "E-invoicing: rejected by the platform"
    162 msgstr ""
    163 
    164 #: includes/class-isfp-order-metabox.php:196
    165165msgid "E-invoicing: %s"
    166166msgstr ""
    167167
    168 #: includes/class-isfp-order-metabox.php:209
     168#: includes/class-isfp-order-metabox.php:203
    169169msgid "Factur-X file"
    170170msgstr ""
    171171
    172 #: includes/class-isfp-order-metabox.php:210
     172#: includes/class-isfp-order-metabox.php:204
    173173msgid "Not a Factur-X file"
    174174msgstr ""
    175175
    176 #: includes/class-isfp-order-metabox.php:228
     176#: includes/class-isfp-order-metabox.php:241
    177177msgid "You are not allowed to do that."
    178178msgstr ""
    179179
    180 #: includes/class-isfp-order-metabox.php:237
     180#: includes/class-isfp-order-metabox.php:250
    181181msgid "Invoice created in Pennylane."
    182182msgstr ""
    183183
    184 #: includes/class-isfp-order-metabox.php:251
     184#: includes/class-isfp-order-metabox.php:267
    185185msgid "The PDF is not available yet — Pennylane generates it asynchronously. Try again in a few minutes."
    186186msgstr ""
    187187
    188 #: includes/class-isfp-order-metabox.php:265
     188#: includes/class-isfp-order-metabox.php:281
    189189msgid "Unknown action."
    190190msgstr ""
    191191
    192 #: includes/class-isfp-order-metabox.php:274
     192#: includes/class-isfp-order-metabox.php:290
    193193msgid "Done."
    194194msgstr ""
    195195
    196 #: includes/class-isfp-order-metabox.php:333
     196#: includes/class-isfp-order-metabox.php:349
    197197msgid "Invoiced"
    198198msgstr ""
    199199
    200 #: includes/class-isfp-order-metabox.php:335
     200#: includes/class-isfp-order-metabox.php:351
    201201msgid "Error"
    202202msgstr ""
    203203
    204 #: includes/class-isfp-order-metabox.php:337
     204#: includes/class-isfp-order-metabox.php:353
    205205msgid "Draft"
    206206msgstr ""
    … …  
    450450msgstr ""
    451451
    452 #: includes/class-isfp-siret-autocomplete.php:46
     452#: includes/class-isfp-siret-autocomplete.php:30
     453msgid "Looking up the company…"
     454msgstr ""
     455
     456#: includes/class-isfp-siret-autocomplete.php:31
     457msgid "Company found"
     458msgstr ""
     459
     460#: includes/class-isfp-siret-autocomplete.php:32
     461msgid "SIRET not found — please check the number."
     462msgstr ""
     463
     464#: includes/class-isfp-siret-autocomplete.php:33
     465msgid "Verification unavailable for now."
     466msgstr ""
     467
     468#: includes/class-isfp-siret-autocomplete.php:44
     469msgid "SIRET lookup is disabled."
     470msgstr ""
     471
     472#: includes/class-isfp-siret-autocomplete.php:49
     473msgid "Invalid SIRET."
     474msgstr ""
     475
     476#: includes/class-isfp-siret-autocomplete.php:56 includes/class-isfp-siret-autocomplete.php:79
     477msgid "Company not found."
     478msgstr ""
     479
     480#: includes/class-isfp-siret-autocomplete.php:60
    453481msgid "Too many lookups — please wait a minute."
    454482msgstr ""
    455483
    456 #: includes/class-isfp-siret-autocomplete.php:52
    457 msgid "Invalid SIRET."
    458 msgstr ""
    459 
    460 #: includes/class-isfp-siret-autocomplete.php:58 includes/class-isfp-siret-autocomplete.php:77
    461 msgid "Company not found."
    462 msgstr ""
    463 
    464 #: includes/class-isfp-siret-autocomplete.php:67
     484#: includes/class-isfp-siret-autocomplete.php:69
    465485msgid "Lookup service unavailable."
    466486msgstr ""
  • ukto-invoice-sync-for-pennylane/trunk/readme.txt

    r3734078 r3734166  
    66Requires PHP: 7.4
    77Requires Plugins: woocommerce
    8 Stable tag: 1.7.9
     8Stable tag: 1.8.0
    99License: GPLv2 or later
    1010License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    142142Yes. With test mode enabled, no write request reaches Pennylane: the API client blocks them
    143143centrally and payloads are only written to the WooCommerce logs so you can review them before
    144 going live.
     144going live — with customer names, addresses, phone numbers and e-mail addresses masked.
    145145
    146146= Where are the logs? =
    147147
    148148WooCommerce → Status → Logs, source "pennylane". Every API error is logged with the full
    149 response, and every order keeps its own note trail.
     149response, and every order keeps its own note trail. Customers' personal data is masked in the
     150logs; to log it in full while debugging, return true from the `isfp_log_personal_data` filter.
    150151
    151152= Can invoices be created automatically? =
    … …  
    164165
    165166== Changelog ==
     167
     168= 1.8.0 =
     169* **Customers buying first as individuals, then as businesses, now get a business invoice on a
     170  business record.** The Pennylane customer remembered on a WordPress account used to be reused
     171  for every later order: a B2B invoice could land on the buyer's individual record, without their
     172  VAT number — a mandatory mention on a VAT-exempt intra-Community invoice. The remembered record
     173  is now only reused for the same identity (individual, or the same company, VAT and SIRET
     174  numbers); otherwise the right record is looked up or created.
     175* Customer lookup in Pennylane is now a single filtered request (e-mail and customer type) instead
     176  of scanning the whole account page by page — up to 50 calls, and a duplicate customer beyond
     177  5,000. If Pennylane rejects or ignores the filter, the previous full scan is used. Two companies
     178  sharing one e-mail address (an accounting firm, for instance) are told apart by their VAT and
     179  SIRET numbers.
     180* **Personal data is no longer written to the logs.** Test mode — on by default — logged each
     181  customer's name, address, phone number and e-mail address. They are now masked (amounts, VAT
     182  rates and references stay readable). The `isfp_log_personal_data` filter restores full values
     183  for one-off debugging.
     184* SIRET lookup at checkout: the setting is now checked on the server side (the endpoint kept
     185  answering for up to 24 hours after the option was turned off); cached answers no longer count
     186  towards the rate limit; a store-wide ceiling is added, and the `isfp_siret_client_ip` filter lets
     187  stores behind a CDN or reverse proxy rate-limit per visitor. Its messages can now be translated
     188  and the server's explanation is shown to the customer.
     189* Order actions: each action link is now signed for that action only (one nonce used to cover
     190  every action of an order).
     191* Customer invoice download (My account): fetched with `wp_safe_remote_get`, size-capped, and only
     192  served when the file really is a PDF — a hosting error page is no longer sent as "invoice.pdf".
     193* Uninstalling the plugin no longer deletes the Pro extension's settings, and now removes all of
     194  its own options (B2B exemption, OSS, the pre-1.7 API token, caches and locks).
    166195
    167196= 1.7.9 =
    … …  
    259288== Upgrade Notice ==
    260289
     290= 1.8.0 =
     291Business orders from customers who previously bought as individuals now go to a business record in Pennylane. Personal data is no longer written to the logs.
     292
    261293= 1.7.9 =
    262294The B2B VAT exemption now requires VIES validation. If you use the exemption without VIES, enable VIES in the Invoicing tab after updating.
  • ukto-invoice-sync-for-pennylane/trunk/ukto-invoice-sync-for-pennylane.php

    r3734078 r3734166  
    44 * Plugin URI:        https://ukto.fr/pennylane.php
    55 * Description:       Send your WooCommerce orders to Pennylane as compliant invoices: one-click invoicing from the order screen, full VAT/OSS mapping, EU VAT number validation (VIES) and SIRET at checkout, invoice PDF in My Account.
    6  * Version:           1.7.9
     6 * Version:           1.8.0
    77 * Requires at least: 5.8
    88 * Requires PHP:      7.4
    … …  
    2020defined( 'ABSPATH' ) || exit;
    2121
    22 define( 'ISFP_VERSION', '1.7.9' ); // Doit égaler l'en-tête Version — vérifié par build.ps1.
     22define( 'ISFP_VERSION', '1.8.0' ); // Doit égaler l'en-tête Version — vérifié par build.ps1.
    2323
    2424/**
  • ukto-invoice-sync-for-pennylane/trunk/uninstall.php

    r3681339 r3734166  
    88 *  - les métadonnées client et produit, pour la même raison.
    99 *
    10  * Le token API est supprimé : c'est un secret, il n'a rien à faire en base
    11  * une fois le plugin retiré.
     10 * Seules les options de CETTE extension sont supprimées. Avant 1.8.0, la liste
     11 * mêlait les réglages de l'extension Pro (déclencheurs, modèles d'e-mail,
     12 * alertes) : désinstaller la gratuite pour la réinstaller effaçait la
     13 * configuration payante. C'est la désinstallation de la Pro qui s'en charge.
     14 *
     15 * Le jeton API est supprimé, sous son nom actuel comme sous l'ancien
     16 * (`pw_api_token`, d'avant le changement de préfixe) : c'est un secret, il n'a
     17 * rien à faire en base une fois le plugin retiré.
    1218 *
    1319 * @package InvoiceSyncForPennylane
    … …  
    1622defined( 'WP_UNINSTALL_PLUGIN' ) || exit;
    1723
    18 $isfp_options = array(
    19         'isfp_api_token',
    20         'isfp_trigger_statuses',
    21         'isfp_paid_statuses',
    22         'isfp_auto_invoice',
    23         'isfp_auto_send_email',
    24         'isfp_auto_credit_notes',
    25         'isfp_auto_mark_paid',
    26         'isfp_dry_run',
    27         'isfp_alerts_enabled',
    28         'isfp_alert_email',
    29         'isfp_alert_queue',
    30         'isfp_weekly_report',
    31         'isfp_vies_enabled',
    32         'isfp_vies_block',
    33         'isfp_siret_autofill',
    34         'isfp_email_delay',
    35         'isfp_email_mode',
    36         'isfp_email_subject',
    37         'isfp_email_body',
    38         'isfp_cn_email_subject',
    39         'isfp_cn_email_body',
    40         'isfp_invoice_notes',
    41         'isfp_language_mode',
     24// Réglages de l'extension gratuite (voir ISFP_Settings::register_settings()).
     25$isfp_keys = array(
     26        'api_token',
     27        'dry_run',
     28        'vies_enabled',
     29        'vies_block',
     30        'b2b_exempt',
     31        'siret_autofill',
     32        'oss_registered',
     33        'language_mode',
     34        'invoice_notes',
    4235);
     36foreach ( $isfp_keys as $isfp_key ) {
     37        delete_option( 'isfp_' . $isfp_key );
     38        delete_option( 'pw_' . $isfp_key ); // Ancien préfixe, encore présent si la migration a eu lieu.
     39}
     40delete_option( 'isfp_prefix_migrated' );
    4341
    44 // Variantes par langue (isfp_email_subject_fr, isfp_email_body_en…). Requête
    45 // directe assumée : les noms d'options sont dynamiques (suffixe de langue),
    46 // et à la désinstallation il n'y a rien à mettre en cache.
     42// Transients et verrous à nom dynamique : cache VIES et SIRET, limites de
     43// débit, messages après action, verrous de facturation. Requête directe
     44// assumée : aucune API WordPress ne liste des options par motif, et à la
     45// désinstallation il n'y a rien à mettre en cache.
    4746global $wpdb;
    4847// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    49 $isfp_localized = $wpdb->get_col(
    50         "SELECT option_name FROM {$wpdb->options}
    51          WHERE option_name LIKE 'pw\\_email\\_subject\\_%'
    52             OR option_name LIKE 'pw\\_email\\_body\\_%'
    53             OR option_name LIKE 'pw\\_cn\\_email\\_subject\\_%'
    54             OR option_name LIKE 'pw\\_cn\\_email\\_body\\_%'"
     48$wpdb->query(
     49        "DELETE FROM {$wpdb->options}
     50         WHERE option_name LIKE '\\_transient\\_isfp\\_vies\\_%'
     51            OR option_name LIKE '\\_transient\\_timeout\\_isfp\\_vies\\_%'
     52            OR option_name LIKE '\\_transient\\_isfp\\_siret\\_%'
     53            OR option_name LIKE '\\_transient\\_timeout\\_isfp\\_siret\\_%'
     54            OR option_name LIKE '\\_transient\\_isfp\\_action\\_notice\\_%'
     55            OR option_name LIKE '\\_transient\\_timeout\\_isfp\\_action\\_notice\\_%'
     56            OR option_name LIKE '\\_isfp\\_lock\\_%'"
    5557);
    56 foreach ( array_merge( $isfp_options, (array) $isfp_localized ) as $isfp_option ) {
    57         delete_option( $isfp_option );
    58 }
    59 
    60 // Transients laissés par le plugin.
    61 delete_transient( 'isfp_products_cache' );
    62 delete_transient( 'isfp_alert_throttle' );
Note: See TracChangeset for help on using the changeset viewer.