' . 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
{
// 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());
}
}
}