<?php
|
|
namespace JVBase\integrations\services;
|
use Exception;
|
use JVBase\integrations\Admin;
|
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\Sanitizer;
|
use JVBase\registrar\Registrar;
|
use WP_Error;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
class Square extends Integrations
|
{
|
use Admin,
|
Instance,
|
Requests,
|
OAuth,
|
SyncFrom,
|
SyncTo,
|
SyncDefaults,
|
Webhooks;
|
|
protected string $orderPostType = '_square_order';
|
protected string $apiVersion = '2025-09-24';
|
|
protected function __construct()
|
{
|
$this->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 <a href="https://developer.squareup.com/apps" target="_blank">Square Developer Dashboard</a>',
|
'Create a new application or select an existing one',
|
'Navigate to the OAuth section',
|
'Add this redirect URL: <code>' . esc_html($this->getRedirectUri()) . '</code>',
|
'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
|
{
|
// TODO: Implement initialize() method.
|
}
|
|
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
|
{
|
// TODO: Implement setContentTypes() method.
|
}
|
|
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['id'];
|
} catch (Exception $e) {
|
return false;
|
}
|
}
|
protected function handleEmailSearchResponse(WP_Error|array $response, string $method):array
|
{
|
|
}
|
|
protected function handleCreateUser(array $data): array
|
{
|
try {
|
return $this->postRequest(
|
sprintf('%s/v2/customers',$this->getBaseURL()),
|
$data
|
);
|
} catch (Exception $e) {
|
return $this->response(false, 'Failed to create customer: '.$e->getMessage());
|
}
|
}
|
protected function handleCreateUserResponse(WP_Error|array $response, string $method):array
|
{
|
|
}
|
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());
|
}
|
}
|
|
|
}
|