get_type(), ['simple','variation'], true) || $product->get_status() === 'trash') { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new InvalidArgumentException(__('Use an existing simple product or variation.', 'orderworth')); } if (!current_user_can('edit_post', $id)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new InvalidArgumentException(__('You cannot edit this product.', 'orderworth')); } return $product; } private static function value($product) { $value = $product->get_cogs_value(); return $value === null ? null : (string) $value; } private static function same($a, $b): bool { return $a === null || $b === null ? $a === $b : (float) $a === (float) $b; } private static function signature($product): string { return hash('sha256', wp_json_encode([$product->get_id(), $product->get_type(), $product->get_sku(), self::value($product)])); } /** fgetcsv accepts unfinished quoting; validate the whole file before previewing. */ public static function csv_syntax(string $csv): void { $state = 'start'; for ($i = 0, $length = strlen($csv); $i < $length; $i++) { $char = $csv[$i]; if ($state === 'quoted') { if ($char === '"') { $state = 'quote'; } continue; } if ($state === 'quote' && $char === '"') { $state = 'quoted'; continue; } if ($char === ',' || $char === "\r" || $char === "\n") { $state = 'start'; continue; } if ($state === 'quote' || ($char === '"' && $state !== 'start')) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- The authenticated handler returns plain-text JSON consumed by textContent. throw new InvalidArgumentException(__('Malformed CSV quoting. Export a complete comma-separated CSV and try again.', 'orderworth')); } $state = $state === 'start' && $char === '"' ? 'quoted' : 'plain'; } if ($state === 'quoted') { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- The authenticated handler returns plain-text JSON consumed by textContent. throw new InvalidArgumentException(__('The CSV ends inside a quoted field. Nothing was imported.', 'orderworth')); } } public static function parse_csv(string $csv): array { if (strlen($csv) > 65536 || strpos($csv, "\0") !== false || !preg_match('//u', $csv)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new InvalidArgumentException(__('Use a UTF-8 CSV of 64 KB or less.', 'orderworth')); } $csv = preg_replace('/^\xEF\xBB\xBF/', '', $csv); self::csv_syntax($csv); // The validated CSV is at most 64 KB, so this object stays in memory. $file = new SplTempFileObject(65537); $file->fwrite($csv); $file->rewind(); try { $header = $file->fgetcsv(',', '"', ''); if (!is_array($header) || in_array(null, $header, true)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new InvalidArgumentException(__('CSV needs a header row.', 'orderworth')); } $header = array_map('trim', $header); if (count($header) !== count(array_unique($header)) || array_diff($header, ['product_id','sku','cost']) || !in_array('cost', $header, true) || (!in_array('product_id', $header, true) && !in_array('sku', $header, true))) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new InvalidArgumentException(__('Use headers product_id,cost or sku,cost. You may include all three.', 'orderworth')); } $rows = []; // PHP 7.4 can return null at EOF; newer runtimes return false. while (is_array($row = $file->fgetcsv(',', '"', ''))) { if ($row === [null] || $row === ['']) { continue; } if (count($row) !== count($header)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new InvalidArgumentException(__('Every CSV row must match the header columns.', 'orderworth')); } $rows[] = self::sanitize_import_row(array_combine($header, $row), count($rows)); } if (!$rows) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new InvalidArgumentException(__('Add at least one product row.', 'orderworth')); } return $rows; } finally { unset($file); } } public static function preview(string $csv, bool $replace, bool $zero): array { if (!self::ready()) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new InvalidArgumentException(__('Enable WooCommerce native Cost of Goods Sold before importing.', 'orderworth')); } if (strlen($csv)>65536) { return OWWC_Manual_Import::create($csv,$replace,$zero); } $inputs=self::parse_csv($csv); $rows=[]; $seen=[]; if (count($inputs)<=self::LIMIT) { foreach ($inputs as $index=>$input) { $rows[]=self::preview_row($input,$index,$replace,$zero,$seen); } } $token = wp_generate_password(32, false, false); $job = ['owner'=>get_current_user_id(), 'created'=>time(), 'expires'=>time()+self::TTL, 'currency'=>OWWC_Reports::unit()['currency'], 'unit'=>OWWC_Reports::unit(), 'rows'=>$rows, 'next'=>0, 'phase'=>$rows ? 'preview' : 'validating']; if (!$rows) { $job['inputs']=$inputs; $job['seen']=[]; $job['replace']=$replace; $job['zero']=$zero; $job['preview_next']=0; } if (!set_transient('owwc_import_' . $token, $job, self::TTL)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new RuntimeException(__('Could not retain preview. Nothing was imported.', 'orderworth')); } return self::response($token, $job); } /** Validate every field before retaining an import, including deferred rows. */ public static function sanitize_import_row(array $input, int $index): array { $out = []; foreach (['product_id', 'sku', 'cost'] as $field) { if (array_key_exists($field, $input) && !is_string($input[$field])) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is returned as JSON and rendered with textContent. throw new InvalidArgumentException(__('Every CSV field must be text.', 'orderworth')); } $out[$field] = trim($input[$field] ?? ''); } if ($out['product_id'] !== '') { // Preserve valid leading-zero IDs while rejecting overflow before casting. $id = ltrim($out['product_id'], '0'); if (OWWC_Admin::request_integer($id) < 1) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is returned as JSON and rendered with textContent. throw new InvalidArgumentException(sprintf(/* translators: 1: CSV row number. */ __('Row %1$s: invalid product ID.', 'orderworth'), $index + 2)); } $out['product_id'] = $id; } $sku = sanitize_text_field($out['sku']); if ($sku !== $out['sku'] || preg_match('/[\x00-\x1F\x7F]/', $sku)) { // Reject changed identifiers instead of matching a different product. // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is returned as JSON and rendered with textContent. throw new InvalidArgumentException(sprintf(/* translators: 1: CSV row number. */ __('Row %1$s: use a plain-text SKU without markup or control characters.', 'orderworth'), $index + 2)); } $out['sku'] = $sku; if ($out['product_id'] === '' && $out['sku'] === '') { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is returned as JSON and rendered with textContent. throw new InvalidArgumentException(__('Each row needs a unique product ID or SKU.', 'orderworth')); } $out['cost'] = OWWC_Settings::number($out['cost']); if (strlen($out['cost']) > 32 || !preg_match('/^\d*(?:\.\d{1,4})?$/D', $out['cost'])) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is returned as JSON and rendered with textContent. throw new InvalidArgumentException(__('Use costs with at most four decimal places.', 'orderworth')); } return $out; } public static function preview_row(array $input,int $index,bool $replace,bool $zero,array &$seen): array { $input = self::sanitize_import_row($input, $index); $id_text = $input['product_id']; $sku = $input['sku']; $id = (int) $id_text; if ($sku !== '') { $sku_id = (int) wc_get_product_id_by_sku($sku); if (!$sku_id || ($id && $id !== $sku_id)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new InvalidArgumentException(sprintf(/* translators: 1: report value or label. */ __('Row %1$s: SKU missing or does not match the product ID.', 'orderworth'), (int) ($index+2))); } $id = $sku_id; } if (!$id || isset($seen[$id])) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new InvalidArgumentException(__('Each row needs a unique product ID or SKU.', 'orderworth')); } $seen[$id] = true; $product = self::product($id); $cost = $input['cost']; if ((float) $cost === 0.0 && !$zero) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new InvalidArgumentException(__('Confirm intentional zero costs before previewing rows with cost 0.', 'orderworth')); } $before = self::value($product); // Native WooCommerce normalizes simple-product zero costs to null. // Let its setter normalize a detached preview; never save here. $preview = clone $product; $preview->set_cogs_value((float) $cost); $after = self::value($preview); $state = self::same($before, $after) ? 'unchanged' : (($before !== null && !$replace) ? 'protected' : 'pending'); return ['id'=>$id, 'sku'=>$product->get_sku(), 'name'=>$product->get_name(), 'before'=>$before, 'after'=>$after, 'requested'=>$cost, 'signature'=>self::signature($product), 'state'=>$state, 'note'=>$state === 'protected' ? __('Existing cost preserved; replacement was not selected.', 'orderworth') : '']; } public static function prepare(string $token): array { $initial=self::load($token); if (!empty($initial['streamed'])) { return OWWC_Manual_Import::prepare($token); } $key='owwc_import_lock_'.$token; $lock=OWWC_Lock::acquire($key); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Authenticated handlers JSON-encode this plain-text validation error; admin.js/setup.js assign the message with textContent. HTML callers separately use esc_html. if (!$lock) { throw new RuntimeException(__('This preview is already processing. Retry shortly.', 'orderworth')); } try { $job=self::load($token); if ($job['phase']!=='validating') { return self::response($token,$job); } $end=min(count($job['inputs']),$job['preview_next']+20); for ($i=$job['preview_next'];$i<$end;$i++) { $job['rows'][]=self::preview_row($job['inputs'][$i],$i,$job['replace'],$job['zero'],$job['seen']); } $job['preview_next']=$end; if ($end===count($job['inputs'])) { $job['phase']='preview'; unset($job['inputs'],$job['seen'],$job['replace'],$job['zero']); } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Authenticated handlers JSON-encode this plain-text validation error; admin.js/setup.js assign the message with textContent. HTML callers separately use esc_html. if (!self::persist($token,$job)) { throw new RuntimeException(__('Could not retain preview progress. Nothing was imported.', 'orderworth')); } return self::response($token,$job); } finally { OWWC_Lock::release($key,$lock); } } public static function preview_page(string $token,int $page=0): array { if ($page < 0) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Authenticated AJAX handlers encode this plain-text message as JSON; the browser uses textContent. throw new InvalidArgumentException(__('Invalid preview page.', 'orderworth')); } return self::response($token,self::load($token),$page); } private static function fresh_job(string $token) { $key = 'owwc_import_'.$token; if (wp_using_ext_object_cache()) { return wp_cache_get($key, 'transient', true); } wp_cache_delete('_transient_'.$key, 'options'); wp_cache_delete('_transient_timeout_'.$key, 'options'); wp_cache_delete('notoptions', 'options'); return get_transient($key); } public static function load(string $token): array { if (!preg_match('/^[a-zA-Z0-9]{32}$/D', $token)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new InvalidArgumentException(__('Invalid import reference.', 'orderworth')); } $job = self::fresh_job($token); if (!is_array($job) || ($job['owner'] ?? 0) !== get_current_user_id() || ($job['expires'] ?? 0) < time()) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new InvalidArgumentException(__('Import preview or undo window expired. Preview the file again.', 'orderworth')); } if (($job['currency'] ?? '') !== OWWC_Reports::unit()['currency'] || (isset($job['unit']) && $job['unit']!==OWWC_Reports::unit()) || !self::ready()) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new InvalidArgumentException(__('Store currency or cost-feature settings changed. Start a fresh preview.', 'orderworth')); } return $job; } private static function response(string $token, array $job, int $page=0): array { if (!empty($job['streamed'])) { return OWWC_Manual_Import::response($token,$job,$page); } $page=max(0,min($page,max(0,(int)ceil(count($job['rows'])/50)-1))); return ['token'=>$token, 'rows'=>array_map(static function ($row) { unset($row['signature']); return $row; }, array_slice($job['rows'],$page*50,50)), 'next'=>$job['next'], 'total'=>isset($job['inputs']) ? count($job['inputs']) : count($job['rows']), 'done'=>in_array($job['phase'], ['complete','undone'], true), 'phase'=>$job['phase'], 'preview_next'=>$job['preview_next'] ?? count($job['rows']), 'counts'=>array_count_values(array_column($job['rows'],'state')), 'page'=>$page,'pages'=>max(1,(int)ceil(count($job['rows'])/50))]; } public static function persist(string $token, array $job): bool { $key = 'owwc_import_' . $token; // WordPress returns false for an unchanged value as well as a failed write. // Retrying a pre-write checkpoint must remain resumable. return set_transient($key, $job, max(1, $job['expires']-time())) || self::fresh_job($token) === $job; } public static function advance(string $token, bool $undo = false): array { $job = self::load($token); if (!empty($job['streamed'])) { return OWWC_Manual_Import::advance($token,$undo); } $key = 'owwc_import_lock_' . $token; $lock = OWWC_Lock::acquire($key); if (!$lock) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new RuntimeException(__('This import is already processing. Retry in a moment.', 'orderworth')); } try { $job = self::load($token); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Authenticated handlers JSON-encode this plain-text validation error; admin.js/setup.js assign the message with textContent. HTML callers separately use esc_html. if ($job['phase']==='validating') { throw new InvalidArgumentException(__('Finish previewing every row before confirming the import.', 'orderworth')); } if ($undo && $job['phase'] === 'complete') { $job['phase'] = 'undo'; $job['next'] = 0; } if ($undo && !in_array($job['phase'], ['undo','undone'], true)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new InvalidArgumentException(__('Finish the import before undoing it.', 'orderworth')); } if (!$undo && in_array($job['phase'], ['undo','undone'], true)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new InvalidArgumentException(__('This import was undone. Create a fresh preview.', 'orderworth')); } if ((!$undo && $job['phase'] === 'complete') || $job['phase'] === 'undone') { return self::response($token, $job); } $job['phase'] = $undo ? 'undo' : 'apply'; $end = min(count($job['rows']), $job['next']+20); for ($i=$job['next']; $i<$end; $i++) { $row = &$job['rows'][$i]; $product_lock = ''; $product_key = 'owwc_product_lock_' . $row['id']; try { if (!$undo && !in_array($row['state'], ['pending','writing'], true)) { $job['next']=$i+1; continue; } if ($undo && !in_array($row['state'], ['applied','writing','undoing','conflict'], true)) { $job['next']=$i+1; continue; } $product_lock = OWWC_Cost_Write_Guard::acquire((int)$row['id']); if (!$product_lock) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new RuntimeException(__('Another import is changing this product. Left untouched; preview it again afterward.', 'orderworth')); } $product = self::product($row['id']); $stamp = $product->get_meta('_owwc_cost_import', true); $owned = is_array($stamp) && ($stamp['token'] ?? '') === $token && ($stamp['owner'] ?? 0) === get_current_user_id(); if (!$undo && $owned && self::same(self::value($product), $row['after'])) { $row['state']='applied'; } elseif ($undo && $row['state'] === 'undoing' && self::same(self::value($product), $row['before']) && !$stamp) { $row['state']='undone'; } else { if ($undo ? (!$owned || !self::same(self::value($product), $row['after'])) : !hash_equals($row['signature'], self::signature($product))) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new InvalidArgumentException(__('Cost or product changed; left untouched.', 'orderworth')); } $row['state'] = $undo ? 'undoing' : 'writing'; if (!self::persist($token, $job)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new RuntimeException(__('Cannot record import progress; stopped before writing.', 'orderworth')); } $value = $undo ? $row['before'] : $row['after']; $product->set_cogs_value($value === null ? null : (float) $value); if ($undo) { $product->delete_meta_data('_owwc_cost_import'); } else { $product->update_meta_data('_owwc_cost_import', ['token'=>$token,'owner'=>get_current_user_id(),'at'=>gmdate('c')]); } OWWC_Cost_Write_Guard::assert_owned((int)$row['id'], $product_lock); $product->save(); $fresh = OWWC_Cost_Write_Guard::fresh((int)$row['id']); if (!$fresh || !self::same(self::value($fresh), $value)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new RuntimeException(__('Write could not be verified. Check this product before continuing.', 'orderworth')); } $row['state'] = $undo ? 'undone' : 'applied'; } $row['note'] = ''; } catch (Throwable $e) { $row['state']='conflict'; $row['note']=OWWC_Settings::translate_error($e->getMessage()); } finally { if ($product_lock) { OWWC_Cost_Write_Guard::after($product ?? null); OWWC_Cost_Write_Guard::release((int)$row['id'], $product_lock); } } $job['next']=$i+1; } unset($row); if ($job['next'] >= count($job['rows'])) { $job['phase'] = $undo ? 'undone' : 'complete'; } if (!self::persist($token, $job)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new RuntimeException(__('Progress could not be confirmed. Check product costs before retrying.', 'orderworth')); } if (!$undo && empty($job['parent'])) { update_user_meta(get_current_user_id(), 'owwc_last_import', $token); } return self::response($token, $job); } finally { OWWC_Lock::release($key, $lock); } } public static function ajax_preview(): void { OWWC_Admin::authorize(); OWWC_Admin::validate_nonce_input(); check_ajax_referer('owwc_reports', 'nonce'); foreach (['replace', 'zero'] as $field) { if (array_key_exists($field, $_POST) && !in_array($_POST[$field], ['0', '1'], true)) { wp_send_json_error(['message'=>__('Invalid import option. Nothing was imported.', 'orderworth')], 400); } } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- preview validates CSV structure and sanitizes every field with sanitize_import_row before retaining any rows or source chunks; applying a text sanitizer to the whole CSV would corrupt quoting. $csv = isset($_POST['csv']) && is_string($_POST['csv']) ? wp_unslash($_POST['csv']) : ''; try { $expected_bytes=isset($_POST['csv_bytes']) && is_string($_POST['csv_bytes']) ? sanitize_text_field(wp_unslash($_POST['csv_bytes'])) : ''; if (isset($_FILES['csv_upload'])) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validate the private PHP upload's status, origin and size; preview then sanitizes every CSV field before retaining any rows or source chunks. $file=$_FILES['csv_upload']; if (!is_array($file) || ($file['error'] ?? -1)!==UPLOAD_ERR_OK || !is_string($file['tmp_name'] ?? null) || !is_uploaded_file($file['tmp_name']) || !ctype_digit($expected_bytes) || (int)$expected_bytes>OWWC_Manual_Import::BYTES || ($file['size'] ?? -1)!==(int)$expected_bytes || filesize($file['tmp_name'])!==(int)$expected_bytes) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Authenticated handlers JSON-encode this plain-text validation error; admin.js/setup.js assign the message with textContent. HTML callers separately use esc_html. throw new InvalidArgumentException(__('The complete CSV upload was not received. Check the server upload limit and try again. Nothing was imported.', 'orderworth')); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Read only the validated private PHP upload; it is never moved into public uploads or fetched over the network. $csv=file_get_contents($file['tmp_name']); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Authenticated handlers JSON-encode this plain-text validation error; admin.js/setup.js assign the message with textContent. HTML callers separately use esc_html. if (!is_string($csv)) { throw new InvalidArgumentException(__('Could not read the complete uploaded CSV. Nothing was imported.', 'orderworth')); } } if (!isset($_POST['csv_complete']) || $_POST['csv_complete']!=='1' || !ctype_digit($expected_bytes) || (int)$expected_bytes!==strlen($csv)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Authenticated handlers JSON-encode this plain-text validation error; admin.js/setup.js assign the message with textContent. HTML callers separately use esc_html. throw new InvalidArgumentException(__('The complete CSV was not received. Check the server upload limit and try again. Nothing was imported.', 'orderworth')); } wp_send_json_success(self::preview($csv, isset($_POST['replace']) && $_POST['replace'] === '1', isset($_POST['zero']) && $_POST['zero'] === '1')); } catch (Throwable $e) { wp_send_json_error(['message'=>OWWC_Settings::translate_error($e->getMessage())], 400); } } public static function ajax_prepare(): void { self::ajax_read(false); } public static function ajax_page(): void { self::ajax_read(true); } private static function ajax_read(bool $page): void { OWWC_Admin::authorize(); OWWC_Admin::validate_nonce_input(); check_ajax_referer('owwc_reports','nonce'); try { $token=isset($_POST['token']) && is_string($_POST['token']) ? sanitize_text_field(wp_unslash($_POST['token'])) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- request_integer rejects non-string, non-canonical, negative and overflowing values before conversion; -1 is rejected by the caller. wp_send_json_success($page ? self::preview_page($token,OWWC_Admin::request_integer(wp_unslash($_POST['page'] ?? '0'))) : self::prepare($token)); } catch (Throwable $e) { wp_send_json_error(['message'=>OWWC_Settings::translate_error($e->getMessage())],400); } } public static function ajax_apply(): void { self::ajax_advance(false); } public static function ajax_undo(): void { self::ajax_advance(true); } private static function ajax_advance(bool $undo): void { OWWC_Admin::authorize(); OWWC_Admin::validate_nonce_input(); check_ajax_referer('owwc_reports', 'nonce'); try { if (!isset($_POST['confirmed']) || $_POST['confirmed'] !== '1') { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text validation error is caught by the admin handler and escaped for HTML or sent as JSON to textContent; escaping here would corrupt those messages. throw new InvalidArgumentException(__('Review and confirm the listed product-cost changes first.', 'orderworth')); } wp_send_json_success(self::advance(isset($_POST['token']) && is_string($_POST['token']) ? sanitize_text_field(wp_unslash($_POST['token'])) : '', $undo)); } catch (Throwable $e) { wp_send_json_error(['message'=>OWWC_Settings::translate_error($e->getMessage())], 409); } } public static function page(): void { echo ('

' . '' . esc_html__('Show me how · optional tutorial', 'orderworth') . '' . '

'); $settings = OWWC_Settings::get(); $ids = wc_get_orders(['type'=>'shop_order','status'=>['wc-processing','wc-completed','wc-refunded'],'limit'=>25,'return'=>'ids','orderby'=>'date','order'=>'DESC']); $ready=0; foreach ($ids as $id) { $row=OWWC_Reports::row((int) $id); if ($row['ready']) { $ready++; } } echo ('
' . '

' . esc_html__('Setup health check', 'orderworth') . '

' . '

' . esc_html__('Fix these inputs once, then use your attention list to review the exceptions.', 'orderworth') . '

' . '' . '

' . '' . esc_html(sprintf(/* translators: 1: report value or label; 2: report value or label. */ __('%1$s of %2$s', 'orderworth'), $ready, count($ids))) . '' . esc_html__(' of the latest 25 eligible orders have complete estimates. This is a sample, not a full-store audit.', 'orderworth') . '

' . '

' . '' . esc_html__('WooCommerce features', 'orderworth') . '' . ' ' . '' . esc_html__('Cost assumptions · conditional rules in Pro', 'orderworth') . '' . ' ' . '' . esc_html__('Run a complete scan', 'orderworth') . '' . '

' . '
'); echo ('
' . '

' . esc_html__('Bring your product costs with you', 'orderworth') . '

' . '

' . esc_html__('Import a CSV using ', 'orderworth') . '' . 'product_id,cost' . '' . esc_html__(' or ', 'orderworth') . '' . 'sku,cost' . '' . esc_html(sprintf(/* translators: 1: report value or label. */ __('. Product IDs must belong to this store. When moving from another plugin, export its costs and map those columns. Preview every row in a UTF-8 CSV up to 8 MiB (or the lower server upload limit), then confirm. Larger row counts continue in small batches; the table shows 50 rows per page. Costs are per product unit in %1$s.', 'orderworth'), OWWC_Reports::unit()['currency'])) . '

' . '

' . esc_html__('Product imports apply to future orders. Existing orders keep their recorded costs and reviews. This is a one-time copy, not ongoing synchronization with another cost plugin.', 'orderworth') . '

'); echo ('
' . '

' . '' . '

' . '

' . '' . '' . '

' . '

' . '' . '

' . '

' . '' . '

' . '' . ' ' . '' . '
'); echo ('

' . esc_html__('No costs changed. A preview is required before importing.', 'orderworth') . '

' . ''); $last = get_user_meta(get_current_user_id(), 'owwc_last_import', true); echo ('

' . '' . '

' . '

' . esc_html__('Undo is available for 24 hours after preview and skips any product whose cost was changed afterward. Review each result: conflicts are left untouched. Keep a store backup when importing costs.', 'orderworth') . '

' . '
'); } }