<?php
|
namespace JVBase\integrations;
|
|
use Exception;
|
use WP_Error;
|
use WP_Post;
|
use JVBase\ui\Checkout;
|
use JVBase\managers\queue\TypeConfig;
|
use JVBase\managers\queue\executors\IntegrationExecutor;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* 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
|
{
|
protected string $service_name = 'helcim';
|
protected string|array $apiBase = 'https://api.helcim.com/v2';
|
|
protected bool $isOAuthService = false;
|
|
/**
|
* 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->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 <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' => 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'),
|
];
|
}
|
}
|