get_charset_collate(); $table = $wpdb->prefix . 'formipay_licenses'; $sql = "CREATE TABLE `$table` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `created_date` datetime DEFAULT CURRENT_TIMESTAMP, `updated_date` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `order_id` bigint(20) NOT NULL, `form_id` int(11) NOT NULL, `customer_email` varchar(191) NULL, `license_key` varchar(191) NOT NULL, `status` varchar(20) DEFAULT 'active', `expires_at` datetime NULL, `meta_data` longtext NULL, PRIMARY KEY (`id`), KEY `order_id` (`order_id`), KEY `form_id` (`form_id`), KEY `customer_email` (`customer_email`), KEY `status` (`status`), KEY `expires_at` (`expires_at`) ) $charset_collate;"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta($sql); // TODO: set/update self::DB_VERSION_OPTION if needed } /** Add Licenses submenu */ public function add_menu() { add_submenu_page( 'formipay', __('Licenses', 'formipay'), __('Licenses', 'formipay'), 'manage_options', 'formipay-licenses', [$this, 'page_licenses'] ); } public function page_licenses() { include FORMIPAY_PATH . 'admin/page-licenses.php'; } /** Enqueue admin assets for Licenses page */ public function enqueue() { global $current_screen; if (!$current_screen) return; if ($current_screen->id === 'formipay_page_formipay-licenses') { wp_enqueue_style('page-licenses', FORMIPAY_URL . 'admin/assets/css/admin-licenses.css', [], FORMIPAY_VERSION, 'all'); wp_enqueue_script('page-licenses', FORMIPAY_URL . 'admin/assets/js/admin-licenses.js', ['jquery', 'gridjs'], FORMIPAY_VERSION, true); wp_localize_script('page-licenses', 'formipay_licenses_page', [ 'ajax_url' => admin_url('admin-ajax.php'), 'site_url' => site_url(), 'columns' => [ 'id' => __('ID','formipay'), 'product' => __('Product','formipay'), 'order' => __('Order','formipay'), 'email' => __('Email','formipay'), 'key' => __('Key','formipay'), 'status' => __('Status','formipay'), 'expiry' => __('Expiry','formipay'), 'date' => __('Date','formipay'), ], 'filter_form' => [ 'products' => [ 'placeholder' => __('Filter by Product','formipay'), 'noresult_text' => __('No results found','formipay') ], 'status' => [ 'placeholder' => __('Filter by Status','formipay'), 'noresult_text' => __('No results found','formipay') ] ], 'modal' => [ 'delete' => [ 'question' => __('Do you want to delete the license?','formipay'), 'cancelButton' => __('Cancel','formipay'), 'confirmButton' => __('Delete Permanently','formipay') ], 'bulk_delete' => [ 'question' => __('Do you want to delete the selected license(s)?','formipay'), 'cancelButton' => __('Cancel','formipay'), 'confirmButton' => __('Confirm','formipay') ], ], 'nonce' => wp_create_nonce('formipay-admin-licenses') ]); } } /** GridJS data source */ public function tabledata() { check_ajax_referer('formipay-admin-licenses', '_wpnonce'); global $wpdb; $table = $wpdb->prefix . 'formipay_licenses'; // Filters / params $status = isset($_REQUEST['status']) ? sanitize_text_field(wp_unslash($_REQUEST['status'])) : 'all'; $product = isset($_REQUEST['product']) ? (int) $_REQUEST['product'] : 0; $search = isset($_REQUEST['search']) ? sanitize_text_field(wp_unslash($_REQUEST['search'])) : ''; $limit = isset($_REQUEST['limit']) ? max(1, (int)$_REQUEST['limit']) : 20; $offset = isset($_REQUEST['offset']) ? max(0, (int)$_REQUEST['offset']) : 0; $wheres = ["`id` > %d"]; $args = [0]; if ($status && $status !== 'all') { $wheres[] = "`status` = %s"; $args[] = $status; } if ($product > 0) { $wheres[] = "`form_id` = %d"; $args[] = $product; } if ($search !== '') { // search in email or tail of license key $wheres[] = "(`customer_email` LIKE %s OR `license_key` LIKE %s)"; $like = '%' . $wpdb->esc_like($search) . '%'; $args[] = $like; $args[] = $like; } $where_sql = implode(' AND ', $wheres); // Total count // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery $total = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM $table WHERE $where_sql", $args )); // Results page $args_page = $args; $args_page[] = (int)$offset; $args_page[] = (int)$limit; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery $rows = $wpdb->get_results($wpdb->prepare( "SELECT * FROM $table WHERE $where_sql ORDER BY `id` DESC LIMIT %d, %d", $args_page ), ARRAY_A); $results = []; if (!empty($rows)) { foreach ($rows as $r) { $pid = (int)$r['form_id']; $title = $pid ? get_the_title($pid) : ''; $results[] = [ 'ID' => (int)$r['id'], 'title' => $title, 'order' => (int)$r['order_id'], 'email' => $r['customer_email'], 'key' => $this->mask_key($r['license_key']), 'status' => $this->compute_effective_status($r), 'expiry' => $r['expires_at'], 'date' => $r['created_date'], ]; } } wp_send_json([ 'results' => $results, 'total' => $total, ]); } /** Delete single license */ public function delete() { check_ajax_referer('formipay-admin-licenses', '_wpnonce'); if (empty($_REQUEST['id'])) { wp_send_json_error([ 'title' => esc_html__('Failed', 'formipay'), 'message' => esc_html__('License id is missing.', 'formipay'), 'icon' => 'error' ]); } global $wpdb; $id = (int) $_REQUEST['id']; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $deleted = $wpdb->delete($wpdb->prefix.'formipay_licenses', ['id' => $id], ['%d']); if ($deleted) { wp_send_json_success([ 'title' => esc_html__('Deleted', 'formipay'), 'message' => esc_html__('License is deleted permanently.', 'formipay'), 'icon' => 'success' ]); } wp_send_json_error([ 'title' => esc_html__('Failed', 'formipay'), 'message' => esc_html__('Failed to delete license. Please try again.', 'formipay'), 'icon' => 'error' ]); } /** Bulk delete */ public function bulk_delete() { check_ajax_referer('formipay-admin-licenses', '_wpnonce'); $ids = isset($_REQUEST['ids']) ? (array) $_REQUEST['ids'] : []; if (empty($ids)) { wp_send_json_error([ 'title' => esc_html__('Failed', 'formipay'), 'message' => esc_html__('There is no license selected. Please try again.', 'formipay'), 'icon' => 'error' ]); } global $wpdb; $success = 0; $failed = 0; foreach ($ids as $id) { $id = (int)$id; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $deleted = $wpdb->delete($wpdb->prefix.'formipay_licenses', ['id' => $id], ['%d']); if ($deleted) $success++; else $failed++; } $report = ''; if ($success > 0) $report .= sprintf(__(' Deleted %d license(s).', 'formipay'), $success); if ($failed > 0) $report .= sprintf(__(' Failed %d license(s).', 'formipay'), $failed); wp_send_json_success([ 'title' => esc_html__('Done!', 'formipay'), 'message' => trim($report) ?: esc_html__('No changes.', 'formipay'), 'icon' => 'info' ]); } /** Issue on completed order */ public function issue_for_order($order) { global $wpdb; if (empty($order)) return; // $order might be object (stdClass) or array (from filter/get) $row = is_array($order) ? (object)$order : $order; if (empty($row->id)) return; // Unserialize DB columns if needed $items = isset($row->items) ? maybe_unserialize($row->items) : []; $formData = isset($row->form_data) ? maybe_unserialize($row->form_data) : []; $metaData = isset($row->meta_data) ? maybe_unserialize($row->meta_data) : []; $form_id = isset($row->form_id) ? (int)$row->form_id : 0; // Quantity: prefer top-level qty in stored order_data; fallback to 1 $qty = 1; if (is_array($formData) && isset($formData['qty'])) { $qty = max(1, (int)$formData['qty']); } elseif (is_array($items) && !empty($items[0]['qty'])) { $qty = max(1, (int)$items[0]['qty']); } if ($form_id <= 0) return; // Read product license settings $settings = $this->get_product_license_settings($form_id); if (!$settings['enabled']) return; // Idempotency: if per-qty is ON, expect qty rows; else expect 1 row $expected = $settings['per_qty'] ? $qty : 1; $already = (int)$wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}formipay_licenses WHERE order_id=%d AND form_id=%d", (int)$row->id, (int)$form_id ) ); if ($already >= $expected) return; // already issued enough $to_issue = max(0, $expected - $already); if ($to_issue < 1) return; // Try to find buyer email from the submitted form payload $customer_email = $this->extract_email_from_order($formData); $issued = []; for ($i = 0; $i < $to_issue; $i++) { $plain_key = apply_filters('formipay/license/generate_key', $this->generate_key(), $row, $form_id, $i); $expires_at = $settings['expiry_days'] > 0 ? gmdate('Y-m-d H:i:s', time() + ($settings['expiry_days'] * DAY_IN_SECONDS)) : null; $row_data = [ 'order_id' => (int)$row->id, 'form_id' => (int)$form_id, 'customer_email' => sanitize_text_field($customer_email), 'license_key' => sanitize_text_field($plain_key), 'status' => 'active', 'expires_at' => $expires_at, 'meta_data' => maybe_serialize([ 'activations' => [ 'max' => (int)$settings['max_activations'], 'count' => 0, 'devices'=> [] ] ]), ]; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->insert($wpdb->prefix.'formipay_licenses', $row_data); $row_data['id'] = (int)$wpdb->insert_id; $issued[] = $row_data; } if (!empty($issued)) { /** * Fires after license rows are issued for an order. * * @param array $issued Rows that were created. * @param object $order Original order object/row. */ do_action('formipay/license/issued', $issued, $row); } } /** Catch immediate completed orders */ public function maybe_issue_for_new_order($order) { $row = is_array($order) ? (object)$order : $order; if (!empty($row->status) && $row->status === 'completed') { $this->issue_for_order($row); } } /** Add small Licensing fields into Product General tab */ public function inject_product_fields($fields) { // Inject a tiny group under General tab (consistent with your structure) $group = [ 'setting_product_licensing' => [ 'type' => 'group_title', 'label' => __('Licensing', 'formipay'), 'description' => __('Enable license issuance for this product.', 'formipay'), 'group' => 'started' ], '_formipay_license_enabled' => [ 'type' => 'checkbox', 'label' => __('Enable License', 'formipay'), ], '_formipay_license_per_quantity' => [ 'type' => 'checkbox', 'label' => __('Issue per Quantity', 'formipay'), 'value' => true, 'dependency' => array( 'key' => '_formipay_license_enabled', 'value' => 'not_empty' ), ], '_formipay_license_expiry_days' => [ 'type' => 'number', 'label' => __('Expiry (days)', 'formipay'), 'value' => 0, 'dependency' => array( 'key' => '_formipay_license_enabled', 'value' => 'not_empty' ), ], '_formipay_license_max_activations' => [ 'type' => 'number', 'label' => __('Max Activations', 'formipay'), 'value' => 1, 'dependency' => array( 'key' => '_formipay_license_enabled', 'value' => 'not_empty' ) ], ]; $last = array_key_last($group); $group[$last]['group'] = 'ended'; $fields['formipay_product_settings']['licenses'] = array( 'name' => __('Licensing', 'formipay'), 'fields' => $group ); return $fields; } // === Helpers (signatures only) === /** Return masked key tail for UI */ private function mask_key($key) { $len = strlen($key); if ($len <= 8) return $key; return substr($key, 0, 4) . str_repeat('*', $len - 8) . substr($key, -4); } /** Compute effective status (treat past-expiry as expired) */ private function compute_effective_status($row) { $status = is_array($row) ? ($row['status'] ?? '') : ($row->status ?? ''); $expires = is_array($row) ? ($row['expires_at'] ?? null) : ($row->expires_at ?? null); if ($status === 'active' && !empty($expires)) { // Compare in UTC $now = gmdate('Y-m-d H:i:s'); if ($expires < $now) return 'expired'; } return $status ?: 'active'; } /** Read product license settings */ private function get_product_license_settings($form_id) { $enabled = (bool) get_post_meta($form_id, '_formipay_license_enabled', true); $per_qty = (bool) get_post_meta($form_id, '_formipay_license_per_quantity', true); $expiry_days = (int) get_post_meta($form_id, '_formipay_license_expiry_days', true); $max_activations = (int) get_post_meta($form_id, '_formipay_license_max_activations', true); return [ 'enabled' => $enabled, 'per_qty' => $per_qty, 'expiry_days' => max(0, $expiry_days), 'max_activations' => $max_activations > 0 ? $max_activations : 1, ]; } /** Generate a random human-friendly license key */ private function generate_key() { // 20 hex chars => split in groups of 4: XXXX-XXXX-XXXX-XXXX-XXXX $hex = strtoupper(bin2hex(random_bytes(10))); return implode('-', str_split($hex, 4)); } /** Try to extract buyer email from stored order_data payload */ private function extract_email_from_order($formData) { if (!is_array($formData)) return ''; // If payload structure is like ['form_data' => [field => ['value' => ...]]] if (isset($formData['form_data']) && is_array($formData['form_data'])) { foreach ($formData['form_data'] as $k => $v) { $val = is_array($v) && isset($v['value']) ? $v['value'] : $v; if (is_string($val) && filter_var($val, FILTER_VALIDATE_EMAIL)) { return $val; } } } // Also check first level foreach ($formData as $k => $v) { if (is_string($v) && filter_var($v, FILTER_VALIDATE_EMAIL)) { return $v; } if (is_array($v) && isset($v['value']) && filter_var($v['value'], FILTER_VALIDATE_EMAIL)) { return $v['value']; } } return ''; } }