| | |
| | | <?php |
| | | namespace JVBase\integrations; |
| | | |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\meta\Form; |
| | | use JVBase\meta\Meta; |
| | | use Exception; |
| | | use JVBase\registrar\Registrar; |
| | | 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; |
| | |
| | | $this->title = 'Square'; |
| | | $this->icon = 'square-logo'; |
| | | |
| | | $this->refresh_interval = 7 * DAY_IN_SECONDS; |
| | | |
| | | // Define credential fields |
| | | $this->fields = [ |
| | | 'environment' => [ |
| | |
| | | 'sync_to_square' => 'Sync Site to Square', |
| | | ] |
| | | ); |
| | | |
| | | add_action('init', [$this, 'registerSquarePostTypes']); |
| | | } |
| | | |
| | | /** |
| | |
| | | |
| | | } |
| | | |
| | | 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 exchangeOAuthCode(string $code): ?array |
| | | { |
| | | error_log('Exchanging tokens with credentials: '.print_r($this->credentials, true)); |
| | | |
| | | $this->ensureInitialized(); |
| | | |
| | | // Prepare the request body as an array |
| | |
| | | } |
| | | |
| | | $data = json_decode(wp_remote_retrieve_body($response), true); |
| | | error_log('OAuth Response: '.print_r($data, true)); |
| | | if (isset($data['access_token'])) { |
| | | return [ |
| | | 'access_token' => $data['access_token'], |
| | |
| | | |
| | | $data = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | error_log('RefreshAccessToken Response: '.print_r($data, true)); |
| | | if (isset($data['access_token'])) { |
| | | $this->credentials['access_token'] = $data['access_token']; |
| | | $this->credentials['expires_at'] = time() + ($data['expires_in'] ?? 2592000); // 30 days |
| | |
| | | { |
| | | // Skip if we don't have credentials yet (during OAuth flow) |
| | | if (empty($this->credentials['access_token'])) { |
| | | error_log('[Square] Skipping loadLocations - no access token yet'); |
| | | return; |
| | | } |
| | | try { |
| | |
| | | if (!$this->isSetUp()) { |
| | | return; |
| | | } |
| | | // User login tracking for security |
| | | |
| | | add_action('wp_login', [$this, 'trackUserLogin'], 10, 2); |
| | | |
| | | add_action('wp_footer', [$this, 'outputCheckout']); |
| | | |
| | | // Enqueue checkout scripts |
| | | add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']); |
| | | } |
| | | |
| | | // Shared checkout UI (replaces outputCheckout) |
| | | add_filter('jvbAdditionalActions', [Checkout::class, 'render']); |
| | | |
| | | public function outputCheckout():void { |
| | | if (is_singular(BASE.'dash') || is_post_type_archive(BASE.'dash')) { |
| | | return; |
| | | } |
| | | ?> |
| | | <button type="button" class="toggle-cart row" title="Your Cart" data-action="toggle-cart" aria-label="Open Cart" aria-controls="checkout" aria-expanded="false" hidden> |
| | | <?= jvbIcon('cart')?><span class="abs"></span><span class="abs count"></span> |
| | | </button> |
| | | <aside id="cart"> |
| | | <form id="checkout" data-form-id="checkout" data-save="checkout"> |
| | | <?php |
| | | $tabs = [ |
| | | 'cartItems' => [ |
| | | 'title' => 'Your Order', |
| | | 'icon' => 'cart', |
| | | 'description' => 'Here\'s your order. You can change quantities, remove items, or clear your cart.', |
| | | 'content' => $this->cartContent() |
| | | ], |
| | | 'checkout' => [ |
| | | 'title' => 'Checkout', |
| | | 'icon' => 'checkout', |
| | | 'description' => 'Securely checkout with your name, email, and payments processed by Square.', |
| | | 'content' => '<div class="checkout-section"> |
| | | <h3>Customer Information</h3> |
| | | |
| | | <input type="text" name="name" placeholder="Full Name" required autocomplete="name"> |
| | | <input type="email" name="email" placeholder="Email" required autocomplete="email"> |
| | | <input type="tel" name="phone" placeholder="Phone" required autocomplete="tel""> |
| | | <h3>Pickup Details</h3> |
| | | <input type="time" name="pickup_time" min="11:00" max="20:00" required> |
| | | <textarea name="special_instructions" placeholder="Special instructions or dietary notes"></textarea> |
| | | </div> |
| | | |
| | | <div class="checkout-section"> |
| | | <h3>Payment Information</h3> |
| | | <div id="saved-cards"></div> |
| | | <div id="square-card-container"></div> |
| | | </div>' |
| | | ], |
| | | 'order' => [ |
| | | 'title' => 'Your Order', |
| | | 'icon' => 'truck', |
| | | 'hidden' => true, |
| | | 'description' => '', |
| | | 'content' => $this->renderOrderStatus() |
| | | ] |
| | | ]; |
| | | jvbRenderTabs($tabs); |
| | | ?> |
| | | |
| | | <div class="cart-total row end"><p class="tax">Tax: <span></span></p><p class="total">GRAND TOTAL: <span></span></p></div> |
| | | </form> |
| | | </aside> |
| | | <template class="restoredCart"> |
| | | <div class="restored"> |
| | | <h3>Looks like we left things hanging</h3> |
| | | <p>We've restored your cart from your last session below.</p> |
| | | <p>If you'd rather start over, click the button below.</p> |
| | | <div class="row btw"> |
| | | <button type="button" onclick="window.squareCheckout.clearCart();this.closest('.restored').remove()"><?=jvbIcon('trash')?>Clear Cart</button> |
| | | <button type="button" onclick="this.closest('.restored').remove()"><?= jvbIcon('x')?>Dismiss</button> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | <template class="cartItem"> |
| | | <tr class="item"> |
| | | <td class="item"> |
| | | <label for="quantity"></label> |
| | | <div class="quantity field" data-min="0" data-max="50" data-step="1" data-price="17" data-id=""> |
| | | |
| | | <button type="button" class="decrease"aria-label="Decrease Add to Order"> |
| | | <i class="icon minus"><svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M208,36H48A12,12,0,0,0,36,48V208a12,12,0,0,0,12,12H208a12,12,0,0,0,12-12V48A12,12,0,0,0,208,36Zm4,172a4,4,0,0,1-4,4H48a4,4,0,0,1-4-4V48a4,4,0,0,1,4-4H208a4,4,0,0,1,4,4Zm-40-80a4,4,0,0,1-4,4H88a4,4,0,0,1,0-8h80A4,4,0,0,1,172,128Z"></path></svg></i> </button> |
| | | |
| | | <input type="number" id="quantity" name="quantity" value="0" min="0" max="50" step="1" class="quantity-input"> |
| | | |
| | | <button type="button" class="increase" aria-label="Increase Add to Order"> |
| | | <i class="icon add"><svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M208,36H48A12,12,0,0,0,36,48V208a12,12,0,0,0,12,12H208a12,12,0,0,0,12-12V48A12,12,0,0,0,208,36Zm4,172a4,4,0,0,1-4,4H48a4,4,0,0,1-4-4V48a4,4,0,0,1,4-4H208a4,4,0,0,1,4,4Zm-40-80a4,4,0,0,1-4,4H132v36a4,4,0,0,1-8,0V132H88a4,4,0,0,1,0-8h36V88a4,4,0,0,1,8,0v36h36A4,4,0,0,1,172,128Z"></path></svg></i> </button> |
| | | </div> |
| | | </td> |
| | | <td class="price"> |
| | | <span class="price"></span> |
| | | </td> |
| | | <td class="total"> |
| | | <span class="total"></span> |
| | | </td> |
| | | <td> |
| | | <button type="button" data-remove-from-cart><?= jvbIcon('trash')?></button> |
| | | </td> |
| | | </tr> |
| | | </template> |
| | | <template class="emptyCart"> |
| | | <div class="empty"> |
| | | <p><i><b>No items in cart.</b></i></p> |
| | | <p>You can <a href="<?= get_post_type_archive_link(BASE.'menu_item')?>" title="Browse our menu">browse our menu</a> to order.</p> |
| | | </div> |
| | | </template> |
| | | <?php |
| | | } |
| | | |
| | | private function cartContent():string |
| | | { |
| | | ob_start(); |
| | | ?> |
| | | <div class="cart-items"> |
| | | <table> |
| | | <thead> |
| | | <tr> |
| | | <th scope="col">Item</th> |
| | | <th scope="col">Price</th> |
| | | <th scope="col">Total</th> |
| | | </tr> |
| | | </thead> |
| | | <tbody> |
| | | |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | | |
| | | <details class="account"> |
| | | <summary> |
| | | <?php |
| | | if (is_user_logged_in()) { |
| | | echo 'Your Favourites and Order History'; |
| | | } else { |
| | | echo '<a href="'.wp_login_url(get_the_permalink()).'">Log in</a> to save your favourites and view order history.'; |
| | | } |
| | | ?> |
| | | </summary> |
| | | <?php |
| | | if (is_user_logged_in()) { |
| | | $tabs = [ |
| | | 'history' => [ |
| | | 'title' => 'Order History', |
| | | 'icon' => 'checkout', |
| | | 'description' => 'View your past orders and quickly reorder', |
| | | 'content' => $this->renderOrderHistory() |
| | | ], |
| | | 'favourites' => [ |
| | | 'title' => 'Favourites', |
| | | 'icon' => 'heart', |
| | | 'description' => 'View your favourites from our menu', |
| | | 'content' => $this->renderFavourites() |
| | | ] |
| | | ]; |
| | | jvbRenderTabs($tabs); |
| | | // 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); |
| | | |
| | | ?> |
| | | </details> |
| | | // Square-specific pickup fields (extracted from old outputCheckout) |
| | | add_filter('jvb_checkout_fields', [$this, 'addPickupFields'], 10, 2); |
| | | |
| | | <?php |
| | | return ob_get_clean(); |
| | | // 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(); |
| | | } |
| | | |
| | | private function renderOrderHistory():string |
| | | /** |
| | | * 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 |
| | | { |
| | | ob_start(); |
| | | //TODO: getRequest, cache for 1 day |
| | | return ob_get_clean(); |
| | | } |
| | | private function renderFavourites():string |
| | | { |
| | | ob_start(); |
| | | //TODO: get user's favourites and list them |
| | | return ob_get_clean(); |
| | | 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 renderOrderStatus():string |
| | | protected function registerQueueTypes(): void |
| | | { |
| | | ob_start(); |
| | | ?> |
| | | <div class="order-confirmation"> |
| | | <h2>Order Confirmed!</h2> |
| | | <div id="order-status" data-order=""> |
| | | <p>Order #<span class="order-num"></span></p> |
| | | <div class="status-timeline"> |
| | | <div class="status-item active" data-status="received">Order Received</div> |
| | | <div class="status-item" data-status="preparing">Preparing</div> |
| | | <div class="status-item" data-status="ready">Ready for Pickup</div> |
| | | </div> |
| | | <div class="pickup-time"> |
| | | Estimated pickup: <span id="eta">Calculating...</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <?php |
| | | return ob_get_clean(); |
| | | $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 |
| | | )); |
| | | } |
| | | |
| | | /****************************************************************** |
| | |
| | | */ |
| | | protected function handleTheSavePost(int $postID, \WP_Post $post, bool $update, array $settings): void |
| | | { |
| | | error_log('Queuing Sync to Square'); |
| | | // Queue the sync operation |
| | | $this->queueOperation('sync_to_square', [ |
| | | 'items' => [$postID], |
| | | 'user_id' => $this->userID |
| | | $this->queueOperation('sync_to', [ |
| | | 'items' => [$postID], |
| | | 'user_id' => $this->userID, |
| | | ], [ |
| | | 'priority' => 'high', |
| | | 'delay' => 30, // Small delay to batch multiple saves |
| | | 'delay' => 30, |
| | | ]); |
| | | |
| | | update_post_meta($postID, BASE . '_square_sync_status', 'queued'); |
| | |
| | | $square_id = get_post_meta($postID, BASE . '_square_catalog_id', true); |
| | | |
| | | if ($square_id) { |
| | | $this->queueOperation('delete_from_square', [ |
| | | 'square_ids' => [$square_id], |
| | | 'post_id' => $postID |
| | | $this->queueOperation('delete_from', [ |
| | | 'external_ids' => [$square_id], |
| | | 'post_id' => $postID, |
| | | ], [ |
| | | 'priority' => 'high' |
| | | 'priority' => 'high', |
| | | ]); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Process queued operations |
| | | * @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; |
| | | switch ($operation->type) { |
| | | case $base.'sync_to_square': |
| | | return $square->processSyncToSquare($data); |
| | | $base = strtolower($this->service_name) . '_'; |
| | | $square = array_key_exists('user', $data) ? new self((int) $data['user']) : $this; |
| | | |
| | | case $base.'delete_from_square': |
| | | return $square->processDeleteFromSquare($data); |
| | | |
| | | case $base.'sync_from_square': |
| | | return $square->processSyncFromSquare($data); |
| | | |
| | | case $base.'sync_customer': |
| | | return $square->processSyncCustomer($data); |
| | | |
| | | default: |
| | | return $result; |
| | | } |
| | | 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 |
| | | */ |
| | |
| | | return new WP_Error('post_not_found', "Post $postID not found"); |
| | | } |
| | | |
| | | $meta = new MetaManager($postID, 'post'); |
| | | $meta = Meta::forPost($postID); |
| | | $post_type = get_post_type($postID); |
| | | |
| | | // Get existing Square catalog ID if it exists |
| | |
| | | } |
| | | |
| | | // Add variations |
| | | $variations = $meta->getValue('product_variations'); |
| | | $variations = $meta->get('product_variations'); |
| | | if (empty($variations)) { |
| | | // Create default variation if none exist |
| | | $price = floatval($meta->getValue('price') ?: 0); |
| | | $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', |
| | |
| | | } |
| | | |
| | | // Add modifiers if they exist |
| | | $modifiers = $meta->getValue('modifiers'); |
| | | $modifiers = $meta->get('modifiers'); |
| | | if (!empty($modifiers)) { |
| | | $modifier_ids = []; |
| | | foreach ($modifiers as $modifier) { |
| | |
| | | } |
| | | |
| | | // Add tax settings |
| | | $tax_ids = $meta->getValue('tax_ids'); |
| | | $tax_ids = $meta->get('tax_ids'); |
| | | if (!empty($tax_ids)) { |
| | | $catalog_object['item_data']['tax_ids'] = $tax_ids; |
| | | } |
| | |
| | | */ |
| | | protected function getVariationMapping(string $post_type): array |
| | | { |
| | | $product_type = JVB_CONTENT[jvbNoBase($post_type)]['integrations']['square']['content_type'] ?? 'REGULAR'; |
| | | $registrar = Registrar::getInstance($post_type); |
| | | if (!$registrar) { |
| | | return []; |
| | | } |
| | | $config = $registrar->getIntegrationConfig($this->service_name); |
| | | $product_type = $config['content_type']??'REGULAR'; |
| | | $valid_fields = $this->getValidFieldsForProductType($product_type); |
| | | |
| | | $defaults = [ |
| | |
| | | */ |
| | | protected function getFieldMapping(string $post_type): array |
| | | { |
| | | $product_type = JVB_CONTENT[jvbNoBase($post_type)]['integrations']['square']['content_type'] ?? 'REGULAR'; |
| | | $registrar = Registrar::getInstance($post_type); |
| | | if (!$registrar) { |
| | | return []; |
| | | } |
| | | $config = $registrar->getIntegrationConfig($this->service_name); |
| | | $product_type = $config['content_type']??'REGULAR'; |
| | | $valid_fields = $this->getValidFieldsForProductType($product_type); |
| | | |
| | | $defaults = [ |
| | |
| | | |
| | | // 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 |
| | | $user->set_role(BASE.'foodie'); // Or whatever role |
| | | |
| | | // Generate password reset key |
| | | $reset_key = get_password_reset_key($user); |
| | |
| | | private function sendWelcomeEmail(\WP_User $user, string $reset_key): void |
| | | { |
| | | $site_name = get_bloginfo('name'); |
| | | $reset_url = get_home_url(2, "wp-login.php?action=rp&key=$reset_key&login=" . rawurlencode($user->user_login), 'login'); |
| | | $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 link below to set your password:\n\n" . |
| | | "Your account has been created. Please click the button below to set your password:\n\n" . |
| | | "%s\n\n" . |
| | | "Once you've set your password, you can log in to:\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%s", |
| | | "Thanks,\n", |
| | | $site_name, |
| | | $reset_url, |
| | | $site_name |
| | | JVB()->email()->button('Reset Password', $reset_url), |
| | | JVB()->email()->link($reset_url), |
| | | ); |
| | | |
| | | jvbMail( |
| | | JVB()->email()->sendEmail( |
| | | $user->user_email, |
| | | sprintf('[%s] Welcome! Set Your Password', $site_name), |
| | | $message |
| | |
| | | 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'])) { |
| | | $role = jvbUserRole($user->ID); |
| | | $registrar = Registrar::getInstance($role); |
| | | if ($registrar) { |
| | | $config = $registrar->getIntegration($this->service_name); |
| | | if ($config->isCustomer()) { |
| | | $login_count = (int)get_user_meta($user->ID, BASE . '_square_login_count', true); |
| | | $login_count++; |
| | | |
| | |
| | | if ($login_count % self::PASSWORD_RESET_INTERVAL === 0) { |
| | | $this->schedulePasswordReset($user->ID); |
| | | } |
| | | |
| | | break; |
| | | } |
| | | } |
| | | } |
| | |
| | | // Send notification |
| | | $user = get_user_by('ID', $user_id); |
| | | if ($user) { |
| | | wp_mail( |
| | | 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.', |
| | | ['Content-Type: text/html; charset=UTF-8'] |
| | | ); |
| | | } |
| | | } |
| | |
| | | /** |
| | | * 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; |
| | | } |
| | | |
| | | // Update cached order status |
| | | // 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 |
| | |
| | | /** |
| | | * Enqueue checkout scripts with Square configuration |
| | | */ |
| | | public function enqueueScripts():void |
| | | public function enqueueScripts(): void |
| | | { |
| | | $this->loadCredentials(); |
| | | $sdk_url = $this->environment === 'production' |
| | |
| | | $sdk_url, |
| | | [], |
| | | null, |
| | | [ |
| | | 'strategy' => 'defer', |
| | | 'in_footer' => true |
| | | ] |
| | | ['strategy' => 'defer', 'in_footer' => true] |
| | | ); |
| | | |
| | | // Register your custom checkout script |
| | | // 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', |
| | | [ |
| | | // 'square-payments-sdk', |
| | | 'jvb-utility', |
| | | 'jvb-queue', |
| | | 'jvb-a11y', |
| | | 'jvb-cache', |
| | | 'jvb-tabs', |
| | | 'jvb-modal', |
| | | ], |
| | | '1.0.0', |
| | | [ |
| | | 'strategy' => 'defer', |
| | | 'in_footer' => true |
| | | ] |
| | | ['jvb-checkout', 'square-payments-sdk'], |
| | | '1.1.31', |
| | | ['strategy' => 'defer', 'in_footer' => true] |
| | | ); |
| | | |
| | | wp_enqueue_script('jvb-square-checkout'); |
| | | |
| | | // Localize the checkout script with Square config |
| | | wp_localize_script( |
| | | 'jvb-square-checkout', |
| | | 'squareConfig', |
| | | [ |
| | | 'isOpen' => jvbIsOpen(), |
| | | 'application_id' => $this->credentials['client_id'] ?? '', |
| | | 'location_id' => $this->locationId, |
| | | 'environment' => $this->environment, |
| | | 'api_url' => rest_url('jvb/v1/square/'), |
| | | 'nonce' => wp_create_nonce('wp_rest'), |
| | | 'currency' => get_option(BASE . 'currency', 'CAD') |
| | | ] |
| | | ); |
| | | wp_localize_script('jvb-square-checkout', 'squareConfig', [ |
| | | //TODO '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 : '', |
| | | ]); |
| | | } |
| | | |
| | | /****************************************************************** |
| | |
| | | */ |
| | | private function importSquareItem(array $item): bool|int |
| | | { |
| | | //TODO: We need to add the post type to custom meta for Square, this is not good if we have multiple post types with the same product type |
| | | // Find matching content type |
| | | $product_type = $item['item_data']['product_type'] ?? 'REGULAR'; |
| | | $post_type = null; |
| | | |
| | | foreach (JVB_CONTENT as $key => $config) { |
| | | if (isset($config['integrations']['square']['content_type']) && |
| | | $config['integrations']['square']['content_type'] === $product_type) { |
| | | $post_type = jvbCheckBase($key); |
| | | foreach (Registrar::getRegistered() as $registrar) { |
| | | if (!$registrar->hasIntegration($this->service_name)) { |
| | | continue; |
| | | } |
| | | $config = $registrar->getIntegration($this->service_name); |
| | | if ($config->getContent_type() && $config->getContent_type() === $product_type) { |
| | | $post_type = jvbCheckBase($registrar->getSlug()); |
| | | break; |
| | | } |
| | | |
| | | } |
| | | |
| | | if (!$post_type) { |
| | |
| | | */ |
| | | private function mapSquareFieldsToWordPress(int $post_id, array $item): void |
| | | { |
| | | $meta = new MetaManager($post_id, 'post'); |
| | | $meta = Meta::forPost($post_id); |
| | | $field_map = $this->getFieldMapping(get_post_type($post_id)); |
| | | |
| | | $values_to_save = []; |
| | |
| | | update_user_meta($user->ID, BASE . '_square_customer_updated', current_time('mysql')); |
| | | |
| | | // Clear cached customer data |
| | | $this->cache->delete('square_customer_' . $user->ID); |
| | | $this->cache->forget('square_customer_' . $user->ID); |
| | | } |
| | | |
| | | return true; |
| | |
| | | |
| | | // Validate environment setting |
| | | if (isset($credentials['environment'])) { |
| | | error_log('Environment: '.print_r($credentials['environment'], true)); |
| | | $validEnvironments = ['sandbox', 'production']; |
| | | if (!in_array($credentials['environment'], $validEnvironments)) { |
| | | $this->logError('Invalid environment setting', [ |
| | |
| | | 'GIFT_CARD' => array_merge($this->setGiftCardFields()) |
| | | ]; |
| | | } |
| | | public function getAdditionalFields(?string $content_type = null):array { |
| | | if ($content_type && array_key_exists($content_type, $this->contentTypes)){ |
| | | $array = $this->contentTypes[$content_type]; |
| | | return array_combine( |
| | | array_map(fn($k) => 'sq_' . $k, array_keys($array)), |
| | | $array |
| | | ); |
| | | } else if ($content_type && !array_key_exists($content_type, $this->contentTypes)) { |
| | | error_log('Could not get default fields for '.$this->service_name.' content type: '.$content_type); |
| | | return []; |
| | | } |
| | | $array = $this->setBaseFields(); |
| | | return array_combine( |
| | | array_map(fn($k) => 'sq_' . $k, array_keys($array)), |
| | | $array |
| | | ); |
| | | } |
| | | protected function setBaseFields():array |
| | | { |
| | | return [ |
| | |
| | | 'name' => $image_title ?: 'Image', |
| | | 'caption' => $alt_text ?: '' |
| | | ] |
| | | ] |
| | | ], |
| | | 'object_id' => $supported_image_id |
| | | ]; |
| | | |
| | | $body = $this->buildMultipartBody($file_path, $request_json, $boundary); |
| | |
| | | $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; |
| | | } |
| | | } |