diff --git a/admin/assets/css/admin-licenses.css b/admin/assets/css/admin-licenses.css new file mode 100644 index 0000000..e69de29 diff --git a/admin/assets/js/admin-licenses.js b/admin/assets/js/admin-licenses.js new file mode 100644 index 0000000..e69de29 diff --git a/admin/page-licenses.php b/admin/page-licenses.php new file mode 100644 index 0000000..e9fc4e5 --- /dev/null +++ b/admin/page-licenses.php @@ -0,0 +1,5 @@ + +
+

+
+
\ No newline at end of file diff --git a/includes/Init.php b/includes/Init.php index 91677c0..7f32c14 100644 --- a/includes/Init.php +++ b/includes/Init.php @@ -50,6 +50,10 @@ class Init { \Formipay\Customer::get_instance(); \Formipay\Render::get_instance(); \Formipay\Thankyou::get_instance(); + + \Formipay\License::get_instance(); + \Formipay\LicenseAPI::get_instance(); + new \Formipay\Token; do_action('formipay_init'); diff --git a/includes/License.php b/includes/License.php new file mode 100644 index 0000000..c1f8fb4 --- /dev/null +++ b/includes/License.php @@ -0,0 +1,477 @@ +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 ''; + } +} \ No newline at end of file diff --git a/includes/LicenseAPI.php b/includes/LicenseAPI.php new file mode 100644 index 0000000..1acbffc --- /dev/null +++ b/includes/LicenseAPI.php @@ -0,0 +1,71 @@ + 'POST', + 'callback' => [$this, 'verify'], + 'permission_callback' => '__return_true', + ]); + register_rest_route($ns, '/license/activate', [ + 'methods' => 'POST', + 'callback' => [$this, 'activate'], + 'permission_callback' => '__return_true', + ]); + register_rest_route($ns, '/license/deactivate', [ + 'methods' => 'POST', + 'callback' => [$this, 'deactivate'], + 'permission_callback' => '__return_true', + ]); + register_rest_route($ns, '/license/revoke', [ + 'methods' => 'POST', + 'callback' => [$this, 'revoke'], + 'permission_callback' => [$this, 'revoke_permission'], // optional secret protection + ]); + } + + // === Handlers (stubs) === + + public function verify(\WP_REST_Request $req) { + // TODO: rate-limit, load by (key, form_id), compute effective status, return JSON + return new \WP_REST_Response(['ok' => true, 'status' => 'active']); + } + + public function activate(\WP_REST_Request $req) { + // TODO: validate, idempotent per device_id, enforce max, update meta_data.activations + return new \WP_REST_Response(['ok' => true]); + } + + public function deactivate(\WP_REST_Request $req) { + // TODO: remove device, idempotent + return new \WP_REST_Response(['ok' => true]); + } + + public function revoke(\WP_REST_Request $req) { + // TODO: optional header check, set status=revoked + return new \WP_REST_Response(['ok' => true, 'status' => 'revoked']); + } + + public function revoke_permission(\WP_REST_Request $req) { + // TODO: if you want to protect revoke via an option-stored secret + return true; // or false + } + + // === Helpers (signatures only) === + + private function rate_limit_ok() { /* TODO */ return true; } + private function load_license($key, $form_id) { /* TODO */ return null; } + private function effective_status($row) { /* TODO */ return 'active'; } + private function update_activations($row, $device_id, $op = 'add', $meta = []) { /* TODO */ return $row; } +} \ No newline at end of file