Current File : //var/www/vinorea/modules/klaviyopsautomation/klaviyo-v3-sdk/KlaviyoV3Api.php
<?php

namespace KlaviyoV3Sdk;

if (!defined('_PS_VERSION_')) {
    exit;
}

use KlaviyoV3Sdk\Exception\KlaviyoAuthenticationException;
use KlaviyoV3Sdk\Exception\KlaviyoAuthorizationException;
use KlaviyoV3Sdk\Exception\KlaviyoException;
use KlaviyoV3Sdk\Exception\KlaviyoRateLimitException;
use KlaviyoV3Sdk\Exception\KlaviyoApiException;
use DateTime;
use Exception;
use KlaviyoPsModule;

class KlaviyoV3Api
{
    /**
     * Host and versions
     */
    const BASE_URL = 'https://a.klaviyo.com/';
    const KLAVIYO_V3_REVISION = '2025-04-15';

    /**
     * Request methods
     */
    const HTTP_GET = 'GET';
    const HTTP_POST = 'POST';

    /**
     * Error messages
     */
    const ERROR_INVALID_API_KEY = 'Invalid API Key.';
    const ERROR_NON_200_STATUS = 'Request Failed with HTTP Status Code: %s';
    const ERROR_API_CALL_FAILED = 'Request could be completed at this time, API call failed';
    const ERROR_MALFORMED_RESPONSE_BODY = 'Response from API could not be decoded from JSON, check response body';

    /**
     * Request options
     */
    const ACCEPT_KEY_HEADER = 'accept';
    const REVISION_KEY_HEADER = 'revision';
    const AUTHORIZATION_KEY_HEADER = 'Authorization';
    const KLAVIYO_API_KEY = 'Klaviyo-API-Key';
    const PROPERTIES = 'properties';
    const KLAVIYO_USER_AGENT_KEY = 'X-Klaviyo-User-Agent';
    const APPLICATION_JSON_HEADER_VALUE = 'application/json';
    const CONTENT_TYPE_HEADER = 'Content-type';

    /**
     * Payload options
     */
    const CUSTOMER_PROPERTIES_MAP = ['$email' => 'email', 'firstname' => 'first_name', 'lastname' => 'last_name'];
    const DATA_KEY_PAYLOAD = 'data';
    const LINKS_KEY_PAYLOAD = 'links';
    const NEXT_KEY_PAYLOAD = 'next';
    const TYPE_KEY_PAYLOAD = 'type';
    const EMAIL_KEY_PAYLOAD = 'email';
    const ATTRIBUTE_KEY_PAYLOAD = 'attributes';
    const PROPERTIES_KEY_PAYLOAD = 'properties';
    const TIME_KEY_PAYLOAD = 'time';
    const VALUE_KEY_PAYLOAD = 'value';
    const VALUE_KEY_PAYLOAD_OLD = '$value';
    const METRIC_KEY_PAYLOAD = 'metric';
    const PROFILE_KEY_PAYLOAD = 'profile';
    const NAME_KEY_PAYLOAD = 'name';
    const EVENT_VALUE_PAYLOAD = 'event';
    const ID_KEY_PAYLOAD = 'id';
    const PROFILE_SUBSCRIPTION_BULK_CREATE_JOB_PAYLOAD_KEY = 'profile-subscription-bulk-create-job';
    const LIST_PAYLOAD_KEY = 'list';
    const RELATIONSHIPS_PAYLOAD_KEY = 'relationships';
    const PROFILE_PAYLOAD_KEY = 'profile';
    const PROFILES_PAYLOAD_KEY = 'profiles';
    const CUSTOM_SOURCE_PAYLOAD_KEY = 'custom_source';
    const PRESTASHOP_PAYLOAD_VALUE = 'Prestashop';
    const SERVICE_PAYLOAD_KEY = 'service';
    const PRESTASHOP_SERVICE_KEY = 'prestashop';
    const TOP_LEVEL_PROFILE_ATTRIBUTE_KEYS = ["first_name", "last_name", "organization","title","image"];

    /**
     * Back in Stock Subscription
     */
    const BIS_SUBSCRIPTION_PAYLOAD_KEY = 'back-in-stock-subscription';
    const VARIANT_PAYLOAD_KEY = 'variant';
    const CATALOG_VARIANT_PAYLOAD_KEY = 'catalog-variant';
    const CHANNELS_PAYLOAD_KEY = 'channels';
    const EMAIL_CHANNEL_PAYLOAD_VALUE = 'EMAIL';

    /**
     * @var string
     */
    protected $private_key;

    /**
     * @var string
     */
    protected $public_key;

    /**
     * Constructor method for Base class.
     *
     * @param string $public_key Public key (account ID) for Klaviyo account
     * @param string $private_key Private API key for Klaviyo account
     */
    public function __construct($public_key, $private_key)
    {
        $this->public_key = $public_key;
        $this->private_key = $private_key;
    }

    /**
     *  Build headers for the all Klaviyo API calls
     * @param $revision
     * @return array
     */
    public function getHeaders($revision)
    {
        $klaviyops = KlaviyoPsModule::getInstance();

        $headers = array(
            CURLOPT_HTTPHEADER => [
                self::REVISION_KEY_HEADER . ': ' . $revision,
                self::CONTENT_TYPE_HEADER . ': ' . self::APPLICATION_JSON_HEADER_VALUE,
                self::ACCEPT_KEY_HEADER . ': ' . self::APPLICATION_JSON_HEADER_VALUE,
                self::KLAVIYO_USER_AGENT_KEY . ': ' . 'prestashop-klaviyo/' . $klaviyops->version . ' ' . self::PRESTASHOP_PAYLOAD_VALUE . '/' . _PS_VERSION_ . ' PHP/' . phpversion(),
                self::AUTHORIZATION_KEY_HEADER . ': ' . self::KLAVIYO_API_KEY . ' ' . $this->private_key
            ]
        );

        return $headers;
    }


    /**
     * Query for all available lists in Klaviyo
     * https://developers.klaviyo.com/en/v2023-08-15/reference/get_lists
     *
     * @return array
     * @throws KlaviyoApiException
     * @throws KlaviyoAuthenticationException
     * @throws KlaviyoRateLimitException
     */
    public function getLists()
    {
        $response = $this->requestV3('api/lists/', self::HTTP_GET);
        $lists = $response[self::DATA_KEY_PAYLOAD];

        $next = $response[self::LINKS_KEY_PAYLOAD][self::NEXT_KEY_PAYLOAD];
        while ($next) {
            $next_qs = explode("?", $next)[1];
            $response = $this->requestV3("api/lists/?$next_qs", self::HTTP_GET);
            array_push($lists, ...$response[self::DATA_KEY_PAYLOAD]);

            $next = $response[self::LINKS_KEY_PAYLOAD][self::NEXT_KEY_PAYLOAD];
        }

        return $lists;
    }

    /**
     * Record an event for a customer on their Klaviyo profile
     * https://developers.klaviyo.com/en/v2023-08-15/reference/create_event
     *
     * @param $config
     * @return array
     * @throws KlaviyoApiException
     * @throws KlaviyoAuthenticationException
     * @throws KlaviyoRateLimitException
     */
    public function createEvent($config)
    {
        $event_time = new DateTime();
        $event_time->setTimestamp($config['time'] ?? time());

        $body = array(
            self::DATA_KEY_PAYLOAD => array(
                self::TYPE_KEY_PAYLOAD => self::EVENT_VALUE_PAYLOAD,
                self::ATTRIBUTE_KEY_PAYLOAD =>
                    $this->buildEventProperties($config['properties'], $event_time->format('Y-m-d\TH:i:sP'), $config['event']) +
                    $this->buildCustomerProperties($config['customer_properties'])
            )
        );

        return $this->requestV3('/api/events/', self::HTTP_POST, $body);
    }

    /**
     * Subscribe members to a Klaviyo list
     * https://developers.klaviyo.com/en/reference/subscribe_profiles
     *
     * @param $listId
     * @param $profiles
     * @return array
     * @throws KlaviyoApiException
     * @throws KlaviyoAuthenticationException
     * @throws KlaviyoRateLimitException
     */
    public function subscribeMembersToList($listId, $profiles)
    {
        $body = array(
            self::DATA_KEY_PAYLOAD => array(
                self::TYPE_KEY_PAYLOAD => self::PROFILE_SUBSCRIPTION_BULK_CREATE_JOB_PAYLOAD_KEY,
                self::ATTRIBUTE_KEY_PAYLOAD => array(
                    self::CUSTOM_SOURCE_PAYLOAD_KEY => self::PRESTASHOP_PAYLOAD_VALUE,
                    self::PROFILES_PAYLOAD_KEY => array(
                        self::DATA_KEY_PAYLOAD => $profiles
                    )
                ),
                self::RELATIONSHIPS_PAYLOAD_KEY => array(
                    self::LIST_PAYLOAD_KEY => array(
                        self::DATA_KEY_PAYLOAD => array(
                            self::TYPE_KEY_PAYLOAD => self::LIST_PAYLOAD_KEY,
                            self::ID_KEY_PAYLOAD => $listId
                        )
                    )
                )
            )
        );

        return $this->requestV3('/api/profile-subscription-bulk-create-jobs/', self::HTTP_POST, $body);
    }

    /**
     * Update customer properties
     * https://developers.klaviyo.com/en/reference/create_or_update_profile
     *
     * @param $email
     * @param $customProperties
     * @return array
     * @throws KlaviyoApiException
     * @throws KlaviyoAuthenticationException
     * @throws KlaviyoRateLimitException
     */
    public function updateProfileCustomProperties($email, $customProperties)
    {
        $attributes = [
            self::EMAIL_KEY_PAYLOAD => $email
        ];

        # some properties must live on the top-level
        foreach ($customProperties as $key => $val) {
            if (in_array($key,self::TOP_LEVEL_PROFILE_ATTRIBUTE_KEYS)) {
                $attributes[$key] = $val;
                unset($customProperties[$key]);
            }
        }

        if (!empty($customProperties)) {
            $attributes[self::PROPERTIES] = $customProperties;
        }

        $body = array(
            self::DATA_KEY_PAYLOAD => array(
                self::TYPE_KEY_PAYLOAD => self::PROFILE_PAYLOAD_KEY,
                self::ATTRIBUTE_KEY_PAYLOAD => $attributes
            )
        );

        return $this->requestV3('/api/profile-import/', self::HTTP_POST, $body, 0);
    }

    /**
     * Create a back in stock subscription event
     * https://developers.klaviyo.com/en/reference/create_back_in_stock_subscription
     *
     * @param $email
     * @param $catalogVariantId
     * @return array|string|null
     */
    public function createBackInStockSubscription($email, $catalogVariantId)
    {
        $body = array(
            self::DATA_KEY_PAYLOAD => array(
                self::TYPE_KEY_PAYLOAD => self::BIS_SUBSCRIPTION_PAYLOAD_KEY,
                self::ATTRIBUTE_KEY_PAYLOAD => array(
                    self::CHANNELS_PAYLOAD_KEY => array(
                        self::EMAIL_CHANNEL_PAYLOAD_VALUE,
                    ),
                    self::PROFILE_PAYLOAD_KEY => array(
                        self::DATA_KEY_PAYLOAD => array(
                            self::TYPE_KEY_PAYLOAD => self::PROFILE_PAYLOAD_KEY,
                            self::ATTRIBUTE_KEY_PAYLOAD => array(
                                self::EMAIL_KEY_PAYLOAD => $email,
                            ),
                        ),
                    ),
                ),
                self::RELATIONSHIPS_PAYLOAD_KEY => array(
                    self::VARIANT_PAYLOAD_KEY => array(
                        self::DATA_KEY_PAYLOAD => array(
                            self::TYPE_KEY_PAYLOAD => self::CATALOG_VARIANT_PAYLOAD_KEY,
                            self::ID_KEY_PAYLOAD => $catalogVariantId
                        ),
                    ),
                ),
            ),
        );

        return $this->requestV3('/api/back-in-stock-subscriptions/', self::HTTP_POST, $body, 0);
    }

    /**
     *  Request method used by all API methods to make calls
     *
     * @param $path
     * @param $method
     * @param $body
     * @param $attempt
     * @param $revision
     * @return array|string|null
     * @throws KlaviyoApiException
     * @throws KlaviyoAuthenticationException
     * @throws KlaviyoRateLimitException
     */
    protected function requestV3($path, $method, $body = null, $attempt = 0, $revision = self::KLAVIYO_V3_REVISION)
    {
        $curl = curl_init();
        $options = array(
                CURLOPT_URL => self::BASE_URL . $path,
            ) + $this->getHeaders($revision) + $this->getDefaultCurlOptions($method);

        curl_setopt_array($curl, $options);

        if ($body !== null) {
            curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($body));
        }

        $response = curl_exec($curl);
        $phpVersionHttpCode = version_compare(phpversion(), '5.5.0', '>') ? CURLINFO_RESPONSE_CODE : CURLINFO_HTTP_CODE;
        $statusCode = curl_getinfo($curl, $phpVersionHttpCode);

        // In the event that the curl_exec fails for whatever reason, it responds with `false`,
        // Implementing a timeout and retry mechanism which will attempt the API call 3 times at 5 second intervals
        if (!$response && ($statusCode < 200 || $statusCode >= 300)){
            if($attempt < 3) {
                sleep(1);
                $this->requestV3($path, $method, $body, $attempt+1);
            } else {
                throw new KlaviyoApiException(self::ERROR_API_CALL_FAILED);
            }
        }

        $phpVersionHttpCode = version_compare(phpversion(), '5.5.0', '>') ? CURLINFO_RESPONSE_CODE : CURLINFO_HTTP_CODE;
        $statusCode = curl_getinfo($curl, $phpVersionHttpCode);
        curl_close($curl);

        return $this->handleAPIResponse($response, $statusCode);
    }

    /**
     * Build customer properties for the api/events endpoint
     *
     * @param $customerProperties
     * @return \array[][]
     */
    public function buildCustomerProperties($customerProperties): array
    {
        $kl_properties = [];

        foreach(array_keys(self::CUSTOMER_PROPERTIES_MAP) as $property_name){
            if (isset($customerProperties[$property_name])) {
                $kl_properties[self::CUSTOMER_PROPERTIES_MAP[$property_name]] = $customerProperties[$property_name];
                unset($customerProperties[$property_name]);
            }
        }

        if (!empty($customerProperties)) {
            $kl_properties[self::PROPERTIES] = $customerProperties;
        }

        return array(
            self::PROFILE_KEY_PAYLOAD => array(
                self::DATA_KEY_PAYLOAD => array(
                    self::TYPE_KEY_PAYLOAD => self::PROFILE_KEY_PAYLOAD,
                    self::ATTRIBUTE_KEY_PAYLOAD => $kl_properties,
                )
            )
        );
    }

    /**
     * Build Event Properties for the api/events endpoint
     *
     * @param $eventProperties
     * @param $time
     * @param $metric
     * @return array
     */
    public function buildEventProperties($eventProperties, $time, $metric): array
    {
        $result = array(
            self::PROPERTIES_KEY_PAYLOAD => $eventProperties,
            self::TIME_KEY_PAYLOAD => $time,
            self::METRIC_KEY_PAYLOAD => array(
                self::DATA_KEY_PAYLOAD => array(
                    self::TYPE_KEY_PAYLOAD => self::METRIC_KEY_PAYLOAD,
                    self::ATTRIBUTE_KEY_PAYLOAD => array(
                        self::NAME_KEY_PAYLOAD => $metric,
                        self::SERVICE_PAYLOAD_KEY => self::PRESTASHOP_SERVICE_KEY
                    )
                )
            )
        );

        if (isset($eventProperties[self::VALUE_KEY_PAYLOAD_OLD])) {
            $result[self::VALUE_KEY_PAYLOAD] = $eventProperties[self::VALUE_KEY_PAYLOAD_OLD];
        }

        return $result;
    }

    /**
     * Get base options array for curl request.
     *
     * @return array
     */
    #[\ReturnTypeWillChange]
    protected function getDefaultCurlOptions($method = self::HTTP_POST)
    {
        return array(
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_ENCODING => "",
            CURLOPT_MAXREDIRS => 10,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_CUSTOMREQUEST => $method,
        );
    }

    /**
     * Handle the API response and return the parsed data.
     *
     * @param string $response The raw API response.
     * @param int $statusCode The HTTP status code of the response.
     * @return array| string |null An array containing the parsed data or null on error.
     * @throws KlaviyoApiException
     * @throws KlaviyoAuthenticationException
     * @throws KlaviyoRateLimitException
     */
    protected function handleAPIResponse($response, $statusCode)
    {
        $decoded_response = $this->decodeJsonResponse($response);
        if ($statusCode == 401) {
            throw new KlaviyoAuthorizationException(self::ERROR_INVALID_API_KEY, $statusCode);
        } elseif ($statusCode == 403) {
            throw new KlaviyoAuthenticationException(self::ERROR_INVALID_API_KEY, $statusCode);
        } elseif ($statusCode == 429) {
            throw new KlaviyoRateLimitException(
                'Rate Limit exceeded'
            );
        } elseif ($statusCode < 200 || $statusCode >= 300) {
            throw new KlaviyoApiException(isset($decoded_response['detail']) ? $decoded_response['detail'] : sprintf(self::ERROR_NON_200_STATUS, $statusCode), $statusCode);
        }

        return $decoded_response;
    }

    /**
     * Return decoded JSON response as associative or empty array.
     * Certain Klaviyo endpoints (such as Delete) return an empty string on success
     * and so PHP versions >= 7 will throw a JSON_ERROR_SYNTAX when trying to decode it
     *
     * @param string $response
     * @return mixed
     * @throws KlaviyoException
     */
    private function decodeJsonResponse($response)
    {
        if (!empty($response)) {
            try {
                return json_decode($response, true);
            } catch (Exception $e) {
                throw new KlaviyoException(self::ERROR_MALFORMED_RESPONSE_BODY);
            }
        }

        return json_decode('{}', true);
    }
}