| | |
| | | <?php |
| | | namespace JVBase\integrations; |
| | | |
| | | use JVBase\meta\Meta; |
| | | use Exception; |
| | | use WP_Error; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | | use WP_Post; |
| | | use JVBase\ui\Checkout; |
| | | use JVBase\managers\queue\TypeConfig; |
| | | use JVBase\managers\queue\executors\IntegrationExecutor; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Helcim Integration for JVBase |
| | | * Handles bidirectional sync, customer management, and order processing |
| | | * Helcim Integration Class |
| | | * |
| | | * Handles HelcimPay.js checkout, invoice retrieval, customer/card management, |
| | | * and bidirectional product sync via API token authentication. |
| | | * |
| | | * Helcim is the source of truth for invoices and orders. |
| | | * Products sync bidirectionally through the standard integration field flow. |
| | | * |
| | | * @since 1.0.0 |
| | | */ |
| | | class Helcim extends Integrations |
| | | { |
| | | // Helcim-specific configuration |
| | | private string $api_token; |
| | | private string $account_id; |
| | | private string $terminal_id; |
| | | private string $webhook_secret; |
| | | protected string $service_name = 'helcim'; |
| | | protected string|array $apiBase = 'https://api.helcim.com/v2'; |
| | | |
| | | // Field mapping cache |
| | | private array $field_mappings = []; |
| | | private array $category_cache = []; |
| | | protected bool $isOAuthService = false; |
| | | |
| | | // Order processing |
| | | private bool $is_test_mode = false; |
| | | private array $payment_form_settings = []; |
| | | |
| | | // User security |
| | | private const PASSWORD_RESET_INTERVAL = 3; // Reset password every 3 logins |
| | | /** |
| | | * Helcim API rate limits |
| | | * @see https://devdocs.helcim.com/docs/api-rate-limits |
| | | */ |
| | | protected array $rate_limits = [ |
| | | 'per_second' => 5, |
| | | 'per_minute' => 60, |
| | | 'per_hour' => 1000 |
| | | ]; |
| | | |
| | | public function __construct(?int $userID = null) |
| | | { |
| | | $this->service_name = 'helcim'; |
| | | $this->title = 'Helcim'; |
| | | $this->icon = 'currency-circle-dollar'; |
| | | $this->icon = 'credit-card'; |
| | | |
| | | // Helcim API endpoints |
| | | $this->apiBase = [ |
| | | 'production' => 'https://api.helcim.com/v2', |
| | | 'sandbox' => 'https://api-sandbox.helcim.com/v2' |
| | | $this->fields = [ |
| | | 'api_token' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'label' => 'API Token', |
| | | 'hint' => 'Found in Helcim Dashboard → Settings → API Access', |
| | | 'required' => true, |
| | | ], |
| | | 'currency' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Currency', |
| | | 'options' => [ |
| | | 'CAD' => 'CAD', |
| | | 'USD' => 'USD', |
| | | ], |
| | | 'default' => 'CAD', |
| | | ], |
| | | ]; |
| | | |
| | | $this->apiEndpoints = [ |
| | | 'commerce/invoice', |
| | | 'commerce/transaction', |
| | | 'commerce/customer', |
| | | 'commerce/card-batch', |
| | | 'commerce/terminal', |
| | | 'commerce/product', |
| | | 'commerce/order', |
| | | 'payment/purchase', |
| | | 'payment/verify', |
| | | 'payment/capture', |
| | | 'payment/refund', |
| | | 'customer/create', |
| | | 'customer/update', |
| | | 'customer/get', |
| | | 'inventory/product', |
| | | 'inventory/batch' |
| | | $this->advanced = [ |
| | | 'fee_saver' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Fee Saver', |
| | | 'hint' => 'Pass processing fees to customers (not compatible with Google Pay)', |
| | | ], |
| | | 'allow_ach' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Allow ACH/Bank Payments', |
| | | 'hint' => 'Enable bank account payments alongside credit card', |
| | | ], |
| | | ]; |
| | | |
| | | $this->instructions = [ |
| | | 'Go to <a href="https://my.helcim.com" target="_blank">Helcim Dashboard</a>', |
| | | 'Navigate to Settings → API Access', |
| | | 'Create a new API Access Configuration', |
| | | 'Enable permissions: General (Customers, Invoices, Products), Transaction Processing', |
| | | 'Copy the API Token and paste it below', |
| | | ]; |
| | | |
| | | $this->canSync = [ |
| | | 'create' => true, |
| | | 'update' => true, |
| | | 'delete' => true |
| | | 'delete' => false, |
| | | ]; |
| | | |
| | | $this->fields = [ |
| | | 'test_mode' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Environment', |
| | | 'options' => [ |
| | | '1' => 'Test Mode', |
| | | '0' => 'Production' |
| | | ], |
| | | 'default' => '1' |
| | | ], |
| | | 'api_token' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'required' => true, |
| | | 'hint' => 'Your Helcim API Token' |
| | | ], |
| | | 'account_id' => [ |
| | | 'type' => 'text', |
| | | 'required' => true, |
| | | 'label' => 'Account ID', |
| | | 'hint' => 'Your Helcim Account ID', |
| | | ], |
| | | 'webhook_secret' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'label' => 'Webhook Secret', |
| | | 'hint' => 'For webhook verification', |
| | | 'required' => true, |
| | | ] |
| | | ]; |
| | | |
| | | $this->advanced = [ |
| | | |
| | | ]; |
| | | |
| | | $this->instructions = [ |
| | | 'Once you are set up, add this URL to your Helcim webhook settings: <code>'.esc_html(rest_url('jvb/v1/webhooks/helcim')).'</code>' |
| | | ]; |
| | | |
| | | $this->defaults = [ |
| | | |
| | | ]; |
| | | |
| | | $this->handleWebhooks = true; |
| | | $this->handleWebhooks = false; |
| | | |
| | | parent::__construct($userID); |
| | | |
| | | $this->actions = array_merge( |
| | | $this->actions, |
| | | [ |
| | | 'import_from_helcim' => 'handleImportFromHelcim', |
| | | 'sync_to_helcim' => 'handleSyncToHelcim' |
| | | ] |
| | | ); |
| | | // Initialize field mappings |
| | | $this->initializeFieldMappings(); |
| | | // Helcim-specific actions (processAction dispatches these) |
| | | $this->actions = array_merge($this->actions, [ |
| | | 'initialize_checkout' => 'initializeCheckout', |
| | | 'get_invoices' => 'handleGetInvoices', |
| | | 'get_invoice' => 'handleGetInvoice', |
| | | 'get_customer_cards' => 'handleGetCustomerCards', |
| | | ]); |
| | | |
| | | $this->buttons = array_merge($this->buttons, [ |
| | | 'import_from_helcim' => 'Import Products from Helcim', |
| | | 'sync_to_helcim' => 'Sync Site to Helcim', |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Initialize service-specific settings |
| | | */ |
| | | /***************************************************************** |
| | | * ABSTRACT IMPLEMENTATIONS |
| | | *****************************************************************/ |
| | | |
| | | protected function initialize(): void |
| | | { |
| | | $this->api_token = $this->credentials['api_token'] ?? ''; |
| | | $this->account_id = $this->credentials['account_id'] ?? ''; |
| | | $this->terminal_id = $this->credentials['terminal_id'] ?? ''; |
| | | $this->webhook_secret = $this->credentials['webhook_secret'] ?? ''; |
| | | $this->is_test_mode = (bool)($this->credentials['test_mode'] ?? false); |
| | | if (empty($this->credentials)) { |
| | | $this->loadCredentials(); |
| | | } |
| | | |
| | | // Set the appropriate API base |
| | | $this->apiBase = $this->is_test_mode ? $this->apiBase['sandbox'] : $this->apiBase['production']; |
| | | |
| | | // Load payment form settings |
| | | $this->payment_form_settings = [ |
| | | 'card' => $this->credentials['enable_card_payments'] ?? true, |
| | | 'ach' => $this->credentials['enable_ach_payments'] ?? false, |
| | | 'apple_pay' => $this->credentials['enable_apple_pay'] ?? false, |
| | | 'google_pay' => $this->credentials['enable_google_pay'] ?? false, |
| | | $this->apiEndpoints = [ |
| | | 'connection-test', |
| | | 'helcim-pay/initialize', |
| | | 'invoices', |
| | | 'customers', |
| | | 'payment/purchase', |
| | | 'payment/preauth', |
| | | 'payment/capture', |
| | | 'payment/refund', |
| | | 'payment/verify', |
| | | 'card-transactions', |
| | | 'card-batches', |
| | | ]; |
| | | } |
| | | |
| | | protected function getRequestHeaders(): array |
| | | { |
| | | return [ |
| | | 'api-token' => $this->credentials['api_token'] ?? '', |
| | | 'Content-Type' => 'application/json', |
| | | 'Accept' => 'application/json', |
| | | ]; |
| | | } |
| | | |
| | | protected function performConnectionTest(): bool |
| | | { |
| | | try { |
| | | $response = $this->getRequest('connection-test', [], null, 'none', true); |
| | | return !is_wp_error($response) && !$this->isErrorResponse($response ?? []); |
| | | } catch (Exception $e) { |
| | | $this->logError('Connection test failed', ['error' => $e->getMessage()]); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * CONTENT TYPES — product field definitions |
| | | *****************************************************************/ |
| | | |
| | | protected function setContentTypes(): void |
| | | { |
| | | $this->has_content = true; |
| | | $this->defaultContent = 'REGULAR'; |
| | | $types = ['REGULAR', 'SERVICE', 'DIGITAL', 'FOOD_AND_BEV', 'EVENT', 'SUBSCRIPTION', 'DONATION']; |
| | | |
| | | foreach ($types as $type) { |
| | | $t = $type === 'REGULAR' ? null : $type; |
| | | $this->contentTypes[$type] = $this->getHelcimMeta($t); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Register additional WordPress hooks |
| | | * Get Helcim product meta fields by type. |
| | | * |
| | | * Used by FieldRegistry when 'use_helcim' => true is set |
| | | * in a JVB_CONTENT definition. |
| | | */ |
| | | public function getHelcimMeta(?string $type = null): array |
| | | { |
| | | $fields = [ |
| | | // Basic Product Fields |
| | | 'price' => [ |
| | | 'type' => 'number', |
| | | 'bulkEdit' => true, |
| | | 'label' => 'Price', |
| | | 'step' => 0.01, |
| | | 'max' => 99999, |
| | | 'description' => 'Product price' |
| | | ], |
| | | |
| | | 'product_type' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Product Type', |
| | | 'options' => [ |
| | | 'REGULAR' => 'Regular Product', |
| | | 'SERVICE' => 'Service', |
| | | 'DIGITAL' => 'Digital Product', |
| | | 'FOOD_AND_BEV' => 'Food & Beverage', |
| | | 'EVENT' => 'Event/Ticket', |
| | | 'SUBSCRIPTION' => 'Subscription', |
| | | 'DONATION' => 'Donation' |
| | | ], |
| | | 'default' => $type ?? 'REGULAR' |
| | | ], |
| | | |
| | | 'cart_quantity' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Quantity', |
| | | 'hidden' => true, |
| | | ], |
| | | |
| | | // Tax & Shipping |
| | | 'tax_exempt' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Tax Exempt', |
| | | 'section' => 'helcim-tax' |
| | | ], |
| | | |
| | | 'shipping_required' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Shipping Required', |
| | | 'section' => 'helcim-shipping' |
| | | ], |
| | | |
| | | 'shipping_weight' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Shipping Weight (kg)', |
| | | 'step' => 0.01, |
| | | 'section' => 'helcim-shipping', |
| | | 'condition' => [ |
| | | 'field' => 'shipping_required', |
| | | 'operator' => '==', |
| | | 'value' => true |
| | | ] |
| | | ], |
| | | |
| | | // Availability |
| | | 'available_online' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available Online', |
| | | 'section' => 'helcim-availability', |
| | | 'default' => true |
| | | ], |
| | | |
| | | 'available_for_pickup' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available for Pickup', |
| | | 'section' => 'helcim-availability', |
| | | 'default' => true |
| | | ], |
| | | |
| | | 'available_for_delivery' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available for Delivery', |
| | | 'section' => 'helcim-availability', |
| | | 'default' => false |
| | | ], |
| | | |
| | | '_helcim_sku' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'SKU', |
| | | 'description' => 'Stock keeping unit', |
| | | 'section' => 'helcim-config' |
| | | ], |
| | | |
| | | // Product Variations |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->getHelcimVariationMeta($type) |
| | | ], |
| | | |
| | | // Product Options |
| | | 'options' => [ |
| | | 'type' => 'group', |
| | | 'label' => 'Product Options', |
| | | 'section'=> 'helcim-options', |
| | | 'fields' => [ |
| | | 'max_order' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Maximum per order', |
| | | 'default' => 50 |
| | | ], |
| | | 'min_order' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Minimum per order', |
| | | 'default' => 0, |
| | | ], |
| | | 'step' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Order increment', |
| | | 'default' => 1, |
| | | ], |
| | | 'preparation_time' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Preparation time (minutes)', |
| | | 'description' => 'Time needed to prepare this item', |
| | | 'condition' => [ |
| | | 'field' => 'product_type', |
| | | 'operator' => 'in', |
| | | 'value' => ['FOOD_AND_BEV', 'SERVICE'] |
| | | ] |
| | | ] |
| | | ] |
| | | ], |
| | | |
| | | // Subscription Fields |
| | | 'subscription_settings' => [ |
| | | 'type' => 'group', |
| | | 'label' => 'Subscription Settings', |
| | | 'section' => 'helcim-subscription', |
| | | 'condition' => [ |
| | | 'field' => 'product_type', |
| | | 'operator' => '==', |
| | | 'value' => 'SUBSCRIPTION' |
| | | ], |
| | | 'fields' => [ |
| | | 'billing_cycle' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Billing Cycle', |
| | | 'options' => [ |
| | | 'daily' => 'Daily', |
| | | 'weekly' => 'Weekly', |
| | | 'monthly' => 'Monthly', |
| | | 'quarterly' => 'Quarterly', |
| | | 'yearly' => 'Yearly' |
| | | ], |
| | | 'default' => 'monthly' |
| | | ], |
| | | 'trial_period' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Trial Period (days)', |
| | | 'description' => 'Free trial period before billing starts', |
| | | 'default' => 0 |
| | | ], |
| | | 'setup_fee' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Setup Fee', |
| | | 'step' => 0.01, |
| | | 'description' => 'One-time setup fee' |
| | | ] |
| | | ] |
| | | ], |
| | | |
| | | // Food & Beverage Specific |
| | | 'food_settings' => [ |
| | | 'type' => 'group', |
| | | 'label' => 'Food & Beverage Settings', |
| | | 'section' => 'helcim-food', |
| | | 'condition' => [ |
| | | 'field' => 'product_type', |
| | | 'operator' => '==', |
| | | 'value' => 'FOOD_AND_BEV' |
| | | ], |
| | | 'fields' => [ |
| | | 'ingredients' => [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Ingredients', |
| | | 'description' => 'List ingredients (comma separated)' |
| | | ], |
| | | 'allergens' => [ |
| | | 'type' => 'checkbox_list', |
| | | 'label' => 'Allergens', |
| | | 'options' => [ |
| | | 'gluten' => 'Contains Gluten', |
| | | 'dairy' => 'Contains Dairy', |
| | | 'nuts' => 'Contains Nuts', |
| | | 'soy' => 'Contains Soy', |
| | | 'eggs' => 'Contains Eggs', |
| | | 'seafood' => 'Contains Seafood' |
| | | ] |
| | | ], |
| | | 'dietary_options' => [ |
| | | 'type' => 'checkbox_list', |
| | | 'label' => 'Dietary Options', |
| | | 'options' => [ |
| | | 'vegetarian' => 'Vegetarian', |
| | | 'vegan' => 'Vegan', |
| | | 'gluten_free' => 'Gluten Free', |
| | | 'dairy_free' => 'Dairy Free', |
| | | 'keto' => 'Keto Friendly', |
| | | 'halal' => 'Halal', |
| | | 'kosher' => 'Kosher' |
| | | ] |
| | | ], |
| | | 'spice_level' => [ |
| | | 'type' => 'range', |
| | | 'label' => 'Spice Level', |
| | | 'min' => 0, |
| | | 'max' => 5, |
| | | 'default' => 0 |
| | | ], |
| | | 'serving_size' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Serving Size', |
| | | 'description' => 'e.g., "Serves 2-3"' |
| | | ] |
| | | ] |
| | | ], |
| | | |
| | | // Service Specific |
| | | 'service_settings' => [ |
| | | 'type' => 'group', |
| | | 'label' => 'Service Settings', |
| | | 'section' => 'helcim-service', |
| | | 'condition' => [ |
| | | 'field' => 'product_type', |
| | | 'operator' => '==', |
| | | 'value' => 'SERVICE' |
| | | ], |
| | | 'fields' => [ |
| | | 'service_duration' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Duration (minutes)', |
| | | 'description' => 'Service duration in minutes' |
| | | ], |
| | | 'booking_required' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Booking Required' |
| | | ], |
| | | 'capacity' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Service Capacity', |
| | | 'description' => 'Maximum number of customers per service' |
| | | ], |
| | | 'staff_required' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Staff Required', |
| | | 'description' => 'Number of staff needed', |
| | | 'default' => 1 |
| | | ] |
| | | ] |
| | | ], |
| | | |
| | | // Event Specific |
| | | 'event_settings' => [ |
| | | 'type' => 'group', |
| | | 'label' => 'Event Settings', |
| | | 'section' => 'helcim-event', |
| | | 'condition' => [ |
| | | 'field' => 'product_type', |
| | | 'operator' => '==', |
| | | 'value' => 'EVENT' |
| | | ], |
| | | 'fields' => [ |
| | | 'event_date' => [ |
| | | 'type' => 'datetime', |
| | | 'label' => 'Event Date & Time' |
| | | ], |
| | | 'event_location' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Event Location' |
| | | ], |
| | | 'max_attendees' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Maximum Attendees' |
| | | ], |
| | | 'early_bird_price' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Early Bird Price', |
| | | 'step' => 0.01, |
| | | 'description' => 'Discounted price for early registrations' |
| | | ], |
| | | 'early_bird_deadline' => [ |
| | | 'type' => 'date', |
| | | 'label' => 'Early Bird Deadline' |
| | | ] |
| | | ] |
| | | ] |
| | | ]; |
| | | |
| | | // Add inventory fields if configured |
| | | if ($config['hasInventory'] ?? false) { |
| | | $fields['_helcim_inventory'] = [ |
| | | 'type' => 'number', |
| | | 'label' => 'Inventory', |
| | | 'bulkEdit' => true, |
| | | 'section' => 'inventory' |
| | | ]; |
| | | |
| | | $fields['track_inventory'] = [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Track Inventory', |
| | | 'section' => 'inventory', |
| | | 'default' => true |
| | | ]; |
| | | |
| | | $fields['low_stock_threshold'] = [ |
| | | 'type' => 'number', |
| | | 'label' => 'Low Stock Alert', |
| | | 'description' => 'Alert when stock falls below this level', |
| | | 'section' => 'inventory', |
| | | 'default' => 5 |
| | | ]; |
| | | |
| | | $fields['product_variations']['fields']['inventory'] = [ |
| | | 'type' => 'number', |
| | | 'label' => 'Stock Quantity', |
| | | 'description' => 'Current stock for this variation' |
| | | ]; |
| | | } |
| | | |
| | | return $fields; |
| | | } |
| | | |
| | | public function getHelcimVariationMeta(?string $type = null):array |
| | | { |
| | | |
| | | $base = [ |
| | | 'name' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Variation Name', |
| | | 'description' => 'e.g., "Small", "Large", "Red"' |
| | | ], |
| | | 'price' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Price', |
| | | 'step' => 0.01, |
| | | 'max' => 99999, |
| | | 'description' => 'Price for this variation' |
| | | ], |
| | | 'sku' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'SKU', |
| | | 'description' => 'Stock keeping unit for this variation' |
| | | ], |
| | | 'track_inventory' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Track Inventory', |
| | | ], |
| | | '_helcim_variation_id' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Helcim Variation ID', |
| | | 'description' => 'Helcim ID for this variation', |
| | | 'hidden' => true |
| | | ], |
| | | '_helcim_last_sync' => [ |
| | | 'type' => 'datetime', |
| | | 'label' => 'Last Sync', |
| | | 'hidden' => true |
| | | ], |
| | | 'options' => [ |
| | | 'type' => 'group', |
| | | 'label' => 'Variation Options', |
| | | 'collapsible' => true, |
| | | 'fields' => [ |
| | | 'color' => [ |
| | | 'type' => 'color', |
| | | 'label' => 'Color', |
| | | 'description' => 'Visual color for this variation' |
| | | ], |
| | | 'size' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Size', |
| | | 'options' => [ |
| | | '' => 'N/A', |
| | | 'xs' => 'Extra Small', |
| | | 's' => 'Small', |
| | | 'm' => 'Medium', |
| | | 'l' => 'Large', |
| | | 'xl' => 'Extra Large', |
| | | 'xxl' => '2X Large', |
| | | 'custom'=> 'Custom' |
| | | ] |
| | | ], |
| | | 'custom_size' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Custom Size', |
| | | 'condition' => [ |
| | | 'field' => 'size', |
| | | 'operator' => '==', |
| | | 'value' => 'custom' |
| | | ] |
| | | ], |
| | | 'weight' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Weight (kg)', |
| | | 'step' => 0.01, |
| | | 'description' => 'Weight of this variation' |
| | | ], |
| | | 'dimensions' => [ |
| | | 'type' => 'group', |
| | | 'label' => 'Dimensions', |
| | | 'fields' => [ |
| | | 'length' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Length (cm)', |
| | | 'step' => 0.1 |
| | | ], |
| | | 'width' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Width (cm)', |
| | | 'step' => 0.1 |
| | | ], |
| | | 'height' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Height (cm)', |
| | | 'step' => 0.1 |
| | | ] |
| | | ] |
| | | ] |
| | | ] |
| | | ] |
| | | ]; |
| | | |
| | | $extras = [ |
| | | 'SERVICE' => [ |
| | | 'service_duration' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Duration (minutes)', |
| | | 'description' => 'Duration for this service variation' |
| | | ], |
| | | 'available_for_booking' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available for Booking' |
| | | ] |
| | | ], |
| | | |
| | | 'FOOD_AND_BEV' => [ |
| | | 'portion_size' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Portion Size', |
| | | 'options' => [ |
| | | 'small' => 'Small', |
| | | 'regular' => 'Regular', |
| | | 'large' => 'Large', |
| | | 'family' => 'Family Size' |
| | | ] |
| | | ], |
| | | 'calories' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Calories', |
| | | 'description' => 'Calorie count for this variation' |
| | | ] |
| | | ], |
| | | |
| | | 'DIGITAL' => [ |
| | | 'download_limit' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Download Limit', |
| | | 'description' => 'Maximum number of downloads', |
| | | 'default' => -1 |
| | | ], |
| | | 'expiry_days' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Access Duration (days)', |
| | | 'description' => 'Days until download expires', |
| | | 'default' => 0 |
| | | ] |
| | | ] |
| | | ]; |
| | | if ($type && array_key_exists($type, $extras)){ |
| | | $base = array_merge($base, $extras[$type]); |
| | | } |
| | | return $base; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * HELCIMPAY.JS — Frontend Scripts & Checkout Initialization |
| | | *****************************************************************/ |
| | | |
| | | protected function registerAdditionalHooks(): void |
| | | { |
| | | $this->ensureInitialized(); |
| | | 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']); |
| | | |
| | | // REST API endpoints for checkout |
| | | add_action('rest_api_init', [$this, 'registerRestRoutes']); |
| | | } |
| | | // Shared checkout UI (replaces provider-specific outputCheckout) |
| | | add_filter('jvbAdditionalActions', [Checkout::class, 'render']); |
| | | |
| | | /** |
| | | * Register REST API routes |
| | | */ |
| | | public function registerRestRoutes(): void |
| | | { |
| | | register_rest_route('jvb/v1', '/helcim/checkout', [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'handleCheckout'], |
| | | 'permission_callback' => '__return_true' |
| | | ]); |
| | | |
| | | register_rest_route('jvb/v1', '/helcim/customer', [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'handleCustomerLookup'], |
| | | 'permission_callback' => '__return_true' |
| | | ]); |
| | | |
| | | register_rest_route('jvb/v1', '/helcim/order-status/(?P<order_id>[a-zA-Z0-9-]+)', [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'handleOrderStatus'], |
| | | 'permission_callback' => '__return_true' |
| | | ]); |
| | | |
| | | register_rest_route('jvb/v1', '/helcim/create-account', [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'handleAccountCreation'], |
| | | 'permission_callback' => '__return_true' |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Initialize field mappings for all content types |
| | | */ |
| | | private function initializeFieldMappings(): void |
| | | { |
| | | foreach (JVB_CONTENT as $key => $config) { |
| | | if (isset($config['integrations']['helcim'])) { |
| | | $post_type = jvbCheckBase($key); |
| | | $this->field_mappings[$post_type] = $this->getFieldMapping($post_type); |
| | | // Checkout description filter |
| | | add_filter('jvb_checkout_description', function (string $desc, string $provider) { |
| | | if ($provider === 'helcim') { |
| | | return 'Securely checkout with your name, email, and payments processed by Helcim.'; |
| | | } |
| | | } |
| | | return $desc; |
| | | }, 10, 2); |
| | | |
| | | // Register queue operation types with IntegrationExecutor |
| | | $this->registerQueueTypes(); |
| | | |
| | | // Register webhook endpoint (handled by parent) |
| | | $this->registerWebhookEndpoint(); |
| | | } |
| | | |
| | | /** |
| | | * Get field mapping for a post type |
| | | */ |
| | | public function getFieldMapping(string $post_type): array |
| | | { |
| | | return apply_filters(BASE . '_helcim_field_mapping', [ |
| | | 'name' => 'title', |
| | | 'description' => 'content', |
| | | 'price' => 'price', |
| | | 'sku' => '_helcim_sku', |
| | | 'product_code' => '_helcim_product_code', |
| | | 'inventory' => '_helcim_inventory', |
| | | 'product_type' => 'product_type', |
| | | 'tax_exempt' => 'tax_exempt', |
| | | 'shipping_required' => 'shipping_required' |
| | | ], $post_type); |
| | | } |
| | | |
| | | /** |
| | | * Output checkout form |
| | | */ |
| | | 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('shopping-cart')?><span class="abs"></span><span class="abs count"></span> |
| | | </button> |
| | | <aside id="cart" class="main"> |
| | | <form id="checkout" data-form-id="checkout" data-save="checkout"> |
| | | <?php |
| | | $tabs = [ |
| | | 'cartItems' => [ |
| | | 'title' => 'Your Order', |
| | | 'icon' => 'cart', |
| | | 'description' => 'Review and modify your order items', |
| | | 'content' => $this->cartContent() |
| | | ], |
| | | 'account' => [ |
| | | 'title' => 'Account', |
| | | 'icon' => 'user', |
| | | 'description' => $this->getAccountTabDescription(), |
| | | 'content' => $this->renderAccountSection() |
| | | ], |
| | | 'checkout' => [ |
| | | 'title' => 'Checkout', |
| | | 'icon' => 'checkout', |
| | | 'description' => 'Complete your order with Helcim secure payments', |
| | | 'content' => $this->renderCheckoutSection() |
| | | ], |
| | | 'order' => [ |
| | | 'title' => 'Order Status', |
| | | 'icon' => 'truck', |
| | | 'hidden' => true, |
| | | 'description' => 'Track your order status', |
| | | '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> |
| | | <?php |
| | | $this->outputCheckoutTemplates(); |
| | | } |
| | | |
| | | /** |
| | | * Get account tab description based on login status |
| | | */ |
| | | private function getAccountTabDescription(): string |
| | | { |
| | | if (is_user_logged_in()) { |
| | | return 'Manage your account and view order history'; |
| | | } |
| | | return 'Login or create an account for faster checkout'; |
| | | } |
| | | |
| | | /** |
| | | * Render account section |
| | | */ |
| | | private function renderAccountSection(): string |
| | | { |
| | | ob_start(); |
| | | ?> |
| | | <div class="account-section"> |
| | | <?php if (is_user_logged_in()): ?> |
| | | <?php $this->renderLoggedInAccount(); ?> |
| | | <?php else: ?> |
| | | <?php $this->renderGuestAccount(); ?> |
| | | <?php endif; ?> |
| | | </div> |
| | | <?php |
| | | return ob_get_clean(); |
| | | } |
| | | |
| | | /** |
| | | * Render logged in account view |
| | | */ |
| | | private function renderLoggedInAccount(): void |
| | | { |
| | | $user = wp_get_current_user(); |
| | | $customer_id = get_user_meta($user->ID, BASE . '_helcim_customer_id', true); |
| | | ?> |
| | | <div class="logged-in-account"> |
| | | <p>Welcome back, <?= esc_html($user->display_name) ?>!</p> |
| | | |
| | | <div class="account-actions"> |
| | | <button type="button" class="button" onclick="helcimCheckout.loadSavedCards()"> |
| | | <?= jvbIcon('credit-card') ?> Saved Cards |
| | | </button> |
| | | <button type="button" class="button" onclick="helcimCheckout.loadOrderHistory()"> |
| | | <?= jvbIcon('receipt') ?> Order History |
| | | </button> |
| | | <button type="button" class="button" onclick="helcimCheckout.loadFavorites()"> |
| | | <?= jvbIcon('heart') ?> Favorites |
| | | </button> |
| | | </div> |
| | | |
| | | <div id="account-content"></div> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | /** |
| | | * Render guest account view |
| | | */ |
| | | private function renderGuestAccount(): void |
| | | { |
| | | ?> |
| | | <div class="guest-account"> |
| | | <div class="login-section"> |
| | | <h3>Returning Customer?</h3> |
| | | <p>Login with your email to access saved cards and order history</p> |
| | | |
| | | <div class="email-login"> |
| | | <input type="email" |
| | | id="login-email" |
| | | placeholder="Enter your email" |
| | | autocomplete="email"> |
| | | <button type="button" |
| | | class="button primary" |
| | | onclick="helcimCheckout.loginWithEmail()"> |
| | | Continue |
| | | </button> |
| | | </div> |
| | | |
| | | <div id="login-status"></div> |
| | | </div> |
| | | |
| | | <div class="guest-checkout"> |
| | | <h3>New Customer?</h3> |
| | | <p>You can checkout as a guest or create an account after your order</p> |
| | | |
| | | <label> |
| | | <input type="checkbox" id="create-account-offer"> |
| | | Offer to create account after checkout |
| | | </label> |
| | | </div> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | /** |
| | | * Render checkout section |
| | | */ |
| | | private function renderCheckoutSection(): string |
| | | { |
| | | ob_start(); |
| | | ?> |
| | | <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/Delivery Details</h3> |
| | | <select name="fulfillment_type" required> |
| | | <option value="pickup">Pickup</option> |
| | | <option value="delivery">Delivery</option> |
| | | </select> |
| | | |
| | | <div class="pickup-details" data-show-if="fulfillment_type:pickup"> |
| | | <input type="datetime-local" name="pickup_time" required> |
| | | </div> |
| | | |
| | | <div class="delivery-details" data-show-if="fulfillment_type:delivery" style="display:none;"> |
| | | <input type="text" name="delivery_address" placeholder="Delivery Address" autocomplete="street-address"> |
| | | <input type="text" name="delivery_instructions" placeholder="Delivery Instructions"> |
| | | </div> |
| | | |
| | | <textarea name="special_instructions" placeholder="Special instructions or dietary notes"></textarea> |
| | | |
| | | <h3>Payment Information</h3> |
| | | <div id="saved-cards"></div> |
| | | <div id="helcim-card-container"></div> |
| | | |
| | | <button type="submit" class="button primary checkout-button"> |
| | | Place Order |
| | | </button> |
| | | </div> |
| | | <?php |
| | | return ob_get_clean(); |
| | | } |
| | | |
| | | /** |
| | | * Render order status section |
| | | */ |
| | | protected function renderOrderStatus(): string |
| | | { |
| | | 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</div> |
| | | <div class="status-item" data-status="complete">Complete</div> |
| | | </div> |
| | | <div class="order-eta"> |
| | | Estimated time: <span id="eta">Calculating...</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <?php |
| | | return ob_get_clean(); |
| | | } |
| | | |
| | | /** |
| | | * Output checkout templates |
| | | */ |
| | | private function outputCheckoutTemplates(): void |
| | | { |
| | | ?> |
| | | <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="" data-id=""> |
| | | <button type="button" class="decrease" aria-label="Decrease quantity"> |
| | | <?= jvbIcon('minus-square') ?> |
| | | </button> |
| | | <input type="number" name="quantity" value="1" min="0" max="50"> |
| | | <button type="button" class="increase" aria-label="Increase quantity"> |
| | | <?= jvbIcon('plus') ?> |
| | | </button> |
| | | </div> |
| | | </td> |
| | | <td class="price"></td> |
| | | <td class="total"></td> |
| | | <td> |
| | | <button type="button" class="remove" aria-label="Remove item"> |
| | | <?= jvbIcon('trash') ?> |
| | | </button> |
| | | </td> |
| | | </tr> |
| | | </template> |
| | | |
| | | <template class="savedCard"> |
| | | <label class="saved-card-option"> |
| | | <input type="radio" name="payment_method" value=""> |
| | | <span class="card-details"> |
| | | <span class="card-brand"></span> |
| | | •••• <span class="last-4"></span> |
| | | <span class="exp-date"></span> |
| | | </span> |
| | | </label> |
| | | </template> |
| | | <?php |
| | | } |
| | | |
| | | /** |
| | | * Cart content section |
| | | */ |
| | | private function cartContent(): string |
| | | { |
| | | ob_start(); |
| | | ?> |
| | | <div class="cart-items"> |
| | | <table> |
| | | <thead> |
| | | <tr> |
| | | <th>Item</th> |
| | | <th>Price</th> |
| | | <th>Total</th> |
| | | <th></th> |
| | | </tr> |
| | | </thead> |
| | | <tbody></tbody> |
| | | </table> |
| | | |
| | | <div class="cart-actions"> |
| | | <button type="button" class="button" onclick="helcimCheckout.clearCart()"> |
| | | <?= jvbIcon('trash') ?> Clear Cart |
| | | </button> |
| | | </div> |
| | | </div> |
| | | <?php |
| | | return ob_get_clean(); |
| | | } |
| | | |
| | | /** |
| | | * Enqueue checkout scripts |
| | | */ |
| | | public function enqueueScripts(): void |
| | | { |
| | | $this->ensureInitialized(); |
| | | if (!$this->isSetUp()) { |
| | | return; |
| | | } |
| | | // Helcim JS SDK |
| | | $sdk_url = $this->is_test_mode |
| | | ? 'https://helcim-js-sandbox.helcim.com/v1/helcim.js' |
| | | : 'https://js.helcim.com/v1/helcim.js'; |
| | | |
| | | // HelcimPay.js SDK |
| | | wp_enqueue_script( |
| | | 'helcim-js-sdk', |
| | | $sdk_url, |
| | | 'helcim-pay-sdk', |
| | | 'https://secure.helcim.app/helcim-pay/services/start.js', |
| | | [], |
| | | null, |
| | | [ |
| | | 'strategy' => 'defer', |
| | | 'in_footer' => true |
| | | ] |
| | | true |
| | | ); |
| | | |
| | | // Register custom checkout script |
| | | // Base cart checkout (shared with Square) |
| | | 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', |
| | | true |
| | | ); |
| | | |
| | | // Helcim checkout (extends CartCheckout) |
| | | wp_register_script( |
| | | 'jvb-helcim-checkout', |
| | | JVB_URL . 'assets/js/min/helcim.min.js', |
| | | [ |
| | | 'jvb-utility', |
| | | 'jvb-queue', |
| | | 'jvb-a11y', |
| | | 'jvb-cache', |
| | | 'jvb-tabs', |
| | | 'jvb-modal', |
| | | ], |
| | | '1.0.0', |
| | | [ |
| | | 'strategy' => 'defer', |
| | | 'in_footer' => true |
| | | ] |
| | | ['jvb-checkout', 'helcim-pay-sdk'], |
| | | '1.1.31', |
| | | true |
| | | ); |
| | | |
| | | wp_localize_script('jvb-helcim-checkout', 'helcimConfig', [ |
| | | 'api_url' => rest_url('jvb/v1/helcim/'), |
| | | 'nonce' => wp_create_nonce('wp_rest'), |
| | | 'currency' => $this->credentials['currency'] ?? 'CAD', |
| | | 'is_logged_in' => is_user_logged_in(), |
| | | 'user_email' => is_user_logged_in() ? wp_get_current_user()->user_email : '', |
| | | 'isOpen' => apply_filters('jvb_store_is_open', '1'), |
| | | ]); |
| | | |
| | | wp_enqueue_script('jvb-helcim-checkout'); |
| | | |
| | | // Localize the checkout script with Helcim config |
| | | wp_localize_script( |
| | | 'jvb-helcim-checkout', |
| | | 'helcimConfig', |
| | | [ |
| | | 'isOpen' => jvbIsOpen(), |
| | | 'apiUrl' => rest_url('jvb/v1/helcim/'), |
| | | 'nonce' => wp_create_nonce('wp_rest'), |
| | | 'accountId' => $this->account_id, |
| | | 'testMode' => $this->is_test_mode |
| | | ] |
| | | ); |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * POST SYNC METHODS |
| | | ******************************************************************/ |
| | | |
| | | protected function registerQueueTypes(): void |
| | | { |
| | | $queue = JVB()->queue(); |
| | | $executor = new IntegrationExecutor(); |
| | | |
| | | $queue->registry()->register('helcim_sync_to', new TypeConfig( |
| | | executor: $executor, |
| | | chunkKey: 'items', |
| | | chunkSize: 10, |
| | | maxRetries: 3 |
| | | )); |
| | | |
| | | $queue->registry()->register('helcim_sync_from', new TypeConfig( |
| | | executor: $executor, |
| | | chunkKey: 'items', |
| | | chunkSize: 10, |
| | | maxRetries: 3 |
| | | )); |
| | | |
| | | $queue->registry()->register('helcim_delete_from', new TypeConfig( |
| | | executor: $executor, |
| | | chunkKey: 'external_ids', |
| | | chunkSize: 20, |
| | | maxRetries: 2 |
| | | )); |
| | | |
| | | $queue->registry()->register('helcim_import', new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 3 |
| | | )); |
| | | |
| | | $queue->registry()->register('helcim_sync_customer', new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 2 |
| | | )); |
| | | } |
| | | |
| | | /** |
| | | * Handle post save for Helcim sync |
| | | * Initialize a HelcimPay.js checkout session. |
| | | * |
| | | * Server-side: POST /helcim-pay/initialize → returns checkoutToken + secretToken. |
| | | * Client-side: appendHelcimPayIframe(checkoutToken) renders the payment modal. |
| | | * Tokens are valid for 60 minutes. |
| | | * |
| | | * @param array $data [ |
| | | * 'amount' => float, // Required |
| | | * 'invoiceId' => string, // Optional — pay a specific invoice |
| | | * 'customerId' => int, // Optional — associate with Helcim customer |
| | | * 'paymentType' => string, // purchase|preauth|verify (default: purchase) |
| | | * ] |
| | | */ |
| | | public function initializeCheckout(array $data): array |
| | | { |
| | | if (empty($data['amount']) || (float) $data['amount'] <= 0) { |
| | | return ['success' => false, 'message' => 'Invalid amount']; |
| | | } |
| | | |
| | | $paymentMethod = !empty($this->credentials['allow_ach']) ? 'cc-ach' : 'cc'; |
| | | |
| | | $body = [ |
| | | 'paymentType' => $data['paymentType'] ?? 'purchase', |
| | | 'amount' => (float) $data['amount'], |
| | | 'currency' => $this->credentials['currency'] ?? 'CAD', |
| | | 'paymentMethod' => $paymentMethod, |
| | | ]; |
| | | |
| | | if (!empty($data['invoiceId'])) { |
| | | $body['invoiceNumber'] = $data['invoiceId']; |
| | | } |
| | | |
| | | if (!empty($data['customerId'])) { |
| | | $body['customerId'] = (int) $data['customerId']; |
| | | } |
| | | |
| | | if (!empty($this->credentials['fee_saver'])) { |
| | | $body['hasConvenienceFee'] = true; |
| | | } |
| | | |
| | | $response = $this->postRequest('helcim-pay/initialize', $body); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return ['success' => false, 'message' => $response->get_error_message()]; |
| | | } |
| | | |
| | | if (empty($response['checkoutToken'])) { |
| | | return ['success' => false, 'message' => 'Failed to initialize checkout']; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'checkoutToken' => $response['checkoutToken'], |
| | | 'secretToken' => $response['secretToken'] ?? '', |
| | | ]; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * INVOICES — Helcim is source of truth |
| | | *****************************************************************/ |
| | | |
| | | /** |
| | | * Get invoices for a customer. |
| | | * |
| | | * @param array $data ['email' => string] or ['customerId' => int] |
| | | */ |
| | | public function handleGetInvoices(array $data): array |
| | | { |
| | | $customerId = $data['customerId'] ?? null; |
| | | |
| | | if (empty($customerId) && !empty($data['email'])) { |
| | | $customerId = $this->getCustomerIdByEmail($data['email']); |
| | | } |
| | | |
| | | if (!$customerId) { |
| | | return ['success' => true, 'invoices' => []]; |
| | | } |
| | | |
| | | $response = $this->getRequest('invoices', ['customerId' => $customerId], null, 'minimal'); |
| | | |
| | | if (is_wp_error($response) || !is_array($response)) { |
| | | return ['success' => false, 'message' => 'Failed to fetch invoices']; |
| | | } |
| | | |
| | | return ['success' => true, 'invoices' => $response]; |
| | | } |
| | | |
| | | /** |
| | | * Get a single invoice by ID. |
| | | */ |
| | | public function handleGetInvoice(array $data): array |
| | | { |
| | | $invoiceId = $data['invoiceId'] ?? null; |
| | | |
| | | if (!$invoiceId) { |
| | | return ['success' => false, 'message' => 'Invoice ID required']; |
| | | } |
| | | |
| | | $response = $this->getRequest("invoices/{$invoiceId}", [], null, 'minimal'); |
| | | |
| | | if (is_wp_error($response) || !is_array($response)) { |
| | | return ['success' => false, 'message' => 'Failed to fetch invoice']; |
| | | } |
| | | |
| | | return ['success' => true, 'invoice' => $response]; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * CUSTOMERS & CARDS |
| | | *****************************************************************/ |
| | | |
| | | /** |
| | | * Find Helcim customer ID by email. |
| | | */ |
| | | public function getCustomerIdByEmail(string $email): ?int |
| | | { |
| | | $cacheKey = 'customer_email_' . md5($email); |
| | | $cached = $this->cache->get($cacheKey); |
| | | |
| | | if ($cached !== false) { |
| | | return (int) $cached; |
| | | } |
| | | |
| | | $response = $this->getRequest('customers', ['search' => $email], null, 'none', true); |
| | | |
| | | if (is_wp_error($response) || empty($response)) { |
| | | return null; |
| | | } |
| | | |
| | | $customers = is_array($response) ? $response : []; |
| | | $emailLower = strtolower($email); |
| | | |
| | | foreach ($customers as $customer) { |
| | | $contactEmail = strtolower($customer['contactEmail'] ?? $customer['email'] ?? ''); |
| | | if ($contactEmail === $emailLower) { |
| | | $this->cache->set($cacheKey, $customer['id'], $this->cacheStrategy['aggressive']); |
| | | return (int) $customer['id']; |
| | | } |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Get or create a Helcim customer. |
| | | */ |
| | | public function getOrCreateCustomer(array $info): ?int |
| | | { |
| | | if (empty($info['email'])) { |
| | | return null; |
| | | } |
| | | |
| | | $existing = $this->getCustomerIdByEmail($info['email']); |
| | | if ($existing) { |
| | | return $existing; |
| | | } |
| | | |
| | | $response = $this->postRequest('customers', [ |
| | | 'contactName' => $info['name'] ?? '', |
| | | 'contactEmail' => $info['email'], |
| | | 'cellphone' => $info['phone'] ?? '', |
| | | ]); |
| | | |
| | | if (is_wp_error($response) || empty($response['id'])) { |
| | | return null; |
| | | } |
| | | |
| | | return (int) $response['id']; |
| | | } |
| | | |
| | | /** |
| | | * Get saved cards for a customer. |
| | | */ |
| | | public function handleGetCustomerCards(array $data): array |
| | | { |
| | | $customerId = $data['customerId'] ?? null; |
| | | |
| | | if (empty($customerId) && !empty($data['email'])) { |
| | | $customerId = $this->getCustomerIdByEmail($data['email']); |
| | | } |
| | | |
| | | if (!$customerId) { |
| | | return ['success' => true, 'cards' => []]; |
| | | } |
| | | |
| | | $response = $this->getRequest("customers/{$customerId}/cards", [], null, 'moderate'); |
| | | |
| | | if (is_wp_error($response) || !is_array($response)) { |
| | | return ['success' => false, 'message' => 'Failed to fetch cards']; |
| | | } |
| | | |
| | | return ['success' => true, 'cards' => $response]; |
| | | } |
| | | |
| | | /** |
| | | * Get bank accounts for a customer. |
| | | */ |
| | | public function getCustomerBankAccounts(int $customerId): array |
| | | { |
| | | $response = $this->getRequest("customers/{$customerId}/bank-accounts", [], null, 'moderate'); |
| | | return (!is_wp_error($response) && is_array($response)) ? $response : []; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * TRANSACTIONS |
| | | *****************************************************************/ |
| | | |
| | | public function getTransactions(array $params = []): array |
| | | { |
| | | $response = $this->getRequest('card-transactions', $params, null, 'minimal'); |
| | | return (!is_wp_error($response) && is_array($response)) ? $response : []; |
| | | } |
| | | |
| | | public function refundPayment(array $data): array |
| | | { |
| | | $response = $this->postRequest('payment/refund', $data); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return ['success' => false, 'message' => $response->get_error_message()]; |
| | | } |
| | | |
| | | return ['success' => true, 'transaction' => $response]; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * PRODUCT SYNC |
| | | *****************************************************************/ |
| | | |
| | | protected function handleTheSavePost(int $postID, \WP_Post $post, bool $update, array $settings): void |
| | | { |
| | | // Queue the sync operation |
| | | $this->queueOperation('sync_to_helcim', [ |
| | | 'items' => [$postID], |
| | | $fields = $this->getSyncFields($postID, 'post', ['share_to_helcim', 'schedule_helcim']); |
| | | |
| | | if (empty($fields['share_to_helcim'])) { |
| | | return; |
| | | } |
| | | |
| | | // Uses IntegrationExecutor via TypeRegistry instead of FilteredExecutor |
| | | $this->queueOperation('sync_to', [ |
| | | 'items' => [$postID], |
| | | 'user_id' => $this->userID, |
| | | 'content_type' => $settings['content_type'] ?? 'REGULAR' |
| | | ], [ |
| | | 'priority' => 'high', |
| | | 'delay' => 30, // Small delay to batch multiple saves |
| | | 'delay' => 30, |
| | | ]); |
| | | |
| | | update_post_meta($postID, BASE . '_helcim_sync_status', 'queued'); |
| | | } |
| | | |
| | | /** |
| | | * Handle post deletion |
| | | */ |
| | | public function handleDeletePost(int $postID): void |
| | | protected function handleImportFromHelcim(): array |
| | | { |
| | | $helcim_id = get_post_meta($postID, BASE . '_helcim_product_id', true); |
| | | $this->queueOperation('import_products', [ |
| | | 'user_id' => $this->userID, |
| | | ], ['priority' => 'normal']); |
| | | |
| | | if ($helcim_id) { |
| | | $this->queueOperation('delete_from_helcim', [ |
| | | 'helcim_ids' => [$helcim_id], |
| | | 'post_id' => $postID |
| | | ], [ |
| | | 'priority' => 'high' |
| | | ]); |
| | | } |
| | | return ['success' => true, 'message' => 'Import from Helcim queued']; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * USER ↔ CUSTOMER LINKING |
| | | *****************************************************************/ |
| | | |
| | | public function linkUserToCustomer(int $userId, int $helcimCustomerId): void |
| | | { |
| | | update_user_meta($userId, BASE . '_helcim_customer_id', $helcimCustomerId); |
| | | } |
| | | |
| | | public function getUserCustomerId(int $userId): ?int |
| | | { |
| | | $id = get_user_meta($userId, BASE . '_helcim_customer_id', true); |
| | | return $id ? (int) $id : null; |
| | | } |
| | | |
| | | /** |
| | | * Process queued operations |
| | | * Resolve customer ID from user meta, falling back to email lookup + auto-link. |
| | | */ |
| | | public function processOperation(WP_Error|array $result, object $operation, array $data): WP_Error|array |
| | | public function resolveCustomerId(int $userId): ?int |
| | | { |
| | | $base = strtolower($this->service_name).'_'; |
| | | $helcim = (array_key_exists('user', $data)) ? new self((int)$data['user']) : $this; |
| | | |
| | | switch ($operation->type) { |
| | | case $base.'sync_to_helcim': |
| | | return $helcim->processSyncToHelcim($data); |
| | | |
| | | case $base.'delete_from_helcim': |
| | | return $helcim->processDeleteFromHelcim($data); |
| | | |
| | | case $base.'import_catalog': |
| | | return $helcim->processImportCatalog($data); |
| | | |
| | | case $base.'sync_customer': |
| | | return $helcim->processSyncCustomer($data); |
| | | |
| | | default: |
| | | return $result; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Process sync to Helcim |
| | | */ |
| | | private function processSyncToHelcim(array $data): array |
| | | { |
| | | $items = $data['items'] ?? []; |
| | | $content_type = $data['content_type'] ?? 'REGULAR'; |
| | | $success_count = 0; |
| | | $errors = []; |
| | | |
| | | foreach ($items as $post_id) { |
| | | try { |
| | | $post = get_post($post_id); |
| | | if (!$post) continue; |
| | | |
| | | $meta = Meta::forPost($post_id); |
| | | $field_map = $this->field_mappings[$post->post_type] ?? []; |
| | | |
| | | // Prepare product data for Helcim |
| | | $product_data = [ |
| | | 'name' => $post->post_title, |
| | | 'description' => $post->post_content, |
| | | 'productCode' => get_post_meta($post_id, BASE . '_helcim_product_code', true) ?: 'WP-' . $post_id, |
| | | 'type' => $content_type, |
| | | 'price' => floatval($meta->get('price')) * 100, // Convert to cents |
| | | 'taxable' => (bool)$meta->get('is_taxable'), |
| | | ]; |
| | | |
| | | // Handle variations |
| | | $variations = $meta->get('product_variations'); |
| | | if (!empty($variations)) { |
| | | $product_data['variations'] = $this->prepareVariations($variations); |
| | | } |
| | | |
| | | // Check if product exists |
| | | $helcim_id = get_post_meta($post_id, BASE . '_helcim_product_id', true); |
| | | |
| | | if ($helcim_id) { |
| | | // Update existing product |
| | | $response = $this->putRequest('inventory/product/' . $helcim_id, $product_data); |
| | | } else { |
| | | // Create new product |
| | | $response = $this->postRequest('inventory/product', $product_data); |
| | | |
| | | if (!is_wp_error($response) && isset($response['productId'])) { |
| | | update_post_meta($post_id, BASE . '_helcim_product_id', $response['productId']); |
| | | $helcim_id = $response['productId']; |
| | | } |
| | | } |
| | | |
| | | if (!is_wp_error($response)) { |
| | | update_post_meta($post_id, BASE . '_helcim_sync_status', 'success'); |
| | | update_post_meta($post_id, BASE . '_helcim_last_sync', current_time('mysql')); |
| | | $success_count++; |
| | | } else { |
| | | throw new Exception($response->get_error_message()); |
| | | } |
| | | |
| | | } catch (Exception $e) { |
| | | $errors[] = "Post $post_id: " . $e->getMessage(); |
| | | update_post_meta($post_id, BASE . '_helcim_sync_status', 'failed'); |
| | | update_post_meta($post_id, BASE . '_helcim_sync_error', $e->getMessage()); |
| | | } |
| | | $id = $this->getUserCustomerId($userId); |
| | | if ($id) { |
| | | return $id; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => count($errors) === 0, |
| | | 'result' => [ |
| | | 'synced' => $success_count, |
| | | 'errors' => $errors |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Prepare variations for Helcim |
| | | */ |
| | | private function prepareVariations(array $variations): array |
| | | { |
| | | $helcim_variations = []; |
| | | |
| | | foreach ($variations as $index => $variation) { |
| | | $helcim_variations[] = [ |
| | | 'name' => $variation['name'] ?? '', |
| | | 'price' => floatval($variation['price'] ?? 0) * 100, |
| | | 'sku' => $variation['sku'] ?? '', |
| | | 'inventory' => intval($variation['inventory'] ?? 0), |
| | | ]; |
| | | } |
| | | |
| | | return $helcim_variations; |
| | | } |
| | | |
| | | /** |
| | | * Process delete from Helcim |
| | | */ |
| | | private function processDeleteFromHelcim(array $data): array |
| | | { |
| | | $helcim_ids = $data['helcim_ids'] ?? []; |
| | | $success_count = 0; |
| | | |
| | | foreach ($helcim_ids as $helcim_id) { |
| | | $response = $this->deleteRequest('inventory/product/' . $helcim_id); |
| | | |
| | | if (!is_wp_error($response)) { |
| | | $success_count++; |
| | | } |
| | | } |
| | | |
| | | return [ |
| | | 'success' => $success_count > 0, |
| | | 'result' => ['deleted' => $success_count] |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Process import from Helcim catalog |
| | | */ |
| | | private function processImportCatalog(array $data): array |
| | | { |
| | | $page = 1; |
| | | $imported = 0; |
| | | |
| | | do { |
| | | $response = $this->getRequest('inventory/product', [ |
| | | 'page' => $page, |
| | | 'limit' => 100 |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | break; |
| | | } |
| | | |
| | | $products = $response['products'] ?? []; |
| | | |
| | | foreach ($products as $product) { |
| | | $this->importHelcimProduct($product); |
| | | $imported++; |
| | | } |
| | | |
| | | $page++; |
| | | $has_more = count($products) === 100; |
| | | |
| | | } while ($has_more); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => ['imported' => $imported] |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Import a single Helcim product |
| | | */ |
| | | private function importHelcimProduct(array $product): void |
| | | { |
| | | // Find existing post by Helcim ID |
| | | $args = [ |
| | | 'post_type' => $this->syncPostTypes, |
| | | 'meta_key' => BASE . '_helcim_product_id', |
| | | 'meta_value' => $product['productId'], |
| | | 'posts_per_page' => 1 |
| | | ]; |
| | | |
| | | $existing = get_posts($args); |
| | | |
| | | if ($existing) { |
| | | $post_id = $existing[0]->ID; |
| | | |
| | | // Update existing post |
| | | wp_update_post([ |
| | | 'ID' => $post_id, |
| | | 'post_title' => $product['name'], |
| | | 'post_content' => $product['description'] ?? '' |
| | | ]); |
| | | } else { |
| | | // Create new post |
| | | $post_id = wp_insert_post([ |
| | | 'post_title' => $product['name'], |
| | | 'post_content' => $product['description'] ?? '', |
| | | 'post_type' => $this->syncPostTypes[0] ?? 'post', |
| | | 'post_status' => 'publish' |
| | | ]); |
| | | } |
| | | |
| | | if ($post_id) { |
| | | // Update meta data |
| | | $meta = Meta::forPost($post_id); |
| | | $meta->setAll([ |
| | | 'price' => $product['price'] / 100, // Convert from cents |
| | | '_helcim_product_id' => $product['productId'], |
| | | '_helcim_product_code' => $product['productCode'], |
| | | '_helcim_last_sync' => current_time('mysql') |
| | | ]); |
| | | } |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * CUSTOMER MANAGEMENT |
| | | ******************************************************************/ |
| | | |
| | | /** |
| | | * Track user login for security |
| | | */ |
| | | public function trackUserLogin(string $user_login, \WP_User $user): void |
| | | { |
| | | // Check if user has Helcim integration |
| | | $user_roles = $user->roles; |
| | | |
| | | foreach ($user_roles as $role) { |
| | | $role_key = jvbNoBase($role); |
| | | if (isset(JVB_USER[$role_key]['integrations']['helcim']['is_customer'])) { |
| | | $login_count = (int)get_user_meta($user->ID, BASE . '_helcim_login_count', true); |
| | | $login_count++; |
| | | |
| | | update_user_meta($user->ID, BASE . '_helcim_login_count', $login_count); |
| | | update_user_meta($user->ID, BASE . '_helcim_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 . '_helcim_password_reset_required', true); |
| | | |
| | | // Send notification |
| | | $user = get_user_by('ID', $user_id); |
| | | if ($user) { |
| | | JVB()->email()->sendEmail( |
| | | $user->user_email, |
| | | 'Security: Password Reset Required', |
| | | 'For your security, please reset your password to continue accessing your account and saved payment methods.', |
| | | ); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle customer lookup |
| | | */ |
| | | public function handleCustomerLookup(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $email = sanitize_email($request->get_param('email')); |
| | | |
| | | if (!$email) { |
| | | return new WP_REST_Response(['error' => 'Email required'], 400); |
| | | } |
| | | |
| | | // Check WordPress user first |
| | | $user = get_user_by('email', $email); |
| | | |
| | | if ($user) { |
| | | // Check if user has customer role |
| | | $has_customer_role = false; |
| | | foreach ($user->roles as $role) { |
| | | $role_key = jvbNoBase($role); |
| | | if (isset(JVB_USER[$role_key]['integrations']['helcim']['is_customer'])) { |
| | | $has_customer_role = true; |
| | | break; |
| | | } |
| | | } |
| | | |
| | | if ($has_customer_role) { |
| | | // Get saved cards and order history |
| | | $customer_id = get_user_meta($user->ID, BASE . '_helcim_customer_id', true); |
| | | |
| | | if ($customer_id) { |
| | | $customer_data = $this->getHelcimCustomer($customer_id); |
| | | |
| | | return new WP_REST_Response([ |
| | | 'exists' => true, |
| | | 'has_account' => true, |
| | | 'customer' => [ |
| | | 'name' => $user->display_name, |
| | | 'email' => $user->user_email |
| | | ], |
| | | 'cards' => $customer_data['cards'] ?? [], |
| | | 'orders' => $this->getUserOrders($user->ID) |
| | | ]); |
| | | } |
| | | } |
| | | |
| | | return new WP_REST_Response([ |
| | | 'exists' => true, |
| | | 'has_account' => true, |
| | | 'no_customer_role' => true, |
| | | 'message' => 'Account exists but not set up for orders. Would you like to enable ordering?' |
| | | ]); |
| | | } |
| | | |
| | | // Check Helcim for customer |
| | | $helcim_customer = $this->searchHelcimCustomer($email); |
| | | |
| | | if ($helcim_customer) { |
| | | return new WP_REST_Response([ |
| | | 'exists' => true, |
| | | 'has_account' => false, |
| | | 'helcim_only' => true, |
| | | 'message' => 'Found your previous orders. Create an account to access them?' |
| | | ]); |
| | | } |
| | | |
| | | return new WP_REST_Response([ |
| | | 'exists' => false, |
| | | 'message' => 'New customer' |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Get Helcim customer data |
| | | */ |
| | | private function getHelcimCustomer(string $customer_id): array |
| | | { |
| | | $cached = $this->cache->get('helcim_customer_' . $customer_id); |
| | | |
| | | if ($cached !== false) { |
| | | return $cached; |
| | | } |
| | | |
| | | $response = $this->getRequest('customer/' . $customer_id); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return []; |
| | | } |
| | | |
| | | // Get saved cards |
| | | $cards_response = $this->getRequest('customer/' . $customer_id . '/cards'); |
| | | $cards = []; |
| | | |
| | | if (!is_wp_error($cards_response) && isset($cards_response['cards'])) { |
| | | foreach ($cards_response['cards'] as $card) { |
| | | $cards[] = [ |
| | | 'id' => $card['cardToken'], |
| | | 'last_4' => $card['cardLast4'], |
| | | 'card_brand' => $card['cardBrand'], |
| | | 'exp_month' => $card['expiryMonth'], |
| | | 'exp_year' => $card['expiryYear'] |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | $customer_data = [ |
| | | 'customer' => $response, |
| | | 'cards' => $cards |
| | | ]; |
| | | |
| | | $this->cache->set('helcim_customer_' . $customer_id, $customer_data, HOUR_IN_SECONDS); |
| | | |
| | | return $customer_data; |
| | | } |
| | | |
| | | /** |
| | | * Search for Helcim customer by email |
| | | */ |
| | | private function searchHelcimCustomer(string $email): ?array |
| | | { |
| | | $response = $this->getRequest('customer/search', [ |
| | | 'email' => $email |
| | | ]); |
| | | |
| | | if (!is_wp_error($response) && isset($response['customers'][0])) { |
| | | return $response['customers'][0]; |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Get user's order history |
| | | */ |
| | | private function getUserOrders(int $user_id): array |
| | | { |
| | | $orders = get_user_meta($user_id, BASE . '_helcim_orders', true) ?: []; |
| | | |
| | | // Get last 10 orders |
| | | return array_slice($orders, -10); |
| | | } |
| | | |
| | | /** |
| | | * Handle account creation |
| | | */ |
| | | public function handleAccountCreation(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $email = sanitize_email($request->get_param('email')); |
| | | $name = sanitize_text_field($request->get_param('name')); |
| | | |
| | | if (!$email || !is_email($email)) { |
| | | return new WP_REST_Response(['error' => 'Valid email required'], 400); |
| | | } |
| | | |
| | | // Check if user already exists |
| | | if (email_exists($email)) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'exists' => true, |
| | | 'message' => 'An account with this email already exists. Please log in instead.' |
| | | ], 409); |
| | | } |
| | | |
| | | // Generate username from email |
| | | $username = sanitize_user(current(explode('@', $email))); |
| | | $username = $this->generateUniqueUsername($username); |
| | | |
| | | // Create user account |
| | | $user_id = wp_create_user( |
| | | $username, |
| | | wp_generate_password(20, true, true), // Temporary 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_REST_Response(['error' => 'Failed to create account'], 500); |
| | | } |
| | | |
| | | // Set user role |
| | | $user = new \WP_User($user_id); |
| | | $user->set_role(BASE.'foodie'); // Or appropriate role from JVB_USER |
| | | |
| | | // Update display name |
| | | if ($name) { |
| | | wp_update_user([ |
| | | 'ID' => $user_id, |
| | | 'display_name' => $name |
| | | ]); |
| | | } |
| | | |
| | | // Generate password reset key |
| | | $reset_key = get_password_reset_key($user); |
| | | |
| | | if (!is_wp_error($reset_key)) { |
| | | $this->sendWelcomeEmail($user, $reset_key); |
| | | } |
| | | |
| | | // Link to Helcim customer if exists |
| | | $helcim_customer = $this->searchHelcimCustomer($email); |
| | | |
| | | if ($helcim_customer) { |
| | | update_user_meta($user_id, BASE . '_helcim_customer_id', $helcim_customer['customerId']); |
| | | } else { |
| | | // Create new Helcim customer |
| | | $customer_response = $this->postRequest('customer', [ |
| | | 'customerCode' => 'WP-' . $user_id, |
| | | 'contactName' => $name ?: $username, |
| | | 'email' => $email |
| | | ]); |
| | | |
| | | if (!is_wp_error($customer_response) && isset($customer_response['customerId'])) { |
| | | update_user_meta($user_id, BASE . '_helcim_customer_id', $customer_response['customerId']); |
| | | } |
| | | } |
| | | |
| | | return new WP_REST_Response([ |
| | | '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 |
| | | */ |
| | | private function sendWelcomeEmail(\WP_User $user, string $reset_key): void |
| | | { |
| | | $site_name = get_bloginfo('name'); |
| | | $reset_url = get_home_url(null, "login?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 |
| | | ); |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * ORDER PROCESSING |
| | | ******************************************************************/ |
| | | |
| | | /** |
| | | * Handle checkout |
| | | */ |
| | | public function handleCheckout(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $cart_items = $request->get_param('items'); |
| | | $customer_info = $request->get_param('customer'); |
| | | $payment_token = $request->get_param('payment_token'); |
| | | |
| | | if (empty($cart_items) || empty($payment_token)) { |
| | | return new WP_REST_Response(['error' => 'Invalid order data'], 400); |
| | | } |
| | | |
| | | // Calculate order total |
| | | $order_total = $this->calculateOrderTotal($cart_items); |
| | | |
| | | // Create Helcim invoice |
| | | $invoice_response = $this->createHelcimInvoice($cart_items, $customer_info, $order_total); |
| | | |
| | | if (is_wp_error($invoice_response)) { |
| | | return new WP_REST_Response(['error' => $invoice_response->get_error_message()], 500); |
| | | } |
| | | |
| | | // Process payment |
| | | $payment_response = $this->processHelcimPayment($payment_token, $invoice_response['invoiceId'], $order_total); |
| | | |
| | | if (is_wp_error($payment_response)) { |
| | | return new WP_REST_Response(['error' => $payment_response->get_error_message()], 500); |
| | | } |
| | | |
| | | // Save order to user if logged in |
| | | if (is_user_logged_in()) { |
| | | $this->saveOrderToUser(get_current_user_id(), $invoice_response['invoiceId']); |
| | | } |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'order_id' => $invoice_response['invoiceId'], |
| | | 'receipt_url' => $payment_response['receiptUrl'] ?? '', |
| | | 'message' => 'Order placed successfully!' |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Calculate order total |
| | | */ |
| | | private function calculateOrderTotal(array $cart_items): int |
| | | { |
| | | $total = 0; |
| | | |
| | | foreach ($cart_items as $item) { |
| | | $post_id = intval($item['id'] ?? 0); |
| | | if (!$post_id) continue; |
| | | |
| | | $meta = Meta::forPost($post_id); |
| | | $price = floatval($meta->get('price')); |
| | | $quantity = intval($item['quantity'] ?? 1); |
| | | |
| | | $total += ($price * $quantity * 100); // Convert to cents |
| | | } |
| | | |
| | | // Add tax |
| | | $tax_rate = floatval(get_option(BASE . 'helcim_tax_rate', 0.05)); |
| | | $tax = intval($total * $tax_rate); |
| | | |
| | | return $total + $tax; |
| | | } |
| | | |
| | | /** |
| | | * Create Helcim invoice |
| | | */ |
| | | private function createHelcimInvoice(array $cart_items, array $customer_info, int $total): array|WP_Error |
| | | { |
| | | $line_items = []; |
| | | |
| | | foreach ($cart_items as $item) { |
| | | $post_id = intval($item['id'] ?? 0); |
| | | if (!$post_id) continue; |
| | | |
| | | $post = get_post($post_id); |
| | | $meta = Meta::forPost($post_id); |
| | | |
| | | $line_items[] = [ |
| | | 'description' => $post->post_title, |
| | | 'quantity' => intval($item['quantity'] ?? 1), |
| | | 'price' => floatval($meta->get('price')) * 100, |
| | | 'productCode' => get_post_meta($post_id, BASE . '_helcim_product_code', true) ?: 'WP-' . $post_id |
| | | ]; |
| | | } |
| | | |
| | | // Get or create customer |
| | | $customer_id = $this->getOrCreateHelcimCustomer($customer_info); |
| | | |
| | | return $this->postRequest('commerce/invoice', [ |
| | | 'customerId' => $customer_id, |
| | | 'invoiceNumber' => 'INV-' . time(), |
| | | 'tipAmount' => 0, |
| | | 'depositAmount' => 0, |
| | | 'notes' => $customer_info['notes'] ?? '', |
| | | 'lineItems' => $line_items |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Process Helcim payment |
| | | */ |
| | | private function processHelcimPayment(string $payment_token, string $invoice_id, int $amount): array|WP_Error |
| | | { |
| | | return $this->postRequest('payment/purchase', [ |
| | | 'paymentToken' => $payment_token, |
| | | 'amount' => $amount, |
| | | 'currency' => 'CAD', |
| | | 'invoiceId' => $invoice_id |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Get or create Helcim customer |
| | | */ |
| | | private function getOrCreateHelcimCustomer(array $customer_info): ?string |
| | | { |
| | | if (empty($customer_info['email'])) { |
| | | $user = get_userdata($userId); |
| | | if (!$user || empty($user->user_email)) { |
| | | return null; |
| | | } |
| | | |
| | | // Search for existing customer |
| | | $existing = $this->searchHelcimCustomer($customer_info['email']); |
| | | |
| | | if ($existing) { |
| | | return $existing['customerId']; |
| | | $id = $this->getCustomerIdByEmail($user->user_email); |
| | | if ($id) { |
| | | $this->linkUserToCustomer($userId, $id); |
| | | } |
| | | |
| | | // Create new customer |
| | | $response = $this->postRequest('customer', [ |
| | | 'customerCode' => 'GUEST-' . time(), |
| | | 'contactName' => $customer_info['name'] ?? '', |
| | | 'email' => $customer_info['email'], |
| | | 'phone' => $customer_info['phone'] ?? '' |
| | | ]); |
| | | |
| | | if (!is_wp_error($response) && isset($response['customerId'])) { |
| | | return $response['customerId']; |
| | | } |
| | | |
| | | return null; |
| | | return $id; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * VALIDATION |
| | | *****************************************************************/ |
| | | |
| | | /** |
| | | * Save order to user meta |
| | | * Validate a HelcimPay.js transaction using the secret token. |
| | | * |
| | | * After the frontend receives a SUCCESS message event, call this |
| | | * server-side to verify the transaction hash. |
| | | */ |
| | | private function saveOrderToUser(int $user_id, string $order_id): void |
| | | public function validateTransaction(string $secretToken, array $transactionData): bool |
| | | { |
| | | $orders = get_user_meta($user_id, BASE . '_helcim_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 . '_helcim_orders', $orders); |
| | | $hash = hash('sha256', $secretToken . json_encode($transactionData)); |
| | | return hash_equals($hash, $transactionData['hash'] ?? ''); |
| | | } |
| | | |
| | | /******************************************************************* |
| | | * WEBHOOKS |
| | | *******************************************************************/ |
| | | /** |
| | | * Handle order status |
| | | */ |
| | | public function handleOrderStatus(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $order_id = $request->get_param('order_id'); |
| | | |
| | | if (!$order_id) { |
| | | return new WP_REST_Response(['error' => 'Order ID required'], 400); |
| | | } |
| | | |
| | | // Check cache first |
| | | $cached_status = get_transient(BASE . 'helcim_order_' . $order_id); |
| | | |
| | | if ($cached_status !== false) { |
| | | return new WP_REST_Response($cached_status); |
| | | } |
| | | |
| | | // Fetch from Helcim |
| | | $response = $this->getRequest('commerce/invoice/' . $order_id); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return new WP_REST_Response(['error' => 'Could not fetch order status'], 500); |
| | | } |
| | | |
| | | $status = [ |
| | | 'status' => $response['status'] ?? 'unknown', |
| | | 'eta' => $response['estimatedTime'] ?? null, |
| | | 'items' => $response['lineItems'] ?? [] |
| | | ]; |
| | | |
| | | // Cache for 1 minute |
| | | set_transient(BASE . 'helcim_order_' . $order_id, $status, MINUTE_IN_SECONDS); |
| | | |
| | | return new WP_REST_Response($status); |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * WEBHOOK HANDLING |
| | | ******************************************************************/ |
| | | |
| | | /** |
| | | * Validate webhook signature |
| | | * Validate Helcim webhook signature. |
| | | * |
| | | * Helcim signs webhooks with HMAC-SHA256 using: |
| | | * signedContent = "{webhook-id}.{webhook-timestamp}.{body}" |
| | | * key = base64_decode(verifierToken) |
| | | * |
| | | * Headers: webhook-id, webhook-timestamp, webhook-signature (v1,{base64hash}) |
| | | * |
| | | * @see https://devdocs.helcim.com/docs/enabling-webhooks-for-transactions |
| | | */ |
| | | protected function validateWebhook(array $payload): bool |
| | | { |
| | | $signature = $_SERVER['HTTP_HELCIM_SIGNATURE'] ?? ''; |
| | | $headers = $payload['_headers'] ?? []; |
| | | |
| | | if (!$signature || !$this->webhook_secret) { |
| | | $webhookId = $headers['webhook_id'][0] ?? $headers['webhook-id'] ?? ''; |
| | | $webhookTimestamp = $headers['webhook_timestamp'][0] ?? $headers['webhook-timestamp'] ?? ''; |
| | | $webhookSignature = $headers['webhook_signature'][0] ?? $headers['webhook-signature'] ?? ''; |
| | | |
| | | if (empty($webhookId) || empty($webhookTimestamp) || empty($webhookSignature)) { |
| | | $this->logError('Webhook missing required headers', [], 'warning'); |
| | | return false; |
| | | } |
| | | |
| | | $body = file_get_contents('php://input'); |
| | | $expected = hash_hmac('sha256', $body, $this->webhook_secret); |
| | | // Verify timestamp is within 5 minutes (prevent replay attacks) |
| | | $now = time(); |
| | | if (abs($now - (int)$webhookTimestamp) > 300) { |
| | | $this->logError('Webhook timestamp too old', [ |
| | | 'webhook_timestamp' => $webhookTimestamp, |
| | | 'server_time' => $now, |
| | | ], 'warning'); |
| | | return false; |
| | | } |
| | | |
| | | return hash_equals($expected, $signature); |
| | | $secret = $this->getAdvancedSetting('webhook_signature_key'); |
| | | if (empty($secret)) { |
| | | // If no signature key configured, allow webhook but log warning |
| | | $this->logDebug('No webhook signature key configured — skipping verification'); |
| | | return true; |
| | | } |
| | | |
| | | // Reconstruct the raw body from the payload (minus our injected _headers) |
| | | $body = $payload['_raw_body'] ?? json_encode( |
| | | array_diff_key($payload, ['_headers' => 1, '_raw_body' => 1]) |
| | | ); |
| | | |
| | | $signedContent = "{$webhookId}.{$webhookTimestamp}.{$body}"; |
| | | $secretBytes = base64_decode($secret); |
| | | $expectedHash = base64_encode( |
| | | hash_hmac('sha256', $signedContent, $secretBytes, true) |
| | | ); |
| | | |
| | | // webhook-signature may contain multiple signatures: "v1,hash1 v2,hash2" |
| | | $signatures = explode(' ', $webhookSignature); |
| | | foreach ($signatures as $sig) { |
| | | $parts = explode(',', $sig, 2); |
| | | if (count($parts) === 2 && hash_equals($expectedHash, $parts[1])) { |
| | | return true; |
| | | } |
| | | } |
| | | |
| | | $this->logError('Webhook signature mismatch', [ |
| | | 'webhook_id' => $webhookId, |
| | | ], 'warning'); |
| | | |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Process webhook event |
| | | * Process a validated Helcim webhook event. |
| | | * |
| | | * Helcim sends minimal payloads: {"id": "12345", "type": "cardTransaction"} |
| | | * We fetch the full transaction details from the API. |
| | | */ |
| | | protected function processWebhook(array $payload): bool |
| | | { |
| | | $event_type = $payload['eventType'] ?? ''; |
| | | $data = $payload['data'] ?? []; |
| | | $type = $payload['type'] ?? ''; |
| | | $id = $payload['id'] ?? ''; |
| | | |
| | | switch ($event_type) { |
| | | case 'transaction.success': |
| | | case 'transaction.declined': |
| | | return $this->handleTransactionWebhook($data); |
| | | |
| | | case 'invoice.paid': |
| | | case 'invoice.updated': |
| | | return $this->handleInvoiceWebhook($data); |
| | | |
| | | case 'customer.created': |
| | | case 'customer.updated': |
| | | return $this->handleCustomerWebhook($data); |
| | | |
| | | default: |
| | | $this->logDebug('Unhandled webhook type', ['type' => $event_type]); |
| | | return true; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle transaction webhook |
| | | */ |
| | | private function handleTransactionWebhook(array $data): bool |
| | | { |
| | | $transaction_id = $data['transactionId'] ?? ''; |
| | | $status = $data['status'] ?? ''; |
| | | |
| | | if (!$transaction_id) { |
| | | if (empty($type) || empty($id)) { |
| | | $this->logError('Webhook missing type or id', $payload, 'warning'); |
| | | return false; |
| | | } |
| | | |
| | | // Update cached transaction status |
| | | set_transient(BASE . 'helcim_transaction_' . $transaction_id, $status, HOUR_IN_SECONDS); |
| | | |
| | | // Trigger action for other integrations |
| | | do_action(BASE . 'helcim_transaction_updated', $transaction_id, $status, $data); |
| | | |
| | | return true; |
| | | return match ($type) { |
| | | 'cardTransaction' => $this->handleTransactionWebhook($id), |
| | | 'terminalCancel' => $this->handleTerminalCancelWebhook($payload), |
| | | default => $this->handleUnknownWebhook($type, $id), |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * Handle invoice webhook |
| | | * Handle a cardTransaction webhook — fetch full transaction, update records. |
| | | */ |
| | | private function handleInvoiceWebhook(array $data): bool |
| | | protected function handleTransactionWebhook(string $transactionId): bool |
| | | { |
| | | $invoice_id = $data['invoiceId'] ?? ''; |
| | | $status = $data['status'] ?? ''; |
| | | // Fetch full transaction from Helcim API |
| | | $transaction = $this->getRequest("card-transactions/{$transactionId}"); |
| | | |
| | | if (!$invoice_id) { |
| | | if (is_wp_error($transaction) || empty($transaction)) { |
| | | $this->logError('Failed to fetch transaction for webhook', [ |
| | | 'transaction_id' => $transactionId, |
| | | ]); |
| | | return false; |
| | | } |
| | | |
| | | // Update cached order status |
| | | set_transient(BASE . 'helcim_order_' . $invoice_id, $status, HOUR_IN_SECONDS); |
| | | $status = $transaction['status'] ?? ''; |
| | | |
| | | // Trigger action for other integrations |
| | | do_action(BASE . 'helcim_order_updated', $invoice_id, $status, $data); |
| | | // Fire action for other parts of the system to react |
| | | do_action('jvb_helcim_transaction', $transaction, $status); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Handle customer webhook |
| | | */ |
| | | private function handleCustomerWebhook(array $data): bool |
| | | { |
| | | $customer_id = $data['customerId'] ?? ''; |
| | | $email = $data['email'] ?? ''; |
| | | |
| | | if (!$customer_id || !$email) { |
| | | return false; |
| | | // If linked to an invoice, update invoice cache |
| | | $invoiceNumber = $transaction['invoiceNumber'] ?? ''; |
| | | if (!empty($invoiceNumber)) { |
| | | $this->cache->delete("invoice_{$invoiceNumber}"); |
| | | do_action('jvb_helcim_invoice_updated', $invoiceNumber, $transaction); |
| | | } |
| | | |
| | | // Find WordPress user with this Helcim customer ID |
| | | $users = get_users([ |
| | | 'meta_key' => BASE . '_helcim_customer_id', |
| | | 'meta_value' => $customer_id, |
| | | 'number' => 1 |
| | | // Log for debugging |
| | | $this->logDebug('Transaction webhook processed', [ |
| | | 'transaction_id' => $transactionId, |
| | | 'status' => $status, |
| | | 'amount' => $transaction['amount'] ?? 0, |
| | | 'type' => $transaction['type'] ?? '', |
| | | ]); |
| | | |
| | | if (!empty($users)) { |
| | | $user = $users[0]; |
| | | update_user_meta($user->ID, BASE . '_helcim_customer_updated', current_time('mysql')); |
| | | return true; |
| | | } |
| | | |
| | | // Clear cached customer data |
| | | $this->cache->forget('helcim_customer_' . $user->ID); |
| | | } |
| | | /** |
| | | * Handle Smart Terminal cancel webhook |
| | | */ |
| | | protected function handleTerminalCancelWebhook(array $payload): bool |
| | | { |
| | | do_action('jvb_helcim_terminal_cancel', $payload); |
| | | |
| | | $this->logDebug('Terminal cancel webhook processed', [ |
| | | 'payload' => $payload, |
| | | ]); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * CONNECTION TESTING |
| | | ******************************************************************/ |
| | | /** |
| | | * Handle unknown webhook types (future-proofing) |
| | | */ |
| | | protected function handleUnknownWebhook(string $type, string $id): bool |
| | | { |
| | | $this->logDebug('Unknown webhook type received', [ |
| | | 'type' => $type, |
| | | 'id' => $id, |
| | | ]); |
| | | |
| | | do_action("jvb_helcim_webhook_{$type}", $id); |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Perform connection test |
| | | * Extract unique webhook ID for deduplication |
| | | */ |
| | | protected function performConnectionTest(): bool |
| | | protected function extractWebhookId(array $payload): ?string |
| | | { |
| | | if (empty($this->api_token) || empty($this->account_id)) { |
| | | throw new Exception('Missing required credentials'); |
| | | $headers = $payload['_headers'] ?? []; |
| | | return $headers['webhook_id'][0] ?? $headers['webhook-id'] ?? $payload['id'] ?? null; |
| | | } |
| | | |
| | | /** |
| | | * Override the webhook request handler to capture raw body for signature verification |
| | | */ |
| | | public function handleWebhookRequest(\WP_REST_Request $request): \WP_REST_Response |
| | | { |
| | | $payload = $request->get_params(); |
| | | $payload['_headers'] = $request->get_headers(); |
| | | $payload['_raw_body'] = $request->get_body(); |
| | | |
| | | $success = $this->handleWebhook($payload); |
| | | |
| | | return new \WP_REST_Response([ |
| | | 'success' => $success, |
| | | ], $success ? 200 : 400); |
| | | } |
| | | /*********************************************************************** |
| | | * POST HOOKS |
| | | ***********************************************************************/ |
| | | /** |
| | | * Sync a WordPress post to Helcim as a product. |
| | | * Called by IntegrationExecutor::processSyncTo() |
| | | */ |
| | | public function syncPostToService(int $postID): array|\WP_Error |
| | | { |
| | | $post = get_post($postID); |
| | | if (!$post) { |
| | | return new \WP_Error('not_found', "Post {$postID} not found"); |
| | | } |
| | | |
| | | $response = $this->getRequest('account'); |
| | | $helcimProductId = get_post_meta($postID, BASE . '_helcim_item_id', true); |
| | | $productData = $this->buildProductPayload($postID); |
| | | |
| | | if (is_wp_error($productData)) { |
| | | return $productData; |
| | | } |
| | | |
| | | if ($helcimProductId) { |
| | | // Update existing |
| | | $response = $this->patchRequest("products/{$helcimProductId}", $productData); |
| | | } else { |
| | | // Create new |
| | | $response = $this->postRequest('products', $productData); |
| | | } |
| | | |
| | | if (is_wp_error($response)) { |
| | | throw new Exception($response->get_error_message()); |
| | | update_post_meta($postID, BASE . '_helcim_sync_status', 'error'); |
| | | return $response; |
| | | } |
| | | |
| | | return isset($response['accountId']); |
| | | // Store Helcim product ID |
| | | $newId = $response['id'] ?? $response['productId'] ?? $helcimProductId; |
| | | update_post_meta($postID, BASE . '_helcim_item_id', $newId); |
| | | update_post_meta($postID, BASE . '_helcim_sync_status', 'synced'); |
| | | update_post_meta($postID, BASE . '_helcim_last_sync', current_time('mysql')); |
| | | |
| | | return ['success' => true, 'helcim_id' => $newId]; |
| | | } |
| | | |
| | | /** |
| | | * Get request headers |
| | | * Delete a product from Helcim. |
| | | * Called by IntegrationExecutor::processDeleteFrom() |
| | | */ |
| | | protected function getRequestHeaders(): array |
| | | public function deleteFromService(string $externalId): array|\WP_Error |
| | | { |
| | | $headers = [ |
| | | 'Content-Type' => 'application/json', |
| | | 'Accept' => 'application/json' |
| | | ]; |
| | | $response = $this->deleteRequest("products/{$externalId}"); |
| | | |
| | | // Add authorization header |
| | | if (!empty($this->api_token)) { |
| | | $headers['api-token'] = $this->api_token; |
| | | if (is_wp_error($response)) { |
| | | return $response; |
| | | } |
| | | |
| | | return $headers; |
| | | return ['success' => true, 'deleted' => $externalId]; |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * INTEGRATION ACTIONS |
| | | ******************************************************************/ |
| | | |
| | | protected function handleImportFromHelcim() |
| | | { |
| | | $this->queueOperation('import_catalog', [ |
| | | 'user_id' => $this->userID |
| | | ], [ |
| | | 'priority' => 'normal' |
| | | ]); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Import synced' |
| | | ]; |
| | | } |
| | | |
| | | protected function handleSyncToHelcim() |
| | | { |
| | | $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_helcim', [ |
| | | 'items' => $post_ids, |
| | | 'user_id' => $this->userID |
| | | ], [ |
| | | 'priority' => 'normal' |
| | | ]); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => sprintf('Queued %d items for sync to Helcim', count($post_ids)) |
| | | ]; |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * ADMIN UI |
| | | ******************************************************************/ |
| | | |
| | | /** |
| | | * Process sync customer operation |
| | | * Build Helcim product payload from a WordPress post. |
| | | */ |
| | | private function processSyncCustomer(array $data): array |
| | | protected function buildProductPayload(int $postID): array|\WP_Error |
| | | { |
| | | $user_id = $data['user_id'] ?? 0; |
| | | $meta = \JVBase\meta\Meta::forPost($postID); |
| | | $post = get_post($postID); |
| | | |
| | | 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 Helcim customer |
| | | $helcim_customer_id = $this->getOrCreateHelcimCustomer([ |
| | | 'email' => $user->user_email, |
| | | 'name' => $user->display_name |
| | | ]); |
| | | |
| | | if ($helcim_customer_id) { |
| | | update_user_meta($user_id, BASE . '_helcim_customer_id', $helcim_customer_id); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => ['customer_id' => $helcim_customer_id] |
| | | ]; |
| | | $price = $meta->get('price'); |
| | | if (empty($price) || !is_numeric($price)) { |
| | | return new \WP_Error('invalid_price', "Post {$postID} has no valid price"); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => ['error' => 'Could not sync customer'] |
| | | 'name' => $post->post_title, |
| | | 'description' => wp_strip_all_tags($post->post_content), |
| | | 'sku' => $meta->get('sku') ?: "wp-{$postID}", |
| | | 'price' => (float) $price, |
| | | 'taxExempt' => (bool) $meta->get('tax_exempt'), |
| | | ]; |
| | | } |
| | | } |