<?php
|
namespace JVBase\integrations;
|
|
use JVBase\meta\Form;
|
use JVBase\meta\Meta;
|
use Exception;
|
use JVBase\registry\PostTypeRegistrar;
|
use WP_Error;
|
use JVBase\ui\Checkout;
|
use JVBase\managers\queue\TypeConfig;
|
use JVBase\managers\queue\executors\IntegrationExecutor;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* Square Integration Class
|
*
|
* Handles OAuth 2.0 authentication and API interactions with Square
|
* Uses the latest Square API version with OAuth code flow
|
*
|
* @since 1.0.0
|
*/
|
class Square extends Integrations
|
{
|
/**
|
* Square API Configuration
|
*/
|
protected string $service_name = 'square';
|
protected array|string $apiBase = [
|
'production' => 'https://connect.squareup.com',
|
'sandbox' => 'https://connect.squareupsandbox.com'
|
];
|
|
protected string $apiVersion = '2025-09-24';
|
|
protected string $environment = 'sandbox';
|
|
private const PASSWORD_RESET_INTERVAL = 3; // Reset password every 3 logins, for customers
|
|
/**
|
* OAuth Configuration
|
*/
|
protected bool $isOAuthService = true;
|
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 <a href="https://developer.squareup.com/apps" target="_blank">Square Developer Dashboard</a>',
|
'Create a new application or select an existing one',
|
'Navigate to the OAuth section',
|
'Add this redirect URL: <code>' . esc_html($this->getRedirectUri()) . '</code>',
|
'Copy the Application ID and Application Secret',
|
'Click "Authorize Connection" below to complete OAuth flow'
|
];
|
|
|
$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) {
|
?>
|
<div class="oauth-status">
|
<span class="status-invalid">⚠️ OAuth token expired - please reconnect</span>
|
</div>
|
<?php
|
}
|
|
$this->loadLocations();
|
if (!empty($this->locations)) {
|
?>
|
<div class="form-field" data-service="square">
|
<label for="square_location_id">Square Location</label>
|
<select name="location_id"
|
id="square_location_id"
|
class="jvb-ajax-update"
|
data-action="select_location">
|
<option value="">Select a location...</option>
|
<?php foreach ($this->locations as $location): ?>
|
<option value="<?php echo esc_attr($location['id']); ?>"
|
<?php selected($this->credentials['location_id'] ?? '', $location['id']); ?>>
|
<?php echo esc_html($location['name']); ?>
|
</option>
|
<?php endforeach; ?>
|
</select>
|
</div>
|
<?php
|
|
// Show current selection status
|
if (!empty($this->credentials['location'])) {
|
?>
|
<div class="connection-status connected">
|
✅ Connected to: <?php echo esc_html($this->credentials['location_id']); ?>
|
</div>
|
<?php
|
}
|
} else {
|
?>
|
<div class="notice notice-warning">
|
<p>No Square locations found. Please ensure your Square credentials are correct.</p>
|
</div>
|
<?php
|
}
|
}
|
|
/**
|
* Handle location selection
|
*/
|
public function handleSelectLocation($data): array
|
{
|
if (empty($data['location_id'])) {
|
return [
|
'success' => false,
|
'message' => 'No location selected'
|
];
|
}
|
|
$this->credentials['location_id'] = sanitize_text_field($data['location_id']);
|
$this->locationId = $this->credentials['location_id'];
|
|
$result = $this->saveCredentials($this->credentials);
|
|
if ($result['success']) {
|
return [
|
'success' => true,
|
'message' => 'Location updated successfully'
|
];
|
}
|
|
return $result;
|
}
|
|
/**
|
* 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
|
. '<h3>Pickup Details</h3>'
|
. Form::render('pickup_time', null, [
|
'type' => 'datetime',
|
'label' => 'Pickup Time',
|
'min' => '11:00',
|
'max' => '20:00',
|
'required' => true,
|
])
|
. Form::render('special_instructions', null, [
|
'type' => 'textarea',
|
'label' => 'Special Instructions',
|
'quill' => true,
|
]);
|
}
|
|
protected function 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
|
{
|
$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 = Meta::forPost($wp_order_id);
|
$updates = [
|
'status' => $state,
|
'updated_at' => current_time('mysql')
|
];
|
|
// Extract fulfillment status and pickup time
|
if (!empty($fulfillments[0])) {
|
$fulfillment = $fulfillments[0];
|
$updates['fulfillment_status'] = $fulfillment['state'] ?? $state;
|
|
if (!empty($fulfillment['pickup_details']['pickup_at'])) {
|
$updates['pickup_time'] = $fulfillment['pickup_details']['pickup_at'];
|
}
|
}
|
|
$meta->setAll($updates);
|
|
// Trigger notification to customer if order is ready
|
if ($state === 'PREPARED') {
|
do_action(BASE . 'square_order_ready', $wp_order_id, $order_id);
|
}
|
}
|
|
// 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
|
{
|
// 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 = 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);
|
}
|
|
/**
|
* 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 = 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')
|
]);
|
|
// 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;
|
}
|
}
|