itemMapper = $itemMapper ?? new OrderItemMapper(); $this->subscriptionMapper = $subscriptionMapper ?? new SubscriptionMapper(); } public function map( \WC_Order $order, ?string $occurredAtOverride = null, ?string $externalEventIdOverride = null ): CreateOrderRequest { $status = $order->get_status(); $orderId = $order->get_id(); $currency = $order->get_currency(); $amountGross = MoneyAmount::toMinorUnits($order->get_total(), $currency); $taxAmount = MoneyAmount::toMinorUnits($order->get_total_tax(), $currency); $amountNet = $amountGross - $taxAmount; $shippingAmount = MoneyAmount::toMinorUnits($order->get_shipping_total(), $currency); $occurredAt = $occurredAtOverride ?? $this->resolveOccurredAt($order); $contactId = absint($order->get_meta('_aivie_contact_id')); $eventId = $externalEventIdOverride ?? $this->buildEventId($order); $payload = new CreateOrderRequest(); $payload->setSource(Options::getString(Options::SOURCE) ?: 'woocommerce'); $payload->setSourceType(Options::getString(Options::SOURCE_TYPE) ?: 'shop'); $payload->setExternalEventId($eventId); $payload->setOrderReferenceId((string) $orderId); $payload->setType($this->mapOrderType($status)); $payload->setContacts([$this->buildPrimaryContact($order, $contactId)]); $payload->setAmountGross($amountGross); $payload->setAmountNet($amountNet); $payload->setTaxAmount($taxAmount); // WooCommerce's discount total aggregates line-item discounts, which // are already mapped on each OrderItem. The API order-level field is // reserved for additional discounts not represented by the items. $payload->setDiscountAmount(0); $payload->setShippingAmount($shippingAmount); $payload->setRoundingAmount(0); $payload->setCurrency($currency); $payload->setOccurredAt($occurredAt); $dateCreated = $order->get_date_created(); if ($dateCreated instanceof \WC_DateTime) { $payload->setOrderPlacedAt($dateCreated->format(\DateTime::ATOM)); } $datePaid = $order->get_date_paid(); if ($datePaid instanceof \WC_DateTime) { $payload->setPaidAt($datePaid->format(\DateTime::ATOM)); } $methodId = $order->get_payment_method(); $methodTitle = $order->get_payment_method_title(); if ('' !== $methodId || '' !== $methodTitle) { $payload->setPaymentType($this->normalizePaymentType($methodId, $methodTitle)); } $orderNumber = $order->get_order_number(); if ('' !== (string) $orderNumber) { $payload->setOrderReference((string) $orderNumber); } $payload->setLanguageCode($this->resolveLanguageCode($order)); $payload->setOrderSummary($this->buildOrderSummary($order)); $payload->setAttributes($this->buildAttributes($order, $contactId)); $payload->setSourceOrderUrl($this->buildSourceOrderUrl($order)); $payload->setItems($this->buildItems($order)); $subscription = $this->resolveSubscriptionForOrder($order); if (null !== $subscription) { $payload->setSubscription($this->subscriptionMapper->map($subscription, $order, $eventId . '::subscription')); } return $payload; } private function buildPrimaryContact(\WC_Order $order, int $contactId): CommerceContactInput { $contact = new CommerceContactInput(); $contact->setRole('primary'); if ($contactId > 0) { $contact->setContactId($contactId); return $contact; } $email = $order->get_billing_email(); if ('' === $email) { throw new \InvalidArgumentException('Order payload requires either a tracking contact ID or billing email for contacts[0].'); } $contact->setEmail($email); $firstName = trim($order->get_billing_first_name()); $lastName = trim($order->get_billing_last_name()); if ('' !== $firstName || '' !== $lastName) { $contactData = new ContactNew(); if ('' !== $firstName) { $contactData->setFirstname($firstName); } if ('' !== $lastName) { $contactData->setLastname($lastName); } $contact->setContactData($contactData); } return $contact; } /** * Build a webhook event ID that is unique per emitted order state. * * The order's modified timestamp is appended so that each genuine change * produces a distinct externalEventId. Without it, every sync of the same * order would reuse the same key and be dropped by the Aivie dedup guard * (findBySourceAndEventId) — even on the PATCH upsert endpoint — so updates * would silently never apply. Example: wc_order_30327_pending_1748352187. */ public function buildEventId(\WC_Order $order): string { $orderId = $order->get_id(); $status = $order->get_status(); $timestamp = $this->resolveEventTimestamp($order); return "wc_order_{$orderId}_{$status}_{$timestamp}"; } private function resolveEventTimestamp(\WC_Order $order): int { $modified = $order->get_date_modified(); if ($modified instanceof \WC_DateTime) { return $modified->getTimestamp(); } $created = $order->get_date_created(); if ($created instanceof \WC_DateTime) { return $created->getTimestamp(); } return time(); } public function resolveOccurredAt(\WC_Order $order): string { $dateModified = $order->get_date_modified(); if ($dateModified instanceof \WC_DateTime) { return $dateModified->format(\DateTime::ATOM); } $dateCreated = $order->get_date_created(); if ($dateCreated instanceof \WC_DateTime) { return $dateCreated->format(\DateTime::ATOM); } return (new \DateTime('now', new \DateTimeZone('UTC')))->format(\DateTime::ATOM); } private function resolveLanguageCode(\WC_Order $order): string { $customerId = (int) $order->get_customer_id(); if ($customerId > 0) { $locale = get_user_locale($customerId); if ('' !== $locale) { return $locale; } } if (defined('ICL_LANGUAGE_CODE')) { $wpmlLang = $order->get_meta('wpml_language'); if ('' !== $wpmlLang) { return (string) $wpmlLang; } } return get_locale(); } private function mapOrderType(string $status): string { return 'checkout-draft' === $status ? 'draft' : $status; } private function normalizePaymentType(string $methodId, string $methodTitle): string { static $idMap = [ // WooCommerce built-ins 'bacs' => 'bank_transfer', 'cheque' => 'other', 'cod' => 'cash', 'paypal' => 'paypal', // Stripe 'stripe' => 'card', 'stripe_afterpay_clearpay' => 'invoice', 'stripe_alipay' => 'other', 'stripe_bancontact' => 'card', 'stripe_boleto' => 'bank_transfer', 'stripe_eps' => 'bank_transfer', 'stripe_fpx' => 'bank_transfer', 'stripe_giropay' => 'bank_transfer', 'stripe_grabpay' => 'other', 'stripe_ideal' => 'bank_transfer', 'stripe_klarna' => 'invoice', 'stripe_multibanco' => 'bank_transfer', 'stripe_oxxo' => 'cash', 'stripe_p24' => 'bank_transfer', 'stripe_sepa' => 'direct_debit', 'stripe_sepa_debit' => 'direct_debit', 'stripe_sofort' => 'bank_transfer', 'stripe_sofortbanking' => 'bank_transfer', 'wc_stripe_sepa_debit' => 'direct_debit', // PayPal 'ppcp-gateway' => 'paypal', 'ppcp_gateway' => 'paypal', 'ppec_paypal' => 'paypal', 'paypal_express' => 'paypal', 'paypal_pro' => 'paypal', // Braintree 'braintree_credit_card' => 'card', 'braintree_paypal' => 'paypal', // Mollie 'mollie_wc_gateway_applepay' => 'card', 'mollie_wc_gateway_creditcard' => 'card', 'mollie_wc_gateway_banktransfer' => 'bank_transfer', 'mollie_wc_gateway_directdebit' => 'direct_debit', 'mollie_wc_gateway_ideal' => 'bank_transfer', 'mollie_wc_gateway_sofort' => 'bank_transfer', 'mollie_wc_gateway_paypal' => 'paypal', 'mollie_wc_gateway_voucher' => 'voucher', // Klarna 'klarna_payments' => 'invoice', 'klarna_checkout' => 'invoice', 'kco' => 'invoice', // Square 'square_credit_card' => 'card', // PostFinance 'postfinance_card' => 'card', 'postfinance_efinance' => 'bank_transfer', // Amazon Pay 'amazonpay' => 'other', 'amazon_payments_advanced' => 'other', 'woocommerce_gateway_amazon_pay' => 'other', // TWINT 'twint' => 'other', // Buy-now-pay-later 'afterpay' => 'invoice', 'afterpay_gateway' => 'invoice', 'clearpay' => 'invoice', 'laybuy' => 'invoice', 'sezzle' => 'invoice', // Crypto 'cryptapi' => 'crypto', 'nowpayments' => 'crypto', 'coinbase_commerce' => 'crypto', 'blockonomics' => 'crypto', ]; if ('' !== $methodId && isset($idMap[$methodId])) { return $idMap[$methodId]; } // Pattern matching on the gateway ID for unrecognised but identifiable families. $lowerId = strtolower($methodId); if ('' !== $lowerId) { if (str_contains($lowerId, 'sepa') || str_contains($lowerId, 'directdebit') || str_contains($lowerId, 'direct_debit')) { return 'direct_debit'; } if (str_contains($lowerId, 'paypal') || str_contains($lowerId, 'ppcp') || str_contains($lowerId, 'ppec')) { return 'paypal'; } if (str_contains($lowerId, 'crypto') || str_contains($lowerId, 'bitcoin') || str_contains($lowerId, 'coinbase')) { return 'crypto'; } if (str_contains($lowerId, 'klarna') || str_contains($lowerId, 'afterpay') || str_contains($lowerId, 'invoice') || str_contains($lowerId, 'rechnung')) { return 'invoice'; } if (str_contains($lowerId, 'voucher') || str_contains($lowerId, 'gutschein')) { return 'voucher'; } if (str_contains($lowerId, 'banktransfer') || str_contains($lowerId, 'bank_transfer') || str_contains($lowerId, 'wire_transfer')) { return 'bank_transfer'; } } // Keyword matching on the human-readable title as a final heuristic. $lowerTitle = strtolower($methodTitle); if ('' !== $lowerTitle) { if (preg_match('/\b(visa|mastercard|maestro|amex|american express|credit.?card|debit.?card|kreditkarte|ec.?karte|postfinance.?card|card)\b/', $lowerTitle)) { return 'card'; } if (preg_match('/\b(sepa|lastschrift|direct.?debit)\b/', $lowerTitle)) { return 'direct_debit'; } if (preg_match('/\b(bank|überweisung|wire.?transfer|bank.?transfer|virement)\b/', $lowerTitle)) { return 'bank_transfer'; } if (preg_match('/\bpaypal\b/', $lowerTitle)) { return 'paypal'; } if (preg_match('/\b(rechnung|invoice|klarna|afterpay|clearpay|buy now pay later|bnpl)\b/', $lowerTitle)) { return 'invoice'; } if (preg_match('/\b(barzahlung|cash on delivery|nachnahme|cash)\b/', $lowerTitle)) { return 'cash'; } if (preg_match('/\b(gutschein|voucher|gift.?card)\b/', $lowerTitle)) { return 'voucher'; } if (preg_match('/\b(crypto|bitcoin|ethereum|btc|eth)\b/', $lowerTitle)) { return 'crypto'; } } return 'unknown'; } private function buildOrderSummary(\WC_Order $order): string { $names = []; foreach ($order->get_items() as $item) { if ($item instanceof \WC_Order_Item_Product) { $names[] = $item->get_name(); } } if (empty($names)) { return ''; } $max = 512; $summary = implode(', ', $names); if (mb_strlen($summary) <= $max) { return $summary; } // Truncate with "+ N more" suffix. $shown = []; $current = ''; $count = count($names); foreach ($names as $i => $name) { $remaining = $count - $i - 1; $suffix = $remaining > 0 ? " + {$remaining} more" : ''; $candidate = (empty($shown) ? '' : ', ') . $name . $suffix; $prospective = implode(', ', $shown) . $candidate; if (mb_strlen($prospective) <= $max) { $shown[] = $name; } else { $remaining = $count - count($shown); if ($remaining > 0) { return implode(', ', $shown) . " + {$remaining} more"; } break; } } return mb_substr(implode(', ', $shown), 0, $max); } private function buildAttributes(\WC_Order $order, int $contactId): array { $tracking = $order->get_meta('_aivie_wc_tracking_contact_id'); $woocommerceAttrs = [ 'order_id' => $order->get_id(), 'order_key' => $order->get_order_key(), 'order_number' => $order->get_order_number(), 'status' => $order->get_status(), 'created_via' => $order->get_created_via(), 'customer_id' => (int) $order->get_customer_id(), 'payment_method' => $order->get_payment_method(), 'payment_method_title' => $order->get_payment_method_title(), 'transaction_id' => $order->get_transaction_id(), 'prices_include_tax' => $order->get_prices_include_tax(), 'cart_hash' => $order->get_cart_hash(), ]; if ($contactId > 0 || '' !== (string) $tracking) { $woocommerceAttrs['tracking_contact_id'] = $contactId > 0 ? $contactId : (int) $tracking; } $attributes = [ 'woocommerce' => $woocommerceAttrs, 'billing' => [ 'email' => $order->get_billing_email(), 'phone' => $order->get_billing_phone(), 'country' => $order->get_billing_country(), 'city' => $order->get_billing_city(), 'postcode' => $order->get_billing_postcode(), ], 'shipping' => [ 'country' => $order->get_shipping_country(), 'city' => $order->get_shipping_city(), 'postcode' => $order->get_shipping_postcode(), ], ]; $paymentFailureReason = StripePaymentFailureRecorder::sanitizeReason( (string) $order->get_meta(StripePaymentFailureRecorder::META_REASON) ); if ('' !== $paymentFailureReason) { $attributes['last_payment_failure'] = [ 'provider' => 'stripe', 'code' => StripePaymentFailureRecorder::classifyReason($paymentFailureReason), 'reason' => $paymentFailureReason, ]; } // Order meta. $rawMeta = []; foreach ($order->get_meta_data() as $metaData) { $rawMeta[$metaData->key] = $metaData->value; } $filteredMeta = MetaSanitizer::filterMeta( $rawMeta, Options::getBool(Options::INCLUDE_PRIVATE_META) ); if (! empty($filteredMeta)) { $attributes['order_meta'] = $filteredMeta; } return $attributes; } private function buildItems(\WC_Order $order): array { $items = []; $position = 1; foreach ($order->get_items() as $item) { if ($item instanceof \WC_Order_Item_Product) { $items[] = $this->itemMapper->map($item, $order, $position); $position++; } } return $items; } private function buildSourceOrderUrl(\WC_Order $order): string { $orderId = $order->get_id(); if (Compatibility::isHposEnabled()) { return admin_url('admin.php?page=wc-orders&action=edit&id=' . $orderId); } return get_edit_post_link($orderId, 'raw') ?: admin_url('post.php?post=' . $orderId . '&action=edit'); } private function resolveSubscriptionForOrder(\WC_Order $order): ?object { if (! function_exists('wcs_order_contains_subscription') || ! function_exists('wcs_get_subscriptions_for_order')) { return null; } $orderId = $order->get_id(); if (! wcs_order_contains_subscription($orderId, ['parent', 'renewal', 'resubscribe', 'switch'])) { return null; } $subscriptions = wcs_get_subscriptions_for_order($orderId, ['order_type' => 'any']); if (! is_array($subscriptions) || empty($subscriptions)) { return null; } ksort($subscriptions); $subscription = reset($subscriptions); return is_object($subscription) ? $subscription : null; } }