itemMapper = $itemMapper ?? new OrderItemMapper(); } public function map(object $subscription, ?\WC_Order $contextOrder = null, ?string $contextEventId = null): SubscriptionInput { $subscriptionId = $this->callInt($subscription, 'get_id'); $status = $this->callString($subscription, 'get_status'); $sourceEventAt = $this->resolveSourceEventAt($subscription, $contextOrder); $parentOrder = $contextOrder ?? $this->resolveParentOrder($subscription); $contactId = $parentOrder instanceof \WC_Order ? (new TrackingContactResolver())->read($parentOrder) : 0; $email = $this->resolveEmail($subscription, $parentOrder); // Empty values are not set at all (the previous array payload filtered // them out the same way). $payload = new SubscriptionInput(); $payload->setSource(Options::getString(Options::SOURCE) ?: 'woocommerce'); $payload->setSourceSubscriptionId((string) $subscriptionId); $payload->setExternalEventId($contextEventId ?: $this->buildEventId($subscription, $sourceEventAt)); $payload->setContacts([$this->buildPrimaryContact($contactId, $email, $parentOrder)]); $payload->setStatus($this->mapStatus($subscription, $status)); $payload->setStartedAt($this->resolveDate($subscription, 'start') ?: $sourceEventAt); $payload->setSourceEventAt($sourceEventAt); $payload->setBillingIntervalCount(max(1, $this->callInt($subscription, 'get_billing_interval'))); $payload->setUnitAmount($this->minorUnits( $this->callFloat($subscription, 'get_total'), $this->callString($subscription, 'get_currency') )); $payload->setAttributes($this->buildAttributes($subscription, $parentOrder)); $payload->setSourceSubscriptionUrl($this->buildSourceSubscriptionUrl($subscriptionId)); $items = $this->buildItems($subscription); $payload->setItems($items); $summary = $this->buildSubscriptionSummary($items); if ('' !== $summary) { $payload->setSubscriptionSummary($summary); } $billingInterval = $this->mapBillingPeriod($this->callString($subscription, 'get_billing_period')); if (null !== $billingInterval) { $payload->setBillingInterval($billingInterval); } $currency = strtoupper($this->callString($subscription, 'get_currency')); if ('' !== $currency) { $payload->setCurrency($currency); } $dates = [ 'setTrialEndAt' => 'trial_end', 'setNextPaymentAt' => 'next_payment', 'setSubscriptionEndAt' => 'end', 'setCancelledAt' => 'cancelled', ]; foreach ($dates as $setter => $subscriptionDateKey) { $value = $this->resolveDate($subscription, $subscriptionDateKey); if (null !== $value) { $payload->{$setter}($value); } } return $payload; } private function buildSourceSubscriptionUrl(int $subscriptionId): string { return get_edit_post_link($subscriptionId, 'raw') ?: admin_url('post.php?post=' . $subscriptionId . '&action=edit'); } private function buildPrimaryContact(int $contactId, string $email, ?\WC_Order $parentOrder): CommerceContactInput { $contact = new CommerceContactInput(); $contact->setRole('primary'); if ($contactId > 0) { $contact->setContactId($contactId); return $contact; } if ('' === $email) { throw new \InvalidArgumentException('Subscription payload requires either a tracking contact ID or billing email for contacts[0].'); } $contact->setEmail($email); if ($parentOrder instanceof \WC_Order) { $firstName = trim($parentOrder->get_billing_first_name()); $lastName = trim($parentOrder->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; } public function buildEventId(object $subscription, ?string $sourceEventAt = null): string { $subscriptionId = $this->callInt($subscription, 'get_id'); $status = $this->callString($subscription, 'get_status') ?: 'updated'; $timestamp = $sourceEventAt ?: $this->resolveSourceEventAt($subscription); $timestamp = preg_replace('/[^0-9A-Za-z]+/', '', $timestamp) ?: gmdate('YmdHis'); return "wc_subscription_{$subscriptionId}_{$status}_{$timestamp}"; } public function resolveParentOrder(object $subscription): ?\WC_Order { $parentId = $this->callInt($subscription, 'get_parent_id'); if ($parentId <= 0 || ! function_exists('wc_get_order')) { return null; } $order = wc_get_order($parentId); return $order instanceof \WC_Order ? $order : null; } private function mapStatus(object $subscription, string $status): string { if ('pending' === $status || 'active' === $status) { // 'pending' is not part of the Aivie subscription status enum; // a pending WooCommerce subscription without a future trial is // reported as active (the enum's closest state). return $this->hasFutureTrialEnd($subscription) ? 'trialing' : 'active'; } return match ($status) { 'active' => 'active', 'on-hold' => 'paused', 'pending-cancel' => 'cancelled', 'cancelled' => 'cancelled', 'expired' => 'expired', default => 'active', }; } private function hasFutureTrialEnd(object $subscription): bool { $trialEnd = $this->resolveDate($subscription, 'trial_end'); return null !== $trialEnd && strtotime($trialEnd) > time(); } private function mapBillingPeriod(string $period): ?string { return match ($period) { 'day', 'week', 'month', 'year' => $period, default => null, }; } private function resolveEmail(object $subscription, ?\WC_Order $parentOrder): string { $email = $this->callString($subscription, 'get_billing_email'); if ('' !== $email) { return $email; } return $parentOrder instanceof \WC_Order ? $parentOrder->get_billing_email() : ''; } private function resolveSourceEventAt(object $subscription, ?\WC_Order $contextOrder = null): string { if ($contextOrder instanceof \WC_Order) { $modified = $contextOrder->get_date_modified(); if ($modified instanceof \WC_DateTime) { return $modified->format(\DateTime::ATOM); } } $modified = $this->callObject($subscription, 'get_date_modified'); if ($modified instanceof \WC_DateTime || $modified instanceof \DateTimeInterface) { return $modified->format(\DateTime::ATOM); } return (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format(\DateTime::ATOM); } private function resolveDate(object $subscription, string $dateKey): ?string { if (! method_exists($subscription, 'get_date')) { return null; } $value = $subscription->get_date($dateKey); if ($value instanceof \DateTimeInterface) { return $value->format(\DateTime::ATOM); } if (! is_string($value) || '' === $value || '0' === $value) { return null; } try { return (new \DateTimeImmutable($value, new \DateTimeZone('UTC')))->format(\DateTime::ATOM); } catch (\Exception) { return null; } } private function buildAttributes(object $subscription, ?\WC_Order $parentOrder): array { $attributes = [ 'woocommerce' => [ 'subscription_id' => $this->callInt($subscription, 'get_id'), 'parent_order_id' => $this->callInt($subscription, 'get_parent_id'), 'status' => $this->callString($subscription, 'get_status'), 'payment_method' => $this->callString($subscription, 'get_payment_method'), 'payment_method_title' => $this->callString($subscription, 'get_payment_method_title'), ], ]; if ($parentOrder instanceof \WC_Order) { $attributes['woocommerce']['order_id'] = $parentOrder->get_id(); } $rawMeta = []; if (method_exists($subscription, 'get_meta_data')) { foreach ($subscription->get_meta_data() as $metaData) { $rawMeta[$metaData->key] = $metaData->value; } } $filteredMeta = MetaSanitizer::filterMeta($rawMeta, Options::getBool(Options::INCLUDE_PRIVATE_META)); if (! empty($filteredMeta)) { $attributes['subscription_meta'] = $filteredMeta; } return array_filter($attributes, static fn($value) => [] !== $value && '' !== $value && null !== $value); } /** * @return array */ private function buildItems(object $subscription): array { if (! $subscription instanceof \WC_Order || ! method_exists($subscription, 'get_items')) { return []; } $items = []; $position = 1; foreach ($subscription->get_items() as $item) { if (! $item instanceof \WC_Order_Item_Product) { continue; } $items[] = $this->itemMapper->map($item, $subscription, $position); ++$position; } return $items; } /** * @param array $items */ private function buildSubscriptionSummary(array $items): string { $titles = []; foreach ($items as $item) { $title = trim((string) $item->getTitle()); if ('' !== $title) { $titles[] = $title; } } return implode(', ', array_values(array_unique($titles))); } private function minorUnits(float $value, ?string $currency = null): int { return MoneyAmount::toMinorUnits($value, $currency); } private function callString(object $object, string $method): string { return method_exists($object, $method) ? (string) $object->{$method}() : ''; } private function callInt(object $object, string $method): int { return method_exists($object, $method) ? (int) $object->{$method}() : 0; } private function callFloat(object $object, string $method): float { return method_exists($object, $method) ? (float) $object->{$method}() : 0.0; } private function callObject(object $object, string $method): mixed { return method_exists($object, $method) ? $object->{$method}() : null; } }