title = 'Square'; $this->icon = 'square-logo'; $this->supportsWebp = false; $this->canSync = [ 'create' => true, 'update' => true, 'delete' => true, ]; $this->defineFields(); $this->defineInstructions(); $this->defineOAuth(); $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 Square Developer Dashboard', 'Create a new application or select an existing one', 'Navigate to the OAuth section', 'Add this redirect URL: ' . esc_html($this->getRedirectUri()) . '', 'Copy the Application ID and Application Secret', 'Click "Authorize Connection" below to complete OAuth flow' ]; } protected function defineActions():void { $this->addAction( 'Select Location', [$this, 'handleSelectLocation'], 'select_location', 'building-office', ); $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']); } protected function initialize(): void { $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'); } $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 ]; $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'); } 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' ], 'square_order_id' => [ 'type' => 'text', 'label' => 'Square Order ID', ], 'square_payment_id' => [ 'type' => 'text', 'label' => 'Square Payment ID', ], 'square_customer_id' => [ 'type' => 'text', 'label' => 'Square Customer ID', ], 'amount' => [ 'type' => 'number', 'label' => 'Total Amount (cents)', ], 'square_payment_status' => [ 'type' => 'select', 'label' => 'Order Status', 'options' => [ 'PROPOSED' => 'Proposed', 'RESERVED' => 'Reserved', 'PREPARED' => 'Prepared (Ready for Pickup)', 'COMPLETED' => 'Completed', 'CANCELED' => 'Canceled' ], ], 'fulfillment_status' => [ 'type' => 'select', 'label' => 'Fulfillment Status', 'options' => [ 'PROPOSED' => 'Proposed', 'RESERVED' => 'Reserved', 'PREPARED' => 'Prepared', 'COMPLETED' => 'Completed', 'CANCELED' => 'Canceled', 'FAILED' => 'Failed' ], ], 'pickup_time' => [ 'type' => 'datetime', 'label' => 'Pickup Time' ], 'customer_email' => [ 'type' => 'email', 'label' => 'Customer Email', ], 'customer_name' => [ 'type' => 'text', 'label' => 'Customer Name', ], 'customer_phone' => [ 'type' => 'phone', 'label' => 'Customer Phone', 'section'=> 'your-account' ], 'special_instructions' => [ 'type' => 'textarea', 'label' => 'Special Instructions', ], 'items' => [ 'type' => 'repeater', 'label' => 'Order Items', 'fields' => [ 'name' => ['type' => 'text', 'label' => 'Item Name'], 'quantity' => ['type' => 'number', 'label' => 'Quantity'], 'price' => ['type' => 'number', 'label' => 'Price'], 'note' => ['type' => 'text', 'label' => 'Note'] ] ], 'receipt_url' => [ 'type' => 'url', 'label' => 'Receipt URL', ], 'created_at' => [ 'type' => 'datetime', 'label' => 'Created At', ], 'updated_at' => [ 'type' => 'datetime', 'label' => 'Last Updated', ] ]; } /******************************************************************** * UTILITY METHODS ********************************************************************/ protected function getEnvironment():string|false { 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 { 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'])) { $locations = $response['locations']; $options=[]; foreach ($locations as $l) { if ($l['status'] === 'ACTIVE') { $options[$l['id']] = $l['name']??$l['id']; } } if (!empty($options)) { $this->saveCredentials(['locations' => $options], false); } return $this->response(true, 'Found locations. Stored in table for reference.', $locations); } return $this->response(false, 'No locations found.'); } protected function getLocationOptions():array { return $this->loadCredentials()['locations']??[]; } protected function getBaseURL():string { return $this->getEnvironment() === 'production' ? 'https://connect.squareup.com' : 'https://connect.squareupsandbox.com'; } protected function getAccessToken():string { return $this->loadCredentials()['access_token']??''; } /*************************************************************** * ACTIONS ***************************************************************/ public function handleSelectLocation(array $data):array { if (empty($data['location_id']??'')) { return $this->response(false, 'No location selected'); } $credentials = [ 'location_id' => sanitize_text_field($data['location_id']) ]; return $this->saveCredentials($credentials, false); } public function handleFetchLocations():array { try { $locations = $this->getLocations(); if (!$locations) { return $this->response(false, 'No locations found'); } return $this->response( true, sprintf('Found %d location(s)', count($locations)), $locations ); } catch (Exception $e) { return $this->response(false, 'Failed to fetch locations: '.$e->getMessage()); } } public function getAdditionalFields(?string $content_type = null):array { $this->setContentTypes(); if (is_null($content_type) || !array_key_exists($content_type, $this->contentTypes)) { return []; } return $this->contentTypes[$content_type]; } }