| | |
| | | <?php |
| | | namespace JVBase\integrations; |
| | | |
| | | use JVBase\meta\MetaForm; |
| | | use JVBase\meta\MetaManager; |
| | | use Exception; |
| | | use JVBase\registry\PostTypeRegistrar; |
| | | use WP_Error; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | |
| | | $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 |
| | | */ |
| | |
| | | // User login tracking for security |
| | | add_action('wp_login', [$this, 'trackUserLogin'], 10, 2); |
| | | |
| | | add_action('jvbAdditionalActions', [$this, 'outputCheckout']); |
| | | |
| | | // Enqueue checkout scripts |
| | | add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']); |
| | | |
| | | add_filter('jvbAdditionalActions', [$this, 'outputCheckout']); |
| | | } |
| | | |
| | | |
| | |
| | | if (is_singular(BASE.'dash') || is_post_type_archive(BASE.'dash')) { |
| | | return $actions; |
| | | } |
| | | |
| | | $meta = new MetaForm(); |
| | | $form = '<aside id="cart" class="right"> |
| | | <form id="checkout" data-form-id="checkout" data-save="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> |
| | | '.$meta->return('cart_name', null, [ |
| | | 'type' => 'text', |
| | | 'label' => 'Your Name', |
| | | 'required' => true, |
| | | 'autocomplete' => 'name' |
| | | ]). |
| | | $meta->return('cart_email', null, [ |
| | | 'type' => 'email', |
| | | 'label' => 'Your Email', |
| | | 'required' => true, |
| | | 'autocomplete'=> 'email', |
| | | ]). |
| | | $meta->return('cart_phone', null, [ |
| | | 'type' => 'tel', |
| | | 'label' => 'Your Phone', |
| | | 'required' => true, |
| | | 'autocomplete'=> 'phone' |
| | | ]).' |
| | | <h3>Pickup Details</h3>'. |
| | | $meta->return('pickup_time', null, [ |
| | | 'type' => 'datetime', |
| | | 'label' => 'Pickup Type', |
| | | 'min' => '11:00', |
| | | 'max' => '20:00', |
| | | 'required' => true, |
| | | ]). |
| | | $meta->return('special_instructions', null, [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Special Instructions', |
| | | 'quill' => true, |
| | | ]).' |
| | | <textarea name="special_instructions" placeholder="Special instructions or dietary notes"></textarea> |
| | | </div> |
| | | |
| | |
| | | 'content' => $this->renderOrderStatus() |
| | | ] |
| | | ]; |
| | | $form .= jvbRenderTabs($tabs); |
| | | $form .= jvbRenderTabs($tabs, true); |
| | | |
| | | $form .= '<div class="cart-total row end"><p class="tax">Tax: <span></span></p><p class="total">GRAND TOTAL: <span></span></p></div> |
| | | </form> |
| | |
| | | |
| | | |
| | | $actions[] = [ |
| | | 'button' => '<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> |
| | | 'button' => '<button type="button" class="toggle-cart row" title="Your Cart" data-action="toggle-cart" aria-label="Open Cart" aria-controls="checkout" aria-expanded="false"> |
| | | '.jvbIcon('cart').'<span class="abs"></span><span class="abs count"></span> |
| | | </button>', |
| | | 'content' => $form |
| | |
| | | /** |
| | | * 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 = new MetaManager($wp_order_id, 'post'); |
| | | $updates = [ |
| | | 'status' => $state, |
| | | 'updated_at' => current_time('mysql') |
| | | ]; |
| | | |
| | | // Extract fulfillment status and pickup time |
| | | if (!empty($fulfillments[0])) { |
| | | $fulfillment = $fulfillments[0]; |
| | | $updates['fulfillment_status'] = $fulfillment['state'] ?? $state; |
| | | |
| | | if (!empty($fulfillment['pickup_details']['pickup_at'])) { |
| | | $updates['pickup_time'] = $fulfillment['pickup_details']['pickup_at']; |
| | | } |
| | | } |
| | | |
| | | $meta->setAll($updates); |
| | | |
| | | // Trigger notification to customer if order is ready |
| | | if ($state === 'PREPARED') { |
| | | do_action(BASE . 'square_order_ready', $wp_order_id, $order_id); |
| | | } |
| | | } |
| | | |
| | | // Also update transient cache for quick status checks |
| | | set_transient(BASE . 'square_order_' . $order_id, $state, HOUR_IN_SECONDS); |
| | | |
| | | // Trigger action for other integrations |
| | |
| | | 'jvb-a11y', |
| | | 'jvb-cache', |
| | | 'jvb-tabs', |
| | | 'jvb-modal', |
| | | 'jvb-popup' |
| | | ], |
| | | '1.0.0', |
| | |
| | | 'jvb-square-checkout', |
| | | 'squareConfig', |
| | | [ |
| | | 'isOpen' => jvbIsOpen(), |
| | | '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') |
| | | 'currency' => get_option(BASE . 'currency', 'CAD'), |
| | | 'is_logged_in' => is_user_logged_in(), |
| | | 'user_email' => is_user_logged_in() ? wp_get_current_user()->user_email : '' // NEW |
| | | ] |
| | | ); |
| | | } |
| | |
| | | '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 = new MetaManager($order_post_id, 'post'); |
| | | $fields = $this->getSquarePostConfig('_sq_orders')['fields']; |
| | | unset($fields['post_title']); |
| | | $meta->setFieldConfig($fields); |
| | | |
| | | $meta->setAll([ |
| | | 'square_order_id' => $order_data['square_order_id'], |
| | | 'square_payment_id' => $order_data['square_payment_id'] ?? '', |
| | | 'square_customer_id' => $order_data['square_customer_id'] ?? '', |
| | | 'amount' => $order_data['amount'], |
| | | 'status' => $order_data['status'], |
| | | 'fulfillment_status' => $order_data['fulfillment_status'] ?? 'PROPOSED', |
| | | 'pickup_time' => $order_data['pickup_time'] ?? '', |
| | | 'customer_email' => $customer_email, |
| | | 'customer_name' => $customer_name, |
| | | 'customer_phone' => $order_data['customer']['phone'] ?? '', |
| | | 'special_instructions' => $order_data['note'] ?? '', |
| | | 'items' => $order_data['items'], |
| | | 'receipt_url' => $order_data['receipt_url'] ?? '', |
| | | 'created_at' => current_time('mysql'), |
| | | 'updated_at' => current_time('mysql') |
| | | ]); |
| | | |
| | | // Index by Square order ID for quick webhook lookups |
| | | update_option(BASE . 'square_order_map_' . $order_data['square_order_id'], $order_post_id); |
| | | |
| | | return $order_post_id; |
| | | } |
| | | |
| | | /** |
| | | * Get currency code |
| | | */ |
| | | private function getCurrency(): string |
| | | { |
| | | return get_option(BASE . 'currency', 'CAD'); |
| | | } |
| | | |
| | | /** |
| | | * Get customer with saved cards (2025-compliant) |
| | | */ |
| | | public function getUserCards(string $customer_id): array |
| | | { |
| | | $response = $this->getRequest('cards?customer_id=' . $customer_id); |
| | | return $response['cards'] ?? []; |
| | | } |
| | | |
| | | |
| | | public function getUserOrders(string $customer_email): array |
| | | { |
| | | // First get Square customer ID |
| | | $customer_response = $this->postRequest('customers/search', [ |
| | | 'filter' => [ |
| | | 'email_address' => ['exact' => $customer_email] |
| | | ] |
| | | ]); |
| | | |
| | | $customer_id = $customer_response['customers'][0]['id'] ?? null; |
| | | if (!$customer_id) { |
| | | return []; |
| | | } |
| | | |
| | | // Get their orders |
| | | $orders_response = $this->postRequest('orders/search', [ |
| | | 'filter' => [ |
| | | 'customer_filter' => [ |
| | | 'customer_ids' => [$customer_id] |
| | | ] |
| | | ], |
| | | 'sort' => [ |
| | | 'sort_field' => 'CREATED_AT', |
| | | 'sort_order' => 'DESC' |
| | | ], |
| | | 'limit' => 50 |
| | | ]); |
| | | |
| | | return $orders_response['orders'] ?? []; |
| | | } |
| | | |
| | | public function checkOrderStatus(string $order_id): ?string |
| | | { |
| | | // Check transient cache first |
| | | $cached = get_transient(BASE . 'square_order_' . $order_id); |
| | | if ($cached) { |
| | | return $cached; |
| | | } |
| | | |
| | | // Fetch from Square |
| | | $response = $this->getRequest('orders/' . $order_id); |
| | | if (!is_wp_error($response)) { |
| | | $state = $response['order']['state'] ?? null; |
| | | set_transient(BASE . 'square_order_' . $order_id, $state, HOUR_IN_SECONDS); |
| | | return $state; |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | } |