5, 'per_minute' => 60, 'per_hour' => 1000 ]; public function __construct(?int $userID = null) { $this->title = 'Helcim'; $this->icon = 'credit-card'; $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->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 Helcim Dashboard', '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' => false, ]; $this->handleWebhooks = false; parent::__construct($userID); // 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', ]); } /***************************************************************** * ABSTRACT IMPLEMENTATIONS *****************************************************************/ protected function initialize(): void { if (empty($this->credentials)) { $this->loadCredentials(); } $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); } } public function getAdditionalFields(?string $content_type = null):array { return array_combine( array_map(fn($k) => 'hc_' . $k, array_keys($this->getHelcimMeta($content_type))), $this->getHelcimMeta($content_type) ); } /** * Get Helcim product meta fields by type. * * Used by Registrar.php when helcim is configured */ 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 { if (!$this->isSetUp()) { return; } add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']); // Shared checkout UI (replaces provider-specific outputCheckout) add_filter('jvbAdditionalActions', [Checkout::class, 'render']); // 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(); } public function enqueueScripts(): void { // HelcimPay.js SDK wp_enqueue_script( 'helcim-pay-sdk', 'https://secure.helcim.app/helcim-pay/services/start.js', [], null, true ); // 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-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'); } 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 )); } /** * 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 { $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, ], [ 'priority' => 'high', 'delay' => 30, ]); update_post_meta($postID, BASE . '_helcim_sync_status', 'queued'); } protected function handleImportFromHelcim(): array { $this->queueOperation('import_products', [ 'user_id' => $this->userID, ], ['priority' => 'normal']); 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; } /** * Resolve customer ID from user meta, falling back to email lookup + auto-link. */ public function resolveCustomerId(int $userId): ?int { $id = $this->getUserCustomerId($userId); if ($id) { return $id; } $user = get_userdata($userId); if (!$user || empty($user->user_email)) { return null; } $id = $this->getCustomerIdByEmail($user->user_email); if ($id) { $this->linkUserToCustomer($userId, $id); } return $id; } /***************************************************************** * VALIDATION *****************************************************************/ /** * 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. */ public function validateTransaction(string $secretToken, array $transactionData): bool { $hash = hash('sha256', $secretToken . json_encode($transactionData)); return hash_equals($hash, $transactionData['hash'] ?? ''); } /******************************************************************* * WEBHOOKS *******************************************************************/ /** * 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 { $headers = $payload['_headers'] ?? []; $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; } // 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; } $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 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 { $type = $payload['type'] ?? ''; $id = $payload['id'] ?? ''; if (empty($type) || empty($id)) { $this->logError('Webhook missing type or id', $payload, 'warning'); return false; } return match ($type) { 'cardTransaction' => $this->handleTransactionWebhook($id), 'terminalCancel' => $this->handleTerminalCancelWebhook($payload), default => $this->handleUnknownWebhook($type, $id), }; } /** * Handle a cardTransaction webhook — fetch full transaction, update records. */ protected function handleTransactionWebhook(string $transactionId): bool { // Fetch full transaction from Helcim API $transaction = $this->getRequest("card-transactions/{$transactionId}"); if (is_wp_error($transaction) || empty($transaction)) { $this->logError('Failed to fetch transaction for webhook', [ 'transaction_id' => $transactionId, ]); return false; } $status = $transaction['status'] ?? ''; // Fire action for other parts of the system to react do_action('jvb_helcim_transaction', $transaction, $status); // 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); } // Log for debugging $this->logDebug('Transaction webhook processed', [ 'transaction_id' => $transactionId, 'status' => $status, 'amount' => $transaction['amount'] ?? 0, 'type' => $transaction['type'] ?? '', ]); return true; } /** * 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; } /** * 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; } /** * Extract unique webhook ID for deduplication */ protected function extractWebhookId(array $payload): ?string { $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"); } $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)) { update_post_meta($postID, BASE . '_helcim_sync_status', 'error'); return $response; } // 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]; } /** * Delete a product from Helcim. * Called by IntegrationExecutor::processDeleteFrom() */ public function deleteFromService(string $externalId): array|\WP_Error { $response = $this->deleteRequest("products/{$externalId}"); if (is_wp_error($response)) { return $response; } return ['success' => true, 'deleted' => $externalId]; } /** * Build Helcim product payload from a WordPress post. */ protected function buildProductPayload(int $postID): array|\WP_Error { $meta = \JVBase\meta\Meta::forPost($postID); $post = get_post($postID); $price = $meta->get('price'); if (empty($price) || !is_numeric($price)) { return new \WP_Error('invalid_price', "Post {$postID} has no valid price"); } return [ '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'), ]; } }