803 lines
24 KiB
PHP
803 lines
24 KiB
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 order customer VAT number.
|
||
*
|
||
* @param \WC_Order $order
|
||
* @return string|null
|
||
*/
|
||
protected function get_order_customer_vat_number( \WC_Order $order ): ?string {
|
||
return apply_filters(
|
||
'wpo_ips_edi_order_customer_vat_number',
|
||
wpo_wcpdf_get_order_customer_vat_number( $order ),
|
||
$order
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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';
|
||
}
|
||
|
||
return $general_settings->get_setting( $key, $language ) ?: '';
|
||
}
|
||
|
||
/**
|
||
* 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 ? $order->get_payment_method() : '';
|
||
$title = $order ? $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,
|
||
);
|
||
|
||
switch ( $method_id ) {
|
||
case 'bacs':
|
||
$accounts = get_option( 'woocommerce_bacs_accounts', array() );
|
||
|
||
if ( empty( $accounts ) ) {
|
||
break;
|
||
}
|
||
|
||
$account = apply_filters( 'wpo_ips_edi_payment_means_bacs_default_account', reset( $accounts ), $accounts, $this );
|
||
$data = array_merge( $data, $account );
|
||
break;
|
||
|
||
case 'paypal':
|
||
$data['transaction_id'] = $order->get_transaction_id();
|
||
break;
|
||
|
||
case 'stripe':
|
||
$data['transaction_id'] = $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_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 );
|
||
|
||
// Skip zero-value lines for grouping; Z group will be enforced separately.
|
||
if ( 0.0 === $net_total ) {
|
||
continue;
|
||
}
|
||
|
||
// Tax meta (scheme, category, percentage).
|
||
$tax_meta = $this->resolve_item_tax_meta( $item );
|
||
$percentage = (float) $tax_meta['percentage'];
|
||
$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:
|
||
// - if we have an order-level reason, use it;
|
||
// - otherwise, if this is a zero-tax line, use zero-tax reason;
|
||
// - otherwise, default to NONE.
|
||
$reason = strtoupper( trim( (string) $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 ) );
|
||
|
||
// Item tax = sum of item tax rows (matching Woo's own storage).
|
||
$item_tax_rows = $this->get_item_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 ( ! 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;
|
||
}
|
||
|
||
// No tax lines at all: one zero-tax group with whole net.
|
||
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' => '',
|
||
);
|
||
}
|
||
|
||
// Enforce exactly one zero tax group if needed.
|
||
$groups = $this->ensure_one_zero_tax_group( $groups );
|
||
|
||
// Reindex so callers get a numeric array.
|
||
$groups = array_values( $groups );
|
||
|
||
return apply_filters( 'wpo_ips_edi_order_tax_data', $groups, $this );
|
||
}
|
||
|
||
/**
|
||
* Consolidate and ensure exactly one zero tax group in the grouped tax data.
|
||
*
|
||
* @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_zero_tax_meta( $this->document->order );
|
||
$zero_category = $zero_meta['category'];
|
||
$zero_scheme = $zero_meta['scheme'];
|
||
$zero_reason = $zero_meta['reason'];
|
||
|
||
$z_first_key = null;
|
||
|
||
// Consolidate any existing "zero tax" groups from $grouped_tax_data.
|
||
foreach ( $grouped_tax_data as $key => $g ) {
|
||
if (
|
||
strtoupper( $g['category'] ?? '' ) === strtoupper( $zero_category ) &&
|
||
(float) ( $g['percentage'] ?? 0 ) === 0.0 &&
|
||
strtoupper( $g['scheme'] ?? '' ) === strtoupper( $zero_scheme )
|
||
) {
|
||
if ( is_null( $z_first_key ) ) {
|
||
$z_first_key = $key;
|
||
} else {
|
||
unset( $grouped_tax_data[ $key ] );
|
||
}
|
||
}
|
||
}
|
||
|
||
// Compute the zero-tax taxable basis strictly from lines.
|
||
$z_basis_from_lines = 0.0;
|
||
$has_z_line = false;
|
||
|
||
foreach ( $this->document->order->get_items( array( 'line_item', 'fee', 'shipping' ) ) as $item ) {
|
||
$line_total = (float) $item->get_total();
|
||
$taxes = $item->get_taxes();
|
||
$rows = ( is_array( $taxes['total'] ?? null ) ) ? $taxes['total'] : array();
|
||
|
||
// Does this line have any non-zero tax amount?
|
||
$has_nonzero_row = false;
|
||
foreach ( $rows as $amt ) {
|
||
if ( is_numeric( $amt ) && (float) $amt !== 0.0 ) {
|
||
$has_nonzero_row = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
$line_is_z = false;
|
||
|
||
if ( $has_nonzero_row ) {
|
||
// Classify by the non-zero row's category/rate.
|
||
foreach ( $rows as $tax_id => $amt ) {
|
||
if ( ! is_numeric( $amt ) || (float) $amt === 0.0 ) {
|
||
continue;
|
||
}
|
||
|
||
$info = $this->document->order_tax_data[ $tax_id ] ?? array();
|
||
$cat = strtoupper( $info['category'] ?? '' );
|
||
$rate = (float) ( $info['percentage'] ?? 0 );
|
||
|
||
// zero-tax lines are either explicitly zero-category or 0% rate.
|
||
if ( $cat === strtoupper( $zero_category ) || 0.0 === $rate ) {
|
||
$line_is_z = true;
|
||
break;
|
||
}
|
||
}
|
||
} else {
|
||
// No non-zero tax rows at all → treat as zero-tax (uses zero_category).
|
||
$line_is_z = true;
|
||
}
|
||
|
||
if ( $line_is_z ) {
|
||
$has_z_line = true;
|
||
$z_basis_from_lines += $line_total; // contributes to zero-tax taxable amount
|
||
}
|
||
}
|
||
|
||
// Ensure exactly one zero-tax group if there is any zero-tax line (even with basis 0).
|
||
if ( $has_z_line || $z_first_key ) {
|
||
$z_key = $z_first_key ?: sprintf( '0|%s|%s|%s', $zero_category, $zero_reason, $zero_scheme );
|
||
|
||
$grouped_tax_data[ $z_key ] = array(
|
||
'total_ex' => $this->format_decimal( wc_round_tax_total( $z_basis_from_lines ) ),
|
||
'total_tax' => '0.00',
|
||
'percentage' => '0.0',
|
||
'category' => $zero_category,
|
||
'reason' => $zero_reason,
|
||
'scheme' => $zero_scheme,
|
||
'name' => ( ! is_null( $z_first_key ) && isset( $grouped_tax_data[ $z_first_key ]['name'] ) )
|
||
? $grouped_tax_data[ $z_first_key ]['name']
|
||
: '',
|
||
);
|
||
}
|
||
|
||
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();
|
||
$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 AND no explicit prepaid set, treat as fully prepaid (paid on issue).
|
||
// - Otherwise, use the provided prepaid (or 0) and compute payable normally.
|
||
if ( $prepaid_amount <= 0.0 && ! $has_due_days ) {
|
||
// 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;
|
||
}
|
||
|
||
$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 tax rows bucket for an order item ('subtotal' for products, 'total' otherwise).
|
||
*
|
||
* @param \WC_Order_Item $item
|
||
* @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();
|
||
}
|
||
|
||
/**
|
||
* Resolve tax meta (scheme/category/percentage) for an item by inspecting its first non-zero tax row.
|
||
*
|
||
* @param \WC_Order_Item $item
|
||
* @return array
|
||
*/
|
||
protected function resolve_item_tax_meta( \WC_Order_Item $item ): array {
|
||
$order_tax_data = $this->document->order_tax_data;
|
||
|
||
$scheme = 'VAT';
|
||
$category = null;
|
||
$percent = 0.0;
|
||
$rows = $this->get_item_tax_rows( $item );
|
||
|
||
foreach ( $rows as $tax_id => $tax_amt ) {
|
||
if ( ! is_numeric( $tax_amt ) || (float) $tax_amt == 0.0 ) {
|
||
continue;
|
||
}
|
||
|
||
$row = $order_tax_data[ $tax_id ] ?? array();
|
||
$scheme = strtoupper( $row['scheme'] ?? 'VAT' );
|
||
$category = strtoupper( $row['category'] ?? 'Z' );
|
||
$percent = (float) ( $row['percentage'] ?? 0 );
|
||
break;
|
||
}
|
||
|
||
// Fallback: no non-zero rows -> use zero-tax meta (0%).
|
||
if ( null === $category ) {
|
||
$percent = 0.0;
|
||
$zero_meta = $this->get_zero_tax_meta( $item );
|
||
|
||
$scheme = $zero_meta['scheme'];
|
||
$category = $zero_meta['category'];
|
||
}
|
||
|
||
return array(
|
||
'scheme' => $scheme,
|
||
'category' => $category,
|
||
'percentage' => $percent,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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, (int) $item->get_quantity() );
|
||
} else {
|
||
$gross_total = (float) $item->get_total();
|
||
$net_total = (float) $item->get_total();
|
||
$qty = 1;
|
||
}
|
||
|
||
$gross_unit = $qty > 0 ? $gross_total / $qty : 0.0;
|
||
$net_unit = $qty > 0 ? $net_total / $qty : (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' );
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
}
|