'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;
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'
]
];
/**
* Square-specific properties
*/
protected string $merchantId = '';
protected string $locationId = '';
protected array $locations = [];
public function __construct(?int $userID = null)
{
// Display properties
$this->title = 'Square';
$this->icon = 'square-logo';
$this->refresh_interval = 7 * DAY_IN_SECONDS;
// 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',
]
];
// 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->instructions = [
'Go to Square Developer Dashboard',
'Create a new application or select an existing one',
'Navigate to the OAuth section',
'Add this redirect URL: ' . esc_html($this->getRedirectUri()) . '',
'Copy the Application ID and Application Secret',
'Click "Authorize Connection" below to complete OAuth flow'
];
$this->canSync = [
'create' => true,
'update' => true,
'delete' => true
];
$this->supportsWebp = false;
$this->handleWebhooks = true;
parent::__construct($userID);
// Add Square-specific actions
$this->actions = array_merge(
$this->actions,
[
'select_location' => 'handleSelectLocation',
'fetch_locations' => 'handleFetchLocations',
'verify_webhook' => 'handleVerifyWebhook',
'test_payment' => 'handleTestPayment'
]
);
$this->buttons = array_merge(
$this->buttons,
[
'import_from_square' => 'Import Catalog from Square',
'sync_to_square' => 'Sync Site to Square',
]
);
add_action('init', [$this, 'registerSquarePostTypes']);
}
/**
* Initialize the integration with loaded credentials
*/
protected function initialize(): void
{
if (empty($this->credentials)) {
$this->loadCredentials();
}
$this->merchantId = $this->credentials['merchant_id'] ?? '';
$this->locationId = $this->credentials['location_id'] ?? '';
$this->environment = $this->credentials['environment'] ?? 'sandbox';
// 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'
];
}
public function getSquarePostConfig(string $post = 'all'):array
{
$posts = [
'_sq_orders' => [
'singular' => 'Square Order',
'plural' => 'Square Orders',
'public' => false,
'fields' => [
'post_title' => [
'type' => 'text',
'label' => 'Order Number'
],
'square_order_id' => [
'type' => 'text',
'label' => 'Square Order ID',
'readonly' => true
],
'square_payment_id' => [
'type' => 'text',
'label' => 'Square Payment ID',
'readonly' => true
],
'square_customer_id' => [
'type' => 'text',
'label' => 'Square Customer ID',
'readonly' => true
],
'amount' => [
'type' => 'number',
'label' => 'Total Amount (cents)',
'readonly' => true
],
'status' => [
'type' => 'select',
'label' => 'Order Status',
'options' => [
'PROPOSED' => 'Proposed',
'RESERVED' => 'Reserved',
'PREPARED' => 'Prepared (Ready for Pickup)',
'COMPLETED' => 'Completed',
'CANCELED' => 'Canceled'
],
'readonly' => true
],
'fulfillment_status' => [
'type' => 'select',
'label' => 'Fulfillment Status',
'options' => [
'PROPOSED' => 'Proposed',
'RESERVED' => 'Reserved',
'PREPARED' => 'Prepared',
'COMPLETED' => 'Completed',
'CANCELED' => 'Canceled',
'FAILED' => 'Failed'
],
'readonly' => true
],
'pickup_time' => [
'type' => 'datetime',
'label' => 'Pickup Time'
],
'customer_email' => [
'type' => 'email',
'label' => 'Customer Email',
'readonly' => true
],
'customer_name' => [
'type' => 'text',
'label' => 'Customer Name',
'readonly' => true
],
'customer_phone' => [
'type' => 'tel',
'label' => 'Customer Phone',
'readonly' => true
],
'special_instructions' => [
'type' => 'textarea',
'label' => 'Special Instructions',
'readonly' => true
],
'items' => [
'type' => 'repeater',
'label' => 'Order Items',
'readonly' => true,
'fields' => [
'name' => ['type' => 'text', 'label' => 'Item Name'],
'quantity' => ['type' => 'number', 'label' => 'Quantity'],
'price' => ['type' => 'number', 'label' => 'Price'],
'note' => ['type' => 'text', 'label' => 'Note']
]
],
'receipt_url' => [
'type' => 'url',
'label' => 'Receipt URL',
'readonly' => true
],
'created_at' => [
'type' => 'datetime',
'label' => 'Created At',
'readonly' => true
],
'updated_at' => [
'type' => 'datetime',
'label' => 'Last Updated',
'readonly' => true
]
]
]
];
if ($post === 'all'){
return $posts;
}elseif(array_key_exists($post, $posts)) {
return $posts[$post];
}
return [];
}
public function registerSquarePostTypes():void
{
$squarePostTypes = $this->getSquarePostConfig();
foreach ($squarePostTypes as $slug => $config) {
$registrar = new PostTypeRegistrar($slug, $config);
$registrar->register();
}
}
/**
* 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
{
// Skip if we don't have credentials yet (during OAuth flow)
if (empty($this->credentials['access_token'])) {
return;
}
try {
$response = $this->getRequest(
'/v2/locations',
[],
$this->environment
);
if (isset($response['locations'])) {
$this->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'];
}
}
if (!empty($locationOptions)) {
$this->fields['location_id']['options'] = $locationOptions;
}
}
} catch (Exception $e) {
$this->logError('Failed to load locations', ['error' => $e->getMessage()]);
// Set empty locations array so we don't retry constantly
$this->locations = [];
}
}
protected function renderOAuthConnectedOptions(): void
{
if (!$this->isSetUp()) {
return;
}
// Check OAuth status
$oauth_valid = $this->isOAuthValid();
if (!$oauth_valid) {
?>
⚠️ OAuth token expired - please reconnect
loadLocations();
if (!empty($this->locations)) {
?>
credentials['location'])) {
?>
✅ Connected to: credentials['location_id']); ?>
No Square locations found. Please ensure your Square credentials are correct.
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;
}
/**
* Handle fetching locations
*/
public function handleFetchLocations(): 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'
];
}
$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'
];
}
/**
* Test payment/connection
*/
public function handleTestPayment(): array
{
try {
// Test with a simple API call to verify connection
$response = $this->getRequest(
'/v2/locations/'.$this->locationId,
[],
$this->environment
);
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()
];
}
}
/**
* Create a Square customer
*/
public function createCustomer(array $customerData): 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' => intval($variation['price'] * 100), // Convert to cents
'currency' => 'USD'
]
]
];
}
} else {
// Default single variation
$variations[] = [
'type' => 'ITEM_VARIATION',
'id' => '#regular',
'item_variation_data' => [
'name' => 'Regular',
'pricing_type' => 'FIXED_PRICING',
'price_money' => [
'amount' => intval(($itemData['price'] ?? 0) * 100),
'currency' => 'USD'
]
]
];
}
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()) {
return;
}
// User login tracking for security
add_action('wp_login', [$this, 'trackUserLogin'], 10, 2);
// Enqueue checkout scripts
add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']);
add_filter('jvbAdditionalActions', [$this, 'outputCheckout']);
}
public function outputCheckout(array $actions):array {
if (is_singular(BASE.'dash') || is_post_type_archive(BASE.'dash')) {
return $actions;
}
$meta = new MetaForm();
$form = '
Looks like we left things hanging
We\'ve restored your cart from your last session below.
If you\'d rather start over, click the button below.
|
|
|
|
|
';
$actions[] = [
'button' => '',
'content' => $form
];
return $actions;
}
private function cartContent():string
{
ob_start();
?>
Log in to save your favourites and view order history.';
}
?>
[
'title' => 'Order History',
'icon' => 'checkout',
'description' => 'View your past orders and quickly reorder',
'content' => $this->renderOrderHistory()
],
'favourites' => [
'title' => 'Favourites',
'icon' => 'heart',
'description' => 'View your favourites from our menu',
'content' => $this->renderFavourites()
]
];
jvbRenderTabs($tabs);
}
?>
Order Confirmed!
Order #
Order Received
Preparing
Ready for Pickup
Estimated pickup: Calculating...
queueOperation('sync_to_square', [
'items' => [$postID],
'user_id' => $this->userID
], [
'priority' => 'high',
'delay' => 30, // Small delay to batch multiple saves
]);
update_post_meta($postID, BASE . '_square_sync_status', 'queued');
}
/**
* Handle post deletion
*/
public function handleDeletePost(int $postID): void
{
$square_id = get_post_meta($postID, BASE . '_square_catalog_id', true);
if ($square_id) {
$this->queueOperation('delete_from_square', [
'square_ids' => [$square_id],
'post_id' => $postID
], [
'priority' => 'high'
]);
}
}
/**
* Process queued operations
*/
public function processOperation(WP_Error|array $result, object $operation, array $data): WP_Error|array
{
$base = strtolower($this->service_name).'_';
$square = (array_key_exists('user', $data)) ? new self((int)$data['user']) : $this;
switch ($operation->type) {
case $base.'sync_to_square':
return $square->processSyncToSquare($data);
case $base.'delete_from_square':
return $square->processDeleteFromSquare($data);
case $base.'sync_from_square':
return $square->processSyncFromSquare($data);
case $base.'sync_customer':
return $square->processSyncCustomer($data);
default:
return $result;
}
}
/**
* 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);
$square_id = $response['objects'][0]['id'];
update_post_meta($postID, BASE . '_square_catalog_id', $square_id);
update_post_meta($postID, BASE . '_square_sync_status', 'synced');
update_post_meta($postID, BASE . '_square_last_sync', current_time('mysql'));
// Save variation IDs
if (!empty($response['objects'][0]['item_data']['variations'])) {
foreach ($response['objects'][0]['item_data']['variations'] as $index => $variation) {
update_post_meta($postID, BASE . '_square_variation_' . $index . '_id', $variation['id']);
}
}
$success[] = $postID;
} 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
{
// 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 (strpos($error, "Post $post_id") !== false) {
$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
*/
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 = new MetaManager($postID, 'post');
$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);
// 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' => 'FOOD_AND_BEV',
'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->getValue('product_variations');
if (empty($variations)) {
// Create default variation if none exist
$price = floatval($meta->getValue('price') ?: 0);
$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' => intval($price * 100), // Convert dollars to cents
'currency' => 'CAD'
],
'sellable' => true,
'stockable' => true
]
];
} else {
foreach ($variations as $index => $variation) {
$existing_var_id = get_post_meta($postID, BASE . '_square_variation_' . $index . '_id', true);
$catalog_object['item_data']['variations'][] = [
'type' => 'ITEM_VARIATION',
'id' => $existing_var_id ?: '#'.BASE.'menu_item_' . $postID . '_var_' . $index,
'item_variation_data' => [
'name' => $variation['name'] ?? 'Variation ' . ($index + 1),
'ordinal' => $index,
'pricing_type' => 'FIXED_PRICING',
'price_money' => [
'amount' => intval(floatval($variation['price'] ?? 0) * 100),
'currency' => 'CAD'
],
'sellable' => true,
'stockable' => true
]
];
}
}
// 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->getValue('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->getValue('tax_ids');
if (!empty($tax_ids)) {
$catalog_object['item_data']['tax_ids'] = $tax_ids;
}
return $catalog_object;
}
/**
* Build variations from repeater field
*/
private function buildVariations(int $postID, array $values, string $post_type): array
{
$variations = [];
$product_variations = $values['product_variations'] ?? [];
// Get variation field mapping
$variation_map = $this->getVariationMapping($post_type);
// If we have repeater variations
if (!empty($product_variations) && is_array($product_variations)) {
foreach ($product_variations as $index => $variation_data) {
// Skip empty variations
if (empty($variation_data['name']) && empty($variation_data['sku'])) {
continue;
}
$variation = [
'type' => 'ITEM_VARIATION',
'id' => '#' . $post_type . '_' . $postID . '_var_' . $index,
'item_variation_data' => [
'name' => $variation_data['name'] ?? 'Variation ' . ($index + 1),
'ordinal' => $index,
'pricing_type' => 'FIXED_PRICING'
]
];
// Check for existing Square variation ID
$square_var_id = get_post_meta($postID, BASE . '_square_variation_' . $index . '_id', true);
if ($square_var_id) {
$variation['id'] = $square_var_id;
}
// Map variation fields
foreach ($variation_map as $square_field => $wp_field) {
if (isset($variation_data[$wp_field])) {
switch ($square_field) {
case 'price':
$variation['item_variation_data']['price_money'] = [
'amount' => intval($variation_data[$wp_field] * 100), // Convert to cents
'currency' => 'CAD'
];
break;
case 'sku':
$variation['item_variation_data']['sku'] = $variation_data[$wp_field];
break;
case 'track_inventory':
$variation['item_variation_data']['track_inventory'] = (bool)$variation_data[$wp_field];
break;
case 'service_duration':
if (!empty($variation_data[$wp_field])) {
$variation['item_variation_data']['service_data'] = [
'duration_minutes' => intval($variation_data[$wp_field])
];
}
break;
default:
$variation['item_variation_data'][$square_field] = $variation_data[$wp_field];
break;
}
}
}
$variations[] = $variation;
}
}
// If no variations exist, create a default one from base price
if (empty($variations) && !empty($values['price'])) {
$variations[] = [
'type' => 'ITEM_VARIATION',
'id' => '#' . $post_type . '_' . $postID . '_var_default',
'item_variation_data' => [
'name' => 'Regular',
'ordinal' => 0,
'pricing_type' => 'FIXED_PRICING',
'price_money' => [
'amount' => intval($values['price'] * 100),
'currency' => 'CAD'
]
]
];
}
return $variations;
}
/**
* Get variation mapping for post type
*/
protected function getVariationMapping(string $post_type): array
{
$product_type = JVB_CONTENT[jvbNoBase($post_type)]['integrations']['square']['content_type'] ?? 'REGULAR';
$valid_fields = $this->getValidFieldsForProductType($product_type);
$defaults = [
'name' => 'name',
'id' => '_square_catalog_id',
'sku' => 'sku',
'price' => 'price',
'track_inventory' => 'track_inventory',
'service_duration' => 'service_duration',
'available_for_booking' => 'available_for_booking',
'gift_card_type' => 'gift_card_type',
'ingredients' => 'ingredients',
'preparation_time_duration' => '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
{
$product_type = JVB_CONTENT[jvbNoBase($post_type)]['integrations']['square']['content_type'] ?? 'REGULAR';
$valid_fields = $this->getValidFieldsForProductType($product_type);
$defaults = [
'name' => 'post_title',
'description_html' => 'post_content',
'abbreviation' => 'abbreviation',
'id' => '_square_catalog_id',
'sku' => 'sku',
'category_id' => 'category',
'image_ids' => 'post_thumbnail',
'price' => 'price',
'tax_ids' => 'tax_ids',
// Availability
'available_online' => 'available_online',
'available_for_pickup' => 'available_for_pickup',
'available_electronically' => '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
*/
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
*/
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?'
];
}
} else {
// 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.'
];
}
// Generate username from email
$username = sanitize_user(current(explode('@', $email)));
$username = $this->generateUniqueUsername($username);
// Create user account without password (they'll set it via email)
$user_id = wp_create_user(
$username,
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'); // Or whatever role from JVB_USER
// 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' => $username
]);
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
];
}
/**
* Generate unique username
*/
private function generateUniqueUsername(string $base): string
{
$username = $base;
$counter = 1;
while (username_exists($username)) {
$username = $base . $counter;
$counter++;
}
return $username;
}
/**
* 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
);
}
/**
* Track user login for security
*/
public function trackUserLogin(string $user_login, \WP_User $user): void
{
// Check if user has Square integration
$roles = array_keys(JVB_USER);
$user_roles = $user->roles;
foreach ($user_roles as $role) {
if (isset(JVB_USER[$role]['integrations']['square']['is_customer'])) {
$login_count = (int)get_user_meta($user->ID, BASE . '_square_login_count', true);
$login_count++;
update_user_meta($user->ID, BASE . '_square_login_count', $login_count);
update_user_meta($user->ID, BASE . '_square_last_login', current_time('mysql'));
// Check if password reset is needed
if ($login_count % self::PASSWORD_RESET_INTERVAL === 0) {
$this->schedulePasswordReset($user->ID);
}
break;
}
}
}
/**
* Schedule password reset for security
*/
private function schedulePasswordReset(int $user_id): void
{
update_user_meta($user_id, BASE . '_square_password_reset_required', true);
// Send notification
$user = get_user_by('ID', $user_id);
if ($user) {
JVB()->email()->sendEmail(
$user->user_email,
'['.get_bloginfo('name').'] Security Code',
'For your security, enter this code to continue accessing your account and saved payment methods.',
);
}
}
/******************************************************************
* 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
*/
/**
* Handle order status webhook - NOW UPDATES POST TYPE
*/
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 = get_option(BASE . 'square_order_map_' . $order_id);
if ($wp_order_id) {
// Update the post meta
$meta = new MetaManager($wp_order_id, 'post');
$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);
}
}
// Also update transient cache for quick status checks
set_transient(BASE . 'square_order_' . $order_id, $state, HOUR_IN_SECONDS);
// 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
{
$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
]
);
// Register your custom checkout script
wp_register_script(
'jvb-square-checkout',
JVB_URL . 'assets/js/min/square.min.js',
[
// 'square-payments-sdk',
'jvb-utility',
'jvb-queue',
'jvb-a11y',
'jvb-cache',
'jvb-tabs',
'jvb-popup'
],
'1.0.0',
[
'strategy' => 'defer',
'in_footer' => true
]
);
wp_enqueue_script('jvb-square-checkout');
// Localize the checkout script with Square config
wp_localize_script(
'jvb-square-checkout',
'squareConfig',
[
'isOpen' => jvbIsOpen(),
'application_id' => $this->credentials['client_id'] ?? '',
'location_id' => $this->locationId,
'environment' => $this->environment,
'api_url' => rest_url('jvb/v1/square/'),
'nonce' => wp_create_nonce('wp_rest'),
'currency' => get_option(BASE . 'currency', 'CAD'),
'is_logged_in' => is_user_logged_in(),
'user_email' => is_user_logged_in() ? wp_get_current_user()->user_email : '' // NEW
]
);
}
/******************************************************************
* HELPER METHODS
******************************************************************/
/**
* Get or create Square customer
*/
private 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;
}
/**
* Save order reference for status tracking
*/
public function saveOrderReference($data): array
{
$order_id = sanitize_text_field($data['order_id'] ?? '');
$payment_id = sanitize_text_field($data['payment_id'] ?? '');
if (!$order_id) {
return ['success' => false, 'message' => 'Invalid order data'];
}
// Save to user if logged in
if (is_user_logged_in()) {
$user_id = get_current_user_id();
$orders = get_user_meta($user_id, BASE . '_square_orders', true) ?: [];
$orders[] = [
'order_id' => $order_id,
'payment_id' => $payment_id,
'date' => current_time('mysql'),
'customer' => $data['customer'] ?? []
];
// Keep last 50 orders
if (count($orders) > 50) {
$orders = array_slice($orders, -50);
}
update_user_meta($user_id, BASE . '_square_orders', $orders);
}
return [
'success' => true,
'order_id' => $order_id,
'message' => 'Order saved'
];
}
/**
* Save order to user meta
*/
private function saveOrderToUser(int $user_id, string $order_id): void
{
$orders = get_user_meta($user_id, BASE . '_square_orders', true) ?: [];
$orders[] = [
'order_id' => $order_id,
'date' => current_time('mysql')
];
// Keep only last 50 orders
if (count($orders) > 50) {
$orders = array_slice($orders, -50);
}
update_user_meta($user_id, BASE . '_square_orders', $orders);
}
/**
* Get order status (for customer feedback)
*/
public function getOrderStatus($data): WP_Error|array
{
$order_id = sanitize_text_field($data['order_id'] ?? '');
if (!$order_id) {
return new WP_Error('error', 'Order ID required');
}
// Fetch from Square
$response = $this->getRequest('v2/orders/' . $order_id);
if (is_wp_error($response)) {
return new WP_Error('error', 'Could not fetch order status');
}
$order = $response['order'] ?? [];
$status_data = [
'state' => $order['state'] ?? 'UNKNOWN',
'fulfillment_eta' => $order['fulfillments'][0]['pickup_details']['pickup_at'] ?? null
];
return [
'success' => true,
'status' => $status_data['state'],
'eta' => $status_data['fulfillment_eta']
];
}
/**
* 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
{
// Find matching content type
$product_type = $item['item_data']['product_type'] ?? 'REGULAR';
$post_type = null;
foreach (JVB_CONTENT as $key => $config) {
if (isset($config['integrations']['square']['content_type']) &&
$config['integrations']['square']['content_type'] === $product_type) {
$post_type = jvbCheckBase($key);
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 = new MetaManager($post_id, 'post');
$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']['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;
}
// Update cached payment status
set_transient(BASE . 'square_payment_' . $payment_id, $status, HOUR_IN_SECONDS);
// 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' || strpos($key, 'enable_') === 0) {
$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())
];
}
protected function setBaseFields():array
{
return [
'price' => [
'type' => 'number',
'bulkEdit' => true,
'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.'
],
'price' => [
'type' => 'number',
'label' => 'Price',
'step' => 0.01,
'max' => 99999,
'description' => 'Price for this variation'
],
'track_inventory' => [
'type' => 'true_false',
'label' => 'Track Inventory',
],
'_square_item_id' => [
'type' => 'text',
'label' => 'Square Variation ID',
'description' => 'Square catalog ID for this variation',
'hidden' => true
],
'_square_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 $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();
}
}
private 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['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);
}
private 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);
}
private 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 . '_sq_orders',
'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 = new MetaManager($order_post_id, 'post');
$fields = $this->getSquarePostConfig('_sq_orders')['fields'];
unset($fields['post_title']);
$meta->setFieldConfig($fields);
$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')
]);
// Index by Square order ID for quick webhook lookups
update_option(BASE . 'square_order_map_' . $order_data['square_order_id'], $order_post_id);
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
{
// Check transient cache first
$cached = get_transient(BASE . 'square_order_' . $order_id);
if ($cached) {
return $cached;
}
// Fetch from Square
$response = $this->getRequest('orders/' . $order_id);
if (!is_wp_error($response)) {
$state = $response['order']['state'] ?? null;
set_transient(BASE . 'square_order_' . $order_id, $state, HOUR_IN_SECONDS);
return $state;
}
return null;
}
}