From 0dfe1d8afafc59c4a5559c498342668d5a58d6ef Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Thu, 23 Jul 2026 22:41:41 +0000
Subject: [PATCH] =Still working away at the Integrations overhaul
---
inc/integrations/services/Square.php | 4095 +++++++++++-----------------------------------------------
1 files changed, 784 insertions(+), 3,311 deletions(-)
diff --git a/inc/integrations/services/Square.php b/inc/integrations/services/Square.php
index 0d18f51..a2b6c9a 100644
--- a/inc/integrations/services/Square.php
+++ b/inc/integrations/services/Square.php
@@ -2,177 +2,100 @@
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',
@@ -181,79 +104,656 @@
'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'
@@ -341,3152 +841,125 @@
'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);
- }
}
--
Gitblit v1.10.0