'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; } add_action('wp_login', [$this, 'trackUserLogin'], 10, 2); add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']); // Shared checkout UI (replaces outputCheckout) add_filter('jvbAdditionalActions', [Checkout::class, 'render']); // Square-specific checkout description 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'; }); // Register queue executor types $this->registerQueueTypes(); } /** * 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 . '

Pickup Details

' . 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 registerQueueTypes(): void { $queue = JVB()->queue(); $executor = new IntegrationExecutor(); $queue->registry()->register('square_sync_to', new TypeConfig( executor: $executor, chunkKey: 'items', chunkSize: 50, maxRetries: 3 )); $queue->registry()->register('square_delete_from', new TypeConfig( executor: $executor, chunkKey: 'external_ids', chunkSize: 200, maxRetries: 2 )); $queue->registry()->register('square_sync_from', new TypeConfig( executor: $executor, maxRetries: 3 )); $queue->registry()->register('square_sync_customer', new TypeConfig( executor: $executor, maxRetries: 2 )); $queue->registry()->register('square_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 { $this->queueOperation('sync_to', [ 'items' => [$postID], 'user_id' => $this->userID, ], [ 'priority' => 'high', 'delay' => 30, ]); 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', [ 'external_ids' => [$square_id], 'post_id' => $postID, ], [ 'priority' => 'high', ]); } } /** * @deprecated IntegrationExecutor handles new operations via registerQueueTypes(). * Kept for legacy-typed operations ('square_sync_to_square') already queued. * Safe to remove once all legacy operations have been processed. */ 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; return match ($operation->type) { $base . 'sync_to_square' => $square->processSyncToSquare($data), $base . 'delete_from_square' => $square->processDeleteFromSquare($data), $base . 'sync_from_square' => $square->processSyncFromSquare($data), $base . 'sync_customer' => $square->processSyncCustomer($data), default => $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 = 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); // 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->get('product_variations'); if (empty($variations)) { // Create default variation if none exist $price = floatval($meta->get('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->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; } /** * 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 { $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' => '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 { $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' => '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 $role = jvbUserRole($user->ID); $registrar = Registrar::getInstance($role); if ($registrar) { $config = $registrar->getIntegration($this->service_name); if ($config->isCustomer()) { $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); } } } } /** * 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 = 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); $meta->save(); // 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] ); // 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'], '1.1.31', ['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.31', ['strategy' => 'defer', 'in_footer' => true] ); wp_enqueue_script('jvb-square-checkout'); 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 : '', ]); } /****************************************************************** * 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 { //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']['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); $meta->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()) ]; } public function getAdditionalFields(?string $content_type = null):array { if ($content_type && array_key_exists($content_type, $this->contentTypes)){ $array = $this->contentTypes[$content_type]; return array_combine( array_map(fn($k) => 'sq_' . $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) => 'sq_' . $k, array_keys($array)), $array ); } 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 = Meta::forPost($order_post_id); $fields = $this->getSquarePostConfig('_sq_orders')['fields']; 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') ]); $meta->save(); // 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; } /** * 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; } }