get_woocommerce_currency(), 'decimals'=>(int) wc_get_price_decimals()]); } /** Bind every report batch to one configuration, including Free scans. */ public static function fingerprint(): string { wp_cache_delete('alloptions','options'); wp_cache_delete('notoptions','options'); foreach (['owwc_settings','owwc_pro_rules','woocommerce_currency','woocommerce_price_num_decimals','timezone_string','gmt_offset'] as $key) { wp_cache_delete($key,'options'); } // Retained rule configuration must invalidate stale scans even without Pro. // Reading this local option does not apply or unlock conditional rules. $rules = array_merge(['payments'=>[], 'shipping'=>[]], (array) get_option('owwc_pro_rules', [])); return hash('sha256',wp_json_encode([OWWC_VERSION, defined('OWWC_PRO_VERSION') ? OWWC_PRO_VERSION : '', OWWC_Reports::unit(), OWWC_Settings::get(), $rules, wp_timezone()->getName(), (bool) apply_filters('owwc_pro_enabled',false)])); } public static function default_range(): array { $today = new DateTimeImmutable('today', wp_timezone()); return [$today->modify('-29 days')->format('Y-m-d'), $today->format('Y-m-d')]; } public static function validate_range(string $from, string $to): array { $dates = []; foreach ([$from, $to] as $date) { $parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $date, wp_timezone()); if (!$parsed || $parsed->format('Y-m-d') !== $date) { // 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 valid dates in YYYY-MM-DD format.', 'orderworth')); } $dates[] = $parsed; } if ($dates[0] > $dates[1] || $dates[1] > new DateTimeImmutable('today', wp_timezone())) { // 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(__('Choose an ordered date range ending no later than today.', 'orderworth')); } if ($dates[0]->diff($dates[1])->days > 3659) { // 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 date range of 3,660 calendar days or less, including both end dates.', 'orderworth')); } return $dates; } public static function ids(string $from, string $to, bool $bounded_probe = false): array { $dates = self::validate_range($from, $to); $ids = array_map('intval', wc_get_orders([ 'type'=>'shop_order', 'status'=>['wc-processing','wc-completed','wc-refunded'], 'date_created'=>$dates[0]->getTimestamp() . '...' . $dates[1]->setTime(23, 59, 59)->getTimestamp(), 'limit'=>self::MAX_ORDERS + 1, 'return'=>'ids', 'orderby'=>'ID', 'order'=>'ASC', ])); if (!$bounded_probe && count($ids)>self::MAX_ORDERS) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text error consumed by authenticated callers. throw new LengthException(__('Use the manual report continuation to select this period without truncation.', 'orderworth')); } if (count($ids) !== count(array_unique($ids))) { // 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(__('Duplicate order IDs returned by the store.', 'orderworth')); } return $ids; } public static function create(string $from, string $to, bool $compare): array { $fingerprint = self::fingerprint(); self::validate_range($from, $to); $ranges = apply_filters('owwc_report_ranges', [['from'=>$from,'to'=>$to]], $compare); if (!is_array($ranges) || !$ranges || count($ranges) > 2) { // 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 report periods.', 'orderworth')); } $groups = array_map(static function($range) { return ['from'=>$range['from'],'to'=>$range['to'],'ids'=>self::ids($range['from'],$range['to'],true)]; }, $ranges); foreach ($groups as $group) { if (count($group['ids']) > self::MAX_ORDERS) { return OWWC_Manual_Reports::create($ranges); } } if (!hash_equals($fingerprint, self::fingerprint())) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Authenticated handlers JSON-encode this plain-text validation error. throw new RuntimeException(__('Reporting settings changed. Start a new scan.', 'orderworth')); } $token = wp_generate_password(32, false, false); $scan = ['version'=>4, 'fingerprint'=>$fingerprint, 'unit'=>self::unit(), 'owner'=>get_current_user_id(), 'groups'=>$groups, 'created'=>time()]; if (!set_transient('owwc_scan_' . $token, $scan, HOUR_IN_SECONDS)) { // 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 save this scan. Check the site database or object cache.', 'orderworth')); } self::progress($token, $scan); return apply_filters('owwc_scan_created_result', [ 'token'=>$token, 'unit'=>self::unit(), 'created'=>gmdate('c', $scan['created']), 'groups'=>array_map(static function ($g) { return ['from'=>$g['from'],'to'=>$g['to'],'total'=>count($g['ids'])]; }, $groups), ], $scan); } public static function batch(string $token, int $group, int $offset): array { if (class_exists('OWWC_Manual_Reports') && OWWC_Manual_Reports::is_manual($token)) { return OWWC_Manual_Reports::batch($token, $group, $offset); } $scan = self::scan($token); if (!isset($scan['groups'][$group]) || $offset < 0 || $offset > count($scan['groups'][$group]['ids'])) { // 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 scan position.', 'orderworth')); } $ids = $scan['groups'][$group]['ids']; $rows = []; foreach (array_slice($ids, $offset, 50) as $id) { $rows[] = self::selected_row(self::row($id), $scan['groups'][$group]); } // A change during calculation must not return mixed financial rows. self::scan($token); $next = $offset + count($rows); // An end position alone cannot prove earlier batches were processed. if ($offset % 50 === 0 && $rows) { $scan['served'][$group][$offset] = count($rows); self::progress($token, $scan); } return apply_filters('owwc_scan_batch_result', ['rows'=>$rows, 'next'=>$next, 'total'=>count($ids), 'done'=>$next >= count($ids)], $token, $scan, $group, $offset); } private static function progress(string $token, array $scan): void { if (!class_exists('OWWC_Guide') || !empty($scan['guide_recorded'])) { return; } $complete=true; foreach ($scan['groups'] as $group=>$data) { $total=count($data['ids']); for ($offset=0; $offset<$total; $offset+=50) { if (($scan['served'][$group][$offset] ?? 0) !== min(50,$total-$offset)) { $complete=false; break 2; } } } if ($complete) { $scan['guide_recorded']=true; } // Keep the original expiry even if a client retries a batch. $ttl=max(1,HOUR_IN_SECONDS-(time()-$scan['created'])); if (set_transient('owwc_scan_' . $token,$scan,$ttl) || get_transient('owwc_scan_' . $token) === $scan) { if ($complete) { OWWC_Guide::report_completed($scan); } if ($complete && class_exists('OWWC_Review_Request')) { OWWC_Review_Request::report_completed($scan); } } } private static function scan(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 scan.', 'orderworth')); } $scan = get_transient('owwc_scan_' . $token); if (!is_array($scan) || ($scan['version'] ?? 0) !== 4 || ($scan['unit'] ?? []) !== self::unit() || $scan['owner'] !== get_current_user_id() || !is_string($scan['fingerprint'] ?? null) || !hash_equals($scan['fingerprint'], self::fingerprint())) { // 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(__('Scan expired, settings changed, or access is unavailable. Start a new scan.', 'orderworth')); } return $scan; } public static function review_order(string $token, int $id) { if (class_exists('OWWC_Manual_Reports') && OWWC_Manual_Reports::is_manual($token)) { // Large frozen reports use the ordinary order review screen. A new // scan is required after editing so aggregates never become stale. // 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(__('Open the order review page, then run a fresh report after saving changes.', 'orderworth')); } $scan = self::scan($token); if (!current_user_can('edit_shop_order', $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 do not have permission to review this order.', 'orderworth')); } if (!in_array($id, $scan['groups'][0]['ids'], 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 order is not part of your current scan.', 'orderworth')); } $order = wc_get_order($id); if (!$order || $order->get_type() !== 'shop_order') { // 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(__('Order unavailable.', 'orderworth')); } return $order; } public static function row(int $id): array { $order = wc_get_order($id); $row = $order ? OWWC_Profit_Calculator::calculate($order) : [ 'id'=>$id, 'number'=>(string) $id, 'ready'=>false, 'state'=>'review', 'issues'=>['order_unavailable'], 'profit'=>null, 'margin'=>null, ]; $row = apply_filters('owwc_report_row', $row); unset($row['lines']); return $row; } public static function group_total(array $group): int { return isset($group['selected']) ? (int) $group['selected'] : count($group['ids']); } public static function group_ids(array $group, int $offset, int $limit = 50): array { return isset($group['selection']) ? OWWC_Manual_Reports::selected_ids($group, $offset, $limit) : array_slice($group['ids'], $offset, $limit); } /** A selected order moved to another date cannot enter this period's totals. */ public static function selected_row(array $row, array $range): array { $date = is_string($row['date'] ?? null) ? DateTimeImmutable::createFromFormat('!Y-m-d', $row['date'], new DateTimeZone('UTC')) : false; $valid_date = $date && $date->format('Y-m-d') === $row['date']; if (!$valid_date || $row['date'] < $range['from'] || $row['date'] > $range['to']) { $row['ready'] = false; $row['state'] = 'review'; $row['profit'] = null; $row['margin'] = null; $issues = is_array($row['issues'] ?? null) ? $row['issues'] : []; if ($valid_date || !in_array('order_unavailable', $issues, true)) { $issues[] = $valid_date ? 'order_date_changed' : 'order_date_unavailable'; } $row['issues'] = array_values(array_unique($issues)); $row['products'] = []; $row['date'] = ''; $row['allocation_status'] = 'incomplete_order'; } return $row; } }