JezK
Edit File: AbstractHandler.php
<?php namespace WPO\IPS\EDI\Abstracts; use WPO\IPS\EDI\Interfaces\HandlerInterface; use WPO\IPS\EDI\Document; use WPO\IPS\EDI\Standards\EN16931; if ( ! defined( 'ABSPATH' ) ) { exit; // Exit if accessed directly } abstract class AbstractHandler implements HandlerInterface { public Document $document; /** * Constructor. * * @param Document $document The document instance. */ public function __construct( Document $document ) { $this->document = $document; } /** * Handle the data and return the formatted output. * * @param array $data The data to be handled. * @param array $options Additional options for handling. * @return array */ abstract public function handle( array $data, array $options = array() ): array; /** * Get the supplier identifiers data. * * @param string $key The data key (e.g., 'shop_name', 'coc_number', 'shop_address_line_1', 'shop_address_postcode'). * @return string */ protected function get_supplier_identifiers_data( string $key ): string { $general_settings = WPO_WCPDF()->settings->general; $language = wpo_ips_edi_get_settings( 'supplier_identifiers_language' ); if ( empty( $language ) ) { $language = 'default'; } $value = $general_settings->get_setting( $key, $language ) ?: ''; $country = $general_settings->get_setting( 'shop_address_country', $language ) ?: ''; if ( 'vat_number' === $key && '' !== $value ) { $value = wpo_ips_edi_format_vat_number( (string) $value, (string) $country ); } if ( 'coc_number' === $key && '' !== $value ) { $value = wpo_ips_edi_format_registration_number( (string) $value, (string) $country ); } return (string) $value; } /** * Returns the due date for the document. * * @return string */ protected function get_due_date(): string { $due_date = is_callable( array( $this->document->order_document, 'get_due_date' ) ) ? $this->document->order_document->get_due_date() : 0; return $this->normalize_date( $due_date, 'Y-m-d' ); } /** * Returns the due date days for the document. * * @return int */ protected function get_due_date_days(): int { $due_date_days = is_callable( array( $this->document->order_document, 'get_setting' ) ) ? absint( $this->document->order_document->get_setting( 'due_date_days' ) ) : 0; return apply_filters( 'wpo_ips_edi_due_date_days', $due_date_days, $this->document->order_document, $this ); } /** * Get normalized WooCommerce payment data for the current order. * * @return array { * @type string $type_code EN16931 TypeCode * @type string $method WooCommerce method ID * @type string $title WooCommerce method title * @type string $iban Optional IBAN * @type string $bic Optional BIC * @type string $transaction_id Optional transaction ID * @type string $account_name Optional account name * } */ protected function get_payment_means_data(): array { $order = \wpo_ips_edi_get_parent_order( $this->document->order ); $method_id = $order ? (string) $order->get_payment_method() : ''; $title = $order ? (string) $order->get_payment_method_title() : ''; $mapping = apply_filters( 'wpo_ips_edi_payment_means_code_mapping', array( 'cheque' => '20', // Cheque 'bacs' => '58', // SEPA Credit Transfer 'paypal' => '68', // Online payment 'stripe' => '54', // Credit card 'cod' => '46', // Interbank debit transfer 'default' => '97', // Clearing between partners ), $method_id, EN16931::get_payment(), $this ); $type_code = $mapping[ $method_id ] ?? $mapping['default']; $data = array( 'type_code' => $type_code, 'method' => $method_id, 'title' => $title, 'iban' => '', 'bic' => '', 'account_name' => '', 'account_number' => '', 'transaction_id' => '', ); $payment_methods_with_bank_details = \wpo_ips_edi_get_settings( 'supplier_bank_details' ); if ( null === $payment_methods_with_bank_details ) { $payment_methods_with_bank_details = array( 'bacs' ); } $show_supplier_bank_details = in_array( $method_id, (array) $payment_methods_with_bank_details, true ); if ( $show_supplier_bank_details ) { $accounts = get_option( 'woocommerce_bacs_accounts', array() ); if ( ! empty( $accounts ) && is_array( $accounts ) ) { $selected_account_index = \wpo_ips_edi_get_settings( 'supplier_bacs_account' ); if ( null !== $selected_account_index && isset( $accounts[ $selected_account_index ] ) ) { $account = $accounts[ $selected_account_index ]; } else { $account = reset( $accounts ); } $account = apply_filters( 'wpo_ips_edi_payment_means_bacs_default_account', $account, $accounts, $this ); $data = array_merge( $data, array( 'account_name' => isset( $account['account_name'] ) ? (string) $account['account_name'] : '', 'account_number' => isset( $account['account_number'] ) ? (string) $account['account_number'] : '', 'iban' => isset( $account['iban'] ) ? (string) $account['iban'] : '', 'bic' => isset( $account['bic'] ) ? (string) $account['bic'] : '', ) ); } } switch ( $method_id ) { case 'paypal': $data['transaction_id'] = $order ? (string) $order->get_transaction_id() : ''; break; case 'stripe': $data['transaction_id'] = $order ? (string) $order->get_meta( '_stripe_source_id', true ) : ''; break; } return apply_filters( 'wpo_ips_edi_payment_means_data', $data, $method_id, $order, $this ); } /** * Normalize a raw date input into a specific format. * * @param mixed $raw The input date (DateTime, string, timestamp, etc.) * @param string $format The output format (default: 'Y-m-d') * @return string */ protected function normalize_date( $raw, string $format = 'Y-m-d' ): string { if ( empty( $raw ) ) { return ''; } // Handle UNIX timestamp (int or numeric string) if ( is_numeric( $raw ) && (int) $raw > 1000000000 ) { try { $datetime = new \DateTimeImmutable( '@' . (int) $raw ); $datetime = $datetime->setTimezone( wp_timezone() ); return $datetime->format( $format ); } catch ( \Exception $e ) { return ''; } } // Handle DateTimeInterface objects if ( $raw instanceof \DateTimeInterface ) { return $raw->format( $format ); } // Try WooCommerce string parser if ( function_exists( 'wc_string_to_datetime' ) ) { try { $datetime = \wc_string_to_datetime( $raw ); return $datetime->format( $format ); } catch ( \Exception $e ) { // fallback below } } // Fallback with strtotime $timestamp = strtotime( $raw ); if ( $timestamp ) { $datetime = new \DateTimeImmutable( '@' . $timestamp ); $datetime = $datetime->setTimezone( function_exists( 'wp_timezone' ) ? \wp_timezone() : new \DateTimeZone( 'UTC' ) ); return $datetime->format( $format ); } return ''; } /** * Format a decimal number to a string with fixed decimal places, * using WooCommerce normalization and avoiding scientific notation. * * @param float|string $amount The amount to format. * @param int $decimal_places Number of decimal places (default: 2). * @return string */ protected function format_decimal( $amount, int $decimal_places = 2 ): string { // Normalize using WooCommerce helper (handles locale, strings, etc.). $value = (float) wc_format_decimal( $amount, $decimal_places, false ); // Treat tiny float residue as zero (10^-(decimal_places + 2)). $tiny_threshold = pow( 10, - ( $decimal_places + 2 ) ); if ( abs( $value ) < $tiny_threshold ) { $value = 0.0; } // Round to 2dp and avoid "-0.00". $value = round( $value, $decimal_places ); if ( $value === -0.0 ) { $value = 0.0; } // Emit plain decimal string (no exponent). return number_format( $value, $decimal_places, '.', '' ); } /** * Get normalized zero-tax meta (scheme/category/reason), with filter support. * * @param \WC_Order_Item|\WC_Abstract_Order|null $context Optional context. * @return array */ protected function get_zero_tax_meta( $context = null ): array { $defaults = array( 'scheme' => 'VAT', 'category' => 'Z', 'reason' => 'NONE', ); $meta = apply_filters( 'wpo_ips_edi_zero_tax_meta', $defaults, $context, $this ); $scheme = strtoupper( trim( (string) ( $meta['scheme'] ?? $defaults['scheme'] ) ) ); $category = strtoupper( trim( (string) ( $meta['category'] ?? $defaults['category'] ) ) ); $reason = strtoupper( trim( (string) ( $meta['reason'] ?? $defaults['reason'] ) ) ); if ( '' === $scheme ) { $scheme = $defaults['scheme']; } if ( '' === $category ) { $category = $defaults['category']; } if ( '' === $reason ) { $reason = $defaults['reason']; } return compact( 'scheme', 'category', 'reason' ); } /** * Get grouped order tax data by rate, category, reason, and scheme. * * @return array */ protected function get_grouped_order_tax_data(): array { $order = $this->document->order; $groups = array(); $line_items = $order->get_items( array( 'line_item', 'fee', 'shipping' ) ); $order_category = wpo_ips_edi_get_tax_data_from_fallback( 'category', null, $order ); $order_reason = wpo_ips_edi_get_tax_data_from_fallback( 'reason', null, $order ); $order_scheme = wpo_ips_edi_get_tax_data_from_fallback( 'scheme', null, $order ); $zero_meta = $this->get_preferred_zero_tax_meta( $order ); $zero_category = $zero_meta['category']; $zero_scheme = $zero_meta['scheme']; $zero_reason = $zero_meta['reason']; foreach ( $line_items as $item ) { $parts = $this->compute_item_price_parts( $item, false ); $net_total = (float) $this->format_decimal( $parts['net_total'], 2 ); // Tax meta (scheme, category, percentage). $tax_meta = $this->resolve_item_tax_meta( $item ); $percentage = (float) ( $tax_meta['percentage'] ?? 0.0 ); $category = strtoupper( trim( (string) ( $tax_meta['category'] ?? $order_category ) ) ); $scheme = strtoupper( trim( (string) ( $tax_meta['scheme'] ?? $order_scheme ) ) ); if ( '' === $scheme ) { $scheme = 'VAT'; } if ( '' === $category ) { $category = ( 0.0 === $percentage ) ? $zero_category : 'S'; } $reason = strtoupper( trim( (string) ( $tax_meta['reason'] ?? $order_reason ) ) ); if ( '' === $reason || 'NONE' === $reason ) { if ( 0.0 === $percentage && $category === $zero_category && $scheme === $zero_scheme ) { $reason = $zero_reason; } else { $reason = 'NONE'; } } $key = implode( '|', array( $percentage, $category, $reason, $scheme ) ); // Sum tax amounts for the item across all tax rows to get the total tax for this line. $item_tax_rows = $this->get_item_final_tax_rows( $item ); $item_tax = 0.0; foreach ( $item_tax_rows as $tax_amt ) { if ( is_numeric( $tax_amt ) ) { $item_tax += (float) $tax_amt; } } if ( $this->is_credit_note_document() && $this->should_normalize_credit_note_signs() ) { $line_sign = (float) $net_total < 0 ? -1 : 1; $item_tax = abs( $item_tax ) * $line_sign; } if ( ! isset( $groups[ $key ] ) ) { $groups[ $key ] = array( 'total_ex' => 0.0, 'total_tax' => 0.0, 'percentage' => $percentage, 'category' => $category, 'reason' => $reason, 'scheme' => $scheme, 'name' => '', ); } $groups[ $key ]['total_ex'] += $net_total; $groups[ $key ]['total_tax'] += $item_tax; } // Fallback only when there are really no grouped lines at all. if ( empty( $groups ) ) { $lines_net = $this->get_lines_net_total( $order ); $groups[ sprintf( '0|%s|%s|%s', $zero_category, $zero_reason, $zero_scheme ) ] = array( 'total_ex' => $lines_net, 'total_tax' => 0.0, 'percentage' => 0.0, 'category' => $zero_category, 'reason' => $zero_reason, 'scheme' => $zero_scheme, 'name' => '', ); } $groups = $this->ensure_one_zero_tax_group( $groups ); $groups = array_values( $groups ); $groups = apply_filters( 'wpo_ips_edi_order_tax_data', $groups, $this ); if ( is_array( $groups ) && apply_filters( 'wpo_ips_edi_reconcile_tax_groups_to_lines', true, $this->document->order, $this ) ) { $groups = $this->reconcile_tax_groups_to_line_totals( $groups, $this->document->order ); } return array_values( $groups ); } /** * Consolidate existing preferred zero-tax groups without creating new ones. * * @param array $grouped_tax_data Grouped tax data. * @return array Updated grouped tax data. */ protected function ensure_one_zero_tax_group( array $grouped_tax_data ): array { $zero_meta = $this->get_preferred_zero_tax_meta( $this->document->order ); $zero_category = strtoupper( $zero_meta['category'] ); $zero_scheme = strtoupper( $zero_meta['scheme'] ); $zero_reason = strtoupper( $zero_meta['reason'] ); $first_zero_key = null; foreach ( $grouped_tax_data as $key => $group ) { $group_category = strtoupper( (string) ( $group['category'] ?? '' ) ); $group_scheme = strtoupper( (string) ( $group['scheme'] ?? '' ) ); $group_reason = strtoupper( (string) ( $group['reason'] ?? 'NONE' ) ); $group_rate = (float) ( $group['percentage'] ?? 0.0 ); $is_preferred_zero_group = ( 0.0 === $group_rate && $group_category === $zero_category && $group_scheme === $zero_scheme && $group_reason === $zero_reason ); if ( ! $is_preferred_zero_group ) { continue; } if ( null === $first_zero_key ) { $first_zero_key = $key; continue; } $grouped_tax_data[ $first_zero_key ]['total_ex'] += (float) ( $group['total_ex'] ?? 0.0 ); $grouped_tax_data[ $first_zero_key ]['total_tax'] += (float) ( $group['total_tax'] ?? 0.0 ); unset( $grouped_tax_data[ $key ] ); } return $grouped_tax_data; } /** * Get calculated payment totals for an order. * * @param \WC_Abstract_Order $order * @return array */ protected function get_order_payment_totals( \WC_Abstract_Order $order ): array { $grouped_tax_data = $this->get_grouped_order_tax_data(); $total_exc_raw = 0.0; $total_tax_raw = 0.0; foreach ( $grouped_tax_data as $g ) { $ex_base = (float) ( $g['total_ex'] ?? 0 ); $rate = (float) ( $g['percentage'] ?? 0 ); // Sum taxable base $total_exc_raw += $ex_base; // Tax per category = base × rate / 100, rounded as tax. $tax_calc = wc_round_tax_total( $ex_base * $rate / 100 ); $total_tax_raw += $tax_calc; } // Invoice total amount without VAT. $total_exc_tax = (float) $this->format_decimal( $total_exc_raw, 2 ); // Invoice total VAT amount (sum of category tax). $total_tax = (float) $this->format_decimal( $total_tax_raw, 2 ); // Invoice total amount with VAT = total_exc_tax + total_tax. $total_inc_tax = (float) $this->format_decimal( $total_exc_tax + $total_tax, 2 ); // For diagnostics also compute invoice line net sum. $lines_net = (float) $this->get_lines_net_total( $order ); $lines_net_rounded = (float) $this->format_decimal( $lines_net, 2 ); // Log if base of groups and base from lines differ materially. $base_diff = $total_exc_tax - $lines_net_rounded; if ( abs( $base_diff ) >= 0.01 ) { wpo_ips_edi_log( sprintf( 'Base mismatch for order #%d: grouped_ex=%s, lines_net=%s, diff=%s', $order->get_id(), $this->format_decimal( $total_exc_tax ), $this->format_decimal( $lines_net_rounded ), $this->format_decimal( $base_diff ) ), 'warning' ); } // Reconcile to WooCommerce order total. $order_total = (float) $order->get_total(); if ( $this->is_credit_note_document() && $this->should_normalize_credit_note_signs() ) { $document_sign = $total_inc_tax < 0 || ( 0.0 === $total_inc_tax && $lines_net_rounded < 0 ) ? -1 : 1; $order_total = abs( $order_total ) * $document_sign; } $rounding_diff = wc_round_tax_total( $order_total - $total_inc_tax ); // Config / inputs. $has_due_days = ! empty( $this->get_due_date_days() ); $prepaid_amount = (float) apply_filters( 'wpo_ips_edi_prepaid_amount', 0.0, $order, $this ); // Threshold for treating rounding diff as significant. $rounding_is_significant = ( abs( $rounding_diff ) >= 0.01 ); if ( $rounding_is_significant ) { wpo_ips_edi_log( 'Rounding difference detected for order #' . $order->get_id() . ': ' . 'order_total=' . $order_total . ', total_inc_tax=' . $total_inc_tax . ', total_exc_tax=' . $total_exc_tax . ', total_tax=' . $total_tax . ', rounding_diff=' . $rounding_diff, 'warning' ); } // Gross invoice amount including rounding (should equal Woo order total). $gross_total = (float) $this->format_decimal( $total_inc_tax + $rounding_diff, 2 ); // Default rule: // - If there's NO due date, no explicit prepaid set, and the order is paid, treat as fully prepaid. // - Otherwise, use the provided prepaid amount and compute payable normally. $is_paid = method_exists( $order, 'is_paid' ) ? $order->is_paid() : false; $is_paid = (bool) apply_filters( 'wpo_ips_edi_order_is_paid', $is_paid, $order, $this ); if ( $prepaid_amount <= 0.0 && ! $has_due_days && $is_paid ) { // Fully prepaid by default. $prepaid_amount = $gross_total; $payable_amount = 0.0; } else { // Not fully prepaid; customer owes the remainder. $payable_amount = $gross_total - $prepaid_amount; } // Compact totals for return. $totals = compact( 'total_exc_tax', 'total_inc_tax', 'total_tax', 'prepaid_amount', 'rounding_diff', 'payable_amount', 'lines_net' ); return apply_filters( 'wpo_ips_edi_order_payment_totals', $totals, $order, $this ); } /** * Get sum of line net amounts (excl. VAT). * * @param \WC_Abstract_Order $order * @return float */ protected function get_lines_net_total( \WC_Abstract_Order $order ): float { $lines_net = 0.0; $include_coupon = apply_filters( 'wpo_ips_edi_ubl_discount_as_line', false, $this ); $line_items = $order->get_items( array( 'line_item', 'fee', 'shipping' ) ); foreach ( $line_items as $item ) { $parts = $this->compute_item_price_parts( $item, (bool) $include_coupon ); $line_net_total = (float) $this->format_decimal( $parts['net_total'], 2 ); $lines_net += $line_net_total; } // If discounts are rendered as separate lines, include them as negative net amounts. if ( $include_coupon ) { $coupons = $order->get_items( 'coupon' ); foreach ( $coupons as $coupon_item ) { if ( ! is_object( $coupon_item ) || ! method_exists( $coupon_item, 'get_discount' ) ) { continue; } $discount_excl_tax = (float) $coupon_item->get_discount(); $net_total = -1.0 * (float) $this->format_decimal( $discount_excl_tax, 2 ); if ( 0.0 === $net_total ) { continue; } $lines_net += $net_total; } } return (float) $this->format_decimal( $lines_net, 2 ); } /** * Get the number of decimal places needed for XML unit prices. * * Some WooCommerce line totals cannot be reproduced by multiplying a * 2-decimal unit price by the quantity. This can happen when discounts are * distributed across multiple units, for example 187.98 / 4 = 46.995. * * Line totals should remain rounded to 2 decimals, but unit prices may need * higher precision so that PriceAmount, BaseAmount, AllowanceCharge and * LineExtensionAmount remain consistent. * * @param array $parts { * Price parts for the order item. * * @type float|int|string $qty Item quantity. * @type float|int|string $net_total Item net line total. * @type float|int|string $gross_total Item gross line total. * } * @return int */ protected function get_line_price_decimal_places( array $parts ): int { $decimal_places = 2; $qty = isset( $parts['qty'] ) ? abs( (float) $parts['qty'] ) : 0.0; if ( $qty <= 0 ) { return $decimal_places; } if ( 'yes' === get_option( 'woocommerce_tax_round_at_subtotal' ) ) { return 4; } $net_total = isset( $parts['net_total'] ) ? (float) $parts['net_total'] : 0.0; $gross_total = isset( $parts['gross_total'] ) ? (float) $parts['gross_total'] : 0.0; $net_unit_2dp = (float) $this->format_decimal( $net_total / $qty, 2 ); $gross_unit_2dp = (float) $this->format_decimal( $gross_total / $qty, 2 ); $net_total_from_unit_2dp = $this->format_decimal( $net_unit_2dp * $qty, 2 ); $gross_total_from_unit_2dp = $this->format_decimal( $gross_unit_2dp * $qty, 2 ); $net_total_2dp = $this->format_decimal( $net_total, 2 ); $gross_total_2dp = $this->format_decimal( $gross_total, 2 ); if ( $net_total_from_unit_2dp !== $net_total_2dp || $gross_total_from_unit_2dp !== $gross_total_2dp ) { $decimal_places = 4; } return $decimal_places; } /** * Get the tax rows used for item tax classification. * * Product items use the subtotal bucket so the original tax class can still be * detected on fully discounted lines. Other item types use the total bucket. * * @param \WC_Order_Item $item Order item instance. * @return array */ protected function get_item_tax_rows( \WC_Order_Item $item ): array { $type = $item->get_type(); $taxes = $item->get_taxes(); $tax_bucket = ( 'line_item' === $type ) ? 'subtotal' : 'total'; return ( isset( $taxes[ $tax_bucket ] ) && is_array( $taxes[ $tax_bucket ] ) ) ? $taxes[ $tax_bucket ] : array(); } /** * Get the final tax rows for an order item. * * These rows reflect the final tax amounts stored on the line after discounts. * * @param \WC_Order_Item $item Order item instance. * @return array */ protected function get_item_final_tax_rows( \WC_Order_Item $item ): array { $taxes = $item->get_taxes(); return ( isset( $taxes['total'] ) && is_array( $taxes['total'] ) ) ? $taxes['total'] : array(); } /** * Get the preferred zero-tax metadata for the current order context. * * @param \WC_Order_Item|\WC_Abstract_Order|null $context Optional context. * @return array */ protected function get_preferred_zero_tax_meta( $context = null ): array { $zero_meta = $this->get_zero_tax_meta( $context ); $order = $this->document->order; if ( ! wpo_wcpdf_order_is_vat_exempt( $order ) ) { return $zero_meta; } $scheme = strtoupper( trim( (string) wpo_ips_edi_get_tax_data_from_fallback( 'scheme', null, $order ) ) ); $category = strtoupper( trim( (string) wpo_ips_edi_get_tax_data_from_fallback( 'category', null, $order ) ) ); $reason = strtoupper( trim( (string) wpo_ips_edi_get_tax_data_from_fallback( 'reason', null, $order ) ) ); if ( '' === $scheme || 'DEFAULT' === $scheme ) { $scheme = $zero_meta['scheme']; } if ( '' === $category || 'DEFAULT' === $category ) { $category = $zero_meta['category']; } if ( '' === $reason || 'DEFAULT' === $reason ) { $reason = ( $category === $zero_meta['category'] && $scheme === $zero_meta['scheme'] ) ? $zero_meta['reason'] : 'NONE'; } return compact( 'scheme', 'category', 'reason' ); } /** * Determine whether the given tax metadata represents the generic zero-tax classification. * * @param array $tax_meta Tax metadata to inspect. * @param \WC_Order_Item|\WC_Abstract_Order|null $context Optional context. * @return bool */ protected function is_generic_zero_tax_meta( array $tax_meta, $context = null ): bool { $zero_meta = $this->get_zero_tax_meta( $context ); $scheme = strtoupper( trim( (string) ( $tax_meta['scheme'] ?? '' ) ) ); $category = strtoupper( trim( (string) ( $tax_meta['category'] ?? '' ) ) ); $percentage = (float) ( $tax_meta['percentage'] ?? 0 ); return ( 0.0 === $percentage && $scheme === $zero_meta['scheme'] && $category === $zero_meta['category'] ); } /** * Determine whether the resolved tax metadata is invalid for standard-rated VAT. * * @param array $tax_meta Tax metadata. * @return bool */ protected function is_invalid_standard_tax_meta( array $tax_meta ): bool { $category = strtoupper( trim( (string) ( $tax_meta['category'] ?? '' ) ) ); $percentage = (float) ( $tax_meta['percentage'] ?? 0.0 ); return 'S' === $category && $percentage <= 0.0; } /** * Build the list of tax classification candidates for an order item. * * @param \WC_Order_Item $item Order item instance. * @return array[] */ protected function get_item_tax_candidates( \WC_Order_Item $item ): array { $candidates = array(); foreach ( $this->get_item_tax_rows( $item ) as $tax_id => $tax_amt ) { if ( ! is_numeric( $tax_amt ) ) { continue; } $row = $this->document->order_tax_data[ $tax_id ] ?? array(); $candidates[] = array( 'tax_id' => $tax_id, 'scheme' => strtoupper( $row['scheme'] ?? 'VAT' ), 'category' => strtoupper( $row['category'] ?? 'Z' ), 'percentage' => (float) ( $row['percentage'] ?? 0 ), 'reason' => strtoupper( $row['reason'] ?? 'NONE' ), 'tax_amount' => (float) $tax_amt, ); } return $candidates; } /** * Select the final tax classification candidate for an order item. * * @param \WC_Order_Item $item Order item instance. * @param array[] $candidates Candidate tax classifications for the item. * @return array */ protected function select_item_tax_classification( \WC_Order_Item $item, array $candidates ): array { $first_candidate = null; $first_non_generic_zero = null; foreach ( $candidates as $candidate ) { if ( null === $first_candidate ) { $first_candidate = $candidate; } if ( ! $this->is_generic_zero_tax_meta( $candidate, $item ) && null === $first_non_generic_zero ) { $first_non_generic_zero = $candidate; } } if ( null !== $first_non_generic_zero ) { return $first_non_generic_zero; } if ( null !== $first_candidate ) { return $first_candidate; } $zero_meta = $this->get_preferred_zero_tax_meta( $item ); return array( 'scheme' => $zero_meta['scheme'], 'category' => $zero_meta['category'], 'percentage' => 0.0, 'reason' => $zero_meta['reason'], ); } /** * Resolve tax metadata for an order item. * * @param \WC_Order_Item $item Order item instance. * @return array */ protected function resolve_item_tax_meta( \WC_Order_Item $item ): array { $candidates = $this->get_item_tax_candidates( $item ); // If there are no usable tax candidates on the item, fall back to the // configured item tax class before using generic zero-tax handling. if ( empty( $candidates ) ) { return $this->get_item_tax_class_fallback_meta( $item ); } $resolved = $this->select_item_tax_classification( $item, $candidates ); if ( $this->is_generic_zero_tax_meta( $resolved, $item ) ) { $zero_meta = $this->get_preferred_zero_tax_meta( $item ); return array( 'scheme' => $zero_meta['scheme'], 'category' => $zero_meta['category'], 'percentage' => 0.0, 'reason' => $zero_meta['reason'], ); } return array( 'scheme' => strtoupper( trim( (string) ( $resolved['scheme'] ?? 'VAT' ) ) ), 'category' => strtoupper( trim( (string) ( $resolved['category'] ?? 'Z' ) ) ), 'percentage' => (float) ( $resolved['percentage'] ?? 0.0 ), 'reason' => strtoupper( trim( (string) ( $resolved['reason'] ?? 'NONE' ) ) ), ); } /** * Compute line totals, unit prices and unit discount for an item. * * @param \WC_Order_Item $item * @param bool $lock_net_to_subtotal * @return array */ protected function compute_item_price_parts( $item, bool $lock_net_to_subtotal = false ): array { if ( is_a( $item, 'WC_Order_Item_Product' ) ) { $gross_total = (float) $item->get_subtotal(); // ex-VAT, before discounts $net_total = (float) ( $lock_net_to_subtotal ? $item->get_subtotal() : $item->get_total() ); // ex-VAT, after discounts $qty = max( 1, absint( $item->get_quantity() ) ); } else { $gross_total = (float) $item->get_total(); $net_total = (float) $item->get_total(); $qty = 1; } $line_sign = $net_total < 0 ? -1 : 1; if ( $this->is_credit_note_document() && $this->should_normalize_credit_note_signs() ) { $line_sign = $this->get_credit_note_line_sign( $item, $net_total ); $gross_total = abs( $gross_total ) * $line_sign; $net_total = abs( $net_total ) * $line_sign; } $gross_unit = $qty > 0 ? abs( $gross_total ) / $qty : 0.0; $net_unit = $qty > 0 ? abs( $net_total ) / $qty : abs( (float) $item->get_total() ); $unit_discount = max( 0.0, $gross_unit - $net_unit ); return compact( 'gross_total', 'net_total', 'qty', 'gross_unit', 'net_unit', 'unit_discount', 'line_sign' ); } /** * Get order item meta. * * @param \WC_Order_Item $item Order item object. * @param array $args * @return array */ protected function get_item_meta( \WC_Order_Item $item, array $args = array() ): array { $args = wp_parse_args( $args, array( 'include_hidden' => false, 'include_empty' => false, 'only_keys' => array(), 'exclude_keys' => array(), 'use_display_label' => true, 'max_length' => 0, ) ); // Skip meta for certain items (override via filter if needed). $skip_types = apply_filters( 'wpo_ips_edi_skip_item_meta_for_types', array( 'WC_Order_Item_Shipping', 'WC_Order_Item_Fee', 'WC_Order_Item_Tax', 'WC_Order_Item_Coupon' ), $item, $args, $this ); foreach ( (array) $skip_types as $class ) { if ( is_a( $item, $class ) ) { $rows = apply_filters( 'wpo_ips_edi_get_item_meta', array(), $item, $args, $this ); return is_array( $rows ) ? $rows : array(); } } $meta_items = method_exists( $item, 'get_all_formatted_meta_data' ) ? $item->get_all_formatted_meta_data() : $item->get_formatted_meta_data(); // Ensure we can iterate even if something exotic was returned. if ( ! is_iterable( $meta_items ) ) { $meta_items = array(); } $rows = array(); foreach ( $meta_items as $meta_id => $m ) { $raw_key = isset( $m->key ) ? (string) $m->key : ''; $raw_value = isset( $m->value ) ? $m->value : ''; // Hidden meta starts with underscore. if ( ! $args['include_hidden'] && '' !== $raw_key && '_' === substr( $raw_key, 0, 1 ) ) { continue; } // Whitelist / blacklist if ( $args['only_keys'] && ! in_array( $raw_key, (array) $args['only_keys'], true ) ) { continue; } if ( $args['exclude_keys'] && in_array( $raw_key, (array) $args['exclude_keys'], true ) ) { continue; } $label = $args['use_display_label'] && isset( $m->display_key ) && '' !== $m->display_key ? (string) $m->display_key : $raw_key; // Prefer WooCommerce's display_value (already humanized), but force plain text. $value = isset( $m->display_value ) ? $m->display_value : $raw_value; // Flatten arrays/objects deterministically. if ( is_array( $value ) || is_object( $value ) ) { $value = wp_json_encode( $value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); } // Strip tags and normalize whitespace for XML. $value = wp_strip_all_tags( (string) $value, true ); $normalized = preg_replace( '/\s+/u', ' ', $value ); // may return null on invalid UTF-8 $value = is_string( $normalized ) ? $normalized : (string) $value; $value = trim( $value ); // Optional truncation. if ( $args['max_length'] ) { $len = (int) $args['max_length']; $value = function_exists( 'mb_substr' ) ? mb_substr( $value, 0, $len ) : substr( $value, 0, $len ); } if ( ! $args['include_empty'] && '' === $value ) { continue; } $rows[] = array( 'name' => $label, 'value' => $value, ); } $rows = apply_filters( 'wpo_ips_edi_get_item_meta', $rows, $item, $args, $this ); if ( ! is_array( $rows ) ) { $rows = array(); } return $rows; } /** * Resolve fallback tax metadata from the order item's tax class. * * This is used when the item has no usable tax rows to classify from, such as * products with a final price of zero or fully discounted items where WooCommerce * no longer provides meaningful tax row data for export classification. * * @param \WC_Order_Item $item Order item instance. * @return array */ protected function get_item_tax_class_fallback_meta( \WC_Order_Item $item ): array { $order = $this->document->order; if ( wpo_wcpdf_order_is_vat_exempt( $order ) ) { $zero_meta = $this->get_preferred_zero_tax_meta( $item ); return array( 'scheme' => $zero_meta['scheme'], 'category' => $zero_meta['category'], 'percentage' => 0.0, 'reason' => $zero_meta['reason'], ); } // Only product items should fall back to their stored tax class. // Shipping/fee items without usable tax rows should not be forced into S. if ( 'line_item' !== $item->get_type() ) { $zero_meta = $this->get_zero_tax_meta( $item ); return array( 'scheme' => $zero_meta['scheme'], 'category' => $zero_meta['category'], 'percentage' => 0.0, 'reason' => $zero_meta['reason'], ); } $tax_class = is_callable( array( $item, 'get_tax_class' ) ) ? (string) $item->get_tax_class() : ''; if ( '' === $tax_class ) { $tax_class = 'standard'; } $settings = (array) wpo_ips_edi_get_tax_settings(); $class_settings = isset( $settings['class'][ $tax_class ] ) && is_array( $settings['class'][ $tax_class ] ) ? $settings['class'][ $tax_class ] : array(); $standard_settings = isset( $settings['class']['standard'] ) && is_array( $settings['class']['standard'] ) ? $settings['class']['standard'] : array(); $scheme = strtoupper( trim( (string) ( $class_settings['scheme'] ?? '' ) ) ); if ( '' === $scheme || 'DEFAULT' === $scheme ) { $scheme = strtoupper( trim( (string) ( $standard_settings['scheme'] ?? 'VAT' ) ) ); } $category = strtoupper( trim( (string) ( $class_settings['category'] ?? '' ) ) ); if ( '' === $category || 'DEFAULT' === $category ) { $category = strtoupper( trim( (string) ( $standard_settings['category'] ?? 'S' ) ) ); } $reason = strtoupper( trim( (string) ( $class_settings['reason'] ?? '' ) ) ); if ( '' === $reason || 'DEFAULT' === $reason ) { $reason = strtoupper( trim( (string) ( $standard_settings['reason'] ?? 'NONE' ) ) ); } if ( '' === $category ) { $zero_meta = $this->get_zero_tax_meta( $item ); return array( 'scheme' => $zero_meta['scheme'], 'category' => $zero_meta['category'], 'percentage' => 0.0, 'reason' => $zero_meta['reason'], ); } $percentage = 0.0; if ( class_exists( '\WC_Tax' ) && is_callable( array( '\WC_Tax', 'find_rates' ) ) ) { $country = $order->get_shipping_country(); $state = $order->get_shipping_state(); $postcode = $order->get_shipping_postcode(); $city = $order->get_shipping_city(); if ( '' === $country ) { $country = $order->get_billing_country(); $state = $order->get_billing_state(); $postcode = $order->get_billing_postcode(); $city = $order->get_billing_city(); } $rates = \WC_Tax::find_rates( array( 'country' => $country, 'state' => $state, 'postcode' => $postcode, 'city' => $city, 'tax_class' => 'standard' === $tax_class ? '' : $tax_class, ) ); if ( ! empty( $rates ) ) { $first_rate = reset( $rates ); if ( is_array( $first_rate ) && isset( $first_rate['rate'] ) && is_numeric( $first_rate['rate'] ) ) { $percentage = (float) $first_rate['rate']; } } } $fallback = array( 'scheme' => '' !== $scheme ? $scheme : 'VAT', 'category' => $category, 'percentage' => $percentage, 'reason' => '' !== $reason ? $reason : 'NONE', ); // Never return S with a zero or missing percentage. if ( $this->is_invalid_standard_tax_meta( $fallback ) ) { $zero_meta = $this->get_zero_tax_meta( $item ); return array( 'scheme' => $zero_meta['scheme'], 'category' => $zero_meta['category'], 'percentage' => 0.0, 'reason' => $zero_meta['reason'], ); } return $fallback; } /** * Check whether the current EDI document is a credit note. * * @return bool */ protected function is_credit_note_document(): bool { $document_type = is_callable( array( $this->document->order_document, 'get_type' ) ) ? (string) $this->document->order_document->get_type() : ''; $root_element = is_callable( array( $this->document, 'get_root_element' ) ) ? (string) $this->document->get_root_element() : ''; $quantity_role = is_callable( array( $this->document, 'get_quantity_role' ) ) ? (string) $this->document->get_quantity_role() : ''; return ( 'credit-note' === $document_type || 'CreditNote' === $root_element || 'Credited' === $quantity_role ); } /** * Whether credit note signs should be normalized from WooCommerce refund signs * to Peppol CreditNote signs. * * @return bool */ protected function should_normalize_credit_note_signs(): bool { return (bool) apply_filters( 'wpo_ips_edi_normalize_credit_note_signs', true, $this ); } /** * Get the sign that should be used for a credit note line. * * For normal WooCommerce refunds, refund item totals are negative, but Peppol * CreditNote lines should mirror the original invoice line sign. * * @param \WC_Order_Item $item * @param float $fallback_amount * @return int */ protected function get_credit_note_line_sign( \WC_Order_Item $item, float $fallback_amount = 0.0 ): int { $sign = 1; if ( ! $this->is_credit_note_document() || ! $this->should_normalize_credit_note_signs() ) { return $fallback_amount < 0 ? -1 : 1; } $refunded_item_id = is_callable( array( $item, 'get_meta' ) ) ? absint( $item->get_meta( '_refunded_item_id', true ) ) : 0; if ( $refunded_item_id > 0 ) { $parent_order = wpo_ips_edi_get_parent_order( $this->document->order ); if ( $parent_order && is_callable( array( $parent_order, 'get_item' ) ) ) { $original_item = $parent_order->get_item( $refunded_item_id ); if ( $original_item instanceof \WC_Order_Item ) { $original_total = is_callable( array( $original_item, 'get_total' ) ) ? (float) $original_item->get_total() : 0.0; if ( 0.0 !== $original_total ) { $sign = $original_total < 0 ? -1 : 1; } elseif ( is_callable( array( $original_item, 'get_quantity' ) ) ) { $sign = (float) $original_item->get_quantity() < 0 ? -1 : 1; } } } } return (int) apply_filters( 'wpo_ips_edi_credit_note_line_sign', $sign, $item, $this->document->order, $this ); } /** * Reconcile VAT breakdown taxable amounts with the line net total. * * @param array $groups Grouped tax data. * @param \WC_Abstract_Order $order WooCommerce order. * @return array */ protected function reconcile_tax_groups_to_line_totals( array $groups, \WC_Abstract_Order $order ): array { if ( empty( $groups ) ) { return $groups; } $lines_net = (float) $this->format_decimal( $this->get_lines_net_total( $order ), 2 ); $groups_total = 0.0; foreach ( $groups as $group ) { $groups_total += (float) $this->format_decimal( $group['total_ex'] ?? 0, 2 ); } $groups_total = (float) $this->format_decimal( $groups_total, 2 ); $diff = (float) $this->format_decimal( $lines_net - $groups_total, 2 ); if ( abs( $diff ) < 0.01 ) { return $groups; } $target_key = $this->get_tax_group_reconciliation_key( $groups ); if ( null === $target_key ) { return $groups; } $groups[ $target_key ]['total_ex'] = (float) $this->format_decimal( (float) ( $groups[ $target_key ]['total_ex'] ?? 0 ) + $diff, 2 ); wpo_ips_edi_log( sprintf( 'Reconciled tax group taxable amount for order #%d: lines_net=%s, groups_total=%s, diff=%s', $order->get_id(), $this->format_decimal( $lines_net ), $this->format_decimal( $groups_total ), $this->format_decimal( $diff ) ), 'warning' ); return $groups; } /** * Pick the safest tax group to receive a minor taxable amount reconciliation. * * Prefer the configured zero-tax group, otherwise use the group with the * largest taxable amount. * * @param array $groups Grouped tax data. * @return int|string|null */ protected function get_tax_group_reconciliation_key( array $groups ) { $zero_meta = $this->get_preferred_zero_tax_meta( $this->document->order ); foreach ( $groups as $key => $group ) { $percentage = (float) ( $group['percentage'] ?? 0 ); $category = strtoupper( (string) ( $group['category'] ?? '' ) ); $scheme = strtoupper( (string) ( $group['scheme'] ?? 'VAT' ) ); if ( 0.0 === $percentage && $category === strtoupper( $zero_meta['category'] ) && $scheme === strtoupper( $zero_meta['scheme'] ) ) { return $key; } } $target_key = null; $max_amount = null; foreach ( $groups as $key => $group ) { $amount = abs( (float) ( $group['total_ex'] ?? 0 ) ); if ( null === $max_amount || $amount > $max_amount ) { $max_amount = $amount; $target_key = $key; } } return $target_key; } }