EsiPeppol-Woocommerce/app/controllers/Peppol_controller.php
2026-01-14 10:02:07 +01:00

1017 lines
40 KiB
PHP

<?php
namespace ESI_PEPPOL\controllers;
use ESI_PEPPOL\models\PEPPOL_Main_model;
use ESI_PEPPOL\helpers\PEPPOL_Woo_Helper;
class PEPPOL_peppol_controller {
/**
* Cache de la configuration chargée.
*
* @var array<string,mixed>|null
*/
private static $config_cache = null;
/**
* Charge la configuration depuis le fichier config.php.
*
* @return array<string,mixed>
*/
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<string,string>
*/
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<string,array<string,mixed>> $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<int,array<string,mixed>> $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();
$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
);
// 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' => 0,
'amount_due' => round($order->get_total(), 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' => $order_number,
'invoice_date' => $invoice_date,
'due_date' => $due_date,
'currency_code' => $currency_code,
'invoice_notes' => $invoice_notes,
'payment_terms' => $payment_terms,
],
'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);
}
}