get_id(), 0 ); } } /** * Auto-send a credit note when its record is created. * * @param WC_Order $order Order. * @param WC_Order_Refund $refund Refund. * @param string $number Credit note number. * @param string $invoice_number Invoice being credited. */ public static function on_credit_note_created( $order, $refund, $number, $invoice_number ) { if ( 'B2C' === (string) $order->get_meta( EFacture_FR_Invoice_Manager::META_TYPE ) ) { self::set_state( $refund, 'skipped', __( 'B2C – relève de l\'e-reporting, non transmis.', 'factella' ) ); return; } if ( self::auto_send_enabled() && self::has_configured_connector() ) { self::enqueue( $order->get_id(), $refund->get_id() ); } } /** * Queue a send through Action Scheduler (falls back to an immediate send if * Action Scheduler is unavailable). * * @param int $order_id Order ID. * @param int $refund_id Refund ID (0 for the invoice itself). * @param int $attempt Attempt number. */ public static function enqueue( $order_id, $refund_id = 0, $attempt = 1 ) { $args = array( 'order_id' => (int) $order_id, 'refund_id' => (int) $refund_id, 'attempt' => (int) $attempt, ); $target = $refund_id ? wc_get_order( $refund_id ) : wc_get_order( $order_id ); if ( $target ) { self::set_state( $target, 'queued', '' ); } if ( function_exists( 'as_enqueue_async_action' ) ) { as_enqueue_async_action( self::ACTION_SEND, $args, self::GROUP ); } else { self::run_send( $order_id, $refund_id, $attempt ); } } /* --------------------------------------------------------------------- * Worker: send * ------------------------------------------------------------------- */ /** * Action Scheduler worker: perform one send attempt. * * @param int $order_id Order ID. * @param int $refund_id Refund ID (0 for the invoice). * @param int $attempt Attempt number. */ public static function run_send( $order_id, $refund_id = 0, $attempt = 1 ) { $result = self::send_now( (int) $order_id, (int) $refund_id ); // Retry transient failures with exponential backoff. if ( is_wp_error( $result ) && self::is_retryable( $result ) && (int) $attempt < self::MAX_ATTEMPTS ) { $delays = array( 1 => MINUTE_IN_SECONDS, 2 => 5 * MINUTE_IN_SECONDS, 3 => 30 * MINUTE_IN_SECONDS ); $delay = isset( $delays[ (int) $attempt ] ) ? $delays[ (int) $attempt ] : HOUR_IN_SECONDS; if ( function_exists( 'as_schedule_single_action' ) ) { as_schedule_single_action( time() + $delay, self::ACTION_SEND, array( 'order_id' => (int) $order_id, 'refund_id' => (int) $refund_id, 'attempt' => (int) $attempt + 1, ), self::GROUP ); } } } /** * Transmit one document now. Returns the connector result (array) or WP_Error. * * @param int $order_id Order ID. * @param int $refund_id Refund ID (0 for the invoice). * @return array|WP_Error */ public static function send_now( $order_id, $refund_id = 0 ) { $order = wc_get_order( $order_id ); if ( ! $order || $order instanceof WC_Order_Refund ) { return new WP_Error( 'factella_pdp_no_order', __( 'Commande introuvable.', 'factella' ) ); } $is_credit = $refund_id > 0; $target = $is_credit ? wc_get_order( $refund_id ) : $order; if ( ! $target ) { return new WP_Error( 'factella_pdp_no_order', __( 'Avoir introuvable.', 'factella' ) ); } // B2C is never transmitted (e-reporting territory). $rule = self::rule_for( $order ); if ( 'B2C' === $rule ) { self::set_state( $target, 'skipped', __( 'B2C – relève de l\'e-reporting, non transmis.', 'factella' ) ); return new WP_Error( 'factella_pdp_b2c', __( 'Les ventes B2C ne sont pas transmises.', 'factella' ) ); } $connector = EFacture_FR_Connectors::active(); if ( ! $connector || ! $connector->is_configured() ) { self::set_state( $target, 'error', __( 'Aucune plateforme connectée.', 'factella' ) ); return new WP_Error( 'factella_pdp_no_connector', __( 'Aucune plateforme connectée.', 'factella' ) ); } $number = $is_credit ? (string) $target->get_meta( EFacture_FR_Invoice_Manager::META_CREDIT_NUMBER ) : (string) $order->get_meta( EFacture_FR_Invoice_Manager::META_NUMBER ); if ( '' === $number ) { return new WP_Error( 'factella_pdp_no_number', __( 'Aucun numéro de facture.', 'factella' ) ); } $pdf = self::ensure_pdf( $order, $is_credit ? $target : null ); if ( '' === $pdf ) { self::set_state( $target, 'error', __( 'Le PDF Factur-X est introuvable.', 'factella' ) ); return new WP_Error( 'factella_pdp_no_pdf', __( 'Le PDF Factur-X est introuvable.', 'factella' ) ); } // Idempotency: skip if this exact PDF was already sent/accepted. $hash = (string) md5_file( $pdf ); $state = (string) $target->get_meta( self::META_STATE ); if ( '' !== (string) $target->get_meta( self::META_SUB_ID ) && in_array( $state, array( 'sent', 'accepted' ), true ) && $hash === (string) $target->get_meta( self::META_HASH ) ) { return array( 'submission_id' => (string) $target->get_meta( self::META_SUB_ID ), 'status' => $state, 'duplicate' => true ); } $result = $connector->send_invoice( $order, $pdf, array( 'external_id' => $number, 'processing_rule' => $rule, 'is_credit' => $is_credit, 'number' => $number, ) ); if ( is_wp_error( $result ) ) { $data = $result->get_error_data(); $detail = is_array( $data ) && isset( $data['detail'] ) ? (string) $data['detail'] : ''; self::set_state( $target, 'error', $result->get_error_message() ); // Record the reason on the order so the shop owner sees it in context. $note = 'SUPER PDP : ' . $result->get_error_message(); $order->add_order_note( $note ); return $result; } $status = isset( $result['status'] ) ? (string) $result['status'] : 'sent'; $target->update_meta_data( self::META_SUB_ID, isset( $result['submission_id'] ) ? (string) $result['submission_id'] : '' ); $target->update_meta_data( self::META_HASH, $hash ); $target->update_meta_data( self::META_RULE, $rule ); self::set_state( $target, $status, isset( $result['label'] ) ? (string) $result['label'] : '' ); /* translators: 1: document number, 2: submission id */ $order->add_order_note( sprintf( __( 'SUPER PDP : %1$s transmis (id %2$s).', 'factella' ), $number, isset( $result['submission_id'] ) ? $result['submission_id'] : '?' ) ); return $result; } /* --------------------------------------------------------------------- * Worker: status sync * ------------------------------------------------------------------- */ /** * Action Scheduler worker: poll the status of everything still "sent". */ public static function run_sync() { $connector = EFacture_FR_Connectors::active(); if ( ! $connector || ! $connector->is_configured() ) { return; } // Poll everything that is still in a non-terminal state. foreach ( array( 'shop_order', 'shop_order_refund' ) as $type ) { $ids = wc_get_orders( array( 'type' => $type, 'limit' => 50, 'return' => 'ids', 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query array( 'key' => self::META_STATE, 'value' => array( 'sent', 'available', 'disputed' ), 'compare' => 'IN', ), ), ) ); foreach ( (array) $ids as $id ) { self::sync_one( wc_get_order( $id ), $connector ); } } } /** * Poll one document and apply any status change. * * @param WC_Order|WC_Order_Refund|false $obj Document. * @param EFacture_FR_Connector $connector Active connector. */ protected static function sync_one( $obj, $connector ) { if ( ! $obj ) { return; } $sub = (string) $obj->get_meta( self::META_SUB_ID ); if ( '' === $sub ) { return; } $status = $connector->get_status( $sub ); if ( is_wp_error( $status ) || ! isset( $status['state'] ) ) { return; } $new = (string) $status['state']; $label = isset( $status['label'] ) ? (string) $status['label'] : ''; $old = (string) $obj->get_meta( self::META_STATE ); if ( $new === $old && $label === (string) $obj->get_meta( self::META_MESSAGE ) ) { return; // Nothing changed. } self::set_state( $obj, $new, $label ); // Note the change on the parent order, using the precise French lifecycle label. $parent = $obj instanceof WC_Order_Refund ? wc_get_order( $obj->get_parent_id() ) : $obj; if ( $parent && $new !== $old ) { /* translators: %s: French lifecycle status label */ $parent->add_order_note( sprintf( __( 'SUPER PDP : %s', 'factella' ), '' !== $label ? $label : $new ) ); } } /* --------------------------------------------------------------------- * Manual send (AJAX) * ------------------------------------------------------------------- */ /** * AJAX: "Send now" from the order screen. */ public static function ajax_send_now() { check_ajax_referer( 'factella_pdp_send_now', 'nonce' ); if ( ! current_user_can( 'edit_shop_orders' ) ) { wp_send_json_error( array( 'message' => __( 'Permission refusée.', 'factella' ) ), 403 ); } $order_id = isset( $_POST['order_id'] ) ? absint( $_POST['order_id'] ) : 0; $refund_id = isset( $_POST['refund_id'] ) ? absint( $_POST['refund_id'] ) : 0; if ( ! $order_id ) { wp_send_json_error( array( 'message' => __( 'Commande invalide.', 'factella' ) ), 400 ); } $result = self::send_now( $order_id, $refund_id ); $target = $refund_id ? wc_get_order( $refund_id ) : wc_get_order( $order_id ); $state = $target ? (string) $target->get_meta( self::META_STATE ) : 'error'; if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message(), 'badge' => self::badge_html( $target ), ) ); } wp_send_json_success( array( 'message' => __( 'Transmis à SUPER PDP.', 'factella' ), 'state' => $state, 'badge' => self::badge_html( $target ), ) ); } /* --------------------------------------------------------------------- * Helpers * ------------------------------------------------------------------- */ /** * Decide the processing rule from the order: FR B2B → B2B, foreign B2B → * B2BInt, no SIRET → B2C (skipped). * * @param WC_Order $order Order. * @return string B2B|B2BInt|B2C */ protected static function rule_for( $order ) { if ( 'B2B' !== (string) $order->get_meta( EFacture_FR_Invoice_Manager::META_TYPE ) ) { return 'B2C'; } $country = strtoupper( (string) $order->get_billing_country() ); return ( '' === $country || 'FR' === $country ) ? 'B2B' : 'B2BInt'; } /** * Store the transmission state on a document (order or refund) + a timestamp. * * @param WC_Order|WC_Order_Refund $obj Document object. * @param string $state State slug. * @param string $message Optional human message. */ protected static function set_state( $obj, $state, $message ) { $obj->update_meta_data( self::META_STATE, $state ); $obj->update_meta_data( self::META_MESSAGE, $message ); $obj->update_meta_data( self::META_UPDATED, time() ); $obj->save(); } /** * Ensure the Factur-X PDF exists and return its absolute path. * * @param WC_Order $order Order. * @param WC_Order_Refund|null $refund Refund for a credit note, or null. * @return string Absolute path, or '' on failure. */ protected static function ensure_pdf( $order, $refund = null ) { if ( ! class_exists( 'EFacture_FR_Facturx' ) || ! EFacture_FR_Facturx::is_available() ) { return ''; } $obj = $refund ? $refund : $order; $file = (string) $obj->get_meta( EFacture_FR_Facturx::META_PDF ); $path = '' !== $file ? EFacture_FR_Facturx::storage_dir() . $file : ''; if ( '' === $path || ! file_exists( $path ) ) { try { $path = $refund ? EFacture_FR_Facturx::generate_for_refund( $order, $refund ) : EFacture_FR_Facturx::generate_for_order( $order ); } catch ( \Throwable $e ) { return ''; } } return is_string( $path ) && file_exists( $path ) ? $path : ''; } /** * Whether a WP_Error from the connector is worth retrying. * * @param WP_Error $error Error. * @return bool */ protected static function is_retryable( $error ) { return in_array( $error->get_error_code(), array( 'factella_pdp_network', 'factella_pdp_server', 'factella_pdp_rate' ), true ); } /** * Is auto-send switched on in the settings? * * @return bool */ protected static function auto_send_enabled() { return 'yes' === get_option( 'efacture_fr_pdp_auto_send', 'no' ); } /** * Is there a configured connector to send with? * * @return bool */ protected static function has_configured_connector() { $connector = EFacture_FR_Connectors::active(); return $connector && $connector->is_configured(); } /** * Coloured status badge for the order screen / list. * * @param WC_Order|WC_Order_Refund|false $obj Document object. * @return string HTML. */ public static function badge_html( $obj ) { if ( ! $obj ) { return ''; } $state = (string) $obj->get_meta( self::META_STATE ); if ( '' === $state ) { return ''; } $map = array( 'skipped' => array( '#646970', __( 'B2C – not sent', 'factella' ) ), 'queued' => array( '#996800', __( 'Queued', 'factella' ) ), 'sent' => array( '#2271b1', __( 'Sent', 'factella' ) ), 'available' => array( '#2271b1', __( 'Delivered to recipient', 'factella' ) ), 'disputed' => array( '#996800', __( 'Disputed', 'factella' ) ), 'accepted' => array( '#1a7f37', __( 'Accepted', 'factella' ) ), 'paid' => array( '#1a7f37', __( 'Paid', 'factella' ) ), 'cancelled' => array( '#646970', __( 'Cancelled', 'factella' ) ), 'rejected' => array( '#d63638', __( 'Rejected', 'factella' ) ), 'error' => array( '#d63638', __( 'Error', 'factella' ) ), ); if ( ! isset( $map[ $state ] ) ) { return ''; } list( $color, $label ) = $map[ $state ]; $title = (string) $obj->get_meta( self::META_MESSAGE ); return '' . esc_html( $label ) . ''; } }