fieldMapper = $fieldMapper ?? new OrderItemFieldMapper(); $this->productMapper = $productMapper ?? new ProductMapper(); } public function map(\WC_Order_Item_Product $item, \WC_Order $order, int $position): OrderItem { $product = $item->get_product(); $currency = $order->get_currency(); $quantity = (int) $item->get_quantity(); $rawMeta = $this->collectItemMeta($item); $taxes = $item->get_taxes(); $appliedRateIds = $this->getAppliedTaxRateIds($taxes); $taxClass = $this->getOrderItemTaxClass($item); $lineSubtotal = MoneyAmount::toMinorUnits($item->get_subtotal(), $currency); $lineSubtotalTax = MoneyAmount::toMinorUnits($item->get_subtotal_tax(), $currency); $lineNet = MoneyAmount::toMinorUnits($item->get_total(), $currency); $lineTax = MoneyAmount::toMinorUnits($item->get_total_tax(), $currency); $lineGross = $lineNet + $lineTax; $subtotalGross = $lineSubtotal + $lineSubtotalTax; $unitGross = $quantity > 0 ? (int) round($subtotalGross / $quantity) : 0; $unitNet = $quantity > 0 ? (int) round($lineSubtotal / $quantity) : 0; $discountAmount = max(0, $subtotalGross - $lineGross); $taxLabel = ''; $payload = new OrderItem(); $payload->setPosition($position); $payload->setOrderReferenceId((string) $item->get_id()); $payload->setTitle($item->get_name()); $payload->setQuantity($quantity); $payload->setCurrency($currency); $payload->setUnitAmountGross($unitGross); $payload->setUnitAmountNet($unitNet); $payload->setLineAmountGross($lineGross); $payload->setLineAmountNet($lineNet); $payload->setDiscountAmount($discountAmount); $payload->setTaxAmount($lineTax); $taxRateId = $this->selectTaxRateIdForDisplay($appliedRateIds); if (null !== $taxRateId) { $payload->setTaxRate(number_format(\WC_Tax::get_rate_percent_value($taxRateId), 4, '.', '')); $taxLabel = \WC_Tax::get_rate_label($taxRateId); } $taxTreatment = $this->resolveTaxTreatment($order, $taxClass, $appliedRateIds, $lineTax); $payload->setTaxTreatment($taxTreatment); $payload->setIsNetPriced(! $order->get_prices_include_tax()); if ($product instanceof \WC_Product) { $productId = $product->get_id(); $variationId = ($product instanceof \WC_Product_Variation) ? $product->get_id() : 0; $parentId = ($product instanceof \WC_Product_Variation) ? $product->get_parent_id() : $productId; $payload->setProductCode((string) ($variationId ?: $productId)); $sku = $product->get_sku(); if ('' !== (string) $sku) { $payload->setSku($sku); } $description = $this->buildDescription($product); if ('' !== $description) { $payload->setDescription($description); } $productData = $this->productMapper->map($product, $order); $productData->setTaxTreatment($taxTreatment); $payload->setProductData($productData); $attributes = []; // The raw WooCommerce tax class and label are not part of the API // contract; keep them in the free-form attributes for reference. $tax = array_filter([ 'class' => $taxClass, 'name' => $taxLabel, ], static fn(string $v) => '' !== $v); if (! empty($tax)) { $attributes['tax'] = $tax; } $attributes['woocommerce'] = [ 'item_id' => $item->get_id(), 'product_id' => $parentId, 'variation_id' => $variationId, ]; $attributes['product'] = [ 'permalink' => get_permalink($parentId), 'categories' => $this->getTermNames($parentId, 'product_cat'), 'tags' => $this->getTermNames($parentId, 'product_tag'), ]; // Variation attributes. if ($product instanceof \WC_Product_Variation) { $variationAttrs = $product->get_variation_attributes(); if (! empty($variationAttrs)) { $attributes['variation'] = $variationAttrs; } } // Item meta. if (Options::getBool(Options::INCLUDE_ORDER_ITEM_META)) { $filteredMeta = MetaSanitizer::filterMeta( $rawMeta, Options::getBool(Options::INCLUDE_PRIVATE_META) ); if (! empty($filteredMeta)) { $attributes['item_meta'] = $filteredMeta; } } if (! empty($attributes)) { $payload->setAttributes($attributes); } } else { $payload->setProductCode((string) $item->get_product_id()); } $this->applyMappedFields($payload, $this->fieldMapper->map($rawMeta)); return $payload; } /** * @return array */ private function collectItemMeta(\WC_Order_Item_Product $item): array { $rawMeta = []; foreach ($item->get_meta_data() as $metaData) { if (is_object($metaData) && isset($metaData->key)) { $rawMeta[(string) $metaData->key] = $metaData->value ?? ''; } } return $rawMeta; } /** * @param array{startDate?: string, endDate?: string, metaData?: array} $mapped */ private function applyMappedFields(OrderItem $payload, array $mapped): void { if (isset($mapped['startDate'])) { $payload->setStartDate($mapped['startDate']); } if (isset($mapped['endDate'])) { $payload->setEndDate($mapped['endDate']); } if (! empty($mapped['metaData'])) { $payload->setMetaData($mapped['metaData']); } } private function getOrderItemTaxClass(\WC_Order_Item_Product $item): string { if (! method_exists($item, 'get_tax_class')) { return ''; } return (string) $item->get_tax_class(); } /** * @param array $taxes * @return int[] */ private function getAppliedTaxRateIds(array $taxes): array { $ids = []; foreach (['total', 'subtotal'] as $taxType) { $amounts = $taxes[$taxType] ?? []; if (! is_array($amounts)) { continue; } foreach (array_keys($amounts) as $rateId) { $id = absint($rateId); if ($id > 0) { $ids[] = $id; } } } return array_values(array_unique($ids)); } /** * @param int[] $appliedRateIds */ private function selectTaxRateIdForDisplay(array $appliedRateIds): ?int { if (empty($appliedRateIds)) { return null; } $map = Options::getTaxTreatmentMap(); foreach ($appliedRateIds as $rateId) { $treatment = $map[$rateId] ?? ''; if (TaxTreatments::isValid($treatment)) { return $rateId; } } return $appliedRateIds[0]; } /** * @param int[] $appliedRateIds */ private function resolveTaxTreatment(\WC_Order $order, string $taxClass, array $appliedRateIds, int $lineTax): string { if ('yes' === $order->get_meta('is_vat_exempt')) { return OrderItem::TAX_TREATMENT_EXEMPT; } // Tax rate mapping configured on the settings page (rate ID => treatment). if (! empty($appliedRateIds)) { $map = Options::getTaxTreatmentMap(); foreach ($appliedRateIds as $rateId) { $treatment = $map[$rateId] ?? ''; if (TaxTreatments::isValid($treatment)) { return $treatment; } } if ($lineTax > 0) { // A WooCommerce tax rate was applied and tax was collected, but // the rate has not been mapped yet. return TaxTreatments::DEFAULT; } } return $this->resolveTaxTreatmentFromItemTaxClass($taxClass, $lineTax); } private function resolveTaxTreatmentFromItemTaxClass(string $taxClass, int $lineTax): string { return match ($taxClass) { '' => $lineTax > 0 ? TaxTreatments::DEFAULT : OrderItem::TAX_TREATMENT_UNKNOWN, 'zero-rate' => OrderItem::TAX_TREATMENT_ZERO_RATED, 'reduced-rate' => OrderItem::TAX_TREATMENT_DOMESTIC_REDUCED, default => OrderItem::TAX_TREATMENT_UNKNOWN, }; } private function getTermNames(int $productId, string $taxonomy): array { $terms = get_the_terms($productId, $taxonomy); if (! is_array($terms)) { return []; } return array_map(static fn(\WP_Term $t) => $t->name, $terms); } private function buildDescription(\WC_Product $product): string { $parts = []; $descriptionProduct = $product; if ($product instanceof \WC_Product_Variation) { $parentProduct = $this->getParentProduct($product); if ($parentProduct instanceof \WC_Product) { $descriptionProduct = $parentProduct; } } $shortDesc = $this->cleanText($descriptionProduct->get_short_description()); if ('' !== $shortDesc) { $parts[] = $shortDesc; } if ($product instanceof \WC_Product_Variation) { $variationLines = $this->buildVariationDescriptionLines($product); if (! empty($variationLines)) { $parts = array_merge($parts, $variationLines); } } return implode("\n", $parts); } private function getParentProduct(\WC_Product_Variation $variation): ?\WC_Product { if (! function_exists('wc_get_product')) { return null; } $parent = wc_get_product($variation->get_parent_id()); return $parent instanceof \WC_Product ? $parent : null; } private function buildVariationDescriptionLines(\WC_Product_Variation $variation): array { $lines = []; foreach ($variation->get_variation_attributes() as $attributeName => $attributeValue) { $value = $this->resolveVariationAttributeValue((string) $attributeName, (string) $attributeValue); if ('' === $value) { continue; } $lines[] = sprintf( '%s: %s', $this->resolveVariationAttributeLabel((string) $attributeName, $variation), $value ); } return $lines; } private function resolveVariationAttributeLabel(string $attributeName, \WC_Product $product): string { $taxonomy = preg_replace('/^attribute_/', '', $attributeName); if (function_exists('wc_attribute_label')) { return $this->cleanText((string) wc_attribute_label((string) $taxonomy, $product)); } $label = str_replace(['pa_', 'attribute_'], '', $attributeName); return ucwords(str_replace(['-', '_'], ' ', $label)); } private function resolveVariationAttributeValue(string $attributeName, string $attributeValue): string { if ('' === $attributeValue) { return ''; } $taxonomy = preg_replace('/^attribute_/', '', $attributeName); if ( function_exists('taxonomy_exists') && taxonomy_exists((string) $taxonomy) && function_exists('get_term_by') ) { $term = get_term_by('slug', $attributeValue, (string) $taxonomy); if ($term instanceof \WP_Term) { return $this->cleanText($term->name); } } return $this->cleanText(str_replace(['-', '_'], ' ', $attributeValue)); } private function cleanText(string $value): string { return trim(html_entity_decode(wp_strip_all_tags($value), ENT_QUOTES | ENT_HTML5, 'UTF-8')); } }