|null */ private static $config_cache = null; /** * Charge la configuration depuis le fichier config.php. * * @return array */ protected static function get_config(): array { if (self::$config_cache === null) { $config_file = ESI_PEPPOL_DIR . 'app/config.php'; if (file_exists($config_file)) { self::$config_cache = require $config_file; } else { self::$config_cache = []; } } return self::$config_cache; } /** * Récupère une valeur de configuration avec une valeur par défaut. * * @param string $key Clé de configuration (peut être un chemin avec des points, ex: 'invoice.default_due_days'). * @param mixed $default Valeur par défaut si la clé n'existe pas. * * @return mixed */ protected static function get_config_value(string $key, $default = null) { $config = self::get_config(); // Support pour les clés imbriquées (ex: 'invoice.default_due_days') if (strpos($key, '.') !== false) { $keys = explode('.', $key); $value = $config; foreach ($keys as $k) { if (!is_array($value) || !isset($value[$k])) { return $default; } $value = $value[$k]; } return $value; } return isset($config[$key]) ? $config[$key] : $default; } /** * Retourne l'URL de base de l'API (filtrable). * Utilise l'URL de test si is_test_environment est activé, sinon l'URL de production. * * @return string */ protected static function get_base_url(): string { $is_test = (bool) self::get_config_value('is_test_environment', false); if ($is_test) { $api_base_url = self::get_config_value('api_test_base_url', 'https://demo.esi-peppol.be/api'); } else { $api_base_url = self::get_config_value('api_base_url', 'https://peppol.esi-web.be/api'); } /** * Filtre pour surcharger l'URL de base de l'API ESIPeppol. * * @param string $base_url URL de base actuelle. */ return (string) \apply_filters('esi_peppol_api_base_url', $api_base_url); } /** * Récupère les identifiants API stockés dans les options WordPress. * * @return array{api_key:string,password:string} */ protected static function get_credentials(): array { $api_key = (string) \get_option('esi_peppol_api_key', ''); $password = (string) \get_option('esi_peppol_password', ''); return [ 'api_key' => $api_key, 'password' => $password, ]; } /** * Construit les en-têtes HTTP pour les appels authentifiés. * * @return array */ protected static function get_auth_headers(): array { $creds = self::get_credentials(); return [ 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'X-API-KEY' => $creds['api_key'], 'X-PASSWORD' => $creds['password'], ]; } /** * Appel générique à l'API ESIPeppol. * * @param string $method Méthode HTTP (GET, POST, ...). * @param string $path Chemin de l'endpoint (ex. '/health'). * @param array $args Arguments supplémentaires (body, headers...). * @param bool $with_auth Indique si l'appel doit être authentifié. * * @return array{ * success:bool, * http_code:int, * message:string, * data:mixed * } */ protected static function request(string $method, string $path, array $args = [], bool $with_auth = true): array { $base_url = rtrim(self::get_base_url(), '/'); $path = '/' . ltrim($path, '/'); $url = $base_url . $path; $headers = [ 'Accept' => 'application/json', ]; if ($with_auth) { $headers = array_merge($headers, self::get_auth_headers()); } if (isset($args['headers']) && is_array($args['headers'])) { $headers = array_merge($headers, $args['headers']); } $wp_args = [ 'method' => $method, 'timeout' => (int) self::get_config_value('api_timeout', 20), 'headers' => $headers, ]; if (isset($args['body'])) { $body = $args['body']; if (!is_string($body)) { $body = \wp_json_encode($body); } $wp_args['body'] = $body; } $response = \wp_remote_request($url, $wp_args); if (\is_wp_error($response)) { return [ 'success' => false, 'http_code' => 0, 'message' => $response->get_error_message(), 'data' => null, ]; } $code = (int) \wp_remote_retrieve_response_code($response); $body = \wp_remote_retrieve_body($response); $data = null; if ($body !== '') { $decoded = \json_decode($body, true); $data = \json_last_error() === \JSON_ERROR_NONE ? $decoded : $body; } $success = $code >= 200 && $code < 300; $message = ''; if (!$success) { if (is_array($data) && isset($data['message'])) { $message = (string) $data['message']; } } return [ 'success' => $success, 'http_code' => $code, 'message' => $message, 'data' => $data, ]; } /** * Appelle l'endpoint public /health (sans authentification). * * @return array */ public static function health_check(): array { return self::request('GET', '/health', [], false); } /** * Appelle l'endpoint /status (avec authentification) pour vérifier * la validité des credentials et l'état du quota. * * @return array */ public static function status(): array { return self::request('GET', '/status', [], true); } /** * Variante de /status qui utilise explicitement les identifiants fournis, * sans passer par les options WordPress (utile pour les tests de connexion). * * @param string $api_key * @param string $password * * @return array */ public static function status_with_credentials(string $api_key, string $password): array { $headers = [ 'X-API-KEY' => $api_key, 'X-PASSWORD' => $password, ]; return self::request('GET', '/status', ['headers' => $headers], false); } /** * Envoie un JSON déjà construit vers /upload-json. * * Si un $order_id est fourni, le résultat est enregistré dans la table custom. * * @param array $payload Données JSON structurées prêtes pour l'API. * @param int|null $order_id ID de commande WooCommerce lié (optionnel). * * @return array Réponse normalisée (success, http_code, message, data). */ public static function upload_json(array $payload, ?int $order_id = null): array { $result = self::request('POST', '/upload-json', ['body' => $payload], true); if ($order_id !== null) { $data = is_array($result['data'] ?? null) ? $result['data'] : []; // Extraire document_id et peppol_document_id depuis la réponse API // La réponse API peut avoir la structure : $result['data']['data'] ou $result['data'] // On vérifie d'abord si les valeurs sont dans $data['data'], sinon on prend directement $data $inner_data = $data; if (isset($data['data']) && is_array($data['data'])) { // Structure imbriquée : $result['data']['data'] $inner_data = $data['data']; } $document_id = isset($inner_data['document_id']) ? trim((string) $inner_data['document_id']) : ''; $peppol_document_id = isset($inner_data['peppol_document_id']) ? trim((string) $inner_data['peppol_document_id']) : ''; $status = isset($inner_data['status']) ? (string) $inner_data['status'] : ($result['success'] ? 'created' : 'error'); $message = $result['message']; if ($message === '' && isset($inner_data['message'])) { $message = (string) $inner_data['message']; } elseif ($message === '' && isset($data['message'])) { $message = (string) $data['message']; } // Log technique optionnel pour vérifier le payload réellement envoyé if (class_exists(PEPPOL_Plugin::class)) { PEPPOL_Plugin::write_debug_file( [ 'event' => 'upload_json', 'order_id' => $order_id, 'payload_sent' => $payload, 'api_result' => $result, 'data_extracted' => $data, 'inner_data' => $inner_data, 'document_id' => $document_id, 'peppol_document_id' => $peppol_document_id, 'status' => $status, ], 'INFO' ); } // Enregistrer ou mettre à jour dans la base de données // On passe $inner_data comme response_data pour conserver la structure complète PEPPOL_Main_model::save_for_order( $order_id, $inner_data, $payload, $document_id, $peppol_document_id, $status, (bool) $result['success'], $message, (int) $result['http_code'] ); // Envoyer un email en cas d'erreur API if (!$result['success'] && class_exists(PEPPOL_Plugin::class)) { PEPPOL_Plugin::send_api_error_email($order_id, $result, $payload); } } return $result; } /** * Ajoute au tableau des totaux de TVA les montants d'une ligne, * en gérant les cas avec plusieurs taux sur la même ligne. * * @param array> $vat_totals_by_rate_code * @param float $taxable_amount Montant HT de la ligne (tous taux confondus). * @param float $vat_amount Montant TVA total de la ligne. * @param array> $tax_details Détails TVA par taux (helper my_get_item_tax_details). * @param float $fallback_vat_rate Taux utilisé si aucun détail exploitable. * * @return void */ private static function accumulate_vat_totals_for_line( array &$vat_totals_by_rate_code, float $taxable_amount, float $vat_amount, array $tax_details, float $fallback_vat_rate ): void { // Si pas de détails ou TVA totale nulle, on retombe sur l'ancien comportement (un seul taux). $positive_taxes = array_filter( $tax_details, static function ($detail): bool { return isset($detail['amount'], $detail['percent']) && (float) $detail['amount'] > 0.0 && (float) $detail['percent'] > 0.0; } ); if (empty($positive_taxes) || $vat_amount <= 0.0 || $taxable_amount <= 0.0) { $key = (string) $fallback_vat_rate; if (!isset($vat_totals_by_rate_code[$key])) { $vat_totals_by_rate_code[$key] = [ 'vat_rate' => $fallback_vat_rate, // La catégorie sera recalculée après accumulation des montants 'vat_category_code' => 'S', 'taxable_amount' => 0.0, 'vat_amount' => 0.0, 'tax_scheme_id' => self::get_config_value('tax.scheme_id', 'VAT'), 'tax_scheme_name' => self::get_config_value('tax.scheme_name', 'Value Added Tax'), ]; } $vat_totals_by_rate_code[$key]['taxable_amount'] += $taxable_amount; $vat_totals_by_rate_code[$key]['vat_amount'] += $vat_amount; return; } // Plusieurs taux positifs sur la même ligne : on répartit le HT au prorata des montants de TVA. $total_positive_vat = 0.0; foreach ($positive_taxes as $detail) { $total_positive_vat += (float) $detail['amount']; } if ($total_positive_vat <= 0.0) { // Sécurité : repli sur le comportement ancien. $key = (string) $fallback_vat_rate; if (!isset($vat_totals_by_rate_code[$key])) { $vat_totals_by_rate_code[$key] = [ 'vat_rate' => $fallback_vat_rate, 'vat_category_code' => 'S', 'taxable_amount' => 0.0, 'vat_amount' => 0.0, 'tax_scheme_id' => self::get_config_value('tax.scheme_id', 'VAT'), 'tax_scheme_name' => self::get_config_value('tax.scheme_name', 'Value Added Tax'), ]; } $vat_totals_by_rate_code[$key]['taxable_amount'] += $taxable_amount; $vat_totals_by_rate_code[$key]['vat_amount'] += $vat_amount; return; } $allocated_taxable = 0.0; $positive_count = count($positive_taxes); $index = 0; foreach ($positive_taxes as $detail) { ++$index; $rate = (float) $detail['percent']; $tax_part = (float) $detail['amount']; $rate_key = (string) $rate; // Répartition du HT au prorata de la TVA de ce taux. if ($index === $positive_count) { // Dernier taux : on prend le reste pour éviter les erreurs d'arrondi. $taxable_share = $taxable_amount - $allocated_taxable; } else { $taxable_share = $taxable_amount * ($tax_part / $total_positive_vat); $allocated_taxable += $taxable_share; } if (!isset($vat_totals_by_rate_code[$rate_key])) { $vat_totals_by_rate_code[$rate_key] = [ 'vat_rate' => $rate, 'vat_category_code' => 'S', 'taxable_amount' => 0.0, 'vat_amount' => 0.0, 'tax_scheme_id' => self::get_config_value('tax.scheme_id', 'VAT'), 'tax_scheme_name' => self::get_config_value('tax.scheme_name', 'Value Added Tax'), ]; } $vat_totals_by_rate_code[$rate_key]['taxable_amount'] += $taxable_share; $vat_totals_by_rate_code[$rate_key]['vat_amount'] += $tax_part; } // S'il reste une partie du HT non allouée (par ex. combiné à un taux 0%), // on l'ajoute dans un bucket 0% (exempt). $remaining_taxable = $taxable_amount - $allocated_taxable; if ($remaining_taxable > 0.0001) { $key_zero = '0'; if (!isset($vat_totals_by_rate_code[$key_zero])) { $vat_totals_by_rate_code[$key_zero] = [ 'vat_rate' => 0.0, 'vat_category_code' => 'E', 'taxable_amount' => 0.0, 'vat_amount' => 0.0, 'tax_scheme_id' => self::get_config_value('tax.scheme_id', 'VAT'), 'tax_scheme_name' => self::get_config_value('tax.scheme_name', 'Value Added Tax'), ]; } $vat_totals_by_rate_code[$key_zero]['taxable_amount'] += $remaining_taxable; } } /** * Récupère les informations de paiement BACS depuis WooCommerce. * Retourne le premier compte bancaire valide configuré. * * @return array{ * payee_iban: string, * payee_bic: string, * bank_name: string * } */ protected static function get_bacs_payment_info(): array { $payment_info = [ 'payee_iban' => '', 'payee_bic' => '', 'bank_name' => '', ]; // Récupérer les comptes BACS configurés dans WooCommerce $accounts = \get_option('woocommerce_bacs_accounts', []); if (empty($accounts) || !is_array($accounts)) { return $payment_info; } // Trouver le premier compte valide avec IBAN foreach ($accounts as $account) { if (!is_array($account)) { continue; } // Vérifier que le compte a au moins un IBAN et un nom de banque $iban = isset($account['iban']) ? trim((string) $account['iban']) : ''; $bank_name = isset($account['bank_name']) ? trim((string) $account['bank_name']) : ''; if ($iban !== '' && $bank_name !== '') { $payment_info['payee_iban'] = $iban; $payment_info['bank_name'] = $bank_name; // BIC est optionnel dans WooCommerce BACS if (isset($account['bic']) && !empty($account['bic'])) { $payment_info['payee_bic'] = trim((string) $account['bic']); } // On prend le premier compte valide trouvé break; } } return $payment_info; } /** * Construit le payload JSON attendu par /upload-json à partir * d'une commande WooCommerce. * * @param \WC_Order $order * * @return array */ public static function build_payload_from_order(\WC_Order $order): array { $order_id = $order->get_id(); $order_number = $order->get_order_number(); $invoice_number = $order_number; $wpo_invoice_number = (string) $order->get_meta('_wcpdf_invoice_number', true); if ($wpo_invoice_number !== '') { $invoice_number = $wpo_invoice_number; } $currency_code = $order->get_currency(); $invoice_date = $order->get_date_created() ? $order->get_date_created()->date('Y-m-d') : \gmdate('Y-m-d'); // Par défaut, échéance configurable (filtrable) $default_due_days = (int) self::get_config_value('invoice.default_due_days', 30); $due_date = \apply_filters( 'esi_peppol_invoice_due_date', (new \DateTimeImmutable($invoice_date)) ->modify('+' . $default_due_days . ' days') ->format('Y-m-d'), $order ); $external_reference_prefix = self::get_config_value('invoice.external_reference_prefix', 'WC-'); $external_reference = \apply_filters( 'esi_peppol_external_reference', $external_reference_prefix . $order_number, $order ); $invoice_notes = \apply_filters( 'esi_peppol_invoice_notes', sprintf( /* translators: %s: order number */ __('Facture générée depuis la commande WooCommerce %s', 'esi_peppol'), $order_number ), $order ); $default_payment_terms = self::get_config_value('invoice.default_payment_terms', __('Paiement à 30 jours nets', 'esi_peppol')); $payment_terms = \apply_filters( 'esi_peppol_payment_terms', $default_payment_terms, $order ); $payment_data = PEPPOL_Woo_Helper::esi_get_order_payment_data($order); $payment_means_code = $payment_data['payment_means_code'] ?? '30'; if (!empty($payment_data['payment_terms'])) { $payment_terms = $payment_data['payment_terms']; } $payment_means_code = \apply_filters( 'esi_peppol_payment_means_code', $payment_means_code, $payment_data, $order ); $payment_terms = \apply_filters( 'esi_peppol_payment_terms_from_payment', $payment_terms, $payment_data, $order ); // Données vendeur (store) - basées sur les options WooCommerce $store_name = \get_bloginfo('name'); $store_address = \get_option('woocommerce_store_address', ''); $store_address2 = \get_option('woocommerce_store_address_2', ''); $store_city = \get_option('woocommerce_store_city', ''); $store_postcode = \get_option('woocommerce_store_postcode', ''); $store_country = \get_option('woocommerce_default_country', ''); $store_vat_number = \get_option('woocommerce_store_vat_number', ''); // Le country peut être sous forme "BE:BE" selon les versions if (strpos($store_country, ':') !== false) { [$store_country] = explode(':', $store_country, 2); } $seller = [ 'name' => $store_name, 'legal_name' => $store_name, 'company_id' => '', 'vat_number' => $store_vat_number, 'address' => [ 'address_line_1' => $store_address, 'address_line_2' => $store_address2, 'city' => $store_city, 'postal_code' => $store_postcode, 'country_code' => $store_country, ], 'contact' => [ 'contact_name' => $store_name, 'contact_email' => \get_option('admin_email', ''), ], ]; $seller = \apply_filters('esi_peppol_seller_party', $seller, $order); // Données client (buyer) à partir de la commande $buyer_country = $order->get_billing_country(); $buyer = [ 'name' => $order->get_billing_company() ?: $order->get_formatted_billing_full_name(), 'legal_name' => $order->get_billing_company() ?: $order->get_formatted_billing_full_name(), 'company_id' => '', 'vat_number' => $order->get_meta('_billing_vat_number') ?: '', 'address' => [ 'address_line_1' => $order->get_billing_address_1(), 'address_line_2' => $order->get_billing_address_2(), 'city' => $order->get_billing_city(), 'postal_code' => $order->get_billing_postcode(), 'country_code' => $buyer_country, ], 'contact' => [ 'contact_name' => $order->get_formatted_billing_full_name(), 'contact_email' => $order->get_billing_email(), 'contact_phone' => $order->get_billing_phone(), ], ]; $buyer = \apply_filters('esi_peppol_buyer_party', $buyer, $order); // Lignes de facture $invoice_lines = []; $total_excl_vat = 0.0; $total_vat = 0.0; $total_incl_vat = 0.0; $vat_totals_by_rate_code = []; $line_number = 1; // 1) Lignes produits foreach ($order->get_items('line_item') as $item) { /** @var \WC_Order_Item_Product $item */ $product = $item->get_product(); $quantity = (float) $item->get_quantity(); $line_subtotal = (float) $item->get_subtotal(); $line_subtotal_tax = (float) $item->get_subtotal_tax(); $line_total = (float) $item->get_total(); $line_total_tax = (float) $item->get_total_tax(); // On travaille sur les montants "total" (après remises) $taxable_amount = $line_total; $vat_amount = $line_total_tax; $line_total_incl_vat = $taxable_amount + $vat_amount; $unit_price = $quantity > 0 ? $taxable_amount / $quantity : 0.0; // Détermination du taux de TVA via le helper centralisé $vat_rate = 0.0; $tax_details = PEPPOL_Woo_Helper::my_get_item_tax_details($item); if (!empty($tax_details)) { // On prend le premier taux non nul (cas standard WooCommerce) foreach ($tax_details as $tax_detail) { if (!empty($tax_detail['percent'])) { $vat_rate = round((float) $tax_detail['percent'], 2); break; } } } // Fallback: approximation du taux de TVA basée sur les montants if ($vat_rate === 0.0) { if ($line_subtotal > 0.0 && $line_subtotal_tax > 0.0) { $vat_rate = ($line_subtotal_tax / $line_subtotal) * 100; } elseif ($taxable_amount > 0.0 && $vat_amount > 0.0) { $vat_rate = ($vat_amount / $taxable_amount) * 100; } $vat_rate = round($vat_rate, 2); } // Log technique pour debug TVA par ligne if (class_exists(PEPPOL_Plugin::class)) { PEPPOL_Plugin::write_debug_file( [ 'event' => 'vat_debug_line', 'order_id' => $order->get_id(), 'line_number' => $line_number, 'product_id' => $product ? $product->get_id() : null, 'product_name' => $item->get_name(), 'quantity' => $quantity, 'line_subtotal' => $line_subtotal, 'line_subtotal_tax' => $line_subtotal_tax, 'line_total' => $line_total, 'line_total_tax' => $line_total_tax, 'taxable_amount' => $taxable_amount, 'vat_amount' => $vat_amount, 'computed_vat_rate' => $vat_rate, 'line_taxes_raw' => $item->get_taxes(), 'tax_details_helper' => $tax_details, ], 'INFO' ); } // Déterminer la catégorie de TVA en fonction du montant de TVA // - Montant TVA 0 => catégorie "E" (exempt / taux zéro appliqué) // - Montant TVA >0 => catégorie "S" (standard) $is_zero_vat_amount = ($vat_amount == 0.0); $vat_category_code = $is_zero_vat_amount ? 'E' : 'S'; $unit_of_measure = self::get_config_value('units.default_unit_of_measure', 'C62'); // "Unit" code par défaut if ($product && $product->get_meta('unit_of_measure')) { $unit_of_measure = (string) $product->get_meta('unit_of_measure'); } $seller_item_id = ''; if ($product) { // Pour les produits : préfixe configurable + SKU si existant, sinon préfixe + ID produit $product_prefix = self::get_config_value('item_ids.product_prefix', 'P_'); $base_id = $product->get_sku() ?: (string) $product->get_id(); $seller_item_id = $product_prefix . $base_id; } // Description courte sans balises HTML pour compatibilité PEPPOL $item_description = ''; if ($product) { $raw_short_description = (string) $product->get_short_description(); // Supprime toutes les balises HTML et compresse les espaces $item_description = \wp_strip_all_tags($raw_short_description, true); } $line = [ 'line_number' => $line_number, 'item_name' => $item->get_name(), 'item_description' => $item_description, 'seller_item_id' => $seller_item_id, 'quantity' => $quantity, 'unit_of_measure' => $unit_of_measure, 'unit_price' => round($unit_price, 2), 'vat_rate' => $vat_rate, 'vat_category_code' => $vat_category_code, 'line_total_amount' => round($taxable_amount, 2), 'line_vat_amount' => round($vat_amount, 2), 'line_total_amount_including_vat' => round($line_total_incl_vat, 2), ]; $invoice_lines[] = \apply_filters('esi_peppol_invoice_line', $line, $item, $order); $total_excl_vat += $taxable_amount; $total_vat += $vat_amount; $total_incl_vat += $line_total_incl_vat; self::accumulate_vat_totals_for_line( $vat_totals_by_rate_code, $taxable_amount, $vat_amount, $tax_details, $vat_rate ); ++$line_number; } // 2) Lignes de livraison foreach ($order->get_items('shipping') as $item) { /** @var \WC_Order_Item_Shipping $item */ $quantity = 1.0; // IMPORTANT : pour la livraison, on recalcule la base HT à partir du montant TTC // et du taux de TVA réel, car les valeurs de WooCommerce (get_total_tax()) peuvent // être incohérentes avec le taux de TVA appliqué. // // Exemple : si total TTC = 50 € et taux TVA = 21% : // - Base HT correcte = 50 / 1.21 = 41.32 € // - TVA correcte = 50 - 41.32 = 8.68 € // // (et NON pas : 50 - 10.5 = 39.5 € comme le donnerait une simple soustraction // de la TVA retournée par WooCommerce, qui peut être erronée) $gross_amount = (float) $item->get_total(); // Montant TVAC $line_total_incl_vat = $gross_amount; // Détermination du taux de TVA pour la livraison via le helper $vat_rate = 0.0; $tax_details = PEPPOL_Woo_Helper::my_get_item_tax_details($item); if (!empty($tax_details)) { foreach ($tax_details as $tax_detail) { if (!empty($tax_detail['percent'])) { $vat_rate = round((float) $tax_detail['percent'], 2); break; } } } // Calcul de la base HT et de la TVA à partir du montant TVAC et du taux. // Pour un taux de 21%, on fait : TVAC / 1.21 pour obtenir la base HT, // puis TVA = TVAC - HT. if ($vat_rate > 0.0) { $divider = 1 + ($vat_rate / 100); $taxable_amount = $divider > 0 ? $gross_amount / $divider : $gross_amount; $vat_amount = $gross_amount - $taxable_amount; } else { // Si pas de taux de TVA détecté, on considère tout en HT. $taxable_amount = $gross_amount; $vat_amount = 0.0; } // Sécurité : éviter les valeurs négatives en cas de configuration exotique. if ($taxable_amount < 0) { $taxable_amount = 0.0; } // Arrondis pour cohérence avec les autres lignes $taxable_amount = round($taxable_amount, 2); $vat_amount = round($vat_amount, 2); $unit_price = $quantity > 0 ? $taxable_amount / $quantity : 0.0; $is_zero_vat_amount = ($vat_amount == 0.0); $vat_category_code = $is_zero_vat_amount ? 'E' : 'S'; // Identifiant vendeur pour les frais de livraison : préfixe configurable + n° de ligne $shipping_prefix = self::get_config_value('item_ids.shipping_prefix', 'SHIPCQT_'); $seller_item_id = $shipping_prefix . $line_number; $line = [ 'line_number' => $line_number, 'item_name' => $item->get_name() ?: \__('Frais de livraison', 'esi_peppol'), 'item_description' => '', 'seller_item_id' => $seller_item_id, 'quantity' => $quantity, 'unit_of_measure' => self::get_config_value('units.default_unit_of_measure', 'C62'), 'unit_price' => round($unit_price, 2), 'vat_rate' => $vat_rate, 'vat_category_code' => $vat_category_code, 'line_total_amount' => $taxable_amount, 'line_vat_amount' => $vat_amount, 'line_total_amount_including_vat' => round($line_total_incl_vat, 2), ]; $invoice_lines[] = \apply_filters('esi_peppol_invoice_line_shipping', $line, $item, $order); $total_excl_vat += $taxable_amount; $total_vat += $vat_amount; $total_incl_vat += $line_total_incl_vat; self::accumulate_vat_totals_for_line( $vat_totals_by_rate_code, $taxable_amount, $vat_amount, $tax_details, $vat_rate ); ++$line_number; } // 3) Lignes de frais supplémentaires foreach ($order->get_items('fee') as $item) { /** @var \WC_Order_Item_Fee $item */ $quantity = 1.0; // Montant du fee dans WooCommerce. // Pour les frais, on considère que get_total() retourne un montant TVAC // et on reconstitue la base HT & la TVA à partir du taux de TVA. $gross_amount = (float) $item->get_total(); // Montant TVAC // Détermination du taux de TVA pour les frais via le helper $vat_rate = 0.0; $tax_details = PEPPOL_Woo_Helper::my_get_item_tax_details($item); if (!empty($tax_details)) { foreach ($tax_details as $tax_detail) { if (!empty($tax_detail['percent'])) { $vat_rate = round((float) $tax_detail['percent'], 2); break; } } } // Calcul de la base HT et de la TVA à partir du montant TVAC. // Exemple : pour 21%, on fait TVAC / 1.21 pour obtenir la base, // puis TVA = TVAC - HT. if ($vat_rate > 0.0) { $divider = 1 + ($vat_rate / 100); $taxable_amount = $divider > 0 ? $gross_amount / $divider : $gross_amount; $vat_amount = $gross_amount - $taxable_amount; } else { // Si pas de taux de TVA détecté, on considère tout en HT. $taxable_amount = $gross_amount; $vat_amount = 0.0; } $line_total_incl_vat = $gross_amount; if ($taxable_amount < 0) { $taxable_amount = 0.0; } // Arrondis pour cohérence $taxable_amount = round($taxable_amount, 2); $vat_amount = round($vat_amount, 2); $unit_price = $quantity > 0 ? $taxable_amount / $quantity : 0.0; $is_zero_vat_amount = ($vat_amount == 0.0); $vat_category_code = $is_zero_vat_amount ? 'E' : 'S'; // Identifiant vendeur pour les frais supplémentaires : préfixe configurable + n° de ligne $fee_prefix = self::get_config_value('item_ids.fee_prefix', 'FEE_'); $seller_item_id = $fee_prefix . $line_number; $line = [ 'line_number' => $line_number, 'item_name' => $item->get_name() ?: \__('Frais supplémentaires', 'esi_peppol'), 'item_description' => '', 'seller_item_id' => $seller_item_id, 'quantity' => $quantity, 'unit_of_measure' => self::get_config_value('units.default_unit_of_measure', 'C62'), 'unit_price' => round($unit_price, 2), 'vat_rate' => $vat_rate, 'vat_category_code' => $vat_category_code, 'line_total_amount' => $taxable_amount, 'line_vat_amount' => $vat_amount, 'line_total_amount_including_vat' => round($line_total_incl_vat, 2), ]; $invoice_lines[] = \apply_filters('esi_peppol_invoice_line_fee', $line, $item, $order); $total_excl_vat += $taxable_amount; $total_vat += $vat_amount; $total_incl_vat += $line_total_incl_vat; self::accumulate_vat_totals_for_line( $vat_totals_by_rate_code, $taxable_amount, $vat_amount, $tax_details, $vat_rate ); ++$line_number; } $vat_totals = array_map( static function (array $row) use ($order): array { // Arrondir la base imposable agrégée $row['taxable_amount'] = round($row['taxable_amount'], 2); // IMPORTANT : recalculer la TVA à partir de la base totale et du taux, // au lieu de sommer les montants de TVA ligne par ligne (produits, frais, etc.). // Cela garantit que la TVA par taux est cohérente avec la base globale. $rate = isset($row['vat_rate']) ? (float) $row['vat_rate'] : 0.0; if ($rate > 0.0) { $row['vat_amount'] = round($row['taxable_amount'] * $rate / 100, 2); } else { $row['vat_amount'] = 0.0; } // Catégorie "E" si aucun montant de TVA pour ce taux, sinon "S" $row['vat_category_code'] = ($row['vat_amount'] == 0.0) ? 'E' : 'S'; // Log technique pour debug TVA agrégée par taux if (class_exists(PEPPOL_Plugin::class)) { \ESI_PEPPOL\controllers\PEPPOL_Plugin::write_debug_file( [ 'event' => 'vat_debug_total', 'order_id' => $order->get_id(), 'vat_rate' => $row['vat_rate'], 'taxable_amount' => $row['taxable_amount'], 'vat_amount' => $row['vat_amount'], 'vat_category_code' => $row['vat_category_code'], ], 'INFO' ); } return $row; }, $vat_totals_by_rate_code ); // Nettoyage : on supprime les lignes de TVA sans montant (taux 0% "artificiel") // afin d'éviter d'avoir un bucket 0% dans vat_totals quand toute la facture // est réellement soumise à un taux positif (ex. 21% partout). $vat_totals = array_filter( $vat_totals, static function (array $row): bool { return isset($row['vat_amount']) && (float) $row['vat_amount'] > 0.0; } ); // Réindexer les clés après le filtre $vat_totals = array_values($vat_totals); $invoice_totals = [ 'total_amount_excluding_vat' => round($total_excl_vat, 2), 'total_vat_amount' => round($total_vat, 2), 'total_amount_including_vat' => round($total_incl_vat, 2), 'total_paid_amount' => round($order->get_total(), 2), 'total_payable_amount' => round($order->get_total(), 2), 'amount_due' => round($order->get_total() - $total_incl_vat, 2), ]; $invoice_totals = \apply_filters('esi_peppol_invoice_totals', $invoice_totals, $order); // Informations de paiement depuis BACS WooCommerce $payment_info = self::get_bacs_payment_info(); $payment_info = \apply_filters('esi_peppol_payment_info', $payment_info, $order); $payload = [ 'document_type' => 'invoice', 'external_reference' => $external_reference, 'invoice' => [ 'invoice_number' => $invoice_number, 'invoice_date' => $invoice_date, 'due_date' => $due_date, 'currency_code' => $currency_code, 'invoice_notes' => $invoice_notes, 'payment_terms' => $payment_terms, 'payment_means_code' => $payment_means_code, ], 'parties' => [ 'seller' => $seller, 'buyer' => $buyer, ], 'invoice_lines' => $invoice_lines, 'vat_totals' => array_values($vat_totals), 'invoice_totals' => $invoice_totals, 'payment_info' => $payment_info, ]; return \apply_filters('esi_peppol_payload_from_order', $payload, $order); } /** * Génère et sauvegarde un numéro WPO si format de secours configuré. */ public static function ensure_invoice_number_meta(\WC_Order $order): void { $wpo_invoice_number = (string) $order->get_meta('_wcpdf_invoice_number', true); if ($wpo_invoice_number !== '') { return; } $use_backup_format = !\ESI_PEPPOL\helpers\PEPPOL_Woo_Helper::esi_has_wpo_invoice_numbers(); if (!$use_backup_format) { return; } $backup_format = (string) \get_option('esi_peppol_invoice_number_format', ''); if ($backup_format === '') { $backup_format = (string) \get_option('esi_peppol_vat_number_format', ''); } if ($backup_format !== '') { $generated = self::build_invoice_number_from_format($backup_format, $order); if ($generated !== '') { $order->update_meta_data('_wcpdf_invoice_number', $generated); $order->save(); } return; } $fallback = (string) $order->get_order_number(); if ($fallback === '') { return; } $order->update_meta_data('_wcpdf_invoice_number', $fallback); $order->save(); } /** * Construit un numéro de facture depuis un format de secours. * Placeholders supportés : {YYYY}, {YY}, {MM}, {DD}, {ORDER_ID}, {ORDER_NUMBER} */ private static function build_invoice_number_from_format(string $format, \WC_Order $order): string { $date = $order->get_date_created(); $year = $date ? $date->date('Y') : \gmdate('Y'); $month = $date ? $date->date('m') : \gmdate('m'); $day = $date ? $date->date('d') : \gmdate('d'); $sequence = self::get_next_invoice_sequence($format, $order, $year); $replacements = [ '{YYYY}' => $year, '{YY}' => substr($year, -2), '{MM}' => $month, '{DD}' => $day, '{ORDER_ID}' => (string) $order->get_id(), '{ORDER_NUMBER}' => (string) $order->get_order_number(), '{number}' => $sequence, ]; $result = strtr($format, $replacements); return trim($result); } private static function get_next_invoice_sequence(string $format, \WC_Order $order, string $current_year): string { if (!function_exists('wc_get_orders')) { return '1'; } $orders = \wc_get_orders([ 'limit' => 1, 'orderby' => 'date', 'order' => 'DESC', 'status' => 'any', 'meta_key' => '_wcpdf_invoice_number', 'meta_compare' => 'EXISTS', 'exclude' => [$order->get_id()], 'return' => 'objects', ]); if (empty($orders)) { return '1'; } $last_order = $orders[0]; $last_invoice = (string) $last_order->get_meta('_wcpdf_invoice_number', true); if ($last_invoice === '') { return '1'; } $matches = self::match_invoice_number_format($format, $last_invoice); if (empty($matches['number'])) { return '1'; } $last_number = (int) $matches['number']; $number_width = strlen((string) $matches['number']); $next_number = $last_number + 1; if (strpos($format, '{YYYY}') !== false) { $last_year = (string) ($matches['year'] ?? ''); if ($last_year !== $current_year) { $next_number = 1; } } elseif (strpos($format, '{YY}') !== false) { $current_year2 = substr($current_year, -2); $last_year2 = (string) ($matches['year2'] ?? ''); if ($last_year2 !== $current_year2) { $next_number = 1; } } $next_str = (string) $next_number; if ($number_width > 1) { $next_str = str_pad($next_str, $number_width, '0', STR_PAD_LEFT); } return $next_str; } private static function match_invoice_number_format(string $format, string $value): array { $pattern = preg_quote($format, '/'); $pattern = str_replace('\{YYYY\}', '(?P\d{4})', $pattern); $pattern = str_replace('\{YY\}', '(?P\d{2})', $pattern); $pattern = str_replace('\{MM\}', '(?P\d{2})', $pattern); $pattern = str_replace('\{DD\}', '(?P\d{2})', $pattern); $pattern = str_replace('\{ORDER_ID\}', '\d+', $pattern); $pattern = str_replace('\{ORDER_NUMBER\}', '.+?', $pattern); $pattern = str_replace('\{number\}', '(?P\d+)', $pattern); $pattern = '/^' . $pattern . '$/'; $matches = []; if (preg_match($pattern, $value, $matches) !== 1) { return []; } return $matches; } }