Changeset 3734166
- Timestamp:
- 10/08/2026 09:50:25 AM (2 weeks ago)
- Location:
- ukto-invoice-sync-for-pennylane
- Files:
-
- 28 edited
- 1 copied
-
tags/1.8.0 (copied) (copied from ukto-invoice-sync-for-pennylane/trunk )
-
tags/1.8.0/assets/js/isfp-siret.js (modified) (4 diffs)
-
tags/1.8.0/includes/class-isfp-api-client.php (modified) (2 diffs)
-
tags/1.8.0/includes/class-isfp-customer-sync.php (modified) (7 diffs)
-
tags/1.8.0/includes/class-isfp-invoice-service.php (modified) (1 diff)
-
tags/1.8.0/includes/class-isfp-logger.php (modified) (3 diffs)
-
tags/1.8.0/includes/class-isfp-my-account.php (modified) (2 diffs)
-
tags/1.8.0/includes/class-isfp-order-metabox.php (modified) (4 diffs)
-
tags/1.8.0/includes/class-isfp-siret-autocomplete.php (modified) (4 diffs)
-
tags/1.8.0/languages/ukto-invoice-sync-for-pennylane-fr_FR.mo (modified) ( previous)
-
tags/1.8.0/languages/ukto-invoice-sync-for-pennylane-fr_FR.po (modified) (3 diffs)
-
tags/1.8.0/languages/ukto-invoice-sync-for-pennylane.pot (modified) (3 diffs)
-
tags/1.8.0/readme.txt (modified) (4 diffs)
-
tags/1.8.0/ukto-invoice-sync-for-pennylane.php (modified) (2 diffs)
-
tags/1.8.0/uninstall.php (modified) (2 diffs)
-
trunk/assets/js/isfp-siret.js (modified) (4 diffs)
-
trunk/includes/class-isfp-api-client.php (modified) (2 diffs)
-
trunk/includes/class-isfp-customer-sync.php (modified) (7 diffs)
-
trunk/includes/class-isfp-invoice-service.php (modified) (1 diff)
-
trunk/includes/class-isfp-logger.php (modified) (3 diffs)
-
trunk/includes/class-isfp-my-account.php (modified) (2 diffs)
-
trunk/includes/class-isfp-order-metabox.php (modified) (4 diffs)
-
trunk/includes/class-isfp-siret-autocomplete.php (modified) (4 diffs)
-
trunk/languages/ukto-invoice-sync-for-pennylane-fr_FR.mo (modified) ( previous)
-
trunk/languages/ukto-invoice-sync-for-pennylane-fr_FR.po (modified) (3 diffs)
-
trunk/languages/ukto-invoice-sync-for-pennylane.pot (modified) (3 diffs)
-
trunk/readme.txt (modified) (4 diffs)
-
trunk/ukto-invoice-sync-for-pennylane.php (modified) (2 diffs)
-
trunk/uninstall.php (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
ukto-invoice-sync-for-pennylane/tags/1.8.0/assets/js/isfp-siret.js
r3681339 r3734166 13 13 14 14 var timer = null; 15 var i18n = window.pwSiret.i18n || {}; 15 16 16 17 // Setter natif : indispensable pour que React (Checkout Blocks) voie la valeur. … … 72 73 } 73 74 } 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 ); 75 76 } 76 77 … … 87 88 clearTimeout( timer ); 88 89 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 ) 91 92 .then( function ( response ) { return response.json(); } ) 92 93 .then( function ( result ) { … … 94 95 fill( input, result.data ); 95 96 } 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 ); 97 100 } 98 101 } ) 99 102 .catch( function () { 100 showStatus( input, 'Vérification indisponible pour le moment.', false );103 showStatus( input, i18n.unavailable, false ); 101 104 } ); 102 105 }, 400 ); -
ukto-invoice-sync-for-pennylane/tags/1.8.0/includes/class-isfp-api-client.php
r3681339 r3734166 124 124 */ 125 125 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 ) ) : '' ) ); 127 127 return new WP_Error( 128 128 'isfp_dry_run', … … 147 147 } 148 148 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 ) ) : '' ) ); 150 150 151 151 $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 6 6 * 7 7 * 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). 11 12 */ 12 13 class ISFP_Customer_Sync { … … 14 15 const USER_META = '_pennylane_customer_id'; 15 16 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'; 16 28 17 29 /** … … 23 35 */ 24 36 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 ); 27 45 if ( $known ) { 28 46 /** … … 37 55 } 38 56 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 ); 42 59 if ( is_wp_error( $found ) ) { 43 60 return $found; … … 84 101 } 85 102 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 ) { 87 128 $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 : ''; 95 142 } 96 143 … … 101 148 if ( $user_id ) { 102 149 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 ) { 119 170 return null; 120 171 } 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 ) { 123 208 $cursor = null; 124 209 $max_pages = (int) apply_filters( 'isfp_customer_search_max_pages', 50 ); // 50 × 100 = 5 000 tiers. … … 129 214 $query['cursor'] = $cursor; 130 215 } 131 $response = $client->list_customers( apply_filters( 'isfp_customer_search_query', $query, $email ));216 $response = $client->list_customers( $query ); 132 217 if ( is_wp_error( $response ) ) { 133 218 return $response; 134 219 } 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 } 149 225 $cursor = isset( $response['next_cursor'] ) ? $response['next_cursor'] : null; 150 226 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() ) ); 154 231 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; 155 280 } 156 281 -
ukto-invoice-sync-for-pennylane/tags/1.8.0/includes/class-isfp-invoice-service.php
r3733469 r3734166 86 86 $customer_preview = ISFP_Customer_Sync::preview_payload( $order ); 87 87 $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 ) ) ); 90 90 $order->add_order_note( __( 'Pennylane [TEST MODE]: invoice simulated — payloads written to the logs, nothing sent to Pennylane.', 'ukto-invoice-sync-for-pennylane' ) ); 91 91 return true; -
ukto-invoice-sync-for-pennylane/tags/1.8.0/includes/class-isfp-logger.php
r3681339 r3734166 3 3 4 4 /** 5 * Wrapper WC_Logger — source unique, secrets masqués.5 * Wrapper WC_Logger — source unique, secrets et données personnelles masqués. 6 6 * 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. 7 15 */ 8 16 class ISFP_Logger { … … 10 18 const SOURCE = 'pennylane'; 11 19 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 12 23 public static function log( $level, $message, array $context = array() ) { 13 24 $logger = wc_get_logger(); 14 25 $message = self::mask_secrets( $message ); 26 if ( ! self::personal_data_allowed() ) { 27 $message = self::mask_emails( $message ); 28 } 15 29 $logger->log( $level, $message, array_merge( array( 'source' => self::SOURCE ), $context ) ); 16 30 } … … 29 43 30 44 /** 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 /** 31 92 * Masque le token API partout où il pourrait apparaître (headers, payloads dumpés). 32 93 */ -
ukto-invoice-sync-for-pennylane/tags/1.8.0/includes/class-isfp-my-account.php
r3681339 r3734166 45 45 } 46 46 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 ) { 49 49 wp_die( esc_html__( 'The download is unavailable at the moment.', 'ukto-invoice-sync-for-pennylane' ), '', array( 'response' => 502 ) ); 50 50 } … … 57 57 header( 'Content-Type: application/pdf' ); 58 58 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. 60 60 exit; 61 61 } 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 } 62 83 } -
ukto-invoice-sync-for-pennylane/tags/1.8.0/includes/class-isfp-order-metabox.php
r3733469 r3734166 113 113 foreach ( $actions as $action => $conf ) { 114 114 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 ); 119 116 printf( 120 117 '<a class="button %s" href="%s">%s</a>', … … 138 135 ); 139 136 } 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 ); 144 138 printf( 145 139 '<a class="button" href="%s">%s</a>', … … 221 215 const NOTICE_TRANSIENT = 'isfp_action_notice_'; 222 216 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 223 236 public static function handle_action() { 224 237 $order_id = isset( $_GET['order_id'] ) ? (int) $_GET['order_id'] : 0; 225 238 $do = isset( $_GET['isfp_do'] ) ? sanitize_key( $_GET['isfp_do'] ) : ''; 226 239 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 ) ) ) { 228 241 wp_die( esc_html__( 'You are not allowed to do that.', 'ukto-invoice-sync-for-pennylane' ) ); 229 242 } … … 246 259 $pdf = $target ? ISFP_Invoice_Service::get_pdf_url( $target ) : ''; 247 260 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' ); 248 264 wp_redirect( $pdf ); // phpcs:ignore WordPress.Security.SafeRedirect -- URL externe Pennylane, voulue. 249 265 exit; -
ukto-invoice-sync-for-pennylane/tags/1.8.0/includes/class-isfp-siret-autocomplete.php
r3713298 r3734166 27 27 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 28 28 '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 ), 29 35 ) ); 30 36 } … … 33 39 check_ajax_referer( 'isfp_siret', 'nonce' ); 34 40 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 ); 47 45 } 48 set_transient( $rl_key, $count + 1, MINUTE_IN_SECONDS );49 46 50 47 $siret = isset( $_GET['siret'] ) ? preg_replace( '/\D/', '', sanitize_text_field( wp_unslash( $_GET['siret'] ) ) ) : ''; … … 53 50 } 54 51 52 // Une réponse en cache ne coûte rien : elle n'entre pas dans la limite. 55 53 $cache_key = 'isfp_siret_' . $siret; 56 54 $cached = get_transient( $cache_key ); 57 55 if ( is_array( $cached ) ) { 58 56 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 ); 59 61 } 60 62 … … 120 122 } 121 123 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 122 158 /** "HALVEN SAS" → "Halven Sas" est moche : on capitalise mais on garde les sigles courts. */ 123 159 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 37 37 msgstr "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." 38 38 39 #: includes/class-isfp-customer-sync.php: 6639 #: includes/class-isfp-customer-sync.php:83 40 40 msgid "Pennylane response contained no customer ID: %s" 41 41 msgstr "La réponse de Pennylane ne contient aucun ID de client : %s" … … 117 117 msgstr "Générer la facture" 118 118 119 #: includes/class-isfp-order-metabox.php:13 7119 #: includes/class-isfp-order-metabox.php:134 120 120 msgid "Download PDF" 121 121 msgstr "Télécharger le PDF" 122 122 123 #: includes/class-isfp-order-metabox.php:14 7123 #: includes/class-isfp-order-metabox.php:141 124 124 msgid "Get PDF" 125 125 msgstr "Récupérer le PDF" 126 126 127 #: includes/class-isfp-order-metabox.php:1 82127 #: includes/class-isfp-order-metabox.php:176 128 128 msgid "E-invoicing: submitted" 129 129 msgstr "Facture électronique : déposée" 130 130 131 #: includes/class-isfp-order-metabox.php:1 83131 #: includes/class-isfp-order-metabox.php:177 132 132 msgid "E-invoicing: sent" 133 133 msgstr "Facture électronique : transmise" 134 134 135 #: includes/class-isfp-order-metabox.php:1 84135 #: includes/class-isfp-order-metabox.php:178 136 136 msgid "E-invoicing: accepted" 137 137 msgstr "Facture électronique : acceptée" 138 138 139 #: includes/class-isfp-order-metabox.php:1 85139 #: includes/class-isfp-order-metabox.php:179 140 140 msgid "E-invoicing: approved" 141 141 msgstr "Facture électronique : approuvée" 142 142 143 #: includes/class-isfp-order-metabox.php:18 6143 #: includes/class-isfp-order-metabox.php:180 144 144 msgid "E-invoicing: collected" 145 145 msgstr "Facture électronique : encaissée" 146 146 147 #: includes/class-isfp-order-metabox.php:18 7147 #: includes/class-isfp-order-metabox.php:181 148 148 msgid "E-invoicing: partially collected" 149 149 msgstr "Facture électronique : partiellement encaissée" 150 150 151 #: includes/class-isfp-order-metabox.php:18 8151 #: includes/class-isfp-order-metabox.php:182 152 152 msgid "E-invoicing: disputed" 153 153 msgstr "Facture électronique : en litige" 154 154 155 #: includes/class-isfp-order-metabox.php:18 9155 #: includes/class-isfp-order-metabox.php:183 156 156 msgid "E-invoicing: refused by the platform" 157 157 msgstr "Facture électronique : refusée par la plateforme" 158 158 159 #: includes/class-isfp-order-metabox.php:1 90159 #: includes/class-isfp-order-metabox.php:184 160 160 msgid "E-invoicing: rejected by the platform" 161 161 msgstr "Facture électronique : rejetée par la plateforme" 162 162 163 #: includes/class-isfp-order-metabox.php:19 6163 #: includes/class-isfp-order-metabox.php:190 164 164 msgid "E-invoicing: %s" 165 165 msgstr "Facture électronique : %s" 166 166 167 #: includes/class-isfp-order-metabox.php:20 9167 #: includes/class-isfp-order-metabox.php:203 168 168 msgid "Factur-X file" 169 169 msgstr "Fichier Factur-X" 170 170 171 #: includes/class-isfp-order-metabox.php:2 10171 #: includes/class-isfp-order-metabox.php:204 172 172 msgid "Not a Factur-X file" 173 173 msgstr "Fichier non Factur-X" 174 174 175 #: includes/class-isfp-order-metabox.php:2 28175 #: includes/class-isfp-order-metabox.php:241 176 176 msgid "You are not allowed to do that." 177 177 msgstr "Action non autorisée." 178 178 179 #: includes/class-isfp-order-metabox.php:2 37179 #: includes/class-isfp-order-metabox.php:250 180 180 msgid "Invoice created in Pennylane." 181 181 msgstr "Facture créée dans Pennylane." 182 182 183 #: includes/class-isfp-order-metabox.php:2 51183 #: includes/class-isfp-order-metabox.php:267 184 184 msgid "The PDF is not available yet — Pennylane generates it asynchronously. Try again in a few minutes." 185 185 msgstr "Le PDF n’est pas encore disponible — Pennylane le génère de façon asynchrone. Réessayez dans quelques minutes." 186 186 187 #: includes/class-isfp-order-metabox.php:2 65187 #: includes/class-isfp-order-metabox.php:281 188 188 msgid "Unknown action." 189 189 msgstr "Action inconnue." 190 190 191 #: includes/class-isfp-order-metabox.php:2 74191 #: includes/class-isfp-order-metabox.php:290 192 192 msgid "Done." 193 193 msgstr "Action effectuée." 194 194 195 #: includes/class-isfp-order-metabox.php:3 33195 #: includes/class-isfp-order-metabox.php:349 196 196 msgid "Invoiced" 197 197 msgstr "Facturée" 198 198 199 #: includes/class-isfp-order-metabox.php:3 35199 #: includes/class-isfp-order-metabox.php:351 200 200 msgid "Error" 201 201 msgstr "Erreur" 202 202 203 #: includes/class-isfp-order-metabox.php:3 37203 #: includes/class-isfp-order-metabox.php:353 204 204 msgid "Draft" 205 205 msgstr "Brouillon" … … 449 449 msgstr "Désactiver" 450 450 451 #: includes/class-isfp-siret-autocomplete.php:46 451 #: includes/class-isfp-siret-autocomplete.php:30 452 msgid "Looking up the company…" 453 msgstr "Recherche de l’entreprise…" 454 455 #: includes/class-isfp-siret-autocomplete.php:31 456 msgid "Company found" 457 msgstr "Entreprise trouvée" 458 459 #: includes/class-isfp-siret-autocomplete.php:32 460 msgid "SIRET not found — please check the number." 461 msgstr "SIRET introuvable — vérifiez le numéro." 462 463 #: includes/class-isfp-siret-autocomplete.php:33 464 msgid "Verification unavailable for now." 465 msgstr "Vérification indisponible pour le moment." 466 467 #: includes/class-isfp-siret-autocomplete.php:44 468 msgid "SIRET lookup is disabled." 469 msgstr "La recherche par SIRET est désactivée." 470 471 #: includes/class-isfp-siret-autocomplete.php:49 472 msgid "Invalid SIRET." 473 msgstr "SIRET invalide." 474 475 #: includes/class-isfp-siret-autocomplete.php:56 includes/class-isfp-siret-autocomplete.php:79 476 msgid "Company not found." 477 msgstr "Entreprise introuvable." 478 479 #: includes/class-isfp-siret-autocomplete.php:60 452 480 msgid "Too many lookups — please wait a minute." 453 481 msgstr "Trop de recherches — veuillez patienter une minute." 454 482 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 464 484 msgid "Lookup service unavailable." 465 485 msgstr "Service de recherche indisponible." -
ukto-invoice-sync-for-pennylane/tags/1.8.0/languages/ukto-invoice-sync-for-pennylane.pot
r3734078 r3734166 38 38 msgstr "" 39 39 40 #: includes/class-isfp-customer-sync.php: 6640 #: includes/class-isfp-customer-sync.php:83 41 41 msgid "Pennylane response contained no customer ID: %s" 42 42 msgstr "" … … 118 118 msgstr "" 119 119 120 #: includes/class-isfp-order-metabox.php:13 7120 #: includes/class-isfp-order-metabox.php:134 121 121 msgid "Download PDF" 122 122 msgstr "" 123 123 124 #: includes/class-isfp-order-metabox.php:14 7124 #: includes/class-isfp-order-metabox.php:141 125 125 msgid "Get PDF" 126 126 msgstr "" 127 127 128 #: includes/class-isfp-order-metabox.php:176 129 msgid "E-invoicing: submitted" 130 msgstr "" 131 132 #: includes/class-isfp-order-metabox.php:177 133 msgid "E-invoicing: sent" 134 msgstr "" 135 136 #: includes/class-isfp-order-metabox.php:178 137 msgid "E-invoicing: accepted" 138 msgstr "" 139 140 #: includes/class-isfp-order-metabox.php:179 141 msgid "E-invoicing: approved" 142 msgstr "" 143 144 #: includes/class-isfp-order-metabox.php:180 145 msgid "E-invoicing: collected" 146 msgstr "" 147 148 #: includes/class-isfp-order-metabox.php:181 149 msgid "E-invoicing: partially collected" 150 msgstr "" 151 128 152 #: includes/class-isfp-order-metabox.php:182 129 msgid "E-invoicing: submitted"153 msgid "E-invoicing: disputed" 130 154 msgstr "" 131 155 132 156 #: includes/class-isfp-order-metabox.php:183 133 msgid "E-invoicing: sent"157 msgid "E-invoicing: refused by the platform" 134 158 msgstr "" 135 159 136 160 #: 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" 161 msgid "E-invoicing: rejected by the platform" 158 162 msgstr "" 159 163 160 164 #: 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:196165 165 msgid "E-invoicing: %s" 166 166 msgstr "" 167 167 168 #: includes/class-isfp-order-metabox.php:20 9168 #: includes/class-isfp-order-metabox.php:203 169 169 msgid "Factur-X file" 170 170 msgstr "" 171 171 172 #: includes/class-isfp-order-metabox.php:2 10172 #: includes/class-isfp-order-metabox.php:204 173 173 msgid "Not a Factur-X file" 174 174 msgstr "" 175 175 176 #: includes/class-isfp-order-metabox.php:2 28176 #: includes/class-isfp-order-metabox.php:241 177 177 msgid "You are not allowed to do that." 178 178 msgstr "" 179 179 180 #: includes/class-isfp-order-metabox.php:2 37180 #: includes/class-isfp-order-metabox.php:250 181 181 msgid "Invoice created in Pennylane." 182 182 msgstr "" 183 183 184 #: includes/class-isfp-order-metabox.php:2 51184 #: includes/class-isfp-order-metabox.php:267 185 185 msgid "The PDF is not available yet — Pennylane generates it asynchronously. Try again in a few minutes." 186 186 msgstr "" 187 187 188 #: includes/class-isfp-order-metabox.php:2 65188 #: includes/class-isfp-order-metabox.php:281 189 189 msgid "Unknown action." 190 190 msgstr "" 191 191 192 #: includes/class-isfp-order-metabox.php:2 74192 #: includes/class-isfp-order-metabox.php:290 193 193 msgid "Done." 194 194 msgstr "" 195 195 196 #: includes/class-isfp-order-metabox.php:3 33196 #: includes/class-isfp-order-metabox.php:349 197 197 msgid "Invoiced" 198 198 msgstr "" 199 199 200 #: includes/class-isfp-order-metabox.php:3 35200 #: includes/class-isfp-order-metabox.php:351 201 201 msgid "Error" 202 202 msgstr "" 203 203 204 #: includes/class-isfp-order-metabox.php:3 37204 #: includes/class-isfp-order-metabox.php:353 205 205 msgid "Draft" 206 206 msgstr "" … … 450 450 msgstr "" 451 451 452 #: includes/class-isfp-siret-autocomplete.php:46 452 #: includes/class-isfp-siret-autocomplete.php:30 453 msgid "Looking up the company…" 454 msgstr "" 455 456 #: includes/class-isfp-siret-autocomplete.php:31 457 msgid "Company found" 458 msgstr "" 459 460 #: includes/class-isfp-siret-autocomplete.php:32 461 msgid "SIRET not found — please check the number." 462 msgstr "" 463 464 #: includes/class-isfp-siret-autocomplete.php:33 465 msgid "Verification unavailable for now." 466 msgstr "" 467 468 #: includes/class-isfp-siret-autocomplete.php:44 469 msgid "SIRET lookup is disabled." 470 msgstr "" 471 472 #: includes/class-isfp-siret-autocomplete.php:49 473 msgid "Invalid SIRET." 474 msgstr "" 475 476 #: includes/class-isfp-siret-autocomplete.php:56 includes/class-isfp-siret-autocomplete.php:79 477 msgid "Company not found." 478 msgstr "" 479 480 #: includes/class-isfp-siret-autocomplete.php:60 453 481 msgid "Too many lookups — please wait a minute." 454 482 msgstr "" 455 483 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 465 485 msgid "Lookup service unavailable." 466 486 msgstr "" -
ukto-invoice-sync-for-pennylane/tags/1.8.0/readme.txt
r3734078 r3734166 6 6 Requires PHP: 7.4 7 7 Requires Plugins: woocommerce 8 Stable tag: 1. 7.98 Stable tag: 1.8.0 9 9 License: GPLv2 or later 10 10 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 142 142 Yes. With test mode enabled, no write request reaches Pennylane: the API client blocks them 143 143 centrally and payloads are only written to the WooCommerce logs so you can review them before 144 going live .144 going live — with customer names, addresses, phone numbers and e-mail addresses masked. 145 145 146 146 = Where are the logs? = 147 147 148 148 WooCommerce → Status → Logs, source "pennylane". Every API error is logged with the full 149 response, and every order keeps its own note trail. 149 response, and every order keeps its own note trail. Customers' personal data is masked in the 150 logs; to log it in full while debugging, return true from the `isfp_log_personal_data` filter. 150 151 151 152 = Can invoices be created automatically? = … … 164 165 165 166 == 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). 166 195 167 196 = 1.7.9 = … … 259 288 == Upgrade Notice == 260 289 290 = 1.8.0 = 291 Business 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 261 293 = 1.7.9 = 262 294 The 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 4 4 * Plugin URI: https://ukto.fr/pennylane.php 5 5 * 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.96 * Version: 1.8.0 7 7 * Requires at least: 5.8 8 8 * Requires PHP: 7.4 … … 20 20 defined( 'ABSPATH' ) || exit; 21 21 22 define( 'ISFP_VERSION', '1. 7.9' ); // Doit égaler l'en-tête Version — vérifié par build.ps1.22 define( 'ISFP_VERSION', '1.8.0' ); // Doit égaler l'en-tête Version — vérifié par build.ps1. 23 23 24 24 /** -
ukto-invoice-sync-for-pennylane/tags/1.8.0/uninstall.php
r3681339 r3734166 8 8 * - les métadonnées client et produit, pour la même raison. 9 9 * 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é. 12 18 * 13 19 * @package InvoiceSyncForPennylane … … 16 22 defined( 'WP_UNINSTALL_PLUGIN' ) || exit; 17 23 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', 42 35 ); 36 foreach ( $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 } 40 delete_option( 'isfp_prefix_migrated' ); 43 41 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. 47 46 global $wpdb; 48 47 // 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\\_%'" 55 57 ); 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 13 13 14 14 var timer = null; 15 var i18n = window.pwSiret.i18n || {}; 15 16 16 17 // Setter natif : indispensable pour que React (Checkout Blocks) voie la valeur. … … 72 73 } 73 74 } 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 ); 75 76 } 76 77 … … 87 88 clearTimeout( timer ); 88 89 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 ) 91 92 .then( function ( response ) { return response.json(); } ) 92 93 .then( function ( result ) { … … 94 95 fill( input, result.data ); 95 96 } 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 ); 97 100 } 98 101 } ) 99 102 .catch( function () { 100 showStatus( input, 'Vérification indisponible pour le moment.', false );103 showStatus( input, i18n.unavailable, false ); 101 104 } ); 102 105 }, 400 ); -
ukto-invoice-sync-for-pennylane/trunk/includes/class-isfp-api-client.php
r3681339 r3734166 124 124 */ 125 125 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 ) ) : '' ) ); 127 127 return new WP_Error( 128 128 'isfp_dry_run', … … 147 147 } 148 148 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 ) ) : '' ) ); 150 150 151 151 $response = wp_remote_request( self::BASE_URL . $path, $args ); -
ukto-invoice-sync-for-pennylane/trunk/includes/class-isfp-customer-sync.php
r3681339 r3734166 6 6 * 7 7 * 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). 11 12 */ 12 13 class ISFP_Customer_Sync { … … 14 15 const USER_META = '_pennylane_customer_id'; 15 16 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'; 16 28 17 29 /** … … 23 35 */ 24 36 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 ); 27 45 if ( $known ) { 28 46 /** … … 37 55 } 38 56 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 ); 42 59 if ( is_wp_error( $found ) ) { 43 60 return $found; … … 84 101 } 85 102 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 ) { 87 128 $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 : ''; 95 142 } 96 143 … … 101 148 if ( $user_id ) { 102 149 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 ) { 119 170 return null; 120 171 } 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 ) { 123 208 $cursor = null; 124 209 $max_pages = (int) apply_filters( 'isfp_customer_search_max_pages', 50 ); // 50 × 100 = 5 000 tiers. … … 129 214 $query['cursor'] = $cursor; 130 215 } 131 $response = $client->list_customers( apply_filters( 'isfp_customer_search_query', $query, $email ));216 $response = $client->list_customers( $query ); 132 217 if ( is_wp_error( $response ) ) { 133 218 return $response; 134 219 } 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 } 149 225 $cursor = isset( $response['next_cursor'] ) ? $response['next_cursor'] : null; 150 226 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() ) ); 154 231 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; 155 280 } 156 281 -
ukto-invoice-sync-for-pennylane/trunk/includes/class-isfp-invoice-service.php
r3733469 r3734166 86 86 $customer_preview = ISFP_Customer_Sync::preview_payload( $order ); 87 87 $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 ) ) ); 90 90 $order->add_order_note( __( 'Pennylane [TEST MODE]: invoice simulated — payloads written to the logs, nothing sent to Pennylane.', 'ukto-invoice-sync-for-pennylane' ) ); 91 91 return true; -
ukto-invoice-sync-for-pennylane/trunk/includes/class-isfp-logger.php
r3681339 r3734166 3 3 4 4 /** 5 * Wrapper WC_Logger — source unique, secrets masqués.5 * Wrapper WC_Logger — source unique, secrets et données personnelles masqués. 6 6 * 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. 7 15 */ 8 16 class ISFP_Logger { … … 10 18 const SOURCE = 'pennylane'; 11 19 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 12 23 public static function log( $level, $message, array $context = array() ) { 13 24 $logger = wc_get_logger(); 14 25 $message = self::mask_secrets( $message ); 26 if ( ! self::personal_data_allowed() ) { 27 $message = self::mask_emails( $message ); 28 } 15 29 $logger->log( $level, $message, array_merge( array( 'source' => self::SOURCE ), $context ) ); 16 30 } … … 29 43 30 44 /** 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 /** 31 92 * Masque le token API partout où il pourrait apparaître (headers, payloads dumpés). 32 93 */ -
ukto-invoice-sync-for-pennylane/trunk/includes/class-isfp-my-account.php
r3681339 r3734166 45 45 } 46 46 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 ) { 49 49 wp_die( esc_html__( 'The download is unavailable at the moment.', 'ukto-invoice-sync-for-pennylane' ), '', array( 'response' => 502 ) ); 50 50 } … … 57 57 header( 'Content-Type: application/pdf' ); 58 58 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. 60 60 exit; 61 61 } 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 } 62 83 } -
ukto-invoice-sync-for-pennylane/trunk/includes/class-isfp-order-metabox.php
r3733469 r3734166 113 113 foreach ( $actions as $action => $conf ) { 114 114 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 ); 119 116 printf( 120 117 '<a class="button %s" href="%s">%s</a>', … … 138 135 ); 139 136 } 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 ); 144 138 printf( 145 139 '<a class="button" href="%s">%s</a>', … … 221 215 const NOTICE_TRANSIENT = 'isfp_action_notice_'; 222 216 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 223 236 public static function handle_action() { 224 237 $order_id = isset( $_GET['order_id'] ) ? (int) $_GET['order_id'] : 0; 225 238 $do = isset( $_GET['isfp_do'] ) ? sanitize_key( $_GET['isfp_do'] ) : ''; 226 239 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 ) ) ) { 228 241 wp_die( esc_html__( 'You are not allowed to do that.', 'ukto-invoice-sync-for-pennylane' ) ); 229 242 } … … 246 259 $pdf = $target ? ISFP_Invoice_Service::get_pdf_url( $target ) : ''; 247 260 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' ); 248 264 wp_redirect( $pdf ); // phpcs:ignore WordPress.Security.SafeRedirect -- URL externe Pennylane, voulue. 249 265 exit; -
ukto-invoice-sync-for-pennylane/trunk/includes/class-isfp-siret-autocomplete.php
r3713298 r3734166 27 27 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 28 28 '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 ), 29 35 ) ); 30 36 } … … 33 39 check_ajax_referer( 'isfp_siret', 'nonce' ); 34 40 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 ); 47 45 } 48 set_transient( $rl_key, $count + 1, MINUTE_IN_SECONDS );49 46 50 47 $siret = isset( $_GET['siret'] ) ? preg_replace( '/\D/', '', sanitize_text_field( wp_unslash( $_GET['siret'] ) ) ) : ''; … … 53 50 } 54 51 52 // Une réponse en cache ne coûte rien : elle n'entre pas dans la limite. 55 53 $cache_key = 'isfp_siret_' . $siret; 56 54 $cached = get_transient( $cache_key ); 57 55 if ( is_array( $cached ) ) { 58 56 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 ); 59 61 } 60 62 … … 120 122 } 121 123 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 122 158 /** "HALVEN SAS" → "Halven Sas" est moche : on capitalise mais on garde les sigles courts. */ 123 159 private static function title_case( $value ) { -
ukto-invoice-sync-for-pennylane/trunk/languages/ukto-invoice-sync-for-pennylane-fr_FR.po
r3734078 r3734166 37 37 msgstr "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." 38 38 39 #: includes/class-isfp-customer-sync.php: 6639 #: includes/class-isfp-customer-sync.php:83 40 40 msgid "Pennylane response contained no customer ID: %s" 41 41 msgstr "La réponse de Pennylane ne contient aucun ID de client : %s" … … 117 117 msgstr "Générer la facture" 118 118 119 #: includes/class-isfp-order-metabox.php:13 7119 #: includes/class-isfp-order-metabox.php:134 120 120 msgid "Download PDF" 121 121 msgstr "Télécharger le PDF" 122 122 123 #: includes/class-isfp-order-metabox.php:14 7123 #: includes/class-isfp-order-metabox.php:141 124 124 msgid "Get PDF" 125 125 msgstr "Récupérer le PDF" 126 126 127 #: includes/class-isfp-order-metabox.php:1 82127 #: includes/class-isfp-order-metabox.php:176 128 128 msgid "E-invoicing: submitted" 129 129 msgstr "Facture électronique : déposée" 130 130 131 #: includes/class-isfp-order-metabox.php:1 83131 #: includes/class-isfp-order-metabox.php:177 132 132 msgid "E-invoicing: sent" 133 133 msgstr "Facture électronique : transmise" 134 134 135 #: includes/class-isfp-order-metabox.php:1 84135 #: includes/class-isfp-order-metabox.php:178 136 136 msgid "E-invoicing: accepted" 137 137 msgstr "Facture électronique : acceptée" 138 138 139 #: includes/class-isfp-order-metabox.php:1 85139 #: includes/class-isfp-order-metabox.php:179 140 140 msgid "E-invoicing: approved" 141 141 msgstr "Facture électronique : approuvée" 142 142 143 #: includes/class-isfp-order-metabox.php:18 6143 #: includes/class-isfp-order-metabox.php:180 144 144 msgid "E-invoicing: collected" 145 145 msgstr "Facture électronique : encaissée" 146 146 147 #: includes/class-isfp-order-metabox.php:18 7147 #: includes/class-isfp-order-metabox.php:181 148 148 msgid "E-invoicing: partially collected" 149 149 msgstr "Facture électronique : partiellement encaissée" 150 150 151 #: includes/class-isfp-order-metabox.php:18 8151 #: includes/class-isfp-order-metabox.php:182 152 152 msgid "E-invoicing: disputed" 153 153 msgstr "Facture électronique : en litige" 154 154 155 #: includes/class-isfp-order-metabox.php:18 9155 #: includes/class-isfp-order-metabox.php:183 156 156 msgid "E-invoicing: refused by the platform" 157 157 msgstr "Facture électronique : refusée par la plateforme" 158 158 159 #: includes/class-isfp-order-metabox.php:1 90159 #: includes/class-isfp-order-metabox.php:184 160 160 msgid "E-invoicing: rejected by the platform" 161 161 msgstr "Facture électronique : rejetée par la plateforme" 162 162 163 #: includes/class-isfp-order-metabox.php:19 6163 #: includes/class-isfp-order-metabox.php:190 164 164 msgid "E-invoicing: %s" 165 165 msgstr "Facture électronique : %s" 166 166 167 #: includes/class-isfp-order-metabox.php:20 9167 #: includes/class-isfp-order-metabox.php:203 168 168 msgid "Factur-X file" 169 169 msgstr "Fichier Factur-X" 170 170 171 #: includes/class-isfp-order-metabox.php:2 10171 #: includes/class-isfp-order-metabox.php:204 172 172 msgid "Not a Factur-X file" 173 173 msgstr "Fichier non Factur-X" 174 174 175 #: includes/class-isfp-order-metabox.php:2 28175 #: includes/class-isfp-order-metabox.php:241 176 176 msgid "You are not allowed to do that." 177 177 msgstr "Action non autorisée." 178 178 179 #: includes/class-isfp-order-metabox.php:2 37179 #: includes/class-isfp-order-metabox.php:250 180 180 msgid "Invoice created in Pennylane." 181 181 msgstr "Facture créée dans Pennylane." 182 182 183 #: includes/class-isfp-order-metabox.php:2 51183 #: includes/class-isfp-order-metabox.php:267 184 184 msgid "The PDF is not available yet — Pennylane generates it asynchronously. Try again in a few minutes." 185 185 msgstr "Le PDF n’est pas encore disponible — Pennylane le génère de façon asynchrone. Réessayez dans quelques minutes." 186 186 187 #: includes/class-isfp-order-metabox.php:2 65187 #: includes/class-isfp-order-metabox.php:281 188 188 msgid "Unknown action." 189 189 msgstr "Action inconnue." 190 190 191 #: includes/class-isfp-order-metabox.php:2 74191 #: includes/class-isfp-order-metabox.php:290 192 192 msgid "Done." 193 193 msgstr "Action effectuée." 194 194 195 #: includes/class-isfp-order-metabox.php:3 33195 #: includes/class-isfp-order-metabox.php:349 196 196 msgid "Invoiced" 197 197 msgstr "Facturée" 198 198 199 #: includes/class-isfp-order-metabox.php:3 35199 #: includes/class-isfp-order-metabox.php:351 200 200 msgid "Error" 201 201 msgstr "Erreur" 202 202 203 #: includes/class-isfp-order-metabox.php:3 37203 #: includes/class-isfp-order-metabox.php:353 204 204 msgid "Draft" 205 205 msgstr "Brouillon" … … 449 449 msgstr "Désactiver" 450 450 451 #: includes/class-isfp-siret-autocomplete.php:46 451 #: includes/class-isfp-siret-autocomplete.php:30 452 msgid "Looking up the company…" 453 msgstr "Recherche de l’entreprise…" 454 455 #: includes/class-isfp-siret-autocomplete.php:31 456 msgid "Company found" 457 msgstr "Entreprise trouvée" 458 459 #: includes/class-isfp-siret-autocomplete.php:32 460 msgid "SIRET not found — please check the number." 461 msgstr "SIRET introuvable — vérifiez le numéro." 462 463 #: includes/class-isfp-siret-autocomplete.php:33 464 msgid "Verification unavailable for now." 465 msgstr "Vérification indisponible pour le moment." 466 467 #: includes/class-isfp-siret-autocomplete.php:44 468 msgid "SIRET lookup is disabled." 469 msgstr "La recherche par SIRET est désactivée." 470 471 #: includes/class-isfp-siret-autocomplete.php:49 472 msgid "Invalid SIRET." 473 msgstr "SIRET invalide." 474 475 #: includes/class-isfp-siret-autocomplete.php:56 includes/class-isfp-siret-autocomplete.php:79 476 msgid "Company not found." 477 msgstr "Entreprise introuvable." 478 479 #: includes/class-isfp-siret-autocomplete.php:60 452 480 msgid "Too many lookups — please wait a minute." 453 481 msgstr "Trop de recherches — veuillez patienter une minute." 454 482 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 464 484 msgid "Lookup service unavailable." 465 485 msgstr "Service de recherche indisponible." -
ukto-invoice-sync-for-pennylane/trunk/languages/ukto-invoice-sync-for-pennylane.pot
r3734078 r3734166 38 38 msgstr "" 39 39 40 #: includes/class-isfp-customer-sync.php: 6640 #: includes/class-isfp-customer-sync.php:83 41 41 msgid "Pennylane response contained no customer ID: %s" 42 42 msgstr "" … … 118 118 msgstr "" 119 119 120 #: includes/class-isfp-order-metabox.php:13 7120 #: includes/class-isfp-order-metabox.php:134 121 121 msgid "Download PDF" 122 122 msgstr "" 123 123 124 #: includes/class-isfp-order-metabox.php:14 7124 #: includes/class-isfp-order-metabox.php:141 125 125 msgid "Get PDF" 126 126 msgstr "" 127 127 128 #: includes/class-isfp-order-metabox.php:176 129 msgid "E-invoicing: submitted" 130 msgstr "" 131 132 #: includes/class-isfp-order-metabox.php:177 133 msgid "E-invoicing: sent" 134 msgstr "" 135 136 #: includes/class-isfp-order-metabox.php:178 137 msgid "E-invoicing: accepted" 138 msgstr "" 139 140 #: includes/class-isfp-order-metabox.php:179 141 msgid "E-invoicing: approved" 142 msgstr "" 143 144 #: includes/class-isfp-order-metabox.php:180 145 msgid "E-invoicing: collected" 146 msgstr "" 147 148 #: includes/class-isfp-order-metabox.php:181 149 msgid "E-invoicing: partially collected" 150 msgstr "" 151 128 152 #: includes/class-isfp-order-metabox.php:182 129 msgid "E-invoicing: submitted"153 msgid "E-invoicing: disputed" 130 154 msgstr "" 131 155 132 156 #: includes/class-isfp-order-metabox.php:183 133 msgid "E-invoicing: sent"157 msgid "E-invoicing: refused by the platform" 134 158 msgstr "" 135 159 136 160 #: 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" 161 msgid "E-invoicing: rejected by the platform" 158 162 msgstr "" 159 163 160 164 #: 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:196165 165 msgid "E-invoicing: %s" 166 166 msgstr "" 167 167 168 #: includes/class-isfp-order-metabox.php:20 9168 #: includes/class-isfp-order-metabox.php:203 169 169 msgid "Factur-X file" 170 170 msgstr "" 171 171 172 #: includes/class-isfp-order-metabox.php:2 10172 #: includes/class-isfp-order-metabox.php:204 173 173 msgid "Not a Factur-X file" 174 174 msgstr "" 175 175 176 #: includes/class-isfp-order-metabox.php:2 28176 #: includes/class-isfp-order-metabox.php:241 177 177 msgid "You are not allowed to do that." 178 178 msgstr "" 179 179 180 #: includes/class-isfp-order-metabox.php:2 37180 #: includes/class-isfp-order-metabox.php:250 181 181 msgid "Invoice created in Pennylane." 182 182 msgstr "" 183 183 184 #: includes/class-isfp-order-metabox.php:2 51184 #: includes/class-isfp-order-metabox.php:267 185 185 msgid "The PDF is not available yet — Pennylane generates it asynchronously. Try again in a few minutes." 186 186 msgstr "" 187 187 188 #: includes/class-isfp-order-metabox.php:2 65188 #: includes/class-isfp-order-metabox.php:281 189 189 msgid "Unknown action." 190 190 msgstr "" 191 191 192 #: includes/class-isfp-order-metabox.php:2 74192 #: includes/class-isfp-order-metabox.php:290 193 193 msgid "Done." 194 194 msgstr "" 195 195 196 #: includes/class-isfp-order-metabox.php:3 33196 #: includes/class-isfp-order-metabox.php:349 197 197 msgid "Invoiced" 198 198 msgstr "" 199 199 200 #: includes/class-isfp-order-metabox.php:3 35200 #: includes/class-isfp-order-metabox.php:351 201 201 msgid "Error" 202 202 msgstr "" 203 203 204 #: includes/class-isfp-order-metabox.php:3 37204 #: includes/class-isfp-order-metabox.php:353 205 205 msgid "Draft" 206 206 msgstr "" … … 450 450 msgstr "" 451 451 452 #: includes/class-isfp-siret-autocomplete.php:46 452 #: includes/class-isfp-siret-autocomplete.php:30 453 msgid "Looking up the company…" 454 msgstr "" 455 456 #: includes/class-isfp-siret-autocomplete.php:31 457 msgid "Company found" 458 msgstr "" 459 460 #: includes/class-isfp-siret-autocomplete.php:32 461 msgid "SIRET not found — please check the number." 462 msgstr "" 463 464 #: includes/class-isfp-siret-autocomplete.php:33 465 msgid "Verification unavailable for now." 466 msgstr "" 467 468 #: includes/class-isfp-siret-autocomplete.php:44 469 msgid "SIRET lookup is disabled." 470 msgstr "" 471 472 #: includes/class-isfp-siret-autocomplete.php:49 473 msgid "Invalid SIRET." 474 msgstr "" 475 476 #: includes/class-isfp-siret-autocomplete.php:56 includes/class-isfp-siret-autocomplete.php:79 477 msgid "Company not found." 478 msgstr "" 479 480 #: includes/class-isfp-siret-autocomplete.php:60 453 481 msgid "Too many lookups — please wait a minute." 454 482 msgstr "" 455 483 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 465 485 msgid "Lookup service unavailable." 466 486 msgstr "" -
ukto-invoice-sync-for-pennylane/trunk/readme.txt
r3734078 r3734166 6 6 Requires PHP: 7.4 7 7 Requires Plugins: woocommerce 8 Stable tag: 1. 7.98 Stable tag: 1.8.0 9 9 License: GPLv2 or later 10 10 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 142 142 Yes. With test mode enabled, no write request reaches Pennylane: the API client blocks them 143 143 centrally and payloads are only written to the WooCommerce logs so you can review them before 144 going live .144 going live — with customer names, addresses, phone numbers and e-mail addresses masked. 145 145 146 146 = Where are the logs? = 147 147 148 148 WooCommerce → Status → Logs, source "pennylane". Every API error is logged with the full 149 response, and every order keeps its own note trail. 149 response, and every order keeps its own note trail. Customers' personal data is masked in the 150 logs; to log it in full while debugging, return true from the `isfp_log_personal_data` filter. 150 151 151 152 = Can invoices be created automatically? = … … 164 165 165 166 == 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). 166 195 167 196 = 1.7.9 = … … 259 288 == Upgrade Notice == 260 289 290 = 1.8.0 = 291 Business 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 261 293 = 1.7.9 = 262 294 The 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 4 4 * Plugin URI: https://ukto.fr/pennylane.php 5 5 * 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.96 * Version: 1.8.0 7 7 * Requires at least: 5.8 8 8 * Requires PHP: 7.4 … … 20 20 defined( 'ABSPATH' ) || exit; 21 21 22 define( 'ISFP_VERSION', '1. 7.9' ); // Doit égaler l'en-tête Version — vérifié par build.ps1.22 define( 'ISFP_VERSION', '1.8.0' ); // Doit égaler l'en-tête Version — vérifié par build.ps1. 23 23 24 24 /** -
ukto-invoice-sync-for-pennylane/trunk/uninstall.php
r3681339 r3734166 8 8 * - les métadonnées client et produit, pour la même raison. 9 9 * 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é. 12 18 * 13 19 * @package InvoiceSyncForPennylane … … 16 22 defined( 'WP_UNINSTALL_PLUGIN' ) || exit; 17 23 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', 42 35 ); 36 foreach ( $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 } 40 delete_option( 'isfp_prefix_migrated' ); 43 41 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. 47 46 global $wpdb; 48 47 // 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\\_%'" 55 57 ); 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.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)