transport = $transport ?? new Transport(); } public function testConnection(): AivieResponse { return $this->transport->get('/api/commerce/status'); } public function searchContactByEmail(string $email): AivieResponse { return $this->transport->get('/api/contacts?search=' . rawurlencode($email) . '&minimal=1'); } public function getContact(int $contactId): AivieResponse { return $this->transport->get("/api/contacts/{$contactId}"); } public function searchCampaigns(): AivieResponse { return $this->transport->get('/api/campaigns?limit=100&minimal=1&published=1&orderBy=name&orderByDir=asc'); } public function searchSegments(): AivieResponse { return $this->transport->get( '/api/segments?limit=100&minimal=1&published=1&orderBy=name', ['X-Requested-With' => 'XMLHttpRequest'] ); } public function listOrderItemFields(): AivieResponse { return $this->transport->get('/api/commerce/fields/order-item'); } public function createContact(ContactNew $contact): AivieResponse { $response = $this->transport->post('/api/contacts/new', $this->serialize($contact)); return $this->retryWithoutInvalidChoiceFields( $contact, $response, fn (object $payload): AivieResponse => $this->transport->post('/api/contacts/new', $payload) ); } public function updateContact(int $contactId, ContactNew $contact): AivieResponse { $response = $this->transport->patch("/api/contacts/{$contactId}/edit", $this->serialize($contact)); return $this->retryWithoutInvalidChoiceFields( $contact, $response, fn (object $payload): AivieResponse => $this->transport->patch("/api/contacts/{$contactId}/edit", $payload) ); } public function mergeContacts(int $winnerId, int $loserId): AivieResponse { $payload = (new ContactMergeRequest()) ->setReason('woocommerce_order_identification') ->setForce(false); return $this->transport->post( "/api/contacts/aivie/{$winnerId}/merge/{$loserId}", $this->serialize($payload) ); } public function addContactToCampaign(int $campaignId, int $contactId): AivieResponse { return $this->transport->post( sprintf('/api/campaigns/%d/contact/%d/add', $campaignId, $contactId), (object) [] ); } public function addContactToSegment(int $segmentId, int $contactId): AivieResponse { return $this->transport->post( sprintf('/api/segments/%d/contact/%d/add', $segmentId, $contactId), (object) [] ); } public function createOrder(CreateOrderRequest $order): AivieResponse { return $this->transport->post('/api/commerce/orders/new', $this->serialize($order)); } /** * The by-reference PATCH endpoint upserts from the full order state, so a * complete CreateOrderRequest is accepted alongside a sparse * UpdateOrderRequest diff. The path is authoritative for source and * orderReferenceId; the server ignores them in the body. */ public function updateOrder(string $source, string $orderReferenceId, CreateOrderRequest|UpdateOrderRequest $order): AivieResponse { return $this->transport->patch( sprintf( '/api/commerce/orders/by-reference/%s/%s', rawurlencode($source), rawurlencode($orderReferenceId) ), $this->serialize($order) ); } public function createSubscription(SubscriptionInput|CreateSubscriptionRequest $subscription): AivieResponse { return $this->transport->post('/api/commerce/subscriptions/new', $this->serialize($subscription)); } public function updateSubscription(string $source, string $sourceSubscriptionId, SubscriptionInput|UpdateSubscriptionRequest $subscription): AivieResponse { return $this->transport->patch( sprintf( '/api/commerce/subscriptions/by-reference/%s/%s', rawurlencode($source), rawurlencode($sourceSubscriptionId) ), $this->serialize($subscription) ); } /** * Safety net for contact requests: when Aivie rejects the payload with a * 400 whose validation errors all concern optional choice fields (state, * country — e.g. "state: Die Auswahl ist ungültig."), resend once without * those fields. Losing an optional field is preferable to the contact not * syncing at all, e.g. on instances with customized choice lists. * * @param \Closure(object): AivieResponse $send */ private function retryWithoutInvalidChoiceFields(ContactNew $contact, AivieResponse $response, \Closure $send): AivieResponse { $fields = $this->invalidChoiceFields($response); $fields = array_values(array_filter($fields, static fn (string $field): bool => isset($contact[$field]))); if (empty($fields)) { return $response; } $stripped = clone $contact; foreach ($fields as $field) { // Bypasses the non-nullable setter; the serializer omits nulls. $stripped[$field] = null; } Logger::warning('Contact rejected for invalid choice values; retrying without those fields.', [ 'fields' => $fields, 'message' => $response->getSafeMessage(), ]); return $send($this->serialize($stripped)); } /** * Field names from a 400 validation message such as * "state: Die Auswahl ist ungültig., country: Die Auswahl ist ungültig.", * but only when every failing field is an optional choice field — * otherwise a retry would fail again. * * @return string[] */ private function invalidChoiceFields(AivieResponse $response): array { if (! $response->isValidationError()) { return []; } preg_match_all('/(?:^|[,;.]\s*)([A-Za-z0-9_]+):\s/u', $response->message, $matches); $fields = array_unique($matches[1]); if (empty($fields) || array_diff($fields, self::OPTIONAL_CHOICE_FIELDS)) { return []; } return $fields; } /** * Spec-shaped JSON structure: null properties are omitted unless * explicitly set to null, which the PATCH endpoints rely on. */ private function serialize(object $model): object { return (object) ObjectSerializer::sanitizeForSerialization($model); } }