array( 'wc-processing', 'wc-completed' ), 'date_created' => '>=' . $since, 'limit' => self::CATCH_UP_PAGE_SIZE, 'page' => $page, 'orderby' => 'date', 'order' => 'DESC', ) ); if ( empty( $orders ) ) { break; } foreach ( $orders as $order ) { if ( ! $order || $order->get_meta( '_convrail_pushed' ) ) { continue; } /* * Espacement croissant: rattraper trente ventes d'un coup enverrait trente * requetes dans la meme minute, ce qui ressemble a une rafale et se fait limiter. */ $args = array( (int) $order->get_id(), 1 ); if ( ! wp_next_scheduled( 'convrail_push_order', $args ) ) { wp_schedule_single_event( time() + 30 + $revived * 20, 'convrail_push_order', $args ); ++$revived; } if ( $revived >= self::CATCH_UP_MAX ) { // Le reste attendra demain: la fenetre est plus large que la periodicite. return $revived; } } if ( count( $orders ) < self::CATCH_UP_PAGE_SIZE ) { break; } } return $revived; } /** * Recopie les cookies d'attribution dans la commande. Idempotent: appele par les deux tunnels, * il ne doit pas ajouter deux fois l'oppref a la meme adresse. */ public static function capture_attribution( $order ) { if ( ! $order || ! is_a( $order, 'WC_Order' ) ) { return; } /* * Consentement au moment du paiement. Il est fige sur la commande parce que l'envoi part * plus tard, en tache de fond, dans une requete de cron ou le visiteur n'existe plus et ou * son etat de consentement n'est plus lisible. */ $answer = Convrail_Pixel::consent_answer(); // « unknown »: aucun plugin de consentement n'a repondu, la posture du marchand decidera. $order->update_meta_data( '_convrail_consent', '' === $answer ? 'unknown' : $answer ); /* * Session WooCommerce au moment du paiement: la cle qui recolle la commande a la session * captee cote serveur a l'atterrissage, celle qui porte l'oppref quand le cookie navigateur * n'a pas survecu. Figee ici parce que l'envoi part plus tard, en tache de fond. */ $session_id = class_exists( 'Convrail_Sessions' ) ? Convrail_Sessions::current_session_id() : ''; if ( '' !== $session_id ) { $order->update_meta_data( '_convrail_session', substr( $session_id, 0, 64 ) ); } if ( isset( $_COOKIE['ocl_landing'] ) ) { $order->update_meta_data( '_convrail_landing_url', esc_url_raw( wp_unslash( $_COOKIE['ocl_landing'] ) ) ); } if ( isset( $_COOKIE['ocl_referrer'] ) ) { $order->update_meta_data( '_convrail_referrer', esc_url_raw( wp_unslash( $_COOKIE['ocl_referrer'] ) ) ); } if ( isset( $_COOKIE['__oppref'] ) ) { // l'oppref est ajoute a la landing memorisee pour l'attribution ads $landing = $order->get_meta( '_convrail_landing_url' ); if ( $landing && false === strpos( $landing, 'oppref=' ) ) { $separator = ( false === strpos( $landing, '?' ) ) ? '?' : '&'; $order->update_meta_data( '_convrail_landing_url', $landing . $separator . 'oppref=' . sanitize_text_field( wp_unslash( $_COOKIE['__oppref'] ) ) ); } } } /** * Programme l'envoi, sans rien faire tout de suite. * * Avec un paiement direct (a la livraison, virement), le passage en « en cours » se produit * DANS la requete du client: il attendait jusqu'a quinze secondes sur « commander » quand * l'API etait lente. Aucun appel reseau ne doit vivre dans le tunnel de commande. */ public static function schedule_push( $order_id ) { if ( ! Convrail_Client::is_connected() ) { return; } if ( ! wp_next_scheduled( 'convrail_push_order', array( (int) $order_id, 1 ) ) ) { wp_schedule_single_event( time() + 5, 'convrail_push_order', array( (int) $order_id, 1 ) ); } } /** * Programme la rectification d'une commande annulee ou remboursee. Rien dans le tunnel: comme * pour l'envoi, aucun appel reseau ne doit vivre dans une requete du client. */ public static function schedule_void( $order_id ) { if ( ! Convrail_Client::is_connected() ) { return; } $order = wc_get_order( $order_id ); if ( ! $order ) { return; } /* * Programme meme si la commande n'a pas encore ete rapportee. Exiger `_convrail_pushed` ouvrait * une course: entre la programmation de l'envoi (cinq secondes, jusqu'a cinq essais) et sa * reussite, une annulation par un filtre anti-fraude ou par la passerelle ne rectifiait * rien, puis l'envoi rapportait la vente. La route est sans effet quand elle ne trouve * rien a corriger. */ self::schedule_status( $order, 'refunded' === $order->get_status() ? 'refunded' : 'cancelled' ); } /** * Une commande rectifiee qui redevient une vente. * * Sans cela, un remboursement annule par erreur sortait le chiffre d'affaires pour toujours: * `push_order` refuse de renvoyer une commande deja rapportee, et rien ne redisait « payee ». */ public static function schedule_repaid( $order_id ) { if ( ! Convrail_Client::is_connected() ) { return; } $order = wc_get_order( $order_id ); if ( ! $order || ! $order->get_meta( '_convrail_status_report' ) ) { return; } self::schedule_status( $order, 'paid' ); } /** * Une seule tache par commande, quel que soit le nombre de changements de statut. * * Le statut a rapporter vit dans la commande et non dans les arguments de la tache: annulee * puis remboursee produisait sinon deux taches distinctes, donc deux appels pour un seul fait. */ private static function schedule_status( $order, $status ) { $order->update_meta_data( '_convrail_status_report', $status ); $order->update_meta_data( '_convrail_status_attempt', 1 ); $order->save(); $args = array( (int) $order->get_id() ); if ( ! wp_next_scheduled( 'convrail_void_order', $args ) ) { wp_schedule_single_event( time() + 5, 'convrail_void_order', $args ); } } /** Rectification reelle, en tache de fond, avec les memes reprises que l'envoi. */ public static function void_order( $order_id ) { if ( ! Convrail_Client::is_connected() ) { return; } $order = wc_get_order( $order_id ); if ( ! $order ) { return; } $status = (string) $order->get_meta( '_convrail_status_report' ); if ( ! in_array( $status, array( 'refunded', 'cancelled', 'paid' ), true ) ) { return; } $result = Convrail_Client::post( '/bridge/wc/orders/status', array( 'id' => (int) $order_id, 'status' => $status, ) ); if ( ! is_wp_error( $result ) ) { // « payee » est un retour a la normale: plus rien a rapporter ensuite. if ( 'paid' === $status ) { $order->delete_meta_data( '_convrail_status_report' ); } $order->delete_meta_data( '_convrail_status_attempt' ); $order->save(); delete_option( 'convrail_order_error' ); return; } /* * Une rectification perdue laisse un remboursement compte comme une vente, pour toujours. * Meme escalier de reprises et meme signalement que l'envoi: rien ne disparait en silence. */ $attempt = (int) $order->get_meta( '_convrail_status_attempt' ); $attempt = $attempt > 0 ? $attempt : 1; if ( $attempt < self::MAX_ATTEMPTS ) { $order->update_meta_data( '_convrail_status_attempt', $attempt + 1 ); $order->save(); wp_schedule_single_event( time() + 60 * $attempt, 'convrail_void_order', array( (int) $order_id ) ); return; } update_option( 'convrail_order_error', array( 'order' => (int) $order_id, 'status' => Convrail_Client::error_status( $result ), 'at' => time(), ), false ); Convrail_Client::log( 'order status update failed for good: ' . (int) $order_id ); } /** * Envoi reel, en tache de fond. Un echec est reessaye avec un delai croissant: une vente * perdue ne laissait aucune trace, et le seul rattrapage etait fortuit (passage ulterieur en * « terminee »). Une commande qui va directement en « terminee » n'en avait aucun. */ public static function push_order( $order_id, $attempt = 1 ) { if ( ! Convrail_Client::is_connected() ) { return; } $order = wc_get_order( $order_id ); if ( ! $order || $order->get_meta( '_convrail_pushed' ) ) { return; } /* * Statut RELU au moment de l'envoi. L'envoi est differe de cinq secondes et reessaye * jusqu'a dix minutes: une commande annulee entre-temps (filtre anti-fraude, passerelle, * refus manuel) partait quand meme comme une vente. */ if ( in_array( $order->get_status(), array( 'cancelled', 'refunded' ), true ) ) { return; } $line_items = array(); foreach ( $order->get_items() as $item ) { $line_items[] = array( 'product_id' => $item->get_product_id(), 'variation_id' => $item->get_variation_id(), 'name' => $item->get_name(), 'quantity' => $item->get_quantity(), 'price' => $order->get_item_total( $item, false ), ); } /* * Sans consentement marketing, la commande reste comptée pour le marchand (c'est son * chiffre d'affaires, dans son propre tableau de bord) mais aucune coordonnée du client ne * part, même hachée : seul le pays reste. La plateforme transmet ensuite la vente à OpenAI * marquée `opt_out`, sans la personne (apps/api/src/bridge/bridge.service.ts). */ $recorded = (string) $order->get_meta( '_convrail_consent' ); if ( '' === $recorded ) { /* * Consentement jamais evalue: commande creee dans l'administration, importee, ou issue * d'un tunnel que nos deux accroches ne couvrent pas. On demande son verdict a la WP * Consent API elle-meme, plutot que de deduire un refus de sa simple presence: elle * repond « accorde » quand aucun regime n'est configure et en regime opt-out sans * choix du visiteur, et traiter ces cas comme un refus faisait disparaitre en silence * les conversions de toutes les boutiques americaines. */ $consent = Convrail_Pixel::consent_answer(); } elseif ( 'unknown' === $recorded ) { $consent = ''; } else { $consent = 'denied' === $recorded ? 'denied' : 'granted'; } $payload = array( /* * Absent quand aucun plugin de consentement n'a repondu: la plateforme applique alors la * posture choisie par le marchand (Reglages > Consentement) et le pays du client. Les * coordonnees partent quand meme vers la plateforme, qui les retire avant OpenAI si la * posture l'exige. */ 'consent' => '' === $consent ? null : $consent, 'id' => $order->get_id(), 'total' => $order->get_total(), 'currency' => $order->get_currency(), /* * VRAIE heure UTC. `format()` sur un WC_DateTime rend l'heure du fuseau de la boutique, * et la plateforme y ajoutait un « Z »: une commande de 14 h a New York arrivait a * 14:00Z, soit quatre heures dans le futur, et OpenAI refuse tout evenement date en * avant. Aucune commande americaine n'atteignait la Conversions API, et en Europe les * ventes de fin de soiree changeaient de jour dans le tableau de bord. */ 'date_created_gmt' => $order->get_date_created() ? gmdate( 'Y-m-d\TH:i:s\Z', $order->get_date_created()->getTimestamp() ) : null, 'billing' => 'denied' === $consent ? array( 'country' => $order->get_billing_country() ) : array( 'email' => $order->get_billing_email(), 'phone' => $order->get_billing_phone(), 'country' => $order->get_billing_country(), 'city' => $order->get_billing_city(), 'postcode' => $order->get_billing_postcode(), // Prenom, nom et region: la Conversions API les accepte comme cles de // correspondance. Les deux premiers sont haches cote API, jamais ici. 'first_name' => $order->get_billing_first_name(), 'last_name' => $order->get_billing_last_name(), 'state' => $order->get_billing_state(), ), 'customer_id' => $order->get_customer_id(), 'line_items' => $line_items, 'ocl_landing_url' => '' !== $order->get_meta( '_convrail_landing_url' ) ? $order->get_meta( '_convrail_landing_url' ) : null, 'ocl_referrer' => '' !== $order->get_meta( '_convrail_referrer' ) ? $order->get_meta( '_convrail_referrer' ) : null, 'ocl_session_id' => '' !== $order->get_meta( '_convrail_session' ) ? $order->get_meta( '_convrail_session' ) : null, ); $result = Convrail_Client::post( '/bridge/wc/orders', $payload ); if ( ! is_wp_error( $result ) ) { $order->update_meta_data( '_convrail_pushed', '1' ); $order->save(); delete_option( 'convrail_order_error' ); return; } $attempt = (int) $attempt; if ( $attempt < self::MAX_ATTEMPTS ) { wp_schedule_single_event( time() + 60 * $attempt, 'convrail_push_order', array( (int) $order_id, $attempt + 1 ) ); return; } // Dernier essai: la vente ne partira pas toute seule, et le marchand doit le savoir. update_option( 'convrail_order_error', array( 'order' => (int) $order_id, 'status' => Convrail_Client::error_status( $result ), 'at' => time(), ), false ); Convrail_Client::log( 'order push failed for good: ' . (int) $order_id ); } }