| | |
| | | |
| | | namespace JVBase\integrations\services; |
| | | use Exception; |
| | | use JVBase\integrations\Admin; |
| | | use JVBase\integrations\APIEndpoint; |
| | | use JVBase\integrations\Instance; |
| | | use JVBase\integrations\Integrations; |
| | | use JVBase\integrations\OAuth; |
| | | use JVBase\integrations\OAuthCredentials; |
| | | use JVBase\integrations\OAuthURLs; |
| | | use JVBase\integrations\Requests; |
| | | use JVBase\integrations\SyncDefaults; |
| | | use JVBase\integrations\SyncFrom; |
| | | use JVBase\integrations\SyncTo; |
| | | use JVBase\integrations\Webhooks; |
| | | use JVBase\managers\queue\executors\IntegrationExecutor; |
| | | use JVBase\managers\queue\TypeConfig; |
| | | use JVBase\meta\Form; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\meta\Sanitizer; |
| | | use JVBase\registrar\Registrar; |
| | | use JVBase\ui\Checkout; |
| | | use JVBase\ui\CRUDSkeleton; |
| | | use WP_Error; |
| | | use WP_Post; |
| | | use WP_Query; |
| | | use WP_User; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Square Integration Class |
| | | * |
| | | * Handles OAuth 2.0 authentication and API interactions with Square |
| | | * Uses the latest Square API version with OAuth code flow |
| | | * |
| | | * @since 1.0.0 |
| | | */ |
| | | class Square extends Integrations |
| | | { |
| | | protected static string $syncCustomer = 'square_sync_customer'; |
| | | protected array $allowedContent = [ |
| | | 'REGULAR', |
| | | 'FOOD_AND_BEV', |
| | | 'APPOINTMENTS_SERVICE', |
| | | 'DIGITAL', |
| | | 'EVENT', |
| | | 'DONATION' |
| | | ]; |
| | | /** |
| | | * Square API Configuration |
| | | */ |
| | | protected string $service_name = 'square'; |
| | | protected array|string $apiBase = [ |
| | | 'production' => 'https://connect.squareup.com', |
| | | 'sandbox' => 'https://connect.squareupsandbox.com' |
| | | ]; |
| | | |
| | | protected string $apiVersion = '2025-09-24'; |
| | | |
| | | protected string $environment = 'sandbox'; |
| | | |
| | | private const PASSWORD_RESET_INTERVAL = 3; // Reset password every 3 logins, for customers |
| | | |
| | | /** |
| | | * OAuth Configuration |
| | | */ |
| | | protected bool $isOAuthService = true; |
| | | use Admin, |
| | | Instance, |
| | | Requests, |
| | | OAuth, |
| | | SyncFrom, |
| | | SyncTo, |
| | | SyncDefaults, |
| | | Webhooks; |
| | | |
| | | protected string $orderPostType = '_square_order'; |
| | | protected array $newOrder = []; |
| | | protected array $oauth = [ |
| | | 'authorize' => '', |
| | | 'token' => '', |
| | | 'revoke' => '', |
| | | 'scopes' => [ |
| | | 'MERCHANT_PROFILE_READ', |
| | | 'MERCHANT_PROFILE_WRITE', |
| | | 'PAYMENTS_WRITE', |
| | | 'PAYMENTS_READ', |
| | | 'CUSTOMERS_WRITE', |
| | | 'CUSTOMERS_READ', |
| | | 'INVENTORY_READ', |
| | | 'INVENTORY_WRITE', |
| | | 'ITEMS_READ', |
| | | 'ITEMS_WRITE', |
| | | 'ORDERS_READ', |
| | | 'ORDERS_WRITE' |
| | | ] |
| | | protected string $apiVersion = '2026-07-15'; |
| | | public bool $hasBatchCreate = true; |
| | | public bool $hasBatchUpdate = true; |
| | | public bool $hasBatchDelete = true; |
| | | public array $apiEndpoints = [ |
| | | |
| | | ]; |
| | | |
| | | /** |
| | | * Square-specific properties |
| | | */ |
| | | protected string $merchantId = ''; |
| | | protected string $locationId = ''; |
| | | protected array $locations = []; |
| | | |
| | | protected static Square $instance; |
| | | |
| | | public static function getInstance():self |
| | | protected function __construct() |
| | | { |
| | | if (!isset(self::$instance)) { |
| | | self::$instance = new self(); |
| | | } |
| | | return self::$instance; |
| | | } |
| | | |
| | | protected function __construct(?int $userID = null) |
| | | { |
| | | |
| | | // Display properties |
| | | $this->title = 'Square'; |
| | | $this->icon = 'square-logo'; |
| | | $this->supportsWebp = false; |
| | | |
| | | $this->refresh_interval = 7 * DAY_IN_SECONDS; |
| | | |
| | | $this->newOrder = [ |
| | | 'post_type' => $this->orderPostType, |
| | | 'post_status' => 'PROPOSED', |
| | | $this->canSync = [ |
| | | 'create' => true, |
| | | 'update' => true, |
| | | 'delete' => true, |
| | | ]; |
| | | |
| | | // Define credential fields |
| | | $this->fields = [ |
| | | 'environment' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Environment', |
| | | 'options' => [ |
| | | 'sandbox' => 'Sandbox', |
| | | 'production'=> 'Production', |
| | | ], |
| | | 'default' => 'sandbox', |
| | | ], |
| | | 'client_id' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Application ID', |
| | | 'hint' => 'Found in Square Developer Dashboard', |
| | | 'required' => true, |
| | | ], |
| | | 'client_secret' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'label' => 'Application Secret', |
| | | 'hint' => 'Found in OAuth section of Square Developer Dashboard', |
| | | 'required' => true, |
| | | ], |
| | | 'location_id' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Default Location', |
| | | 'options' => [], |
| | | 'hint' => 'Select default Square location for transactions', |
| | | ] |
| | | ]; |
| | | $this->defineFields(); |
| | | $this->defineInstructions(); |
| | | $this->defineOAuth(); |
| | | |
| | | // Advanced settings |
| | | $this->advanced = [ |
| | | 'webhook_signature_key' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'label' => 'Webhook Signature Key', |
| | | 'hint' => 'Used to verify webhook authenticity', |
| | | ], |
| | | 'access_token' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'label' => 'Access Token', |
| | | 'hint' => 'Generated automatically after OAuth authorization', |
| | | 'readonly' => true, |
| | | ], |
| | | 'refresh_token' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'label' => 'Refresh Token', |
| | | 'hint' => 'Used to refresh expired access tokens', |
| | | 'readonly' => true, |
| | | ], |
| | | 'merchant_id' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Merchant ID', |
| | | 'hint' => 'Square Merchant ID (obtained after authorization)', |
| | | 'readonly' => true, |
| | | ], |
| | | ]; |
| | | $this->baseUrl = $this->getBaseURL(); |
| | | |
| | | parent::__construct(); |
| | | |
| | | $this->defineActions(); |
| | | } |
| | | |
| | | protected function defineFields():void |
| | | { |
| | | $env = $this->addField('environment', 'Environment','select','manage_options'); |
| | | $env->setOptions([ |
| | | 'sandbox' => 'Sandbox', |
| | | 'production'=> 'Production' |
| | | ]); |
| | | $env->setDefault('sandbox'); |
| | | $id = $this->addField('client_id', 'Application ID', 'text', 'manage_options'); |
| | | $id->setRequired(); |
| | | $id->setHint('Found in Square Developer Dashboard'); |
| | | |
| | | $sec = $this->addField('client_secret', 'Application Secret', 'text', 'manage_options'); |
| | | $sec->setSubType('password'); |
| | | $sec->setRequired(); |
| | | $sec->setHint('Found in OAuth section of Square Developer Dashboard'); |
| | | |
| | | //Locations are set from the first response from the integration |
| | | $locs = $this->addField('locations', 'Locations', 'select','manage_options'); |
| | | $locs->setHidden(); |
| | | $locs->setOptions($this->getLocationOptions()); |
| | | |
| | | $loc = $this->addField('location_id', 'Default Location', 'select', 'manage_options'); |
| | | $loc->setOptions($this->loadCredentials()['locations']); |
| | | $loc->setHint('Select default Square location for transactions'); |
| | | } |
| | | |
| | | protected function defineInstructions():void |
| | | { |
| | | $this->instructions = [ |
| | | 'Go to <a href="https://developer.squareup.com/apps" target="_blank">Square Developer Dashboard</a>', |
| | | 'Create a new application or select an existing one', |
| | |
| | | 'Copy the Application ID and Application Secret', |
| | | 'Click "Authorize Connection" below to complete OAuth flow' |
| | | ]; |
| | | } |
| | | |
| | | |
| | | $this->canSync = [ |
| | | 'create' => true, |
| | | 'update' => true, |
| | | 'delete' => true |
| | | ]; |
| | | $this->supportsWebp = false; |
| | | $this->hasWebhooks = true; |
| | | |
| | | parent::__construct(); |
| | | |
| | | // Add Square-specific actions |
| | | $this->actions = array_merge( |
| | | $this->actions, |
| | | [ |
| | | 'select_location' => 'handleSelectLocation', |
| | | 'fetch_locations' => 'handleFetchLocations', |
| | | 'verify_webhook' => 'handleVerifyWebhook', |
| | | 'test_payment' => 'handleTestPayment' |
| | | ] |
| | | protected function defineActions():void |
| | | { |
| | | $this->addAction( |
| | | 'Select Location', |
| | | [$this, 'handleSelectLocation'], |
| | | 'select_location', |
| | | 'building-office', |
| | | ); |
| | | |
| | | $this->buttons = array_merge( |
| | | $this->buttons, |
| | | [ |
| | | 'import_from_square' => 'Import Catalog from Square', |
| | | 'sync_to_square' => 'Sync Site to Square', |
| | | ] |
| | | $this->addAction( |
| | | 'Fetch Locations', |
| | | [$this, 'getLocations'], |
| | | 'fetch_locations', |
| | | 'building-office', |
| | | ); |
| | | |
| | | $this->addAction( |
| | | 'Import From Square', |
| | | [$this, 'handleImportFromSquare'], |
| | | 'import_from_square', |
| | | 'download-simple' |
| | | ); |
| | | |
| | | $this->addAction( |
| | | 'Sync to Square', |
| | | [$this, 'handleSyncToSquare'], |
| | | 'sync_to_square', |
| | | 'export', |
| | | ); |
| | | |
| | | // $this->addAction( |
| | | // 'Verify Webhook', |
| | | // [$this, 'handleVerifyWebhook'], |
| | | // 'verify_webhook', |
| | | // ); |
| | | } |
| | | |
| | | protected function defineOAuth():void |
| | | { |
| | | $this->baseOAuthUrl = $this->getBaseURL(); |
| | | $base = $this->getBaseURL(); |
| | | |
| | | $this->oauth = new OAuthURLs( |
| | | sprintf('%s/oauth2/authorize', $base), |
| | | sprintf('%s/oauth2/token', $base), |
| | | sprintf('%s/oauth2/revoke', $base) |
| | | ); |
| | | } |
| | | |
| | | protected function registerAdditionalHooks():void |
| | | { |
| | | add_action('init', [$this, 'registerSquarePostTypes'], 5); |
| | | add_action('init', [$this, 'addDashboardPages'], 10); |
| | | add_action(BASE.'dashboard_page_orders', [$this, 'renderDashPage']); |
| | | } |
| | | |
| | | /** |
| | | * Initialize the integration with loaded credentials |
| | | */ |
| | | protected function initialize(): void |
| | | { |
| | | if (empty($this->credentials)) { |
| | | $this->loadCredentials(); |
| | | $this->defineEndpoints(); |
| | | } |
| | | protected function defineEndpoints():void |
| | | { |
| | | $this->defineCustomerEndpoints(); |
| | | $this->defineProductEndpoints(); |
| | | $this->defineOrderEndpoints(); |
| | | $this->definePaymentEndpoints(); |
| | | } |
| | | protected function defineCustomerEndpoints():void |
| | | { |
| | | $customer = new APIEndpoint(); |
| | | $customer->setCreate('/v2/customers'); |
| | | $customer->setBatchCreate('/v2/customers/bulk-create'); |
| | | $customer->setDelete('/v2/customers/');//add the item_id to the end |
| | | $customer->setBatchDelete('/v2/customers/bulk-delete'); |
| | | $customer->setUpdate('/v2/customers/');//add the item_id to the end |
| | | $customer->setBatchUpdate('/v2/customers/bulk-update'); |
| | | $customer->setSearch('/v2/customers/search'); |
| | | $customer->setImport('/v2/customers'); |
| | | $this->apiEndpoints['customer'] = $customer; |
| | | } |
| | | protected function defineProductEndpoints():void |
| | | { |
| | | $product = new APIEndpoint(); |
| | | $product->setSearch('/v2/catalog/search-catalog-items'); |
| | | $product->setImport('/v2/catalog/list'); |
| | | $product->setImage('/v2/catalog/images'); |
| | | $product->setCreate('/v2/catalog/object'); |
| | | $product->setBatchCreate('/v2/catalog/batch-upsert'); |
| | | $product->setUpdate('/v2/catalog/object'); |
| | | $product->setBatchUpdate('/v2/catalog/batch-upsert'); |
| | | $product->setDelete('/v2/catalog/object/');//add the item_id to the end |
| | | $product->setBatchDelete('/v2/catalog/batch-delete'); |
| | | $this->apiEndpoints['product'] = $product; |
| | | } |
| | | |
| | | protected function defineOrderEndpoints():void |
| | | { |
| | | $order = new APIEndpoint(); |
| | | $order->setCreate('/v2/orders'); |
| | | $order->setSearch('/v2/orders/search'); |
| | | $order->setUpdate('/v2/orders/'); //add order_id |
| | | $this->apiEndpoints['order'] = $order; |
| | | } |
| | | protected function definePaymentEndpoints():void |
| | | { |
| | | $payment = new APIEndpoint(); |
| | | $payment->setCreate('/v2/payments'); |
| | | |
| | | $this->apiEndpoints['payment'] = $payment; |
| | | } |
| | | |
| | | protected function refreshAccessToken(OAuthCredentials $credentials): array |
| | | { |
| | | if (!$this->getClientID() || !$this->getClientSecret()) |
| | | $response = wp_remote_post( |
| | | $this->oauth->getToken(), |
| | | [ |
| | | 'body' => [ |
| | | 'client_id' => $this->getClientID(), |
| | | 'client_secret' => $this->getClientSecret(), |
| | | 'refresh_token' => $credentials->refresh_token, |
| | | 'grant_type' => 'refresh_token' |
| | | ], |
| | | 'headers' => $this->getOAuthRequestHeaders() |
| | | ] |
| | | ); |
| | | if (is_wp_error($response)) { |
| | | $this->logError( |
| | | 'refreshAccessToken', |
| | | 'Failed to refresh Square token', |
| | | [ |
| | | 'error' => $response->get_error_message() |
| | | ] |
| | | ); |
| | | return $this->response(false, 'Could not refresh token'); |
| | | } |
| | | |
| | | $this->merchantId = $this->credentials['merchant_id'] ?? ''; |
| | | $this->locationId = $this->credentials['location_id'] ?? ''; |
| | | $this->environment = $this->credentials['environment'] ?? 'sandbox'; |
| | | $data = json_decode(wp_remote_retrieve_body($response)); |
| | | if (isset($data['access_token'])) { |
| | | $credentials = [ |
| | | 'access_token' => $data['access_token'], |
| | | 'expires_at' => time() + ($data['expires_in'] ?? 2592000) // 30 days default |
| | | ]; |
| | | |
| | | // Set OAuth URLs based on environment |
| | | $baseUrl = ($this->environment === 'production') |
| | | ? 'https://connect.squareup.com' |
| | | : 'https://connect.squareupsandbox.com'; |
| | | |
| | | $this->oauth['authorize'] = $baseUrl . '/oauth2/authorize'; |
| | | $this->oauth['token'] = $baseUrl . '/oauth2/token'; |
| | | $this->oauth['revoke'] = $baseUrl . '/oauth2/revoke'; |
| | | |
| | | // Set up API endpoints with version |
| | | $this->apiEndpoints = [ |
| | | 'locations' => '/v2/locations', |
| | | 'customers' => '/v2/customers', |
| | | 'catalog' => '/v2/catalog', |
| | | 'payments' => '/v2/payments', |
| | | 'orders' => '/v2/orders', |
| | | 'inventory' => '/v2/inventory' |
| | | ]; |
| | | |
| | | $this->saveCredentials($credentials); |
| | | return $this->response(true, 'Successfully refreshed access token'); |
| | | } |
| | | return $this->response(false, 'Something went wrong that wasn\'t caught - could not refresh access token'); |
| | | } |
| | | |
| | | public function getOrderFields():array |
| | | protected function getRequestHeaders(): array |
| | | { |
| | | return [ |
| | | 'Authorization' => 'Bearer ' . ($this->getAccessToken()), |
| | | 'Square-Version' => $this->apiVersion, |
| | | 'Content-Type' => 'application/json' |
| | | ]; |
| | | } |
| | | |
| | | protected function handleResponse(\WP_Error|array $response, string $method, string $endpoint): array |
| | | { |
| | | if (is_wp_error($response) && $response->get_error_message() === 'rate_limit_exceeded') { |
| | | return $this->response(false, 'Rate limit exceeded'); |
| | | } |
| | | $endpoint = str_replace($this->getBaseURL(), '', $endpoint); |
| | | switch ($endpoint) { |
| | | case '/v2/customers/search': |
| | | return $this->handleEmailSearchResponse($response, $method); |
| | | case '/v2/customers': |
| | | return $this->handleCreateUserResponse($response, $method); |
| | | case '/v2/locations': |
| | | return $this->handleGetLocationResponse($response, $method); |
| | | } |
| | | return $this->response(false, 'Unhandled Response for '.$endpoint.' endpoint, with method: '.$method); |
| | | } |
| | | |
| | | protected function setContentTypes(): void |
| | | { |
| | | $base = $this->getBaseFields(); |
| | | $this->contentTypes = [ |
| | | 'customer' => $this->getCustomerFields(), |
| | | 'REGULAR' => $base, |
| | | 'FOOD_AND_BEV' => array_merge($base, $this->setFoodAndBevFields()), |
| | | 'APPOINTMENTS_SERVICE' => array_merge($base, $this->setAppointmentServiceFields()), |
| | | 'EVENT' => array_merge($base, $this->setEventFields()), |
| | | 'GIFT_CARD' => array_merge($base, $this->setGiftCardFields()), |
| | | ]; |
| | | } |
| | | protected function getCustomerFields():array |
| | | { |
| | | return [ |
| | | 'address' => [ |
| | | 'type' => 'group', |
| | | 'fields' => [ |
| | | 'address_line_1' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Address Line 1', |
| | | 'hint' => 'ex: 6551 111 St NW', |
| | | 'required' => true, |
| | | 'section' => 'address' |
| | | ], |
| | | 'address_line_2' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Address Line 2', |
| | | 'hint' => 'ex: Unit 2', |
| | | 'section' => 'address' |
| | | ], |
| | | 'locality' => [ |
| | | 'type' => 'text', |
| | | 'label'=> 'City', |
| | | 'section' => 'address', |
| | | 'required' => true, |
| | | ], |
| | | 'administrative_district_level_1' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Province', |
| | | 'hint' => 'The two-character code, example: AB', |
| | | 'default'=> 'AB', |
| | | 'section' => 'address', |
| | | 'required' => true, |
| | | ], |
| | | 'postal_code' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Postal Code', |
| | | 'section'=> 'address' |
| | | ], |
| | | 'country' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Country Code', |
| | | 'hint' => 'The tw-character country code, example: CA', |
| | | 'default' => 'CA', |
| | | 'section' => 'address', |
| | | 'required' => true, |
| | | ], |
| | | 'first_name' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'First Name (if different)', |
| | | 'hint' => 'Optional field to save the address to someone that isn\'t you' |
| | | ], |
| | | 'last_name' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Last Name (if different)', |
| | | 'hint' => 'Optional field to save the address to someone that isn\'t you' |
| | | ] |
| | | ] |
| | | ] |
| | | ]; |
| | | } |
| | | protected function getBaseFields():array |
| | | { |
| | | return [ |
| | | 'price' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Price', |
| | | 'step' => 0.01, |
| | | 'max' => 99999, |
| | | 'description' => 'Price for this variation' |
| | | ], |
| | | 'sku' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'SKU', |
| | | 'description' => 'Stock keeping unit' |
| | | ], |
| | | 'cart_quantity' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Quantity', |
| | | 'hidden' => true, |
| | | ], |
| | | 'available_for_pickup' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available for Pick Up', |
| | | 'section'=> 'square-advanced' |
| | | ], |
| | | 'available_online' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available online', |
| | | 'section'=> 'square-advanced' |
| | | ], |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product (sizes, colors, etc.)', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->setVariationFields() |
| | | ], |
| | | ]; |
| | | } |
| | | protected function setVariationFields(?string $type = null):array |
| | | { |
| | | $fields = [ |
| | | 'name' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Variation Name', |
| | | 'description' => 'e.g., "Small", "Large", "Red", etc.' |
| | | ], |
| | | 'sku' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'SKU', |
| | | 'description' => 'Stock keeping unit' |
| | | ], |
| | | 'price' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Price', |
| | | 'step' => 0.01, |
| | | 'max' => 99999, |
| | | 'description' => 'Price for this variation' |
| | | ], |
| | | 'track_inventory' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Track Inventory', |
| | | ], |
| | | 'item_id' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Square Variation ID', |
| | | 'description' => 'Square catalog ID for this variation', |
| | | 'hidden' => true |
| | | ], |
| | | 'last_sync' => [ |
| | | 'type' => 'datetime', |
| | | 'label' => 'Last Sync', |
| | | 'hidden' => true |
| | | ] |
| | | ]; |
| | | |
| | | switch ($type) { |
| | | case 'FOOD_AND_BEV': |
| | | $fields['ingredients'] = [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Ingredients List', |
| | | 'description' => 'Separate ingredients with commas', |
| | | ]; |
| | | $fields['preparation_time_duration'] = [ |
| | | 'type' => 'number', |
| | | 'label' => 'Preparation time (in minutes)', |
| | | ]; |
| | | break; |
| | | case 'GIFT_CARD': |
| | | $fields['gift_card_type'] = [ |
| | | 'type' => 'select', |
| | | 'label' => 'Gift Card Type', |
| | | 'options' => [ |
| | | 'PHYSICAL' => 'Physical', |
| | | 'DIGITAL' => 'Digital', |
| | | ], |
| | | 'default' => 'DIGITAL', |
| | | ]; |
| | | break; |
| | | case 'APPOINTMENTS_SERVICE': |
| | | $fields['service_duration'] = [ |
| | | 'type' => 'number', |
| | | 'label' => 'Duration of Service in Minutes' |
| | | ]; |
| | | $fields['available_for_booking'] = [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available for Booking' |
| | | ]; |
| | | break; |
| | | } |
| | | return array_combine( |
| | | array_map(fn($k) => '_square_' . $k, array_keys($fields)), |
| | | $fields |
| | | ); |
| | | } |
| | | protected function setFoodAndBevFields():array |
| | | { |
| | | return [ |
| | | 'ingredients' => [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Ingredients', |
| | | 'hint' => 'A comma separated list of ingredients' |
| | | ], |
| | | 'preparation_time_duration' => [ |
| | | 'label' => 'Preparation Time', |
| | | 'type' => 'number', |
| | | 'hint' => 'Preparation time in minutes' |
| | | ], |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product (sizes, colors, etc.)', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->setVariationFields('FOOD_AND_BEV') |
| | | ], |
| | | ]; |
| | | /* TODO: Set definitions for: |
| | | 'dietary_preferences' => [ |
| | | |
| | | ], |
| | | 'calories_text' => [ |
| | | |
| | | ], |
| | | */ |
| | | } |
| | | protected function setAppointmentServiceFields():array |
| | | { |
| | | return [ |
| | | |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product (sizes, colors, etc.)', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->setVariationFields('APPOINTMENTS_SERVICE') |
| | | ], |
| | | ]; |
| | | } |
| | | |
| | | protected function setEventFields():array |
| | | { |
| | | return [ |
| | | 'venue_details' => [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Venue Details', |
| | | ], |
| | | 'capacity' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Capacity' |
| | | ], |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product (sizes, colors, etc.)', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->setVariationFields('EVENT') |
| | | ], |
| | | ]; |
| | | } |
| | | protected function setGiftCardFields():array |
| | | { |
| | | return [ |
| | | // 'allowed_locations' => [ |
| | | // |
| | | // ], |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product (sizes, colors, etc.)', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->setVariationFields('GIFT_CARD') |
| | | ], |
| | | ]; |
| | | } |
| | | |
| | | protected function formatForService(int $itemID, string $type = 'post'): array |
| | | { |
| | | return match ($type) { |
| | | 'post' => $this->formatPostItem($itemID), |
| | | 'user' => $this->formatUserItem($itemID), |
| | | 'term' => $this->formatTermItem($itemID), |
| | | default => [] |
| | | }; |
| | | } |
| | | |
| | | public function handleEmailSearch(string $email): string|false |
| | | { |
| | | try { |
| | | $response = $this->postRequest( |
| | | sprintf('%s/v2/customers/search', $this->getBaseURL()), |
| | | [ |
| | | 'query' => [ |
| | | 'filter' => [ |
| | | 'email_address' => [ |
| | | 'exact' => $email |
| | | ] |
| | | ] |
| | | ] |
| | | ] |
| | | ); |
| | | return $response['data']; |
| | | } catch (Exception $e) { |
| | | return false; |
| | | } |
| | | } |
| | | protected function handleEmailSearchResponse(WP_Error|array $response, string $method):array |
| | | { |
| | | $data = empty($response['customers']) |
| | | ? false |
| | | : $response['customers'][0]??false; |
| | | return $this->response(true, 'Successfully got results.', $data); |
| | | } |
| | | |
| | | protected function handleCreateUser(array $data): array |
| | | { |
| | | try { |
| | | return $this->postRequest( |
| | | sprintf( |
| | | '%s%s', |
| | | $this->getBaseURL(), |
| | | $this->apiEndpoints['customer']['create']->getEndpoint() |
| | | ), |
| | | $data |
| | | ); |
| | | } catch (Exception $e) { |
| | | return $this->response(false, 'Failed to create customer: '.$e->getMessage()); |
| | | } |
| | | } |
| | | protected function handleCreateUserResponse(WP_Error|array $response, string $method):array |
| | | { |
| | | $data = empty($response['customer']) |
| | | ? false |
| | | : $response['customer']; |
| | | return $this->response(true, 'Successfully got results.', $data); |
| | | } |
| | | protected function validateUserFields(int $userID, array $fields):array|false |
| | | { |
| | | $allowed = [ |
| | | 'given_name', |
| | | 'family_name', |
| | | 'company_name', |
| | | 'email_address', |
| | | 'phone_number', |
| | | 'idempotency_key', |
| | | 'nickname', |
| | | 'address', |
| | | 'reference_id', // The WP user id, in this case |
| | | 'note', |
| | | 'birthday' |
| | | ]; |
| | | $fields = array_filter($fields, function ($f) use ($allowed, $fields) { |
| | | if (!in_array($f, $allowed)) { |
| | | $this->logError('validateUserFields', 'Stripped attempted field for user: '.$f.' with value: '.print_r($fields[$f], true)); |
| | | } |
| | | return in_array($f, $allowed) && !empty($fields[$f]); |
| | | }, ARRAY_FILTER_USE_KEY); |
| | | if (empty($fields)) { |
| | | $this->logError('validateUserFields', 'No valid fields found'); |
| | | return false; |
| | | } |
| | | |
| | | |
| | | //Square requires at least one of these fields |
| | | $any = [ |
| | | 'given_name', |
| | | 'family_name', |
| | | 'company_name', |
| | | 'email_address', |
| | | 'phone_number' |
| | | ]; |
| | | |
| | | $doIt = false; |
| | | foreach ($any as $a) { |
| | | if (array_key_exists($a, $fields) && !empty($fields[$a])) { |
| | | $doIt = true; |
| | | } |
| | | } |
| | | if (!$doIt) { |
| | | $this->logError('validateUserFields', 'We need at least one of the following fields: '.print_r($any, true)); |
| | | return false; |
| | | } |
| | | |
| | | $addressAllowed = [ |
| | | 'address_line_1', |
| | | 'address_line_2', |
| | | 'address_line_3', |
| | | 'locality', // city |
| | | 'sublocality', // |
| | | 'sublocality_2', // |
| | | 'sublocality_3', // |
| | | 'administrative_district_1', //Province, in our case |
| | | 'administrative_district_2', // |
| | | 'administrative_district_3', // |
| | | 'postal_code', |
| | | 'country', |
| | | 'first_name', |
| | | 'last_name', |
| | | ]; |
| | | if (array_key_exists('address', $fields)) { |
| | | $fields['address'] = array_filter($fields['address'], function ($f) use ($addressAllowed, $fields){ |
| | | return array_key_exists($f, $addressAllowed) && !empty($fields['address'][$f]); |
| | | }, ARRAY_FILTER_USE_KEY); |
| | | } |
| | | |
| | | foreach ($fields as $f => $v) { |
| | | $v = match ($f) { |
| | | 'email' => filter_var($v, FILTER_SANITIZE_EMAIL), |
| | | 'phone' => Sanitizer::sanitizePhone($v), |
| | | default => sanitize_text_field($v), |
| | | }; |
| | | if (empty($v) && in_array($f, $this->requiredUserFields())) { |
| | | return false; |
| | | } |
| | | $fields[$f] = $v; |
| | | } |
| | | //Add the WP User ID as the reference |
| | | $fields['reference_id'] = $userID; |
| | | return $fields; |
| | | } |
| | | |
| | | |
| | | protected function validateWebhook(array $payload): bool |
| | | { |
| | | // TODO: Implement validateWebhook() method. |
| | | } |
| | | |
| | | protected function processWebhook(array $payload): bool |
| | | { |
| | | // TODO: Implement processWebhook() method. |
| | | } |
| | | |
| | | protected function extractWebhookId(array $payload): string |
| | | { |
| | | // TODO: Implement extractWebhookId() method. |
| | | } |
| | | |
| | | /******************************************************************** |
| | | * CUSTOM POST TYPES |
| | | ********************************************************************/ |
| | | protected function registerAdditionalQueueTypes(IntegrationExecutor $executor): void |
| | | { |
| | | $queue = JVB()->queue(); |
| | | $queue->registry()->register( |
| | | self::$syncCustomer, |
| | | new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 1, |
| | | ) |
| | | ); |
| | | |
| | | $queue->registry()->register( |
| | | self::$import, |
| | | new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 2 |
| | | ) |
| | | ); |
| | | } |
| | | |
| | | public function registerSquarePostTypes():void |
| | | { |
| | | $orders = Registrar::forPost($this->orderPostType, 'Square Order', 'Square Orders'); |
| | | $orders->make([ |
| | | 'public' => true |
| | | ]); |
| | | $orders->setAll(['system']); |
| | | |
| | | $fields = $orders->fields(); |
| | | foreach ($this->getOrderFields() as $fieldName => $config) { |
| | | $fields->addField($fieldName, $config); |
| | | } |
| | | } |
| | | public function getOrderFields():array |
| | | { |
| | | return [ |
| | | 'post_title' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Order Number' |
| | |
| | | 'label' => 'Last Updated', |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | public function registerSquarePostTypes():void |
| | | { |
| | | $orders = Registrar::forPost($this->orderPostType, 'Square Order', 'Square Orders'); |
| | | $orders->make([ |
| | | 'public' => true |
| | | ]); |
| | | $orders->setAll(['system']); |
| | | |
| | | $fields = $orders->fields(); |
| | | foreach ($this->getOrderFields() as $fieldName => $config) { |
| | | $fields->addField($fieldName, $config); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get request headers for API calls |
| | | */ |
| | | protected function getRequestHeaders(): array |
| | | { |
| | | return [ |
| | | 'Authorization' => 'Bearer ' . ($this->credentials['access_token'] ?? ''), |
| | | 'Square-Version' => $this->apiVersion, |
| | | 'Content-Type' => 'application/json' |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Add Square-specific OAuth parameters |
| | | */ |
| | | protected function addOAuthParams(array $params): array |
| | | { |
| | | // Load current environment setting |
| | | $this->ensureInitialized(); |
| | | |
| | | // For production, add session=false |
| | | // For sandbox, session parameter is not supported and should be omitted |
| | | if ($this->environment === 'production') { |
| | | $params['session'] = 'false'; |
| | | } else { |
| | | // Ensure session is not included for sandbox |
| | | unset($params['session']); |
| | | } |
| | | |
| | | return $params; |
| | | } |
| | | |
| | | /** |
| | | * Exchange OAuth code for tokens |
| | | * Override to handle Square's specific response format |
| | | */ |
| | | protected function exchangeOAuthCode(string $code): ?array |
| | | { |
| | | $this->ensureInitialized(); |
| | | |
| | | // Prepare the request body as an array |
| | | $body = [ |
| | | 'client_id' => $this->credentials['client_id'] ?? '', |
| | | 'client_secret' => $this->credentials['client_secret'] ?? '', |
| | | 'code' => $code, |
| | | 'grant_type' => 'authorization_code', |
| | | 'redirect_uri' => $this->getRedirectUri() |
| | | ]; |
| | | |
| | | $response = wp_remote_post($this->oauth['token'], [ |
| | | 'body' => json_encode($body), |
| | | 'headers' => [ |
| | | 'Content-Type' => 'application/json', |
| | | 'Square-Version' => $this->apiVersion |
| | | ] |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $this->logError('OAuth token exchange failed', ['error' => $response->get_error_message()]); |
| | | return null; |
| | | } |
| | | |
| | | $data = json_decode(wp_remote_retrieve_body($response), true); |
| | | if (isset($data['access_token'])) { |
| | | return [ |
| | | 'access_token' => $data['access_token'], |
| | | 'refresh_token' => $data['refresh_token'] ?? '', |
| | | 'expires_in' => $data['expires_in'] ?? 2592000, // 30 days default |
| | | 'token_type' => $data['token_type'] ?? 'Bearer', |
| | | 'merchant_id' => $data['merchant_id'] ?? '', |
| | | 'scope' => $data['scope'] ?? '' |
| | | ]; |
| | | } |
| | | |
| | | $this->logError('Failed to obtain access token', ['response' => $data]); |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Add Square-specific credential data after OAuth |
| | | */ |
| | | protected function addCredentialData(array $credentials, array $tokens): array |
| | | { |
| | | // Store Square-specific data |
| | | $credentials['merchant_id'] = $tokens['merchant_id'] ?? ''; |
| | | |
| | | // Preserve selected location if it exists |
| | | $credentials['location_id'] = $this->credentials['location_id'] ?? ''; |
| | | |
| | | // Preserve sandbox setting |
| | | $credentials['environment'] = $this->credentials['environment'] ?? 'sandbox'; |
| | | |
| | | return $credentials; |
| | | } |
| | | |
| | | /** |
| | | * Refresh OAuth token |
| | | * Override to handle Square's refresh token flow |
| | | */ |
| | | protected function refreshOAuthToken(): bool |
| | | { |
| | | if (!$this->isOAuthService || empty($this->credentials['refresh_token'])) { |
| | | return false; |
| | | } |
| | | |
| | | $response = wp_remote_post($this->oauth['token'], [ |
| | | 'body' => [ |
| | | 'client_id' => $this->credentials['client_id'], |
| | | 'client_secret' => $this->credentials['client_secret'], |
| | | 'refresh_token' => $this->credentials['refresh_token'], |
| | | 'grant_type' => 'refresh_token' |
| | | ], |
| | | 'headers' => $this->getRequestHeaders() |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $this->logError('Failed to refresh Square token', [ |
| | | 'error' => $response->get_error_message() |
| | | ]); |
| | | return false; |
| | | } |
| | | |
| | | $data = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | if (isset($data['access_token'])) { |
| | | $this->credentials['access_token'] = $data['access_token']; |
| | | $this->credentials['expires_at'] = time() + ($data['expires_in'] ?? 2592000); // 30 days |
| | | // Note: Square returns the SAME refresh token |
| | | if (isset($data['refresh_token'])) { |
| | | $this->credentials['refresh_token'] = $data['refresh_token']; |
| | | } |
| | | |
| | | $this->saveCredentials($this->credentials); |
| | | return true; |
| | | } |
| | | |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Load Square locations |
| | | */ |
| | | protected function loadLocations(): void |
| | | /******************************************************************** |
| | | * UTILITY METHODS |
| | | ********************************************************************/ |
| | | protected function getEnvironment():string|false |
| | | { |
| | | // Skip if we don't have credentials yet (during OAuth flow) |
| | | if (empty($this->credentials['access_token'])) { |
| | | return; |
| | | return $this->loadCredentials()['environment']??false; |
| | | } |
| | | protected function getClientID():string|false |
| | | { |
| | | return $this->loadCredentials()['client_id']??false; |
| | | } |
| | | protected function getClientSecret():string|false |
| | | { |
| | | return $this->loadCredentials()['client_secret']??false; |
| | | } |
| | | protected function getMerchantID():string|false |
| | | { |
| | | return $this->loadCredentials()['merchant_id']??false; |
| | | } |
| | | protected function getLocationID():string|false |
| | | { |
| | | return $this->loadCredentials()['location_id']??false; |
| | | } |
| | | protected function getLocations():array|false |
| | | { |
| | | if (!$this->getAccessToken()) { |
| | | return false; |
| | | } |
| | | try { |
| | | $response = $this->getRequest( |
| | | '/v2/locations', |
| | | [], |
| | | $this->environment |
| | | return $this->getRequest( |
| | | sprintf('%s/v2/locations', $this->getBaseURL()), |
| | | ); |
| | | |
| | | } catch (Exception $e) { |
| | | $this->logError( |
| | | 'getLocations', |
| | | 'Failed to load locations', |
| | | [ |
| | | 'error' => $e->getMessage() |
| | | ] |
| | | ); |
| | | } |
| | | return []; |
| | | } |
| | | protected function handleGetLocationResponse(WP_Error|array $response, string $method):array |
| | | { |
| | | if (isset($response['locations'])) { |
| | | $this->locations = $response['locations']; |
| | | $locations = $response['locations']; |
| | | |
| | | // Update location options in fields |
| | | $locationOptions = []; |
| | | foreach ($this->locations as $location) { |
| | | if ($location['status'] === 'ACTIVE') { |
| | | $locationOptions[$location['id']] = $location['name'] ?? $location['id']; |
| | | $options=[]; |
| | | foreach ($locations as $l) { |
| | | if ($l['status'] === 'ACTIVE') { |
| | | $options[$l['id']] = $l['name']??$l['id']; |
| | | } |
| | | } |
| | | |
| | | if (!empty($locationOptions)) { |
| | | $this->fields['location_id']['options'] = $locationOptions; |
| | | if (!empty($options)) { |
| | | $this->saveCredentials(['locations' => $options], false); |
| | | } |
| | | return $this->response(true, 'Found locations. Stored in table for reference.', $locations); |
| | | } |
| | | } catch (Exception $e) { |
| | | $this->logError('Failed to load locations', ['error' => $e->getMessage()]); |
| | | // Set empty locations array so we don't retry constantly |
| | | $this->locations = []; |
| | | return $this->response(false, 'No locations found.'); |
| | | } |
| | | } |
| | | |
| | | protected function renderOAuthConnectedOptions(): void |
| | | protected function getLocationOptions():array |
| | | { |
| | | return $this->loadCredentials()['locations']??[]; |
| | | } |
| | | protected function getBaseURL():string |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return; |
| | | } |
| | | |
| | | // Check OAuth status |
| | | $oauth_valid = $this->isOAuthValid(); |
| | | |
| | | if (!$oauth_valid) { |
| | | ?> |
| | | <div class="oauth-status"> |
| | | <span class="status-invalid">⚠️ OAuth token expired - please reconnect</span> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | $this->loadLocations(); |
| | | if (!empty($this->locations)) { |
| | | ?> |
| | | <div class="form-field" data-service="square"> |
| | | <label for="square_location_id">Square Location</label> |
| | | <select name="location_id" |
| | | id="square_location_id" |
| | | class="jvb-ajax-update" |
| | | data-action="select_location"> |
| | | <option value="">Select a location...</option> |
| | | <?php foreach ($this->locations as $location): ?> |
| | | <option value="<?php echo esc_attr($location['id']); ?>" |
| | | <?php selected($this->credentials['location_id'] ?? '', $location['id']); ?>> |
| | | <?php echo esc_html($location['name']); ?> |
| | | </option> |
| | | <?php endforeach; ?> |
| | | </select> |
| | | </div> |
| | | <?php |
| | | |
| | | // Show current selection status |
| | | if (!empty($this->credentials['location'])) { |
| | | ?> |
| | | <div class="connection-status connected"> |
| | | ✅ Connected to: <?php echo esc_html($this->credentials['location_id']); ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | } else { |
| | | ?> |
| | | <div class="notice notice-warning"> |
| | | <p>No Square locations found. Please ensure your Square credentials are correct.</p> |
| | | </div> |
| | | <?php |
| | | } |
| | | return $this->getEnvironment() === 'production' |
| | | ? 'https://connect.squareup.com' |
| | | : 'https://connect.squareupsandbox.com'; |
| | | } |
| | | |
| | | /** |
| | | * Handle location selection |
| | | */ |
| | | public function handleSelectLocation($data): array |
| | | protected function getAccessToken():string |
| | | { |
| | | if (empty($data['location_id'])) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'No location selected' |
| | | ]; |
| | | } |
| | | |
| | | $this->credentials['location_id'] = sanitize_text_field($data['location_id']); |
| | | $this->locationId = $this->credentials['location_id']; |
| | | |
| | | $result = $this->saveCredentials($this->credentials); |
| | | |
| | | if ($result['success']) { |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Location updated successfully' |
| | | ]; |
| | | } |
| | | |
| | | return $result; |
| | | return $this->loadCredentials()['access_token']??''; |
| | | } |
| | | |
| | | /** |
| | | * Handle fetching locations |
| | | */ |
| | | public function handleFetchLocations(): array |
| | | /*************************************************************** |
| | | * ACTIONS |
| | | ***************************************************************/ |
| | | public function handleSelectLocation(array $data):array |
| | | { |
| | | try { |
| | | $this->loadLocations(); |
| | | |
| | | if (empty($this->locations)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'No locations found' |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'data' => $this->locations, |
| | | 'message' => sprintf('Found %d location(s)', count($this->locations)) |
| | | ]; |
| | | } catch (Exception $e) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to fetch locations: ' . $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle webhook verification |
| | | */ |
| | | public function handleVerifyWebhook($data): array |
| | | { |
| | | if (empty($data['signature']) || empty($data['body'])) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Missing signature or body' |
| | | ]; |
| | | if (empty($data['location_id']??'')) { |
| | | return $this->response(false, 'No location selected'); |
| | | } |
| | | |
| | | $signatureKey = $this->credentials['webhook_signature_key'] ?? ''; |
| | | |
| | | if (empty($signatureKey)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Webhook signature key not configured' |
| | | ]; |
| | | } |
| | | |
| | | // Calculate expected signature |
| | | $stringToSign = $data['url'] . $data['body']; |
| | | $expectedSignature = base64_encode( |
| | | hash_hmac('sha256', $stringToSign, $signatureKey, true) |
| | | ); |
| | | |
| | | $isValid = hash_equals($expectedSignature, $data['signature']); |
| | | |
| | | return [ |
| | | 'success' => $isValid, |
| | | 'message' => $isValid ? 'Valid webhook signature' : 'Invalid webhook signature' |
| | | $credentials = [ |
| | | 'location_id' => sanitize_text_field($data['location_id']) |
| | | ]; |
| | | return $this->saveCredentials($credentials, false); |
| | | } |
| | | |
| | | /** |
| | | * Test payment/connection |
| | | */ |
| | | public function handleTestPayment(): array |
| | | public function handleFetchLocations():array |
| | | { |
| | | try { |
| | | // Test with a simple API call to verify connection |
| | | $response = $this->getRequest( |
| | | '/v2/locations/'.$this->locationId, |
| | | [], |
| | | $this->environment |
| | | $locations = $this->getLocations(); |
| | | if (!$locations) { |
| | | return $this->response(false, 'No locations found'); |
| | | } |
| | | return $this->response( |
| | | true, |
| | | sprintf('Found %d location(s)', count($locations)), |
| | | $locations |
| | | ); |
| | | if (!empty($response['location'])) { |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Successfully connected to Square', |
| | | 'data' => [ |
| | | 'location_name' => $response['location']['name'] ?? 'Unknown', |
| | | 'merchant_id' => $this->merchantId, |
| | | 'environment' => $this->environment |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Connection test failed' |
| | | ]; |
| | | } catch (Exception $e) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Connection test failed: ' . $e->getMessage() |
| | | ]; |
| | | return $this->response(false, 'Failed to fetch locations: '.$e->getMessage()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Create a Square customer |
| | | */ |
| | | public function createCustomer(array $customerData): array |
| | | public function getAdditionalFields(?string $content_type = null):array |
| | | { |
| | | try { |
| | | $response = $this->postRequest('/v2/customers', [ |
| | | 'given_name' => $customerData['first_name'] ?? '', |
| | | 'family_name' => $customerData['last_name'] ?? '', |
| | | 'email_address' => $customerData['email'] ?? '', |
| | | 'phone_number' => $customerData['phone'] ?? '', |
| | | 'reference_id' => $customerData['reference_id'] ?? '', |
| | | 'note' => $customerData['note'] ?? '' |
| | | ], $this->environment); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'data' => $response['customer'] ?? [] |
| | | ]; |
| | | } catch (Exception $e) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to create customer: ' . $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Create a catalog item |
| | | */ |
| | | public function createCatalogItem(array $itemData): array |
| | | { |
| | | try { |
| | | $response = $this->postRequest('/v2/catalog/object', [ |
| | | 'idempotency_key' => wp_generate_uuid4(), |
| | | 'object' => [ |
| | | 'type' => 'ITEM', |
| | | 'id' => '#' . sanitize_title($itemData['name']), |
| | | 'item_data' => [ |
| | | 'name' => $itemData['name'], |
| | | 'description' => $itemData['description'] ?? '', |
| | | 'variations' => $this->buildItemVariations($itemData) |
| | | ] |
| | | ] |
| | | ], $this->environment); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'data' => $response['catalog_object'] ?? [] |
| | | ]; |
| | | } catch (Exception $e) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to create catalog item: ' . $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Build item variations for catalog |
| | | */ |
| | | protected function buildItemVariations(array $itemData): array |
| | | { |
| | | $variations = []; |
| | | |
| | | if (isset($itemData['variations']) && is_array($itemData['variations'])) { |
| | | foreach ($itemData['variations'] as $variation) { |
| | | $variations[] = [ |
| | | 'type' => 'ITEM_VARIATION', |
| | | 'id' => '#' . sanitize_title($variation['name']), |
| | | 'item_variation_data' => [ |
| | | 'name' => $variation['name'], |
| | | 'pricing_type' => 'FIXED_PRICING', |
| | | 'price_money' => [ |
| | | 'amount' => $this->formatPrice($variation['_square_price']??0), |
| | | 'currency' => $this->getCurrency() |
| | | ] |
| | | ] |
| | | ]; |
| | | } |
| | | } else { |
| | | // Default single variation |
| | | $variations[] = [ |
| | | 'type' => 'ITEM_VARIATION', |
| | | 'id' => '#regular', |
| | | 'item_variation_data' => [ |
| | | 'name' => 'Regular', |
| | | 'pricing_type' => 'FIXED_PRICING', |
| | | 'price_money' => [ |
| | | 'amount' => $this->formatPrice($itemData['_square_price'] ?? 0), |
| | | 'currency' => $this->getCurrency() |
| | | ] |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | return $variations; |
| | | } |
| | | |
| | | /** |
| | | * Handle OAuth disconnect |
| | | */ |
| | | public function handleOAuthDisconnect(): array |
| | | { |
| | | try { |
| | | // Revoke the token with Square |
| | | if (!empty($this->credentials['access_token'])) { |
| | | wp_remote_post($this->oauth['revoke'], [ |
| | | 'body' => [ |
| | | 'client_id' => $this->credentials['client_id'] ?? '', |
| | | 'access_token' => $this->credentials['access_token'] |
| | | ], |
| | | 'headers' => $this->getRequestHeaders() |
| | | ]); |
| | | } |
| | | |
| | | // Clear stored credentials |
| | | $this->credentials = [ |
| | | 'client_id' => $this->credentials['client_id'] ?? '', |
| | | 'client_secret' => $this->credentials['client_secret'] ?? '' |
| | | ]; |
| | | |
| | | $this->saveCredentials($this->credentials); |
| | | $this->clearCache(); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Successfully disconnected from Square' |
| | | ]; |
| | | } catch (Exception $e) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to disconnect: ' . $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Register additional WordPress hooks |
| | | */ |
| | | protected function registerAdditionalHooks(): void |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | error_log('Square is not setup'); |
| | | return; |
| | | } |
| | | |
| | | add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']); |
| | | |
| | | add_filter('jvbAdditionalActions', [Checkout::class, 'render']); |
| | | |
| | | add_filter('jvb_checkout_description', function (string $desc, string $provider) { |
| | | if ($provider === 'square') { |
| | | return 'Securely checkout with your name, email, and payments processed by Square.'; |
| | | } |
| | | return $desc; |
| | | }, 10, 2); |
| | | |
| | | // Square-specific pickup fields (extracted from old outputCheckout) |
| | | add_filter('jvb_checkout_fields', [$this, 'addPickupFields'], 10, 2); |
| | | |
| | | // Browse URL for this client (restaurant menu) |
| | | add_filter('jvb_checkout_browse_url', function () { |
| | | return get_post_type_archive_link(BASE . 'menu_item'); |
| | | }); |
| | | add_filter('jvb_checkout_browse_text', function () { |
| | | return 'browse our menu'; |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Pickup/ordering fields for the shared checkout form. |
| | | * Specific to this Square client's food ordering use case. |
| | | */ |
| | | public function addPickupFields(string $html, string $provider): string |
| | | { |
| | | if ($provider !== 'square') { |
| | | return $html; |
| | | } |
| | | |
| | | return $html |
| | | . '<h3>Pickup Details</h3>' |
| | | . Form::render('pickup_time', null, [ |
| | | 'type' => 'datetime', |
| | | 'label' => 'Pickup Time', |
| | | 'min' => '11:00', |
| | | 'max' => '20:00', |
| | | 'required' => true, |
| | | ]) |
| | | . Form::render('special_instructions', null, [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Special Instructions', |
| | | 'quill' => true, |
| | | ]); |
| | | } |
| | | |
| | | protected function registerAdditionalQueueTypes(IntegrationExecutor $executor): void |
| | | { |
| | | $queue = JVB()->queue(); |
| | | |
| | | $queue->registry()->register(self::$syncCustomer, new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 2 |
| | | )); |
| | | |
| | | $queue->registry()->register(self::$import, new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 3 |
| | | )); |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * POST SYNC METHODS |
| | | ******************************************************************/ |
| | | |
| | | /** |
| | | * Handle post save for Square sync |
| | | */ |
| | | protected function handleTheSavePost(int $postID, WP_Post $post, bool $update, array $settings): void |
| | | { |
| | | error_log('==== [Square]::handleTheSavePost ===='); |
| | | $this->queueOperation(self::$syncTo, [ |
| | | 'items' => [$postID], |
| | | 'user' => user_can($post->post_author, 'manage_options') ? null : $post->post_author |
| | | ], [ |
| | | 'priority' => 'high', |
| | | 'delay' => 30, |
| | | ]); |
| | | Meta::forPost($postID)->set('_'.$this->service_name.'_sync_status', 'queued'); |
| | | } |
| | | |
| | | /** |
| | | * Handle post deletion |
| | | */ |
| | | public function handleDeletePost(int $postID): void |
| | | { |
| | | $item_id = $this->getServiceItemID($postID); |
| | | |
| | | if (empty($item_id)) { |
| | | return; |
| | | } |
| | | $this->queueOperation(self::$deleteFrom, [ |
| | | 'external_ids' => [$item_id], |
| | | 'post_id' => $postID, |
| | | ], [ |
| | | 'priority' => 'high', |
| | | ]); |
| | | |
| | | } |
| | | |
| | | /** |
| | | * Process sync to Square |
| | | */ |
| | | private function processSyncToSquare(array $data): array |
| | | { |
| | | $items = $data['items'] ?? []; |
| | | $success = []; |
| | | $errors = []; |
| | | |
| | | if (empty($items)) { |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => [ |
| | | 'synced' => [], |
| | | 'errors' => ['No items to sync'] |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | //Check images are already uploaded first |
| | | $image_mappings = $this->batchUploadImages($items); |
| | | |
| | | $catalog = []; |
| | | $map = []; |
| | | foreach ($items as $postID) { |
| | | $post = get_post($postID); |
| | | if (!$post) { |
| | | $errors[] = "Post $postID not found"; |
| | | continue; |
| | | } |
| | | |
| | | $square_image_id = $image_mappings[$postID] ?? null; |
| | | $catalog_object = $this->buildCatalogObject($postID, $square_image_id); |
| | | if (is_wp_error($catalog_object)) { |
| | | $errors[] = $catalog_object->get_error_message(); |
| | | continue; |
| | | } |
| | | $map[$catalog_object['id']] = $postID; |
| | | $catalog[] = $catalog_object; |
| | | } |
| | | |
| | | if (!empty($catalog)) { |
| | | $response = $this->postRequest('catalog/batch-upsert', [ |
| | | 'idempotency_key' => wp_generate_uuid4(), |
| | | 'batches' => [[ |
| | | 'objects' => $catalog |
| | | ]] |
| | | ]); |
| | | |
| | | if (!is_wp_error($response)) { |
| | | $this->processBatchSyncResponse($response, $map, $success, $errors); |
| | | } else { |
| | | // Handle batch request failure |
| | | $error_message = 'Batch sync failed'; |
| | | $this->logError($error_message, [ |
| | | 'method' => 'processSyncToSquare', |
| | | 'post_ids' => $items, |
| | | 'error' => $response |
| | | ]); |
| | | |
| | | // Mark all items as failed |
| | | foreach ($items as $postID) { |
| | | if (get_post($postID)) { // Only if post exists |
| | | $errors[] = "Failed to sync post $postID"; |
| | | update_post_meta($postID, BASE . '_square_sync_status', 'error'); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | return [ |
| | | 'success' => count($success) > 0, |
| | | 'result' => [ |
| | | 'synced' => $success, |
| | | 'errors' => $errors |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | private function processBatchSyncResponse(array $response, array $map, array &$success, array &$errors):void |
| | | { |
| | | error_log('==== SQUARE::processBatchSyncResponse ====='); |
| | | error_log('Full response: '.print_r($response, true)); |
| | | |
| | | // Handle successful objects |
| | | if (!empty($response['objects'])) { |
| | | foreach ($response['objects'] as $object) { |
| | | $object_id = $object['id']; |
| | | $post_id = $map[$object_id] ?? null; |
| | | |
| | | if (!$post_id) { |
| | | // Try to find post ID from the object ID pattern |
| | | if (preg_match('/#\w+_(\d+)/', $object_id, $matches)) { |
| | | $post_id = (int)$matches[1]; |
| | | } |
| | | } |
| | | |
| | | if ($post_id && get_post($post_id)) { |
| | | // Update post meta with Square data |
| | | update_post_meta($post_id, BASE . '_square_catalog_id', $object['id']); |
| | | update_post_meta($post_id, BASE . '_square_sync_status', 'synced'); |
| | | update_post_meta($post_id, BASE . '_square_last_sync', current_time('mysql')); |
| | | |
| | | // Save variation IDs if present |
| | | if (!empty($object['item_data']['variations'])) { |
| | | foreach ($object['item_data']['variations'] as $index => $variation) { |
| | | update_post_meta($post_id, BASE . '_square_variation_' . $index . '_id', $variation['id']); |
| | | } |
| | | } |
| | | |
| | | $success[] = $post_id; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Handle errors |
| | | if (!empty($response['errors'])) { |
| | | foreach ($response['errors'] as $error) { |
| | | $error_detail = $error['detail'] ?? 'Unknown error'; |
| | | $error_field = $error['field'] ?? ''; |
| | | |
| | | // Try to extract post ID from error field if possible |
| | | $post_id = null; |
| | | if (preg_match('/batches\[0\]\.objects\[(\d+)\]/', $error_field, $matches)) { |
| | | $object_index = (int)$matches[1]; |
| | | // Get post ID from the object at this index |
| | | if (isset($catalog_objects[$object_index])) { |
| | | $object_id = $catalog_objects[$object_index]['id']; |
| | | $post_id = $map[$object_id] ?? null; |
| | | } |
| | | } |
| | | |
| | | $error_message = $post_id |
| | | ? "Post $post_id: $error_detail" |
| | | : "Batch error: $error_detail"; |
| | | |
| | | $errors[] = $error_message; |
| | | |
| | | if ($post_id) { |
| | | update_post_meta($post_id, BASE . '_square_sync_status', 'error'); |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Handle any posts that weren't in the response (shouldn't happen but good to check) |
| | | foreach ($map as $object_id => $post_id) { |
| | | if (!in_array($post_id, $success)) { |
| | | $found_in_errors = false; |
| | | foreach ($errors as $error) { |
| | | if (str_contains($error, "Post $post_id")) { |
| | | $found_in_errors = true; |
| | | break; |
| | | } |
| | | } |
| | | |
| | | if (!$found_in_errors) { |
| | | $errors[] = "Post $post_id: No response from Square"; |
| | | update_post_meta($post_id, BASE . '_square_sync_status', 'error'); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Build catalog object from WordPress post |
| | | * |
| | | * @param int $postID WordPress post ID |
| | | * @param string|null $square_image_id Previously uploaded Square image ID |
| | | * @return array|WP_Error Catalog object or error |
| | | */ |
| | | //TODO: Get to work with Registrar settings |
| | | protected function buildCatalogObject(int $postID, ?string $square_image_id = null): array|WP_Error |
| | | { |
| | | $post = get_post($postID); |
| | | if (!$post) { |
| | | return new WP_Error('post_not_found', "Post $postID not found"); |
| | | } |
| | | |
| | | $meta = Meta::forPost($postID); |
| | | $post_type = get_post_type($postID); |
| | | |
| | | // Get existing Square catalog ID if it exists |
| | | $existing_square_id = get_post_meta($postID, BASE . '_square_catalog_id', true); |
| | | $registrar = Registrar::getInstance($post_type); |
| | | $product_type = 'FOOD_AND_BEV'; |
| | | if ($registrar) { |
| | | $conf = $registrar->getIntegration($this->service_name); |
| | | if ($conf) { |
| | | $product_type = $conf->getContentType(); |
| | | } |
| | | } |
| | | |
| | | // Build the base catalog object |
| | | $catalog_object = [ |
| | | 'type' => 'ITEM', |
| | | 'id' => $existing_square_id ?: '#menu_item_item_' . $postID, |
| | | 'item_data' => [ |
| | | 'name' => $post->post_title, |
| | | 'description' => wp_strip_all_tags($post->post_content), |
| | | 'product_type' => $product_type, |
| | | 'variations' => [], |
| | | 'is_taxable' => true, |
| | | ] |
| | | ]; |
| | | |
| | | // Add image ID if provided (must be already uploaded to Square) |
| | | if (!empty($square_image_id)) { |
| | | $catalog_object['item_data']['image_ids'] = [$square_image_id]; |
| | | } |
| | | |
| | | // Add variations |
| | | $variations = $meta->get('_square_product_variations'); |
| | | if (empty($variations)) { |
| | | // Create default variation if none exist |
| | | $catalog_object['item_data']['variations'][] = [ |
| | | 'type' => 'ITEM_VARIATION', |
| | | 'id' => $existing_square_id ? null : '#'.BASE.'menu_item_' . $postID . '_var_default', |
| | | 'item_variation_data' => [ |
| | | 'name' => 'Regular', |
| | | 'ordinal' => 0, |
| | | 'pricing_type' => 'FIXED_PRICING', |
| | | 'price_money' => [ |
| | | 'amount' => $this->formatPrice($meta->get('_square_price')), |
| | | 'currency' => $this->getCurrency() |
| | | ], |
| | | 'sellable' => true, |
| | | 'stockable' => true |
| | | ] |
| | | ]; |
| | | } else { |
| | | $resetVariations = false; |
| | | foreach ($variations as $index => $variation) { |
| | | $id = '#'.BASE.'menu_item_' . $postID . '_var_' . $index; |
| | | if (empty($variation['item_id'])) { |
| | | $resetVariations = true; |
| | | $variations[$index]['item_id'] = $id; |
| | | $variation['item_id'] = $id; |
| | | } |
| | | $catalog_object['item_data']['variations'][] = [ |
| | | 'type' => 'ITEM_VARIATION', |
| | | 'id' => $variation['item_id'], |
| | | 'item_variation_data' => [ |
| | | 'name' => $variation['name'] ?? 'Variation ' . ($index + 1), |
| | | 'ordinal' => $index, |
| | | 'pricing_type' => 'FIXED_PRICING', |
| | | 'price_money' => [ |
| | | 'amount' =>$this->formatPrice($variation['price'] ?? 0), |
| | | 'currency' => $this->getCurrency() |
| | | ], |
| | | 'sellable' => true, |
| | | 'stockable' => true |
| | | ] |
| | | ]; |
| | | } |
| | | if ($resetVariations) { |
| | | $meta->set('_square_product_variations', $variations); |
| | | } |
| | | } |
| | | |
| | | // Add categories if they exist |
| | | $categories = wp_get_post_terms($postID, $post_type . '_category', ['fields' => 'ids']); |
| | | if (!empty($categories)) { |
| | | $category_ids = []; |
| | | foreach ($categories as $term_id) { |
| | | $square_cat_id = get_term_meta($term_id, BASE . '_square_category_id', true); |
| | | if ($square_cat_id) { |
| | | $category_ids[] = $square_cat_id; |
| | | } |
| | | } |
| | | if (!empty($category_ids)) { |
| | | $catalog_object['item_data']['category_ids'] = $category_ids; |
| | | } |
| | | } |
| | | |
| | | // Add modifiers if they exist |
| | | $modifiers = $meta->get('modifiers'); |
| | | if (!empty($modifiers)) { |
| | | $modifier_ids = []; |
| | | foreach ($modifiers as $modifier) { |
| | | if (!empty($modifier['square_id'])) { |
| | | $modifier_ids[] = $modifier['square_id']; |
| | | } |
| | | } |
| | | if (!empty($modifier_ids)) { |
| | | $catalog_object['item_data']['modifier_list_info'] = array_map(function($id) { |
| | | return ['modifier_list_id' => $id]; |
| | | }, $modifier_ids); |
| | | } |
| | | } |
| | | |
| | | // Add tax settings |
| | | $tax_ids = $meta->get('tax_ids'); |
| | | if (!empty($tax_ids)) { |
| | | $catalog_object['item_data']['tax_ids'] = $tax_ids; |
| | | } |
| | | |
| | | return $catalog_object; |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Get variation mapping for post type |
| | | */ |
| | | protected function getVariationMapping(string $post_type): array |
| | | { |
| | | $registrar = Registrar::getInstance($post_type); |
| | | if (!$registrar) { |
| | | $this->setContentTypes(); |
| | | if (is_null($content_type) || !array_key_exists($content_type, $this->contentTypes)) { |
| | | return []; |
| | | } |
| | | $config = $registrar->getIntegrationConfig($this->service_name); |
| | | $product_type = $config['content_type']??'REGULAR'; |
| | | $valid_fields = $this->getValidFieldsForProductType($product_type); |
| | | |
| | | $defaults = [ |
| | | 'name' => 'name', |
| | | 'id' => '_square_item_id', |
| | | 'sku' => '_square_sku', |
| | | 'price' => '_square_price', |
| | | 'track_inventory' => '_square_track_inventory', |
| | | 'service_duration' => '_square_service_duration', |
| | | 'available_for_booking' => '_square_available_for_booking', |
| | | 'gift_card_type' => '_square_gift_card_type', |
| | | 'ingredients' => '_square_ingredients', |
| | | 'preparation_time_duration' => '_square_preparation_time_duration' |
| | | ]; |
| | | |
| | | $extended = apply_filters( |
| | | BASE . $this->service_name . '_variation_mapping', |
| | | $defaults, |
| | | $post_type, |
| | | $this |
| | | ); |
| | | |
| | | return array_filter($extended, function($key) use ($valid_fields) { |
| | | return in_array($key, $valid_fields); |
| | | }, ARRAY_FILTER_USE_KEY); |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Get or create Square category |
| | | */ |
| | | private function getOrCreateSquareCategory(string $name): ?string |
| | | { |
| | | // Check cached mapping |
| | | $cached_id = get_option(BASE . 'square_category_' . sanitize_title($name)); |
| | | if ($cached_id) { |
| | | return $cached_id; |
| | | } |
| | | |
| | | // Create new category |
| | | $response = $this->postRequest('catalog/batch-upsert', [ |
| | | 'idempotency_key' => wp_generate_uuid4(), |
| | | 'batches' => [[ |
| | | 'objects' => [[ |
| | | 'type' => 'CATEGORY', |
| | | 'id' => '#category_' . sanitize_title($name), |
| | | 'category_data' => [ |
| | | 'name' => $name |
| | | ] |
| | | ]] |
| | | ]] |
| | | ]); |
| | | |
| | | if (!is_wp_error($response) && !empty($response['objects'][0]['id'])) { |
| | | $category_id = $response['objects'][0]['id']; |
| | | update_option(BASE . 'square_category_' . sanitize_title($name), $category_id); |
| | | return $category_id; |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Get field mapping for post type |
| | | */ |
| | | protected function getFieldMapping(string $post_type): array |
| | | { |
| | | $registrar = Registrar::getInstance($post_type); |
| | | if (!$registrar) { |
| | | return []; |
| | | } |
| | | $config = $registrar->getIntegrationConfig($this->service_name); |
| | | $product_type = $config['content_type']??'REGULAR'; |
| | | $valid_fields = $this->getValidFieldsForProductType($product_type); |
| | | |
| | | $defaults = [ |
| | | 'name' => 'post_title', |
| | | 'description_html' => 'post_content', |
| | | 'abbreviation' => 'abbreviation', |
| | | 'id' => '_square_catalog_id', |
| | | 'sku' => '_square_sku', |
| | | 'category_id' => 'category', |
| | | 'image_ids' => 'post_thumbnail', |
| | | 'price' => '_square_price', |
| | | 'tax_ids' => 'tax_ids', |
| | | // Availability |
| | | 'available_online' => '_square_available_online', |
| | | 'available_for_pickup' => '_square_available_for_pickup', |
| | | 'available_electronically' => '_square_available_electronically', |
| | | // Modifiers |
| | | 'modifier_list_info' => 'modifiers', |
| | | // Item options |
| | | 'item_options' => 'options', |
| | | // Reporting |
| | | 'reporting_category' => 'reporting_category', |
| | | ]; |
| | | |
| | | $extended = apply_filters( |
| | | BASE . $this->service_name . '_field_mapping', |
| | | $defaults, |
| | | $post_type, |
| | | $this |
| | | ); |
| | | |
| | | return array_filter($extended, function($key) use ($valid_fields) { |
| | | return in_array($key, $valid_fields); |
| | | }, ARRAY_FILTER_USE_KEY); |
| | | } |
| | | |
| | | /** |
| | | * Get valid fields for Square product type |
| | | */ |
| | | //TODO: This feels redundant now, with how we've defined fields in getAdditionalFields |
| | | private function getValidFieldsForProductType(string $product_type): array |
| | | { |
| | | $fields = ['name', 'description_html', 'sku', 'price', 'image_ids', 'category_id']; |
| | | |
| | | $type_specific = [ |
| | | 'FOOD_AND_BEV' => [ |
| | | 'ingredients', |
| | | 'preparation_time_duration', |
| | | 'dietary_preferences', |
| | | 'calories_text' |
| | | ], |
| | | 'APPOINTMENTS_SERVICE' => [ |
| | | 'service_duration', |
| | | 'available_for_booking', |
| | | 'team_member_ids', |
| | | 'booking_availability' |
| | | ], |
| | | 'EVENT' => [ |
| | | 'start_date', |
| | | 'end_date', |
| | | 'venue_details', |
| | | 'capacity' |
| | | ], |
| | | 'GIFT_CARD' => [ |
| | | 'gift_card_type', |
| | | 'allowed_locations' |
| | | ] |
| | | ]; |
| | | |
| | | if (isset($type_specific[$product_type])) { |
| | | $fields = array_merge($fields, $type_specific[$product_type]); |
| | | } |
| | | |
| | | return $fields; |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * CUSTOMER MANAGEMENT |
| | | ******************************************************************/ |
| | | |
| | | /** |
| | | * Handle customer authentication during checkout |
| | | */ |
| | | //TODO: Is this necessary? |
| | | public function handleCustomerAuth($data):WP_Error|array |
| | | { |
| | | $email = sanitize_email($data['email'] ?? ''); |
| | | $action = sanitize_text_field($data['action_type'] ?? 'check'); |
| | | |
| | | if (!$email) { |
| | | return new WP_Error('error', 'Email required'); |
| | | } |
| | | |
| | | switch ($action) { |
| | | case 'check': |
| | | return $this->checkCustomerExists($email); |
| | | case 'create': |
| | | return $this->createCustomerAccount($email); |
| | | default: |
| | | $this->logError('No action set for customer auth: '.$action, [ |
| | | 'method' => 'handleCustomerAuth' |
| | | ]); |
| | | return new WP_Error('error', 'No action set for customer auth'); |
| | | } |
| | | |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Check if customer exists |
| | | */ |
| | | private function checkCustomerExists(string $email):WP_Error|array |
| | | { |
| | | // Check WordPress user |
| | | $user = get_user_by('email', $email); |
| | | |
| | | if ($user) { |
| | | // Check if user has Square customer integration |
| | | $square_customer_id = get_user_meta($user->ID, BASE . '_square_customer_id', true); |
| | | |
| | | if ($square_customer_id) { |
| | | return [ |
| | | 'exists' => true, |
| | | 'has_account' => true, |
| | | 'message' => 'Account found. Please enter your password to continue' |
| | | ]; |
| | | } else { |
| | | return [ |
| | | 'exists' => true, |
| | | 'has_account' => false, |
| | | 'message' => 'Email found. Would you like to create an account to save your order history?' |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | // Check Square for customer |
| | | $response = $this->postRequest('customers/search', [ |
| | | 'filter' => [ |
| | | 'email_address' => [ |
| | | 'exact' => $email |
| | | ] |
| | | ] |
| | | ]); |
| | | |
| | | if (!is_wp_error($response) && !empty($response['customers'])) { |
| | | return [ |
| | | 'exists' => true, |
| | | 'has_account' => false, |
| | | 'square_only' => true, |
| | | 'message' => 'Found your previous orders. Create an account to access them?' |
| | | ]; |
| | | } else { |
| | | return [ |
| | | 'exists' => false, |
| | | 'message' => 'New customer' |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | private function createCustomerAccount(string $email):WP_Error|array |
| | | { |
| | | // Validate email format |
| | | if (!is_email($email)) { |
| | | return new WP_Error('error', 'Invalid email address'); |
| | | } |
| | | |
| | | // Check if user already exists |
| | | if (email_exists($email)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'exists' => true, |
| | | 'message' => 'An account with this email already exists. Please log in instead.' |
| | | ]; |
| | | } |
| | | |
| | | // Create user account without password (they'll set it via email) |
| | | $user_id = wp_create_user( |
| | | $email, |
| | | wp_generate_password(20, true, true), // Temporary random password |
| | | $email |
| | | ); |
| | | |
| | | if (is_wp_error($user_id)) { |
| | | $this->logError('Failed to create customer account', [ |
| | | 'email' => $email, |
| | | 'error' => $user_id->get_error_message() |
| | | ]); |
| | | return new WP_Error('error', 'Failed to create account. Please try again.'); |
| | | } |
| | | |
| | | // Set user role (assuming you have a customer role defined) |
| | | $user = new WP_User($user_id); |
| | | $user->set_role(BASE.'foodie'); |
| | | |
| | | // Generate password reset key |
| | | $reset_key = get_password_reset_key($user); |
| | | |
| | | if (is_wp_error($reset_key)) { |
| | | return new WP_Error('error', 'Account created, but couldn\'t send email. Please use password reset.'); |
| | | } |
| | | |
| | | |
| | | // Send welcome email with password setup link |
| | | $this->sendWelcomeEmail($user, $reset_key); |
| | | |
| | | // Link to Square customer if exists |
| | | $square_customer_id = $this->getOrCreateSquareCustomer([ |
| | | 'email' => $email, |
| | | 'name' => $email |
| | | ]); |
| | | |
| | | if ($square_customer_id) { |
| | | update_user_meta($user_id, BASE . '_square_customer_id', $square_customer_id); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Account created! Check your email to set your password.', |
| | | 'user_id' => $user_id |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Send welcome email with password setup |
| | | */ |
| | | private function sendWelcomeEmail(WP_User $user, string $reset_key): void |
| | | { |
| | | $site_name = get_bloginfo('name'); |
| | | $reset_url = get_home_url(null, "wp-login.php?action=rp&key=$reset_key&login=" . rawurlencode($user->user_login), 'login'); |
| | | |
| | | $message = sprintf( |
| | | "Welcome to %s!\n\n" . |
| | | "Your account has been created. Please click the button below to set your password:\n\n" . |
| | | "%s\n\n" . |
| | | "Or, copy and paste the link below:\n\n". |
| | | "%s\n\n" . |
| | | "Once you've set your password, you can:\n" . |
| | | "- View your order history\n" . |
| | | // "- Save your favorite items\n" . |
| | | "- Speed up checkout with saved payment methods\n\n" . |
| | | "If you didn't create this account, please ignore this email.\n\n" . |
| | | "Thanks,\n", |
| | | $site_name, |
| | | JVB()->email()->button('Reset Password', $reset_url), |
| | | JVB()->email()->link($reset_url), |
| | | ); |
| | | |
| | | JVB()->email()->sendEmail( |
| | | $user->user_email, |
| | | sprintf('[%s] Welcome! Set Your Password', $site_name), |
| | | $message |
| | | ); |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * WEBHOOK HANDLING |
| | | ******************************************************************/ |
| | | |
| | | /** |
| | | * Validate webhook signature |
| | | */ |
| | | protected function validateWebhook(array $payload): bool |
| | | { |
| | | // Get signature from headers |
| | | $signature = $_SERVER['HTTP_X_SQUARE_SIGNATURE'] ?? ''; |
| | | |
| | | // If no signature provided by Square, validation fails |
| | | if (empty($signature)) { |
| | | $this->logDebug('No webhook signature provided by Square'); |
| | | return false; |
| | | } |
| | | |
| | | // If webhook signature key is not configured, we can't validate |
| | | // but we might want to process the webhook anyway (less secure) |
| | | if (empty($this->webhook_signature_key)) { |
| | | $this->logDebug('Webhook signature key not configured - processing without validation'); |
| | | // You might want to return false here for stricter security |
| | | // return false; |
| | | return true; // Process webhook but log that it's unverified |
| | | } |
| | | |
| | | // Get the raw request body |
| | | $body = file_get_contents('php://input'); |
| | | |
| | | // Calculate expected signature |
| | | $expected = base64_encode(hash_hmac('sha256', $body, $this->webhook_signature_key, true)); |
| | | |
| | | // Use timing-safe comparison |
| | | $is_valid = hash_equals($expected, $signature); |
| | | |
| | | if (!$is_valid) { |
| | | $this->logError('Invalid webhook signature', [ |
| | | 'provided' => substr($signature, 0, 10) . '...', |
| | | 'expected' => substr($expected, 0, 10) . '...' |
| | | ]); |
| | | } |
| | | |
| | | return $is_valid; |
| | | } |
| | | |
| | | /** |
| | | * Process webhook event |
| | | */ |
| | | protected function processWebhook(array $payload): bool |
| | | { |
| | | $event_type = $payload['type'] ?? ''; |
| | | $data = $payload['data'] ?? []; |
| | | |
| | | switch ($event_type) { |
| | | case 'payment.created': |
| | | case 'payment.updated': |
| | | return $this->handlePaymentWebhook($data); |
| | | |
| | | case 'order.created': |
| | | case 'order.updated': |
| | | case 'order.fulfillment.updated': |
| | | return $this->handleOrderWebhook($data); |
| | | |
| | | case 'catalog.version.updated': |
| | | return $this->handleCatalogWebhook($data); |
| | | |
| | | case 'customer.created': |
| | | case 'customer.updated': |
| | | return $this->handleCustomerWebhook($data); |
| | | |
| | | default: |
| | | $this->logDebug('Unhandled webhook type', ['type' => $event_type]); |
| | | return true; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle order status webhook |
| | | */ |
| | | private function handleOrderWebhook(array $data): bool |
| | | { |
| | | $order_id = $data['object']['order']['id'] ?? ''; |
| | | $state = $data['object']['order']['state'] ?? ''; |
| | | $fulfillments = $data['object']['order']['fulfillments'] ?? []; |
| | | |
| | | if (!$order_id) { |
| | | return false; |
| | | } |
| | | |
| | | // Find the WP post for this order |
| | | $wp_order_id = $this->getOrderPost($order_id); |
| | | |
| | | if ($wp_order_id) { |
| | | // Update the post meta |
| | | $meta = Meta::forPost($wp_order_id); |
| | | $updates = [ |
| | | 'status' => $state, |
| | | 'updated_at' => current_time('mysql') |
| | | ]; |
| | | |
| | | // Extract fulfillment status and pickup time |
| | | if (!empty($fulfillments[0])) { |
| | | $fulfillment = $fulfillments[0]; |
| | | $updates['fulfillment_status'] = $fulfillment['state'] ?? $state; |
| | | |
| | | if (!empty($fulfillment['pickup_details']['pickup_at'])) { |
| | | $updates['pickup_time'] = $fulfillment['pickup_details']['pickup_at']; |
| | | } |
| | | } |
| | | |
| | | $meta->setAll($updates); |
| | | |
| | | // Trigger notification to customer if order is ready |
| | | if ($state === 'PREPARED') { |
| | | do_action(BASE . 'square_order_ready', $wp_order_id, $order_id); |
| | | } |
| | | } |
| | | // Trigger action for other integrations |
| | | do_action(BASE . 'square_order_updated', $order_id, $state, $data); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * CONNECTION TESTING |
| | | ******************************************************************/ |
| | | protected function performConnectionTest(): bool |
| | | { |
| | | try { |
| | | $response = $this->getRequest('/v2/merchants/me'); |
| | | if (is_wp_error($response)) { |
| | | $this->logError('Connection test failed', ['error' => $response->get_error_message()]); |
| | | return false; |
| | | } |
| | | |
| | | // Check if we got valid merchant data |
| | | return !empty($response['merchant']); |
| | | } catch (Exception $e) { |
| | | $this->logError('Connection test failed', ['error' => $e->getMessage()]); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Check OAuth connection status |
| | | */ |
| | | protected function checkOAuthStatus(): array |
| | | { |
| | | if (!$this->hasOAuthCredentials()) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'OAuth not configured. Please authorize with Square.', |
| | | 'authorized' => false |
| | | ]; |
| | | } |
| | | |
| | | // Test the connection |
| | | if ($this->performConnectionTest()) { |
| | | $merchant = $this->getRequest('merchants/me'); |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Successfully connected to Square', |
| | | 'authorized' => true, |
| | | 'merchant' => $merchant['merchant'] ?? [] |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'OAuth token may be expired. Please re-authorize.', |
| | | 'authorized' => false |
| | | ]; |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * ADMIN UI |
| | | ******************************************************************/ |
| | | /** |
| | | * Enqueue checkout scripts with Square configuration |
| | | */ |
| | | public function enqueueScripts(): void |
| | | { |
| | | jvbInlineStyles('forms'); |
| | | $this->loadCredentials(); |
| | | $sdk_url = $this->environment === 'production' |
| | | ? 'https://web.squarecdn.com/v1/square.js' |
| | | : 'https://sandbox.web.squarecdn.com/v1/square.js'; |
| | | |
| | | wp_enqueue_script( |
| | | 'square-payments-sdk', |
| | | $sdk_url, |
| | | [], |
| | | null, |
| | | ['strategy' => 'defer', 'in_footer' => true] |
| | | ); |
| | | |
| | | // Shared cart checkout base class |
| | | wp_register_script( |
| | | 'jvb-checkout', |
| | | JVB_URL . 'assets/js/min/checkout.min.js', |
| | | ['jvb-utility', 'jvb-queue', 'jvb-a11y', 'jvb-cache', 'jvb-tabs', 'jvb-popup', 'jvb-login'], |
| | | '1.1.32', |
| | | ['strategy' => 'defer', 'in_footer' => true] |
| | | ); |
| | | |
| | | // Square checkout extends CartCheckout |
| | | wp_register_script( |
| | | 'jvb-square-checkout', |
| | | JVB_URL . 'assets/js/min/square.min.js', |
| | | ['jvb-checkout', 'square-payments-sdk'], |
| | | '1.1.32', |
| | | ['strategy' => 'defer', 'in_footer' => true] |
| | | ); |
| | | |
| | | wp_enqueue_script('jvb-square-checkout'); |
| | | |
| | | wp_localize_script('jvb-square-checkout', 'squareConfig', [ |
| | | 'isOpen' => jvbIsOpen()?'1':'0', |
| | | 'application_id' => $this->credentials['client_id'] ?? '', |
| | | 'location_id' => $this->locationId, |
| | | 'environment' => $this->environment, |
| | | 'currency' => $this->getCurrency(), |
| | | 'is_logged_in' => is_user_logged_in(), |
| | | 'user_email' => is_user_logged_in() ? wp_get_current_user()->user_email : '', |
| | | ]); |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * HELPER METHODS |
| | | ******************************************************************/ |
| | | |
| | | /** |
| | | * Get or create Square customer |
| | | */ |
| | | public function getOrCreateSquareCustomer(array $customer_info): ?string |
| | | { |
| | | if (empty($customer_info['email'])) { |
| | | return null; |
| | | } |
| | | |
| | | // Search for existing customer |
| | | $search_response = $this->postRequest('customers/search', [ |
| | | 'filter' => [ |
| | | 'email_address' => [ |
| | | 'exact' => $customer_info['email'] |
| | | ] |
| | | ] |
| | | ]); |
| | | |
| | | if (!is_wp_error($search_response) && !empty($search_response['customers'])) { |
| | | return $search_response['customers'][0]['id']; |
| | | } |
| | | |
| | | // Create new customer |
| | | $create_response = $this->postRequest('customers', [ |
| | | 'given_name' => $customer_info['name'] ?? '', |
| | | 'email_address' => $customer_info['email'], |
| | | 'phone_number' => $customer_info['phone'] ?? '' |
| | | ]); |
| | | |
| | | if (!is_wp_error($create_response) && !empty($create_response['customer']['id'])) { |
| | | return $create_response['customer']['id']; |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Process delete from Square |
| | | */ |
| | | private function processDeleteFromSquare(array $data): array |
| | | { |
| | | $square_ids = $data['square_ids'] ?? []; |
| | | |
| | | if (empty($square_ids)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => ['error' => 'No Square IDs provided'] |
| | | ]; |
| | | } |
| | | |
| | | $response = $this->postRequest('catalog/batch-delete', [ |
| | | 'object_ids' => $square_ids |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => ['error' => $response->get_error_message()] |
| | | ]; |
| | | } |
| | | |
| | | // Clean up post meta |
| | | if (!empty($data['post_id'])) { |
| | | delete_post_meta($data['post_id'], BASE . '_square_catalog_id'); |
| | | delete_post_meta($data['post_id'], BASE . '_square_sync_status'); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => ['deleted' => $square_ids] |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Process sync from Square |
| | | */ |
| | | private function processSyncFromSquare(array $data): array |
| | | { |
| | | $cursor = $data['cursor'] ?? null; |
| | | $types = $data['types'] ?? ['ITEM']; |
| | | |
| | | $request_data = [ |
| | | 'types' => implode(',', $types), |
| | | 'limit' => 100 |
| | | ]; |
| | | |
| | | if ($cursor) { |
| | | $request_data['cursor'] = $cursor; |
| | | } |
| | | |
| | | $response = $this->getRequest('catalog/list?' . http_build_query($request_data)); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => ['error' => $response->get_error_message()] |
| | | ]; |
| | | } |
| | | |
| | | $imported = 0; |
| | | $errors = []; |
| | | |
| | | foreach ($response['objects'] ?? [] as $object) { |
| | | if ($object['type'] === 'ITEM') { |
| | | $result = $this->importSquareItem($object); |
| | | if ($result) { |
| | | $imported++; |
| | | } else { |
| | | $errors[] = $object['id']; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Queue next batch if cursor exists |
| | | if (!empty($response['cursor'])) { |
| | | $this->queueOperation('sync_from_square', [ |
| | | 'cursor' => $response['cursor'], |
| | | 'types' => $types |
| | | ]); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => [ |
| | | 'imported' => $imported, |
| | | 'errors' => $errors, |
| | | 'has_more' => !empty($response['cursor']) |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Import Square item to WordPress |
| | | */ |
| | | private function importSquareItem(array $item): bool|int |
| | | { |
| | | //TODO: We need to add the post type to custom meta for Square, this is not good if we have multiple post types with the same product type |
| | | // Find matching content type |
| | | $product_type = $item['item_data']['product_type'] ?? 'REGULAR'; |
| | | $post_type = null; |
| | | |
| | | foreach (Registrar::getRegistered() as $registrar) { |
| | | if (!$registrar->hasIntegration($this->service_name)) { |
| | | continue; |
| | | } |
| | | $config = $registrar->getIntegration($this->service_name); |
| | | if ($config->getContent_type() && $config->getContent_type() === $product_type) { |
| | | $post_type = jvbCheckBase($registrar->getSlug()); |
| | | break; |
| | | } |
| | | |
| | | } |
| | | |
| | | if (!$post_type) { |
| | | return false; |
| | | } |
| | | |
| | | // Check if item already exists |
| | | $existing = get_posts([ |
| | | 'post_type' => $post_type, |
| | | 'meta_key' => BASE . '_square_catalog_id', |
| | | 'meta_value' => $item['id'], |
| | | 'posts_per_page' => 1 |
| | | ]); |
| | | |
| | | $post_data = [ |
| | | 'post_title' => $item['item_data']['name'] ?? '', |
| | | 'post_content' => $item['item_data']['description'] ?? '', |
| | | 'post_type' => $post_type, |
| | | 'post_status' => 'publish' |
| | | ]; |
| | | |
| | | if (!empty($existing)) { |
| | | $post_data['ID'] = $existing[0]->ID; |
| | | $post_id = wp_update_post($post_data); |
| | | } else { |
| | | $post_id = wp_insert_post($post_data); |
| | | } |
| | | |
| | | if (!$post_id || is_wp_error($post_id)) { |
| | | return false; |
| | | } |
| | | |
| | | // Map and save meta fields |
| | | $this->mapSquareFieldsToWordPress($post_id, $item); |
| | | |
| | | return $post_id; |
| | | } |
| | | |
| | | /** |
| | | * Map Square fields to WordPress meta |
| | | */ |
| | | private function mapSquareFieldsToWordPress(int $post_id, array $item): void |
| | | { |
| | | $meta = Meta::forPost($post_id); |
| | | $field_map = $this->getFieldMapping(get_post_type($post_id)); |
| | | |
| | | $values_to_save = []; |
| | | |
| | | // Save Square ID |
| | | $values_to_save['_square_catalog_id'] = $item['id']; |
| | | $values_to_save['_square_last_sync'] = current_time('mysql'); |
| | | |
| | | // Handle variations if present |
| | | if (!empty($item['item_data']['variations'])) { |
| | | $variations_data = []; |
| | | |
| | | foreach ($item['item_data']['variations'] as $index => $variation) { |
| | | $var_data = [ |
| | | 'name' => $variation['item_variation_data']['name'] ?? '', |
| | | 'sku' => $variation['item_variation_data']['_square_sku'] ?? '', |
| | | ]; |
| | | |
| | | // Extract price |
| | | if (!empty($variation['item_variation_data']['price_money'])) { |
| | | $var_data['price'] = $variation['item_variation_data']['price_money']['amount'] / 100; |
| | | } |
| | | |
| | | // Extract other variation fields |
| | | $variation_map = $this->getVariationMapping(get_post_type($post_id)); |
| | | foreach ($variation_map as $square_field => $wp_field) { |
| | | if (isset($variation['item_variation_data'][$square_field])) { |
| | | $var_data[$wp_field] = $variation['item_variation_data'][$square_field]; |
| | | } |
| | | } |
| | | |
| | | $variations_data[] = $var_data; |
| | | |
| | | // Save variation Square ID |
| | | update_post_meta($post_id, BASE . '_square_variation_' . $index . '_id', $variation['id']); |
| | | } |
| | | |
| | | $values_to_save['product_variations'] = $variations_data; |
| | | } |
| | | |
| | | // Map other fields |
| | | foreach ($field_map as $square_field => $wp_field) { |
| | | if ($square_field !== 'price' && isset($item['item_data'][$square_field])) { |
| | | $values_to_save[$wp_field] = $item['item_data'][$square_field]; |
| | | } |
| | | } |
| | | |
| | | // Save all values at once |
| | | $meta->setAll($values_to_save); |
| | | } |
| | | |
| | | /** |
| | | * Process customer sync |
| | | */ |
| | | private function processSyncCustomer(array $data): array |
| | | { |
| | | $user_id = $data['user_id'] ?? 0; |
| | | |
| | | if (!$user_id) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => ['error' => 'No user ID provided'] |
| | | ]; |
| | | } |
| | | |
| | | $user = get_user_by('ID', $user_id); |
| | | if (!$user) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => ['error' => 'User not found'] |
| | | ]; |
| | | } |
| | | |
| | | // Get or create Square customer |
| | | $square_customer_id = $this->getOrCreateSquareCustomer([ |
| | | 'email' => $user->user_email, |
| | | 'name' => $user->display_name |
| | | ]); |
| | | |
| | | if ($square_customer_id) { |
| | | update_user_meta($user_id, BASE . '_square_customer_id', $square_customer_id); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => ['customer_id' => $square_customer_id] |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => ['error' => 'Could not sync customer'] |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Handle catalog webhook |
| | | */ |
| | | private function handleCatalogWebhook(array $data): bool |
| | | { |
| | | // Queue sync from Square for updated items |
| | | $this->queueOperation('import_catalog', [ |
| | | 'types' => ['ITEM'], |
| | | 'updated_at' => $data['object']['catalog_version']['updated_at'] ?? null |
| | | ], [ |
| | | 'delay' => 60 // Wait a minute to batch multiple updates |
| | | ]); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Handle customer webhook |
| | | */ |
| | | private function handleCustomerWebhook(array $data): bool |
| | | { |
| | | $square_customer_id = $data['object']['customer']['id'] ?? ''; |
| | | $email = $data['object']['customer']['email_address'] ?? ''; |
| | | |
| | | if (!$square_customer_id || !$email) { |
| | | return false; |
| | | } |
| | | |
| | | // Find WordPress user with this Square customer ID |
| | | $users = get_users([ |
| | | 'meta_key' => BASE . '_square_customer_id', |
| | | 'meta_value' => $square_customer_id, |
| | | 'number' => 1 |
| | | ]); |
| | | |
| | | if (!empty($users)) { |
| | | // Update user meta with latest Square data |
| | | $user = $users[0]; |
| | | update_user_meta($user->ID, BASE . '_square_customer_updated', current_time('mysql')); |
| | | |
| | | // Clear cached customer data |
| | | $this->cache->forget('square_customer_' . $user->ID); |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Handle payment webhook |
| | | */ |
| | | private function handlePaymentWebhook(array $data): bool |
| | | { |
| | | $payment_id = $data['object']['payment']['id'] ?? ''; |
| | | $order_id = $data['object']['payment']['order_id'] ?? ''; |
| | | $status = $data['object']['payment']['status'] ?? ''; |
| | | |
| | | if (!$payment_id) { |
| | | return false; |
| | | } |
| | | |
| | | if ($order_id) { |
| | | $order = $this->getOrderPost($order_id); |
| | | if ($order) { |
| | | Meta::forPost($order)->set('square_payment_status', $status); |
| | | } |
| | | } |
| | | |
| | | // Trigger action for other integrations |
| | | do_action(BASE . 'square_payment_updated', $payment_id, $status, $order_id, $data); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Get the appropriate API base URL |
| | | */ |
| | | protected function getApiUrl(string $endpoint, ?string $baseKey = null): string |
| | | { |
| | | return rtrim($this->apiBase[$this->environment], '/') . '/' . ltrim($endpoint, '/'); |
| | | } |
| | | |
| | | /** |
| | | * Handle importing catalog from Square |
| | | */ |
| | | protected function handleImportFromSquare(): array |
| | | { |
| | | if (!$this->locationId) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Please select a location first' |
| | | ]; |
| | | } |
| | | |
| | | $this->queueOperation('import_catalog', [ |
| | | 'types' => ['ITEM'], |
| | | 'user_id' => $this->userID |
| | | ], [ |
| | | 'priority' => 'normal' |
| | | ]); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Import from Square queued' |
| | | ]; |
| | | } |
| | | /** |
| | | * Handle syncing to Square |
| | | */ |
| | | protected function handleSyncToSquare(): array |
| | | { |
| | | if (!$this->locationId) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Please select a location first' |
| | | ]; |
| | | } |
| | | |
| | | $post_types = array_map(function($type) { |
| | | return jvbCheckBase($type); |
| | | }, $this->syncPostTypes); |
| | | |
| | | // Get all posts to sync |
| | | $posts = get_posts([ |
| | | 'post_type' => $post_types, |
| | | 'posts_per_page' => -1, |
| | | 'post_status' => 'publish' |
| | | ]); |
| | | |
| | | $post_ids = wp_list_pluck($posts, 'ID'); |
| | | |
| | | // Queue sync operation |
| | | $this->queueOperation('sync_to_square', [ |
| | | 'items' => $post_ids, |
| | | 'user_id' => $this->userID |
| | | ], [ |
| | | 'priority' => 'normal' |
| | | ]); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => sprintf('Queued %d items for sync to Square', count($post_ids)) |
| | | ]; |
| | | } |
| | | /** |
| | | * Refresh category mappings from Square |
| | | */ |
| | | protected function handleRefreshCategories(): array |
| | | { |
| | | // Refresh category mappings from Square |
| | | $response = $this->getRequest('catalog/list?types=CATEGORY'); |
| | | |
| | | if (!is_wp_error($response) && isset($response['objects'])) { |
| | | $count = 0; |
| | | foreach ($response['objects'] as $category) { |
| | | if ($category['type'] === 'CATEGORY') { |
| | | $name = $category['category_data']['name'] ?? ''; |
| | | if ($name) { |
| | | update_option( |
| | | BASE . 'square_category_' . sanitize_title($name), |
| | | $category['id'] |
| | | ); |
| | | $count++; |
| | | } |
| | | } |
| | | } |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => sprintf('Refreshed %d categories', $count), |
| | | 'count' => $count |
| | | ]; |
| | | } else { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to refresh categories' |
| | | ]; |
| | | } |
| | | } |
| | | /** |
| | | * Get available Square locations |
| | | */ |
| | | protected function handleGetLocations(): array |
| | | { |
| | | // Make sure we have an access token |
| | | if (empty($this->access_token)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Access token required to fetch locations' |
| | | ]; |
| | | } |
| | | |
| | | // Fetch available Square locations |
| | | $response = $this->getRequest('locations'); |
| | | |
| | | if (!is_wp_error($response) && isset($response['locations'])) { |
| | | $locations = array_map(function($loc) { |
| | | return [ |
| | | 'id' => $loc['id'], |
| | | 'name' => $loc['name'] ?? 'Unnamed Location', |
| | | 'address' => $loc['address']['address_line_1'] ?? '', |
| | | 'status' => $loc['status'] ?? 'ACTIVE' |
| | | ]; |
| | | }, $response['locations']); |
| | | |
| | | // Filter to only active locations |
| | | $active_locations = array_filter($locations, function($loc) { |
| | | return $loc['status'] === 'ACTIVE'; |
| | | }); |
| | | |
| | | // Format for select field |
| | | if (isset($_REQUEST['for_select'])) { |
| | | $options = []; |
| | | foreach ($active_locations as $location) { |
| | | $label = $location['name']; |
| | | if ($location['address']) { |
| | | $label .= ' - ' . $location['address']; |
| | | } |
| | | $options[$location['id']] = $label; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'options' => $options |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'locations' => array_values($active_locations) |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to fetch locations. Please check your access token and environment settings.' |
| | | ]; |
| | | } |
| | | |
| | | protected function validateCredentials(array $credentials): bool |
| | | { |
| | | // For OAuth services, we need different validation based on setup stage |
| | | if ($this->isOAuthService) { |
| | | // Stage 1: Initial setup (need app credentials) |
| | | $hasAppCredentials = !empty($credentials['client_id']) |
| | | && !empty($credentials['client_secret']); |
| | | |
| | | if (!$hasAppCredentials) { |
| | | $this->logError('Missing required Square application credentials', [ |
| | | 'has_app_id' => !empty($credentials['client_id']), |
| | | 'has_app_secret' => !empty($credentials['client_secret']) |
| | | ]); |
| | | return false; |
| | | } |
| | | |
| | | // Stage 2: After OAuth (should have access token) |
| | | // Access token might not exist yet if we're just saving app credentials |
| | | // before OAuth flow, so we only validate format if it exists |
| | | if (!empty($credentials['access_token'])) { |
| | | // Validate token format (Square tokens are long alphanumeric strings) |
| | | if (!is_string($credentials['access_token']) || |
| | | strlen($credentials['access_token']) < 20) { |
| | | $this->logError('Invalid access token format'); |
| | | return false; |
| | | } |
| | | |
| | | // Validate merchant_id if present (should be set after OAuth) |
| | | if (!empty($credentials['merchant_id'])) { |
| | | if (!is_string($credentials['merchant_id']) || |
| | | strlen($credentials['merchant_id']) < 10) { |
| | | $this->logError('Invalid merchant ID format'); |
| | | return false; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Validate environment setting |
| | | if (isset($credentials['environment'])) { |
| | | $validEnvironments = ['sandbox', 'production']; |
| | | if (!in_array($credentials['environment'], $validEnvironments)) { |
| | | $this->logError('Invalid environment setting', [ |
| | | 'provided' => $credentials['environment'], |
| | | 'valid' => $validEnvironments |
| | | ]); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | // Fallback for non-OAuth (shouldn't happen for Square, but good practice) |
| | | return !empty($credentials); |
| | | } |
| | | |
| | | |
| | | protected function sanitizeCredentials(array $credentials): array |
| | | { |
| | | $sanitized = []; |
| | | |
| | | // Define which fields should be sanitized and how |
| | | $text_fields = [ |
| | | 'client_id', |
| | | 'client_secret', |
| | | 'access_token', |
| | | 'refresh_token', |
| | | 'merchant_id', |
| | | 'location_id', |
| | | 'webhook_signature_key' |
| | | ]; |
| | | |
| | | foreach ($credentials as $key => $value) { |
| | | // Handle text fields - trim whitespace but preserve case |
| | | if (in_array($key, $text_fields)) { |
| | | if (is_string($value)) { |
| | | $sanitized[$key] = trim($value); |
| | | } else { |
| | | $sanitized[$key] = $value; |
| | | } |
| | | } |
| | | // Handle environment field |
| | | elseif ($key === 'environment') { |
| | | $sanitized[$key] = sanitize_key($value); |
| | | } |
| | | // Handle boolean fields |
| | | elseif ($key === 'use_sandbox' || str_starts_with($key, 'enable_')) { |
| | | $sanitized[$key] = (bool) $value; |
| | | } |
| | | // Handle numeric fields |
| | | elseif ($key === 'expires_at' || $key === 'updated_at' || $key === 'expires_in') { |
| | | $sanitized[$key] = is_numeric($value) ? (int) $value : $value; |
| | | } |
| | | // Handle arrays (like locations) |
| | | elseif (is_array($value)) { |
| | | $sanitized[$key] = array_map('sanitize_text_field', $value); |
| | | } |
| | | // Default sanitization for other string fields |
| | | else { |
| | | if (is_string($value)) { |
| | | $sanitized[$key] = sanitize_text_field($value); |
| | | } else { |
| | | $sanitized[$key] = $value; |
| | | } |
| | | } |
| | | } |
| | | |
| | | return $sanitized; |
| | | } |
| | | |
| | | public function getServiceDescription(): string |
| | | { |
| | | return "Connect with Square for payment processing, inventory management, and customer synchronization."; |
| | | } |
| | | |
| | | public function setContentTypes(): void |
| | | { |
| | | $this->has_content = true; |
| | | $this->defaultContent = 'REGULAR'; |
| | | $base = $this->setBaseFields(); |
| | | $this->contentTypes = [ |
| | | 'REGULAR' => $base, |
| | | 'FOOD_AND_BEV' => array_merge($base, $this->setFoodAndBevFields()), |
| | | 'APPOINTMENTS_SERVICE' => array_merge($this->setAppointmentServiceFields()), |
| | | 'EVENT' => array_merge($this->setEventFields()), |
| | | 'GIFT_CARD' => array_merge($this->setGiftCardFields()) |
| | | ]; |
| | | } |
| | | public function getAdditionalFields(?string $content_type = null):array { |
| | | if ($content_type === 'customer') { |
| | | return $this->getCustomerFields(); |
| | | } |
| | | if ($content_type && array_key_exists($content_type, $this->contentTypes)){ |
| | | $array = $this->contentTypes[$content_type]; |
| | | return array_combine( |
| | | array_map(fn($k) => '_square_' . $k, array_keys($array)), |
| | | $array |
| | | ); |
| | | } else if ($content_type && !array_key_exists($content_type, $this->contentTypes)) { |
| | | error_log('Could not get default fields for '.$this->service_name.' content type: '.$content_type); |
| | | return []; |
| | | } |
| | | $array = $this->setBaseFields(); |
| | | return array_combine( |
| | | array_map(fn($k) => '_square_' . $k, array_keys($array)), |
| | | $array |
| | | ); |
| | | } |
| | | |
| | | protected function getCustomerFields():array |
| | | { |
| | | return [ |
| | | 'customer_id' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Square Customer ID', |
| | | 'hidden'=> true, |
| | | 'section'=> 'your-account' |
| | | ], |
| | | 'address_line_1' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Address Line 1', |
| | | 'hint' => 'ex: 6551 111 St NW', |
| | | 'required' => true, |
| | | 'section' => 'address' |
| | | ], |
| | | 'address_line_2' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Address Line 2', |
| | | 'hint' => 'ex: Unit 2', |
| | | 'section' => 'address' |
| | | ], |
| | | 'city' => [ |
| | | 'type' => 'text', |
| | | 'label'=> 'City', |
| | | 'section' => 'address', |
| | | 'required' => true, |
| | | ], |
| | | 'state' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Province', |
| | | 'hint' => 'The two-character code, example: AB', |
| | | 'default'=> 'AB', |
| | | 'section' => 'address', |
| | | 'required' => true, |
| | | ], |
| | | 'zip_code' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Postal Code', |
| | | 'section'=> 'address' |
| | | ], |
| | | 'countryCode' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Country Code', |
| | | 'hint' => 'The tw-character country code, example: CA', |
| | | 'default' => 'CA', |
| | | 'section' => 'address', |
| | | 'required' => true, |
| | | ] |
| | | ]; |
| | | } |
| | | protected function setBaseFields():array |
| | | { |
| | | return [ |
| | | 'price' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Price', |
| | | 'step' => 0.01, |
| | | 'max' => 99999, |
| | | 'description' => 'Price for this variation' |
| | | ], |
| | | 'sku' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'SKU', |
| | | 'description' => 'Stock keeping unit' |
| | | ], |
| | | 'cart_quantity' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Quantity', |
| | | 'hidden' => true, |
| | | ], |
| | | 'available_for_pickup' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available for Pick Up', |
| | | 'section'=> 'square-advanced' |
| | | ], |
| | | 'available_online' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available online', |
| | | 'section'=> 'square-advanced' |
| | | ], |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product (sizes, colors, etc.)', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->setVariationFields() |
| | | ], |
| | | ]; |
| | | } |
| | | |
| | | protected function setVariationFields(?string $type = null):array |
| | | { |
| | | $fields = [ |
| | | 'name' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Variation Name', |
| | | 'description' => 'e.g., "Small", "Large", "Red", etc.' |
| | | ], |
| | | 'sku' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'SKU', |
| | | 'description' => 'Stock keeping unit' |
| | | ], |
| | | 'price' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Price', |
| | | 'step' => 0.01, |
| | | 'max' => 99999, |
| | | 'description' => 'Price for this variation' |
| | | ], |
| | | 'track_inventory' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Track Inventory', |
| | | ], |
| | | 'item_id' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Square Variation ID', |
| | | 'description' => 'Square catalog ID for this variation', |
| | | 'hidden' => true |
| | | ], |
| | | 'last_sync' => [ |
| | | 'type' => 'datetime', |
| | | 'label' => 'Last Sync', |
| | | 'hidden' => true |
| | | ] |
| | | ]; |
| | | |
| | | switch ($type) { |
| | | case 'FOOD_AND_BEV': |
| | | $fields['ingredients'] = [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Ingredients List', |
| | | 'description' => 'Separate ingredients with commas', |
| | | ]; |
| | | $fields['preparation_time_duration'] = [ |
| | | 'type' => 'number', |
| | | 'label' => 'Preparation time (in minutes)', |
| | | ]; |
| | | break; |
| | | case 'GIFT_CARD': |
| | | $fields['gift_card_type'] = [ |
| | | 'type' => 'select', |
| | | 'label' => 'Gift Card Type', |
| | | 'options' => [ |
| | | 'PHYSICAL' => 'Physical', |
| | | 'DIGITAL' => 'Digital', |
| | | ], |
| | | 'default' => 'DIGITAL', |
| | | ]; |
| | | break; |
| | | case 'APPOINTMENTS_SERVICE': |
| | | $fields['service_duration'] = [ |
| | | 'type' => 'number', |
| | | 'label' => 'Duration of Service in Minutes' |
| | | ]; |
| | | $fields['available_for_booking'] = [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available for Booking' |
| | | ]; |
| | | break; |
| | | } |
| | | return array_combine( |
| | | array_map(fn($k) => '_square_' . $k, array_keys($fields)), |
| | | $fields |
| | | ); |
| | | } |
| | | protected function setFoodAndBevFields():array |
| | | { |
| | | return [ |
| | | 'ingredients' => [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Ingredients', |
| | | 'hint' => 'A comma separated list of ingredients' |
| | | ], |
| | | 'preparation_time_duration' => [ |
| | | 'label' => 'Preparation Time', |
| | | 'type' => 'number', |
| | | 'hint' => 'Preparation time in minutes' |
| | | ], |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product (sizes, colors, etc.)', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->setVariationFields('FOOD_AND_BEV') |
| | | ], |
| | | ]; |
| | | /* TODO: Set definitions for: |
| | | 'dietary_preferences' => [ |
| | | |
| | | ], |
| | | 'calories_text' => [ |
| | | |
| | | ], |
| | | */ |
| | | } |
| | | protected function setAppointmentServiceFields():array |
| | | { |
| | | return [ |
| | | |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product (sizes, colors, etc.)', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->setVariationFields('APPOINTMENTS_SERVICE') |
| | | ], |
| | | ]; |
| | | } |
| | | protected function setEventFields():array |
| | | { |
| | | return [ |
| | | 'venue_details' => [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Venue Details', |
| | | ], |
| | | 'capacity' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Capacity' |
| | | ], |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product (sizes, colors, etc.)', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->setVariationFields('EVENT') |
| | | ], |
| | | ]; |
| | | } |
| | | protected function setGiftCardFields():array |
| | | { |
| | | return [ |
| | | // 'allowed_locations' => [ |
| | | // |
| | | // ], |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product (sizes, colors, etc.)', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->setVariationFields('GIFT_CARD') |
| | | ], |
| | | ]; |
| | | } |
| | | |
| | | /********************************************************* |
| | | IMAGE PROCESSING |
| | | *********************************************************/ |
| | | /** |
| | | * Upload image file to Square |
| | | * |
| | | * @param int $imgID WordPress attachment ID |
| | | * @return array|WP_Error Upload result or error |
| | | */ |
| | | protected function uploadImageToSquare(int $imgID): array|WP_Error |
| | | { |
| | | $supported_image_id = $this->getSupportedImage($imgID); |
| | | |
| | | // Check if already uploaded |
| | | $existing_square_image_id = $this->getSquareImageId($supported_image_id); |
| | | if ($existing_square_image_id) { |
| | | return [ |
| | | 'success' => true, |
| | | 'image_id' => $existing_square_image_id |
| | | ]; |
| | | } |
| | | |
| | | $file_path = get_attached_file($supported_image_id); |
| | | if (!file_exists($file_path)) { |
| | | return new WP_Error('file_not_found', 'Image file not found'); |
| | | } |
| | | |
| | | // Verify file type |
| | | $mime_type = get_post_mime_type($supported_image_id); |
| | | if (!in_array($mime_type, ['image/jpeg', 'image/png', 'image/gif'])) { |
| | | return new WP_Error('invalid_type', 'Square only supports JPEG, PNG, and GIF images'); |
| | | } |
| | | |
| | | $image_title = get_the_title($supported_image_id); |
| | | $alt_text = get_post_meta($supported_image_id, '_wp_attachment_image_alt', true); |
| | | |
| | | // Build multipart request - SINGLE STEP |
| | | $boundary = wp_generate_password(24); |
| | | $headers = $this->getRequestHeaders(); |
| | | $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary; |
| | | |
| | | // Request JSON part |
| | | $request_json = [ |
| | | 'idempotency_key' => wp_generate_uuid4(), |
| | | 'image' => [ |
| | | 'type' => 'IMAGE', |
| | | 'id' => '#IMAGE_' . $supported_image_id . '_' . time(), |
| | | 'image_data' => [ |
| | | 'name' => $image_title ?: 'Image', |
| | | 'caption' => $alt_text ?: '' |
| | | ] |
| | | ], |
| | | 'object_id' => $supported_image_id |
| | | ]; |
| | | |
| | | $body = $this->buildMultipartBody($file_path, $request_json, $boundary); |
| | | |
| | | $response = wp_remote_post( |
| | | $this->getApiUrl('v2/catalog/images'), |
| | | [ |
| | | 'headers' => $headers, |
| | | 'body' => $body, |
| | | 'timeout' => 60 |
| | | ] |
| | | ); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return $response; |
| | | } |
| | | |
| | | $data = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | if (!empty($data['errors'])) { |
| | | return new WP_Error('upload_error', $data['errors'][0]['detail'] ?? 'Unknown error'); |
| | | } |
| | | |
| | | if (!empty($data['image']['id'])) { |
| | | $this->setSquareImageId($supported_image_id, $data['image']['id']); |
| | | return [ |
| | | 'success' => true, |
| | | 'image_id' => $data['image']['id'] |
| | | ]; |
| | | } |
| | | |
| | | return new WP_Error('upload_failed', 'Failed to upload image'); |
| | | } |
| | | |
| | | protected function buildMultipartBody(string $file_path, array $request_json, string $boundary): string |
| | | { |
| | | $eol = "\r\n"; |
| | | $body = ''; |
| | | |
| | | // Add request JSON part |
| | | $body .= '--' . $boundary . $eol; |
| | | $body .= 'Content-Disposition: form-data; name="request"' . $eol; |
| | | $body .= 'Content-Type: application/json' . $eol . $eol; |
| | | $body .= json_encode($request_json) . $eol; |
| | | |
| | | // Add image file part |
| | | $filename = basename($file_path); |
| | | $file_contents = file_get_contents($file_path); |
| | | $mime_type = mime_content_type($file_path); |
| | | |
| | | $body .= '--' . $boundary . $eol; |
| | | $body .= 'Content-Disposition: form-data; name="file"; filename="' . $filename . '"' . $eol; |
| | | $body .= 'Content-Type: ' . $mime_type . $eol . $eol; |
| | | $body .= $file_contents . $eol; |
| | | $body .= '--' . $boundary . '--' . $eol; |
| | | |
| | | return $body; |
| | | } |
| | | |
| | | /** |
| | | * Attach image to catalog item |
| | | * |
| | | * @param int $postID WordPress post ID |
| | | * @param string $square_image_id Square image ID |
| | | * @return array|WP_Error Result or error |
| | | */ |
| | | public function attachImageToCatalogItem(int $postID, string $square_image_id): array|WP_Error |
| | | { |
| | | $square_catalog_id = get_post_meta($postID, BASE . '_square_catalog_id', true); |
| | | |
| | | if (!$square_catalog_id) { |
| | | return new WP_Error('no_catalog_id', 'Post has not been synced to Square'); |
| | | } |
| | | |
| | | // Get the current catalog item |
| | | $response = $this->getRequest('catalog/object/' . $square_catalog_id); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return $response; |
| | | } |
| | | |
| | | if (empty($response['object'])) { |
| | | return new WP_Error('item_not_found', 'Catalog item not found in Square'); |
| | | } |
| | | |
| | | $catalog_object = $response['object']; |
| | | |
| | | // Add image to item data |
| | | $catalog_object['item_data']['image_ids'] = [$square_image_id]; |
| | | |
| | | // Update the catalog item |
| | | $update_response = $this->postRequest('catalog/batch-upsert', [ |
| | | 'idempotency_key' => wp_generate_uuid4(), |
| | | 'batches' => [[ |
| | | 'objects' => [$catalog_object] |
| | | ]] |
| | | ]); |
| | | |
| | | if (is_wp_error($update_response)) { |
| | | return $update_response; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Image attached to catalog item' |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Get stored Square image ID for an attachment |
| | | * |
| | | * @param int $attachment_id WordPress attachment ID |
| | | * @return string|false Square image ID or false if not found |
| | | */ |
| | | public function getSquareImageId(int $attachment_id): string|false |
| | | { |
| | | $image_id = get_post_meta($attachment_id, BASE . 'square_image_id', true); |
| | | |
| | | if ($image_id) { |
| | | // Verify it still exists in Square |
| | | return $this->verifySquareImage($image_id) ? $image_id : false; |
| | | } |
| | | |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Store Square image ID for an attachment |
| | | * |
| | | * @param int $attachment_id WordPress attachment ID |
| | | * @param string $square_image_id Square image ID |
| | | * @return bool Success |
| | | */ |
| | | public function setSquareImageId(int $attachment_id, string $square_image_id): bool |
| | | { |
| | | return update_post_meta($attachment_id, BASE . 'square_image_id', $square_image_id); |
| | | } |
| | | |
| | | /** |
| | | * Verify image exists in Square catalog |
| | | * |
| | | * @param string $square_image_id Square image ID |
| | | * @return bool Whether image exists |
| | | */ |
| | | protected function verifySquareImage(string $square_image_id): bool |
| | | { |
| | | $response = $this->getRequest('catalog/object/' . $square_image_id); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return false; |
| | | } |
| | | |
| | | return !empty($response['object']) && $response['object']['type'] === 'IMAGE'; |
| | | } |
| | | |
| | | /** |
| | | * Batch upload images for multiple posts |
| | | * |
| | | * @param array $post_ids Array of WordPress post IDs |
| | | * @return array Results for each post |
| | | */ |
| | | private function batchUploadImages(array $post_ids): array |
| | | { |
| | | $image_mappings = []; |
| | | $image_objects = []; |
| | | |
| | | // First pass: collect images that need uploading |
| | | foreach ($post_ids as $post_id) { |
| | | $thumbnail_id = get_post_thumbnail_id($post_id); |
| | | if (!$thumbnail_id) { |
| | | $image_mappings[$post_id] = null; |
| | | continue; |
| | | } |
| | | |
| | | // Check if already uploaded and still exists in Square |
| | | $existing_square_id = get_post_meta($thumbnail_id, BASE . '_square_image_id', true); |
| | | if ($existing_square_id && $this->verifySquareImage($existing_square_id)) { |
| | | $image_mappings[$post_id] = $existing_square_id; |
| | | continue; |
| | | } |
| | | |
| | | // Get supported image format (handles WebP conversion) |
| | | $supported_image_id = $this->getSupportedImage($thumbnail_id); |
| | | if (!$supported_image_id) { |
| | | $image_mappings[$post_id] = null; |
| | | continue; |
| | | } |
| | | |
| | | // Upload the image to Square using the proper endpoint |
| | | $square_image_id = $this->uploadSingleImageToSquare($supported_image_id, $post_id); |
| | | |
| | | if ($square_image_id && !is_wp_error($square_image_id)) { |
| | | $image_mappings[$post_id] = $square_image_id; |
| | | // Store the Square image ID for future reference |
| | | update_post_meta($thumbnail_id, BASE . '_square_image_id', $square_image_id); |
| | | } else { |
| | | $this->logError('Failed to upload image for post', [ |
| | | 'post_id' => $post_id, |
| | | 'thumbnail_id' => $thumbnail_id, |
| | | 'error' => is_wp_error($square_image_id) ? $square_image_id->get_error_message() : 'Unknown error' |
| | | ]); |
| | | $image_mappings[$post_id] = null; |
| | | } |
| | | } |
| | | |
| | | return $image_mappings; |
| | | } |
| | | |
| | | /** |
| | | * Upload a single image to Square using the CreateCatalogImage endpoint |
| | | * |
| | | * @param int $attachment_id WordPress attachment ID |
| | | * @param int $post_id Associated post ID |
| | | * @return string|WP_Error Square image ID or error |
| | | */ |
| | | private function uploadSingleImageToSquare(int $attachment_id, int $post_id): string|WP_Error |
| | | { |
| | | $file_path = get_attached_file($attachment_id); |
| | | |
| | | if (!file_exists($file_path)) { |
| | | return new WP_Error('file_not_found', 'Image file not found'); |
| | | } |
| | | |
| | | // Verify file type |
| | | $mime_type = get_post_mime_type($attachment_id); |
| | | $allowed_types = ['image/jpeg', 'image/png', 'image/gif']; |
| | | |
| | | if (!in_array($mime_type, $allowed_types)) { |
| | | return new WP_Error('invalid_type', 'Square only supports JPEG, PNG, and GIF images'); |
| | | } |
| | | |
| | | // Prepare multipart form data |
| | | $boundary = wp_generate_password(24); |
| | | $headers = $this->getRequestHeaders(); |
| | | $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary; |
| | | |
| | | // Build the request body |
| | | $eol = "\r\n"; |
| | | $body = ''; |
| | | |
| | | // Add request JSON |
| | | $request_data = [ |
| | | 'idempotency_key' => wp_generate_uuid4(), |
| | | 'image' => [ |
| | | 'type' => 'IMAGE', |
| | | 'id' => '#TEMP_IMAGE_' . $attachment_id . '_' . time(), |
| | | 'image_data' => [ |
| | | 'name' => get_the_title($attachment_id) ?: basename($file_path), |
| | | 'caption' => get_post_meta($attachment_id, '_wp_attachment_image_alt', true) ?: |
| | | sprintf('Image for %s', get_the_title($post_id)) |
| | | ] |
| | | ] |
| | | ]; |
| | | |
| | | $body .= '--' . $boundary . $eol; |
| | | $body .= 'Content-Disposition: form-data; name="request"' . $eol; |
| | | $body .= 'Content-Type: application/json' . $eol . $eol; |
| | | $body .= json_encode($request_data) . $eol; |
| | | |
| | | // Add image file |
| | | $filename = basename($file_path); |
| | | $file_contents = file_get_contents($file_path); |
| | | |
| | | $body .= '--' . $boundary . $eol; |
| | | $body .= 'Content-Disposition: form-data; name="image"; filename="' . $filename . '"' . $eol; |
| | | $body .= 'Content-Type: ' . $mime_type . $eol . $eol; |
| | | $body .= $file_contents . $eol; |
| | | |
| | | // End boundary |
| | | $body .= '--' . $boundary . '--' . $eol; |
| | | |
| | | // Make the request to the CreateCatalogImage endpoint |
| | | $response = wp_remote_post( |
| | | $this->getApiUrl('catalog/images'), |
| | | [ |
| | | 'headers' => $headers, |
| | | 'body' => $body, |
| | | 'timeout' => 60 |
| | | ] |
| | | ); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return $response; |
| | | } |
| | | |
| | | $response_body = wp_remote_retrieve_body($response); |
| | | $data = json_decode($response_body, true); |
| | | |
| | | if (!empty($data['errors'])) { |
| | | $error_message = $data['errors'][0]['detail'] ?? 'Unknown error'; |
| | | return new WP_Error('upload_error', $error_message); |
| | | } |
| | | |
| | | if (!empty($data['image']['id'])) { |
| | | $this->logDebug('Successfully uploaded image to Square', [ |
| | | 'post_id' => $post_id, |
| | | 'attachment_id' => $attachment_id, |
| | | 'square_image_id' => $data['image']['id'] |
| | | ]); |
| | | return $data['image']['id']; |
| | | } |
| | | |
| | | return new WP_Error('upload_failed', 'Failed to get image ID from Square response'); |
| | | } |
| | | |
| | | protected function handleApiError(int $code, string $body, string $endpoint): void |
| | | { |
| | | parent::handleApiError($code, $body, $endpoint); |
| | | |
| | | $decoded = json_decode($body, true); |
| | | $errorCode = $decoded['errors'][0]['code'] ?? ''; |
| | | |
| | | // Handle Square-specific OAuth errors |
| | | if ($errorCode === 'ACCESS_TOKEN_EXPIRED') { |
| | | $this->logDebug('Access token expired, attempting refresh'); |
| | | if ($this->refreshOAuthToken()) { |
| | | // Token refreshed, could retry the request |
| | | $this->logDebug('Token refreshed successfully'); |
| | | } |
| | | } elseif ($errorCode === 'UNAUTHORIZED' || $errorCode === 'ACCESS_TOKEN_REVOKED') { |
| | | $this->logError('Square authorization revoked or invalid', [ |
| | | 'error_code' => $errorCode, |
| | | 'user_id' => $this->userID |
| | | ]); |
| | | // Clear invalid credentials |
| | | $this->deleteCredentials(); |
| | | } |
| | | } |
| | | |
| | | public function createSquareOrder(array $items, ?string $customer_id, array $data): array|WP_Error |
| | | { |
| | | // Build line items for Square |
| | | $line_items = []; |
| | | foreach ($items as $item) { |
| | | $line_item = [ |
| | | 'quantity' => (string)$item['quantity'], // MUST be string! |
| | | ]; |
| | | |
| | | // Use catalog_object_id if available (recommended) |
| | | if (!empty($item['catalog_object_id'])) { |
| | | $line_item['catalog_object_id'] = $item['catalog_object_id']; |
| | | $line_item['catalog_version'] = $item['catalog_version'] ?? null; |
| | | } else { |
| | | // Ad-hoc line item (not recommended - no tax/inventory automation) |
| | | $line_item['name'] = $item['name']; |
| | | $line_item['base_price_money'] = [ |
| | | 'amount' => (int)$item['_square_price'], |
| | | 'currency' => $this->getCurrency() |
| | | ]; |
| | | } |
| | | |
| | | if (!empty($item['note'])) { |
| | | $line_item['note'] = $item['note']; |
| | | } |
| | | |
| | | $line_items[] = $line_item; |
| | | } |
| | | |
| | | $order_data = [ |
| | | 'idempotency_key' => wp_generate_uuid4(), // Different from payment idempotency key |
| | | 'order' => [ |
| | | 'location_id' => $this->locationId, |
| | | 'line_items' => $line_items, |
| | | 'state' => 'OPEN' |
| | | ] |
| | | ]; |
| | | |
| | | // Add customer if available |
| | | if ($customer_id) { |
| | | $order_data['order']['customer_id'] = $customer_id; |
| | | } |
| | | |
| | | // Add metadata |
| | | if (!empty($data['note'])) { |
| | | $order_data['order']['metadata'] = [ |
| | | 'special_instructions' => $data['note'] |
| | | ]; |
| | | } |
| | | |
| | | if (!empty($data['pickup_time'])) { |
| | | $order_data['order']['metadata']['pickup_time'] = $data['pickup_time']; |
| | | } |
| | | |
| | | return $this->postRequest('orders', $order_data); |
| | | } |
| | | |
| | | public function createSquarePayment( |
| | | string $source_id, |
| | | string $idempotency_key, |
| | | int $amount_cents, |
| | | string $order_id, |
| | | ?string $customer_id |
| | | ): array|WP_Error |
| | | { |
| | | $payment_data = [ |
| | | 'idempotency_key' => $idempotency_key, |
| | | 'source_id' => $source_id, |
| | | 'amount_money' => [ |
| | | 'amount' => $amount_cents, // Already in cents! |
| | | 'currency' => $this->getCurrency() |
| | | ], |
| | | 'order_id' => $order_id, |
| | | 'location_id' => $this->locationId, |
| | | 'autocomplete' => true, // Capture immediately |
| | | ]; |
| | | |
| | | // Add customer if available |
| | | if ($customer_id) { |
| | | $payment_data['customer_id'] = $customer_id; |
| | | } |
| | | |
| | | // Add reference ID for tracking |
| | | $payment_data['reference_id'] = 'WP_' . time(); |
| | | |
| | | return $this->postRequest('payments', $payment_data); |
| | | } |
| | | |
| | | public function saveOrderToWordPress(array $order_data): int |
| | | { |
| | | // Extract customer info |
| | | $customer_email = $order_data['customer']['email'] ?? ''; |
| | | $customer_name = $order_data['customer']['name'] ?? ''; |
| | | |
| | | // Find or create WP user for logged-in association |
| | | $user_id = 0; |
| | | if ($customer_email) { |
| | | $user = get_user_by('email', $customer_email); |
| | | if ($user) { |
| | | $user_id = $user->ID; |
| | | // Store Square customer ID on user |
| | | if (!empty($order_data['square_customer_id'])) { |
| | | update_user_meta($user_id, BASE . '_square_customer_id', $order_data['square_customer_id']); |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Create order post |
| | | $order_post_id = wp_insert_post([ |
| | | 'post_type' => BASE.$this->orderPostType, |
| | | 'post_title' => 'Order #' . $order_data['square_order_id'], |
| | | 'post_status' => 'publish', |
| | | 'post_author' => $user_id // Associate with user if logged in |
| | | ]); |
| | | |
| | | if (!$order_post_id || is_wp_error($order_post_id)) { |
| | | $this->logError('Failed to create order post', ['order_data' => $order_data]); |
| | | return 0; |
| | | } |
| | | |
| | | // Save all order meta |
| | | $meta = Meta::forPost($order_post_id); |
| | | $fields = $this->getOrderFields(); |
| | | unset($fields['post_title']); |
| | | |
| | | $meta->setAll([ |
| | | 'square_order_id' => $order_data['square_order_id'], |
| | | 'square_payment_id' => $order_data['square_payment_id'] ?? '', |
| | | 'square_customer_id' => $order_data['square_customer_id'] ?? '', |
| | | 'amount' => $order_data['amount'], |
| | | 'status' => $order_data['status'], |
| | | 'fulfillment_status' => $order_data['fulfillment_status'] ?? 'PROPOSED', |
| | | 'pickup_time' => $order_data['pickup_time'] ?? '', |
| | | 'customer_email' => $customer_email, |
| | | 'customer_name' => $customer_name, |
| | | 'customer_phone' => $order_data['customer']['phone'] ?? '', |
| | | 'special_instructions' => $order_data['note'] ?? '', |
| | | 'items' => $order_data['items'], |
| | | 'receipt_url' => $order_data['receipt_url'] ?? '', |
| | | 'created_at' => current_time('mysql'), |
| | | 'updated_at' => current_time('mysql') |
| | | ]); |
| | | |
| | | return $order_post_id; |
| | | } |
| | | |
| | | /** |
| | | * Get currency code |
| | | */ |
| | | private function getCurrency(): string |
| | | { |
| | | return get_option(BASE . 'currency', 'CAD'); |
| | | } |
| | | |
| | | /** |
| | | * Get customer with saved cards (2025-compliant) |
| | | */ |
| | | public function getUserCards(string $customer_id): array |
| | | { |
| | | $response = $this->getRequest('cards?customer_id=' . $customer_id); |
| | | return $response['cards'] ?? []; |
| | | } |
| | | |
| | | |
| | | public function getUserOrders(string $customer_email): array |
| | | { |
| | | // First get Square customer ID |
| | | $customer_response = $this->postRequest('customers/search', [ |
| | | 'filter' => [ |
| | | 'email_address' => ['exact' => $customer_email] |
| | | ] |
| | | ]); |
| | | |
| | | $customer_id = $customer_response['customers'][0]['id'] ?? null; |
| | | if (!$customer_id) { |
| | | return []; |
| | | } |
| | | |
| | | // Get their orders |
| | | $orders_response = $this->postRequest('orders/search', [ |
| | | 'filter' => [ |
| | | 'customer_filter' => [ |
| | | 'customer_ids' => [$customer_id] |
| | | ] |
| | | ], |
| | | 'sort' => [ |
| | | 'sort_field' => 'CREATED_AT', |
| | | 'sort_order' => 'DESC' |
| | | ], |
| | | 'limit' => 50 |
| | | ]); |
| | | |
| | | return $orders_response['orders'] ?? []; |
| | | } |
| | | |
| | | public function checkOrderStatus(string $order_id): ?string |
| | | { |
| | | // Fetch from Square |
| | | $response = $this->getRequest('orders/' . $order_id); |
| | | if (!is_wp_error($response)) { |
| | | return $response['order']['state'] ?? null; |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Single-item sync. Called by IntegrationExecutor::processSyncTo(). |
| | | * Delegates to syncBatchToService since Square uses batch-upsert. |
| | | */ |
| | | public function syncPostToService(int $postID): array|WP_Error |
| | | { |
| | | return $this->syncBatchToService(['items' => [$postID]]); |
| | | } |
| | | |
| | | /** |
| | | * Batch sync — preferred by IntegrationExecutor when available. |
| | | * Wraps existing processSyncToSquare which already handles batches. |
| | | */ |
| | | public function syncBatchToService(array $data): array|WP_Error |
| | | { |
| | | $result = $this->processSyncToSquare($data); |
| | | |
| | | if (empty($result['success'])) { |
| | | $errors = implode(', ', $result['result']['errors'] ?? ['Sync failed']); |
| | | return new WP_Error('square_sync_failed', $errors); |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | |
| | | /** |
| | | * Delete catalog object from Square. |
| | | * Called by IntegrationExecutor::processDeleteFrom(). |
| | | */ |
| | | public function deleteFromService(string $externalId): array|WP_Error |
| | | { |
| | | $result = $this->processDeleteFromSquare(['square_ids' => [$externalId]]); |
| | | |
| | | if (empty($result['success'])) { |
| | | return new WP_Error('square_delete_failed', $result['result']['error'] ?? 'Delete failed'); |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | |
| | | /** |
| | | * Import from Square catalog → WordPress. |
| | | * Called by IntegrationExecutor::processImport(). |
| | | */ |
| | | public function importFromService(array $data): array|WP_Error |
| | | { |
| | | $result = $this->processSyncFromSquare($data); |
| | | |
| | | if (empty($result['success'])) { |
| | | return new WP_Error('square_import_failed', $result['result']['error'] ?? 'Import failed'); |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | |
| | | /** |
| | | * Sync customer to Square. |
| | | * Called by IntegrationExecutor::processSyncCustomer(). |
| | | */ |
| | | public function syncCustomer(array $data): array|WP_Error |
| | | { |
| | | $result = $this->processSyncCustomer($data); |
| | | |
| | | if (empty($result['success'])) { |
| | | return new WP_Error('square_customer_sync_failed', $result['result']['error'] ?? 'Customer sync failed'); |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | |
| | | public function addDashboardPages():void |
| | | { |
| | | $page = JVB()->dashboard()->addPage('Your Orders', 'orders', 'receipt'); |
| | | $page->setScripts(['jvb-crud']); |
| | | } |
| | | public function renderDashPage():void |
| | | { |
| | | $this->determineUser(); |
| | | |
| | | $crud = new CRUDSkeleton(); |
| | | $crud->icon('receipt'); |
| | | $crud->title('Your Orders','Here you can see your past orders, and reorder from there if you\'d like'); |
| | | $crud->content($this->orderPostType, 'Order', 'Orders'); |
| | | $crud->addSearch(); |
| | | $crud->addCapabilities(['view']); |
| | | $crud->setEmptyState(sprintf( |
| | | '<div class="empty-state"> |
| | | <h3>%sNothing here%s</h3> |
| | | <p>It doesn\'t look like you have any orders yet.</p> |
| | | <p>Head on over to <a href="%s">our menu</a> and make your first order!</p> |
| | | </div>', |
| | | jvbDashIcon($crud->getIcon()), |
| | | jvbDashIcon($crud->getIcon()), |
| | | get_post_type_archive_link(BASE.'menu_item') |
| | | )); |
| | | |
| | | |
| | | $crud->render(); |
| | | return $this->contentTypes[$content_type]; |
| | | } |
| | | |
| | | |
| | | public function getOrderPost(string $order_id):int|false |
| | | { |
| | | $posts = new WP_Query([ |
| | | 'post_type' => BASE.$this->orderPostType, |
| | | 'posts_per_page' => 1, |
| | | 'meta_key' => BASE.'square_order_id', |
| | | 'meta_value' => $order_id, |
| | | 'fields' => 'ids', |
| | | ]); |
| | | wp_reset_postdata(); |
| | | return $posts->have_posts() ? $posts[0] : false; |
| | | } |
| | | public function getOrderHistory(int $user_id):array |
| | | { |
| | | $posts = new WP_Query([ |
| | | 'post_type' => BASE.$this->orderPostType, |
| | | 'posts_per_page' => 25, |
| | | 'author' => $user_id, |
| | | 'orderby' => 'date', |
| | | 'order' => 'desc', |
| | | 'fields' => 'ids', |
| | | ]); |
| | | wp_reset_postdata(); |
| | | return array_map(function ($post) { |
| | | $fields = Meta::forPost($post); |
| | | $fields['wp_order_id'] = $post; |
| | | return $fields; |
| | | }, $posts->posts); |
| | | |
| | | } |
| | | |
| | | public function formatPrice(string|float|int $priceValue):int |
| | | { |
| | | //Convert dollars to cents |
| | | return intval(floatval($priceValue) * 100); |
| | | } |
| | | } |