[ // Basic info 'invoice_number' => '_easy_invoice_number', 'created_date' => '_easy_invoice_date', 'due_date' => '_easy_invoice_due_date', 'status' => '_easy_invoice_status', 'invoice_status' => '_easy_invoice_status', // Old plugin specific status field 'currency' => '_easy_invoice_currency_code', 'description' => '_easy_invoice_description', 'notes' => '_easy_invoice_notes', 'terms_and_conditions' => '_easy_invoice_terms', // Tax and discount 'tax_type' => '_easy_invoice_prices_include_tax', 'tax_rate' => '_easy_invoice_tax_rate', 'discount' => '_easy_invoice_discount_value', 'discount_type' => '_easy_invoice_discount_type', 'discount_calculation_method' => '_easy_invoice_discount_calculation_method', // Items 'easy_invoice_line_items' => '_easy_invoice_items', // Client info (these are used to find/create WordPress user) 'client_name' => '_easy_invoice_client_name', 'client_email' => '_easy_invoice_client_email', 'client_company' => '_easy_invoice_client_company', 'client_address' => '_easy_invoice_client_address', 'client_phone' => '_easy_invoice_client_phone', 'client_vat' => '_easy_invoice_client_vat', // Template settings 'template' => '_easy_invoice_template' ], 'easy-invoice-quotes' => [ // Basic info 'quote_number' => '_easy_invoice_quote_number', 'created_date' => '_easy_invoice_quote_date', 'valid_until' => '_easy_invoice_quote_expiry_date', 'status' => '_easy_invoice_quote_status', 'currency' => '_easy_invoice_quote_currency', 'description' => '_easy_invoice_quote_description', 'notes' => '_easy_invoice_quote_notes', // Tax and discount 'tax_type' => '_easy_invoice_quote_prices_include_tax', 'tax_rate' => '_easy_invoice_quote_tax_rate', 'discount' => '_easy_invoice_quote_discount_value', 'discount_type' => '_easy_invoice_quote_discount_type', 'discount_calculation_method' => '_easy_invoice_quote_discount_calculation_method', // Items 'easy_invoice_quote_line_items' => '_easy_invoice_quote_items', // Client info (these are used to find/create WordPress user) 'client_name' => '_easy_invoice_quote_client_name', 'client_email' => '_easy_invoice_quote_client_email', 'client_company' => '_easy_invoice_quote_client_company', 'client_address' => '_easy_invoice_quote_client_address', 'client_phone' => '_easy_invoice_quote_client_phone', 'client_vat' => '_easy_invoice_quote_client_vat', // Template settings 'template' => '_easy_invoice_quote_template' ], 'easy-invoice-payment' => [ // Basic info 'payment_number' => '_easy_payment_number', 'payment_date' => '_easy_payment_date', 'status' => '_easy_payment_status', 'payment_method' => '_easy_payment_method', 'transaction_id' => '_easy_payment_transaction_id', 'notes' => '_easy_payment_notes', // Invoice info 'invoice_id' => '_easy_payment_invoice_id', 'invoice_number' => '_easy_payment_invoice_number', // Amount info 'amount' => '_easy_payment_amount', 'currency' => '_easy_payment_currency_code' ] ]; // ... (keep the rest of the existing code until transform_items_meta method) /** * Transform items meta. * * @since 2.0.0 * @param mixed $items Items array * @return array */ /** * Migrate post meta from old post to new post. * * @since 2.0.0 * @param int $old_post_id Old post ID * @param int $new_post_id New post ID * @param string $post_type New post type * @return array */ public function migrate_post_meta(int $old_post_id, int $new_post_id, string $post_type): array { try { global $wpdb; // Get all meta for the old post directly from database to ensure we get everything $meta_values = $wpdb->get_results($wpdb->prepare( "SELECT meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id = %d", $old_post_id ), ARRAY_A); if (empty($meta_values)) { return [ 'success' => true, 'message' => 'No meta to migrate', 'migrated_count' => 0 ]; } // Convert to format similar to get_post_custom $meta_data = []; foreach ($meta_values as $row) { $meta_data[$row['meta_key']][] = $row['meta_value']; } // Get the old post type $old_post_type = get_post_type($old_post_id); if (empty($old_post_type)) { $old_post_type = $wpdb->get_var($wpdb->prepare( "SELECT post_type FROM {$wpdb->posts} WHERE ID = %d", $old_post_id )); } if (empty($old_post_type)) { return [ 'success' => false, 'message' => 'Could not determine old post type' ]; } $migrated_count = 0; $errors = []; // Handle items first if they exist $possible_item_keys = []; $new_items_key = ''; if ($post_type === 'easy_invoice') { $possible_item_keys = [ 'easy_invoice_line_items', '_easy_invoice_line_items', 'invoice_items', '_invoice_items', 'line_items', '_line_items' ]; $new_items_key = '_easy_invoice_items'; } elseif ($post_type === 'easy_invoice_quote') { $possible_item_keys = [ 'easy_invoice_quote_line_items', '_easy_invoice_quote_line_items', 'quote_items', '_quote_items', 'line_items', '_line_items' ]; $new_items_key = '_easy_invoice_quote_items'; } // Try to find items using the correct old plugin meta keys $items = null; if ($post_type === 'easy_invoice') { // For invoices, look for the correct old meta key if (isset($meta_data['easy_invoice_line_items'])) { $items = $meta_data['easy_invoice_line_items'][0]; $this->log(sprintf('Found invoice items using key: easy_invoice_line_items for post %d', $old_post_id)); } } elseif ($post_type === 'easy_invoice_quote') { // For quotes, look for the correct old meta key if (isset($meta_data['easy_invoice_quote_line_items'])) { $items = $meta_data['easy_invoice_quote_line_items'][0]; $this->log(sprintf('Found quote items using key: easy_invoice_quote_line_items for post %d', $old_post_id)); } } if ($items !== null) { $items = $this->transform_items_meta($items); if (!empty($items)) { delete_post_meta($new_post_id, $new_items_key); update_post_meta($new_post_id, $new_items_key, $items); $migrated_count++; $this->log(sprintf('Migrated %d items for post %d to %d', count($items), $old_post_id, $new_post_id)); } } // Log available meta keys for debugging $this->log(sprintf('Available meta keys for post %d (type: %s): %s', $old_post_id, $old_post_type, implode(', ', array_keys($meta_data)) )); // Get meta mappings for this post type $mappings = $this->meta_mappings[$old_post_type] ?? []; if (empty($mappings)) { return [ 'success' => false, 'message' => sprintf('No meta mappings found for post type: %s', $old_post_type) ]; } // Migrate each meta value foreach ($meta_data as $old_key => $values) { // Skip items keys as we've already handled them if (in_array($old_key, $possible_item_keys)) { continue; } // Find the new key in mappings $new_key = $mappings[$old_key] ?? ''; if (empty($new_key)) { // Try without underscore prefix if (strpos($old_key, '_') === 0) { $new_key = $mappings[substr($old_key, 1)] ?? ''; } // Try with underscore prefix if (empty($new_key) && strpos($old_key, '_') !== 0) { $new_key = $mappings['_' . $old_key] ?? ''; } if (empty($new_key)) { continue; // Skip if no mapping found } } // Get the first value since WordPress stores meta as arrays $value = $values[0] ?? null; if ($value === null) { continue; } // Delete any existing meta with the new key delete_post_meta($new_post_id, $new_key); // Skip invoice_number as it will be generated if ($old_key === 'invoice_number') { continue; } // Transform and update the meta value $value = maybe_unserialize($value); // Special handling for tax_type if ($old_key === 'tax_type') { $value = $value === 'inclusive' ? 'yes' : 'no'; } // Special handling for status fields if ($old_key === 'status') { $value = $this->transform_status_value($value, $post_type); $this->log(sprintf('Transformed status from %s to %s for post type %s', $meta_data[$old_key][0], $value, $post_type)); } // Special handling for dates if (in_array($old_key, ['created_date', 'due_date', 'valid_until'])) { // Convert from "August 11, 2025" to "2025-08-11" if ($timestamp = strtotime($value)) { $value = date('Y-m-d', $timestamp); $this->log(sprintf('Converted date %s to %s for key %s', $meta_data[$old_key][0], $value, $old_key)); } else { $this->log(sprintf('Failed to parse date: %s for key %s', $value, $old_key), 'warning'); } } // Update meta $result = update_post_meta($new_post_id, $new_key, $value); if ($result) { $migrated_count++; $this->log(sprintf('Migrated meta %s to %s for post %d', $old_key, $new_key, $new_post_id)); } else { $errors[] = sprintf('Failed to migrate meta: %s', $old_key); $this->log(sprintf('Failed to migrate meta %s to %s for post %d', $old_key, $new_key, $new_post_id), 'error'); } } // After migrating all meta, handle client ID assignment if ($post_type === 'easy_invoice' || $post_type === 'easy_invoice_quote') { // Get client email from meta $client_email = ''; foreach ($meta_data as $key => $values) { if (in_array($key, ['client_email', '_client_email', 'easy_invoice_client_email', 'easy_invoice_quote_client_email'])) { $client_email = $values[0] ?? ''; break; } } if (!empty($client_email)) { // Try to find existing user by email first $user = get_user_by('email', $client_email); if ($user) { $user_id = $user->ID; } else { // Use ClientMigration to get/create user if not found $client_migration = new ClientMigration(); // Get client name from meta with all possible keys $client_name = ''; $name_keys = [ 'client_name', '_client_name', 'easy_invoice_client_name', 'easy_invoice_quote_client_name', 'name', '_name', 'customer_name', '_customer_name' ]; foreach ($name_keys as $key) { if (isset($meta_data[$key][0]) && !empty($meta_data[$key][0])) { $client_name = $meta_data[$key][0]; break; } } // Create client data array $client_data = [ 'email' => $client_email, 'name' => $client_name, 'company' => $meta_data['client_company'][0] ?? $meta_data['_client_company'][0] ?? $meta_data['company'][0] ?? $meta_data['_company'][0] ?? '', 'address' => $meta_data['client_address'][0] ?? $meta_data['_client_address'][0] ?? $meta_data['address'][0] ?? $meta_data['_address'][0] ?? '', 'phone' => $meta_data['client_phone'][0] ?? $meta_data['_client_phone'][0] ?? $meta_data['phone'][0] ?? $meta_data['_phone'][0] ?? '', 'vat' => $meta_data['client_vat'][0] ?? $meta_data['_client_vat'][0] ?? $meta_data['vat'][0] ?? $meta_data['_vat'][0] ?? '' ]; // Get user ID from ClientMigration $user_id = $client_migration->get_or_create_user($client_data); } if ($user_id > 0) { // Set client ID in post meta $client_id_meta_key = $post_type === 'easy_invoice' ? '_easy_invoice_client_id' : '_easy_invoice_quote_client_id'; update_post_meta($new_post_id, $client_id_meta_key, $user_id); $this->log(sprintf('Assigned client ID %d to %s %d', $user_id, $post_type, $new_post_id)); } } } // Generate new invoice/quote number if ($post_type === 'easy_invoice') { if (class_exists('\\EasyInvoice\\Services\\InvoiceNumberService')) { $invoice_service = new \EasyInvoice\Services\InvoiceNumberService(); $new_invoice_number = $invoice_service->generateUniqueNumber(); update_post_meta($new_post_id, '_easy_invoice_number', $new_invoice_number); $this->log(sprintf('Generated new invoice number: %s for post %d', $new_invoice_number, $new_post_id)); $migrated_count++; } } elseif ($post_type === 'easy_invoice_quote') { if (class_exists('\\EasyInvoice\\Services\\QuoteNumberService')) { $quote_service = new \EasyInvoice\Services\QuoteNumberService(); $new_quote_number = $quote_service->generateUniqueNumber(); update_post_meta($new_post_id, '_easy_invoice_quote_number', $new_quote_number); $this->log(sprintf('Generated new quote number: %s for post %d', $new_quote_number, $new_post_id)); $migrated_count++; } } if (empty($errors)) { return [ 'success' => true, 'message' => sprintf('Successfully migrated %d meta entries', $migrated_count), 'migrated_count' => $migrated_count ]; } else { return [ 'success' => false, 'message' => 'Meta migration completed with errors: ' . implode(', ', $errors), 'migrated_count' => $migrated_count ]; } } catch (\Exception $e) { $this->log(sprintf('Failed to migrate meta for post %d: %s', $old_post_id, $e->getMessage()), 'error'); return [ 'success' => false, 'message' => $e->getMessage() ]; } } private function transform_items_meta($items): array { if (!is_array($items)) { $items = maybe_unserialize($items); } if (!is_array($items)) { return []; } $transformed = []; foreach ($items as $item) { if (!is_array($item)) { $item = maybe_unserialize($item); } if (!is_array($item)) { continue; } // The old format has these keys: // quantity, item_title, adjust, rate, description, taxable $title = $item['item_title'] ?? ''; $description = $item['description'] ?? ''; // Handle quantity (always a string in old format) $quantity = isset($item['quantity']) ? floatval($item['quantity']) : 1; // Allow quantity 0 - don't force minimum of 1 // Handle price (called 'rate' in old format) $price = isset($item['rate']) ? floatval($item['rate']) : 0; // Handle adjustment (called 'adjust' in old format) // The old system stored adjust as a percentage value (e.g., 10 for 10%) $adjust_percentage = 0; if (isset($item['adjust']) && !empty($item['adjust'])) { $adjust_percentage = floatval($item['adjust']); // Ensure it's a valid percentage if ($adjust_percentage < -100) { $adjust_percentage = -100; // Cap at -100% } } // Skip if no title if (empty($title)) { continue; } // Calculate total based on old system logic // Old system: total = quantity * rate * (1 + adjust_percentage/100) $total = $quantity * $price; if ($adjust_percentage != 0) { $total = $total * (1 + $adjust_percentage / 100); } // Handle taxable field - this is critical for tax calculations // The old plugin logic: isset($line_item['taxable']) && (boolean)$line_item['taxable'] // This means: if field doesn't exist OR is falsy → NOT taxable $taxable = '0'; // Default to NOT taxable (following old plugin logic) $original_taxable = $item['taxable'] ?? 'NOT_SET'; if (isset($item['taxable'])) { // The old system used (boolean)$item['taxable'] // Convert various formats to '1' or '0' $tax_value = $item['taxable']; // Log the original taxable value and type for debugging $this->log(sprintf('Processing taxable field: value=%s, type=%s', var_export($tax_value, true), gettype($tax_value))); if (is_bool($tax_value)) { $taxable = $tax_value ? '1' : '0'; $this->log(sprintf('Boolean taxable: %s -> %s', var_export($tax_value, true), $taxable)); } elseif (is_string($tax_value)) { $tax_value = strtolower(trim($tax_value)); if (in_array($tax_value, ['0', 'false', 'no', 'n', 'off'], true)) { $taxable = '0'; $this->log(sprintf('String taxable (false): %s -> %s', $tax_value, $taxable)); } else { $taxable = '1'; // Only set to taxable if explicitly truthy $this->log(sprintf('String taxable (true): %s -> %s', $tax_value, $taxable)); } } elseif (is_numeric($tax_value)) { $taxable = $tax_value > 0 ? '1' : '0'; $this->log(sprintf('Numeric taxable: %s -> %s', $tax_value, $taxable)); } else { $this->log(sprintf('Unknown taxable type: %s, value: %s', gettype($tax_value), var_export($tax_value, true))); } } elseif (isset($item['tax_exempt'])) { // Some versions used tax_exempt instead $exempt_value = $item['tax_exempt']; $this->log(sprintf('Using tax_exempt field: %s', var_export($exempt_value, true))); if (is_bool($exempt_value)) { $taxable = $exempt_value ? '0' : '1'; // tax_exempt = true means NOT taxable $this->log(sprintf('Boolean tax_exempt: %s -> taxable: %s', var_export($exempt_value, true), $taxable)); } elseif (is_string($exempt_value)) { $exempt_value = strtolower(trim($exempt_value)); if (in_array($exempt_value, ['1', 'true', 'yes', 'y', 'on'], true)) { $taxable = '0'; // tax_exempt = true means NOT taxable $this->log(sprintf('String tax_exempt (true): %s -> taxable: %s', $exempt_value, $taxable)); } else { $this->log(sprintf('String tax_exempt (false): %s -> taxable: %s', $exempt_value, $taxable)); } } } elseif (isset($item['is_taxable'])) { // Some versions used is_taxable $is_taxable = $item['is_taxable']; $this->log(sprintf('Using is_taxable field: %s', var_export($is_taxable, true))); if (is_bool($is_taxable)) { $taxable = $is_taxable ? '1' : '0'; $this->log(sprintf('Boolean is_taxable: %s -> taxable: %s', var_export($is_taxable, true), $taxable)); } elseif (is_string($is_taxable)) { $is_taxable = strtolower(trim($is_taxable)); if (in_array($is_taxable, ['0', 'false', 'no', 'n', 'off'], true)) { $taxable = '0'; $this->log(sprintf('String is_taxable (false): %s -> taxable: %s', $is_taxable, $taxable)); } else { $this->log(sprintf('String is_taxable (true): %s -> taxable: %s', $is_taxable, $taxable)); } } } else { $this->log(sprintf('No taxable field found, using default: %s', $taxable)); } // Log the transformation for debugging $this->log(sprintf( 'Transformed item: title="%s", quantity=%f, price=%f, adjust_percentage=%f, taxable=%s, total=%f (original taxable: %s, original adjust: %s)', $title, $quantity, $price, $adjust_percentage, $taxable, $total, var_export($item['taxable'] ?? 'NOT_SET', true), var_export($item['adjust'] ?? 'NOT_SET', true) )); $transformed[] = [ 'title' => $title, 'description' => $description, 'quantity' => $quantity, 'price' => $price, 'adjust_percentage' => $adjust_percentage, 'total' => $total, 'taxable' => $taxable, 'id' => 0 // Required by new format ]; } return $transformed; } /** * Check if migration is needed. * * @since 2.0.0 * @return bool */ public function is_needed(): bool { global $wpdb; // Check if any old post types have meta that needs migration $old_post_types = ['easy-invoice', 'easy-invoice-quotes', 'easy-invoice-payment']; foreach ($old_post_types as $post_type) { $count = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} pm JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.post_type = %s AND p.post_status NOT IN ('auto-draft', 'trash', 'inherit')", $post_type )); if ($count > 0) { $this->log(sprintf('Found %d meta entries for post type %s that need migration', $count, $post_type)); return true; } } return false; } /** * Run the migration. * * @since 2.0.0 * @return array */ public function migrate(): array { try { global $wpdb; $migrated_count = 0; $errors = []; // Get all posts from old post types $old_post_types = ['easy-invoice', 'easy-invoice-quotes', 'easy-invoice-payment']; $new_post_types = [ 'easy-invoice' => 'easy_invoice', 'easy-invoice-quotes' => 'easy_invoice_quote', 'easy-invoice-payment' => 'easy_payment' ]; foreach ($old_post_types as $old_post_type) { $posts = $wpdb->get_results($wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status NOT IN ('auto-draft', 'trash', 'inherit')", $old_post_type )); if (empty($posts)) { continue; } foreach ($posts as $post) { // Get the new post ID from the mapping $new_post_id = get_option('easy_invoice_migration_post_mapping_' . $post->ID, 0); if ($new_post_id <= 0) { $errors[] = sprintf('No mapping found for old post ID: %d', $post->ID); continue; } // Get the new post type $new_post_type = $new_post_types[$old_post_type] ?? ''; if (empty($new_post_type)) { $errors[] = sprintf('No post type mapping found for: %s', $old_post_type); continue; } // Migrate meta $result = $this->migrate_post_meta($post->ID, $new_post_id, $new_post_type); if ($result['success']) { $migrated_count += $result['migrated_count']; } else { $errors[] = $result['message']; } } } if (empty($errors)) { return [ 'success' => true, 'message' => sprintf('Successfully migrated %d meta entries', $migrated_count), 'migrated_count' => $migrated_count ]; } else { return [ 'success' => false, 'message' => 'Meta migration completed with errors: ' . implode(', ', $errors), 'migrated_count' => $migrated_count ]; } } catch (\Exception $e) { $this->log('Meta migration failed: ' . $e->getMessage(), 'error'); return [ 'success' => false, 'message' => $e->getMessage() ]; } } /** * Transform status value from old plugin format to new plugin format. * * @since 2.0.0 * @param string $old_status Old status value * @param string $post_type Post type (easy_invoice or easy_invoice_quote) * @return string New status value */ private function transform_status_value(string $old_status, string $post_type): string { $old_status = strtolower(trim($old_status)); if ($post_type === 'easy_invoice') { // Invoice status mapping $status_mapping = [ 'available' => 'unpaid', // Old 'available' becomes 'unpaid' in new plugin 'draft' => 'draft', // 'draft' stays the same 'paid' => 'paid', // 'paid' stays the same 'cancelled' => 'cancelled' // 'cancelled' stays the same ]; } elseif ($post_type === 'easy_invoice_quote') { // Quote status mapping $status_mapping = [ 'available' => 'available', // 'available' stays the same 'draft' => 'draft', // 'draft' stays the same 'sent' => 'sent', // 'sent' stays the same 'declined' => 'declined', // 'declined' stays the same 'cancelled' => 'cancelled' // 'cancelled' stays the same ]; } else { // Unknown post type, return original status return $old_status; } // Return mapped status or original if no mapping found $this->log(sprintf('Status transformation: %s -> %s for post type %s', $old_status, $status_mapping[$old_status] ?? $old_status, $post_type)); return $status_mapping[$old_status] ?? $old_status; } }