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];
}
}