Explorar o código

Release v1.4.3 — resolve every in-scope finding from revise-1.4.2

Adds docs/revise-1.4.2.md (fresh-eye review of 1.4.2) alongside the
fixes for the issues it raised.

High:
- import_delivered_protocol now skips orders in cancelled/refunded/failed
  states with a per-row error notice. Previously the guard existed only
  in import_inprint_protocol; delivered-protocol imports could silently
  reactivate cancelled/refunded orders to "completed". completed is
  intentionally still allowed through (re-running the same delivered
  protocol is a benign no-op).
- Import Protocols screen and POST handler switched from manage_woocommerce
  to edit_shop_orders so Shop Managers can run imports, matching the
  bulk-action capability requirement.

Medium:
- Studiou_DB_Manager::table_exists wraps the table name in $wpdb->esc_like
  before passing to $wpdb->prepare. SHOW TABLES LIKE treats _ as a single-
  character wildcard; conventional prefixes (wp_test_) hit that.
- CSV formula-injection defence: csv_escape() prefixes any cell beginning
  with =, +, -, @, \t, or \r with a single quote, applied to every value
  in the export post-processing loop.
- datetime_local_to_mysql validates ranges via checkdate() + hour/minute/
  second bounds. Direct-POST or DevTools-edited bogus values are rejected.
- Removed set_order_processing_status and its woocommerce_payment_complete
  hook — dead code on modern WC. WC_Order::payment_complete already sets
  the status before firing the action, so our handler was always either
  a no-op same-state update or a deliberate skip. Translation entry for
  the "Payment received..." note removed too.

Low:
- format_quantity normalises SUM(DECIMAL) — "2.0000" → "2", "2.5000" → "2.5".
- Both import handlers pass an explanatory note to update_status and emit
  per-row UtilsLog::log entries (symmetric with the bulk-action path).
- emit_import_summary deduplicates the error list before formatting; the
  numeric total still reflects the full error count.
- UtilsLog::render_notices uses esc_html instead of wp_kses_post. All
  current notice call sites pass plain text; tighter API trust boundary.
- array_filter in get_orders_for_printing uses an explicit > 0 callback.

Docs:
- README "Known limitations" gains two entries (Excel CS-locale CSV
  separator, trashed/draft products in historical orders).
- CLAUDE.md picks up the 1.4.3 changes.
- docs/revise-1.4.2.md is the new fresh-eye review of 1.4.2; every finding
  carries an inline ✅ resolved / ⏸ deferred status marker.
- docs/translations.txt: dead "Payment received..." entry removed; two
  new strings ("Marked In Printing by InPrint Protocol import.",
  "Marked completed by Delivered Protocol import.") added.

Deferred (carried over): HPOS search-filter live verification, block-
based order edit screen, Network: plugin header, uninstall.php, custom-
order-number plugin integration, register_post_status under HPOS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dalibor Votruba hai 2 meses
pai
achega
b5fd5bb14f

+ 18 - 1
studiou-wc-ord-print-statuses/CLAUDE.md

@@ -8,7 +8,7 @@ This is a WordPress plugin that extends WooCommerce functionality to manage prin
 
 
 **Plugin Details:**
 **Plugin Details:**
 - Text Domain: `studiou-wc-ord-print-statuses`
 - Text Domain: `studiou-wc-ord-print-statuses`
-- Current Version: 1.4.2
+- Current Version: 1.4.3
 - Requires: WordPress 5.8+, WooCommerce 3.0+, PHP 7.2+
 - Requires: WordPress 5.8+, WooCommerce 3.0+, PHP 7.2+
 - Tested with: WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled)
 - Tested with: WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled)
 
 
@@ -23,11 +23,28 @@ User-facing and historical documentation lives at the project root and under `do
 - [`docs/revise-1.3.2.md`](docs/revise-1.3.2.md) — Analytical review of v1.3.2. Every finding listed there is resolved in 1.4.0 (see README changelog).
 - [`docs/revise-1.3.2.md`](docs/revise-1.3.2.md) — Analytical review of v1.3.2. Every finding listed there is resolved in 1.4.0 (see README changelog).
 - [`docs/revise-1.4.0.md`](docs/revise-1.4.0.md) — Analytical review of v1.4.0. All in-scope findings are resolved in 1.4.1; the doc carries per-item status annotations.
 - [`docs/revise-1.4.0.md`](docs/revise-1.4.0.md) — Analytical review of v1.4.0. All in-scope findings are resolved in 1.4.1; the doc carries per-item status annotations.
 - [`docs/revise-1.4.1.md`](docs/revise-1.4.1.md) — Fresh-eye analytical review of v1.4.1. All in-scope findings are resolved in 1.4.2; per-item status annotations inside.
 - [`docs/revise-1.4.1.md`](docs/revise-1.4.1.md) — Fresh-eye analytical review of v1.4.1. All in-scope findings are resolved in 1.4.2; per-item status annotations inside.
+- [`docs/revise-1.4.2.md`](docs/revise-1.4.2.md) — Fresh-eye analytical review of v1.4.2. All in-scope findings are resolved in 1.4.3; per-item status annotations inside.
 
 
 When adding new documentation files, place them in `docs/` and add a one-line pointer here.
 When adding new documentation files, place them in `docs/` and add a one-line pointer here.
 
 
 ## Recent Changes
 ## Recent Changes
 
 
+### Version 1.4.3 (2026-05-12)
+
+Bug-fix release resolving the fresh-eye findings in `docs/revise-1.4.2.md`. Notable code changes:
+
+- **Symmetric terminal-status guard in `import_delivered_protocol`.** Previously absent — `cancelled`/`refunded`/`failed` orders in the delivered CSV were silently reactivated to `completed`. Now skipped with a per-row error.
+- **Import cap aligned with bulk-action cap.** `add_submenu_page` and the handler check both use `edit_shop_orders` (was `manage_woocommerce`) so Shop Managers can run protocol imports.
+- **CSV formula-injection defence.** `Studiou_DB_Manager::csv_escape()` prefixes any cell value beginning with `=`, `+`, `-`, `@`, `\t`, or `\r` with a single quote.
+- **`table_exists()` uses `$wpdb->esc_like($table)`.** `SHOW TABLES LIKE` interprets `_` and `%` as wildcards; conventional WP prefixes (e.g., `wp_test_`) contain `_`. Mitigated previously by post-query equality check; now mitigated correctly at the query layer.
+- **Datetime form values are range-validated.** `Order_Fields_Manager::datetime_local_to_mysql()` runs `checkdate()` plus hour/minute/second bounds. Direct-POST or DevTools-edited bogus values are rejected.
+- **`qty` normalised in PHP.** `SUM(DECIMAL(8,4))` returns `"2.0000"` — the post-processing step formats whole numbers as integers and trims trailing zeros from decimals.
+- **Order notes on import status changes.** Both import paths now pass an explanatory note to `update_status`.
+- **Per-row logging symmetry.** Import handlers now log per-row to `UtilsLog::log`, matching the bulk-action path.
+- **Import summary error dedup.** `emit_import_summary` shows unique error strings; the count is the total.
+- **`UtilsLog::render_notices` uses `esc_html`** instead of `wp_kses_post` — tighter escape, all current call sites pass plain text.
+- **Removed `set_order_processing_status` + its `woocommerce_payment_complete` hook.** Dead code on modern WC — WC sets the status itself before firing the action; our handler was a no-op or skip in every path. Related translation entry removed.
+
 ### Version 1.4.2 (2026-05-12)
 ### Version 1.4.2 (2026-05-12)
 
 
 Bug-fix release addressing the fresh-eye findings in `docs/revise-1.4.1.md`. Notable code changes:
 Bug-fix release addressing the fresh-eye findings in `docs/revise-1.4.1.md`. Notable code changes:

+ 24 - 1
studiou-wc-ord-print-statuses/README.md

@@ -3,7 +3,7 @@
 WordPress plugin that extends WooCommerce with a dedicated workflow for managing print orders sent to third-party print providers. It adds custom order statuses, tracking fields, CSV export, protocol-based CSV imports, and search/filter capabilities tailored to a print fulfillment pipeline.
 WordPress plugin that extends WooCommerce with a dedicated workflow for managing print orders sent to third-party print providers. It adds custom order statuses, tracking fields, CSV export, protocol-based CSV imports, and search/filter capabilities tailored to a print fulfillment pipeline.
 
 
 - **Plugin slug / text domain:** `studiou-wc-ord-print-statuses`
 - **Plugin slug / text domain:** `studiou-wc-ord-print-statuses`
-- **Current version:** 1.4.2
+- **Current version:** 1.4.3
 - **Author:** Dalibor Votruba — [quadarax.com](https://www.quadarax.com)
 - **Author:** Dalibor Votruba — [quadarax.com](https://www.quadarax.com)
 - **License:** GPL v2 or later
 - **License:** GPL v2 or later
 
 
@@ -195,6 +195,7 @@ Reference documents live under [`docs/`](docs/):
 - [`docs/revise-1.3.2.md`](docs/revise-1.3.2.md) — Analytical review of v1.3.2 (all findings addressed in 1.4.0).
 - [`docs/revise-1.3.2.md`](docs/revise-1.3.2.md) — Analytical review of v1.3.2 (all findings addressed in 1.4.0).
 - [`docs/revise-1.4.0.md`](docs/revise-1.4.0.md) — Analytical review of v1.4.0 (all in-scope findings addressed in 1.4.1).
 - [`docs/revise-1.4.0.md`](docs/revise-1.4.0.md) — Analytical review of v1.4.0 (all in-scope findings addressed in 1.4.1).
 - [`docs/revise-1.4.1.md`](docs/revise-1.4.1.md) — Analytical review of v1.4.1 (all in-scope findings addressed in 1.4.2).
 - [`docs/revise-1.4.1.md`](docs/revise-1.4.1.md) — Analytical review of v1.4.1 (all in-scope findings addressed in 1.4.2).
+- [`docs/revise-1.4.2.md`](docs/revise-1.4.2.md) — Fresh-eye analytical review of v1.4.2 (all in-scope findings addressed in 1.4.3).
 
 
 Project-internal guidance for AI-assisted development is documented in [`CLAUDE.md`](CLAUDE.md).
 Project-internal guidance for AI-assisted development is documented in [`CLAUDE.md`](CLAUDE.md).
 
 
@@ -211,9 +212,31 @@ Project-internal guidance for AI-assisted development is documented in [`CLAUDE.
 - **Custom order numbers** — the protocols operate on WC internal order IDs (see the note above the Export section). Sequential Order Numbers / custom-order-number plugins are not integrated.
 - **Custom order numbers** — the protocols operate on WC internal order IDs (see the note above the Export section). Sequential Order Numbers / custom-order-number plugins are not integrated.
 - **The `attribute_pa_format` taxonomy is hardcoded** in the export SQL. Stores using a different attribute name will get a blank `prod_var_type`.
 - **The `attribute_pa_format` taxonomy is hardcoded** in the export SQL. Stores using a different attribute name will get a blank `prod_var_type`.
 - **MySQL `group_concat_max_len`** is raised to 64 KB via `SET SESSION` per export — connection-scoped, harmless but worth knowing if you maintain custom queries on the same connection.
 - **MySQL `group_concat_max_len`** is raised to 64 KB via `SET SESSION` per export — connection-scoped, harmless but worth knowing if you maintain custom queries on the same connection.
+- **Excel CS-locale CSV separator** — the export uses `,` as the field separator. Czech Excel defaults to `;` as the list separator and may render the file as one column. Workaround: open with the Text Import wizard or set Windows regional list-separator to `,`. Other consumers (LibreOffice Calc, Numbers, programmatic CSV parsers) handle the comma natively.
+- **Trashed/draft products in historical orders** still appear in the export. Order line items reference product IDs; the export joins by ID without filtering on `post_status`. The intended behaviour: show what was sold, even if the product has since been removed from the catalogue.
 
 
 ## Changelog
 ## Changelog
 
 
+### 1.4.3 — 2026-05-12
+
+Bug-fix release addressing the findings catalogued in [`docs/revise-1.4.2.md`](docs/revise-1.4.2.md):
+
+- **Fixed (high):** `import_delivered_protocol` was missing the terminal-status guard that `import_inprint_protocol` had. A `cancelled`, `refunded`, or `failed` order in the delivered CSV was silently reactivated to `completed`. Now skipped with a per-row error notice. `completed` orders are still allowed through (re-running the same protocol is a benign no-op transition).
+- **Fixed (high):** Import Protocols screen required `manage_woocommerce` (Administrator-only) while bulk actions only required `edit_shop_orders` (Shop Manager). Shop Managers can now run protocol imports — the two paths share the same capability requirement.
+- **Fixed:** `Studiou_DB_Manager::table_exists()` wraps the table name in `$wpdb->esc_like()`. The previous `SHOW TABLES LIKE %s` left the conventional `_` in `$wpdb->prefix` as a SQL wildcard; the post-query equality comparison mitigated false positives but the safety relied on the wrong layer.
+- **Fixed:** CSV export now defuses Excel/LibreOffice/Sheets formula injection — any cell beginning with `=`, `+`, `-`, `@`, `\t`, or `\r` is prefixed with a single quote. OWASP-recommended pattern; the leading quote is hidden by spreadsheet apps but disables formula evaluation.
+- **Fixed:** `Order_Fields_Manager::datetime_local_to_mysql()` now validates date and time ranges via `checkdate()` plus hour/minute/second bounds. Direct POST or DevTools-edited form values like `2026-13-45T25:99` no longer slip through to be stored as bogus MySQL datetimes.
+- **Fixed:** `qty` is now formatted in PHP — `SUM(DECIMAL)` returns `"2.0000"` from MySQL; the post-processing step normalises whole numbers to `"2"` and trims trailing zeros from decimals.
+- **Fixed:** Both import handlers now pass an explanatory note to `update_status` ("Marked In Printing by InPrint Protocol import." / "Marked completed by Delivered Protocol import."). The order activity feed reflects how the transition was triggered.
+- **Fixed:** Both import handlers now log per-row to `UtilsLog::log` — symmetric with the bulk-action path. Helps debug failed protocol imports.
+- **Fixed:** `Import_Manager::emit_import_summary()` deduplicates repeated error strings. A CSV with 100 malformed rows shows "100 errors occurred: Invalid row format in CSV." once, not 100 times concatenated.
+- **Changed:** `UtilsLog::render_notices()` switched from `wp_kses_post()` to `esc_html()` — current notice messages are plain text, the tighter escape narrows the trust boundary at the API edge.
+- **Removed:** `Order_Status_Manager::set_order_processing_status()` and its `woocommerce_payment_complete` hook. On modern WC the handler was effectively dead code — `WC_Order::payment_complete()` sets the order to `processing` (or `completed` for downloadable-only) *before* firing the action, so our handler was either a no-op same-state update or a deliberate skip. The corresponding translation string was removed too.
+- **Doc:** README "Known limitations" gains two entries (Excel CS-locale CSV separator, trashed/draft products in historical orders). CLAUDE.md picks up the 1.4.3 changes.
+- **Doc:** [`docs/revise-1.4.2.md`](docs/revise-1.4.2.md) updated with per-item status annotations.
+
+> **Deferred** (carried over from earlier reviews): live HPOS search-filter verification, block-based order edit screen, `Network:` plugin header, `uninstall.php`, custom-order-number plugin integration.
+
 ### 1.4.2 — 2026-05-12
 ### 1.4.2 — 2026-05-12
 
 
 Bug-fix release addressing the findings catalogued in [`docs/revise-1.4.1.md`](docs/revise-1.4.1.md):
 Bug-fix release addressing the findings catalogued in [`docs/revise-1.4.1.md`](docs/revise-1.4.1.md):

+ 328 - 0
studiou-wc-ord-print-statuses/docs/revise-1.4.2.md

@@ -0,0 +1,328 @@
+# Plugin Revision — v1.4.2 (Fresh-Eye Analytical Review)
+
+> **Status as of 1.4.3 (2026-05-12):** every in-scope finding below has been resolved in plugin version **1.4.3**. Each finding now carries an inline status marker — ✅ resolved, ⏸ deliberately deferred. See the README changelog for the corresponding code changes.
+
+**Scope:** Independent read-only analytical pass over the v1.4.2 codebase. Re-read all eight PHP files from scratch and looked for issues regardless of whether they'd appeared in earlier reviews. This time I deliberately compared paired code paths (the two import handlers, the two cap-checks, the listener-vs-bulk stamps) and walked through user scenarios that the previous reviews didn't exercise.
+
+**Reviewer date:** 2026-05-12
+**Target environment:** WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled), PHP 7.2+.
+**Files reviewed:** all PHP under `includes/`, the bootstrap `studiou-wc-ord-print-statuses.php`, and the markdown docs.
+**Verification limitations:** Static review only. No live PHP/WP/WC available.
+
+Severity reflects user-visible or correctness impact, not implementation effort.
+
+---
+
+## 1. Critical
+
+*(None.)*
+
+---
+
+## 2. High
+
+### 2.1 `import_delivered_protocol` does not skip terminal-status orders — asymmetry with `import_inprint_protocol` — ✅ resolved in 1.4.3
+
+Resolution: added a `has_status(['cancelled', 'refunded', 'failed'])` guard with the same per-row error and `UtilsLog::log` call as the InPrint path. `completed` is intentionally excluded — re-running a delivered protocol on already-completed orders is a benign no-op.
+
+`class-db-manager.php:200-230` (InPrint) and `:238-258` (Delivered).
+
+The InPrint import explicitly skips orders in `cancelled`, `refunded`, `failed`, or `completed` states:
+
+```php
+if ($order->has_status(self::TERMINAL_STATUSES)) {
+    $errors[] = sprintf(__('Order %s is in a final state and was not updated.', …), $row['order_no']);
+    continue;
+}
+$order->update_status('in-print');
+```
+
+The Delivered import does not:
+
+```php
+$order = wc_get_order($row['order_no']);
+if (!$order) { /* ... */ continue; }
+// ← no terminal-status guard here
+$order->update_status('completed');
+$order->update_meta_data('external_ref_ord_delivered', current_time('mysql'));
+```
+
+**Concrete failure modes:**
+
+- A `cancelled` order that appears in the print shop's delivered CSV (operator error, stale data, ghost entry) is silently **reactivated to `completed`**.
+- A `refunded` order is reactivated to `completed`. The refund accounting in WC is now inconsistent with order state.
+- A `failed` order is reactivated to `completed`. Same problem.
+
+Whether the asymmetry is intentional cannot be inferred from the spec (`docs/instructions.txt` line 32-36 does not address terminal states). It looks accidental — the cancellation paths in `bulk_set_print_status` and `import_inprint_protocol` are clearly defensive, and `import_delivered_protocol` was implemented to a parallel template but the guard wasn't ported.
+
+**Suggested fix:** mirror the InPrint guard. Optionally skip `completed` from the list since `completed → completed` is a no-op transition anyway and operators may legitimately re-deliver the same protocol.
+
+### 2.2 Import handlers require `manage_woocommerce`, bulk actions require `edit_shop_orders` — shop managers cannot use the import screen — ✅ resolved in 1.4.3
+
+Resolution: both `add_submenu_page()` and the `current_user_can` check in `run_import()` now use `edit_shop_orders`. Shop Managers see and can use the Import Protocols screen, matching the bulk-action capability requirement.
+
+`class-import-manager.php:106` (`current_user_can('manage_woocommerce')`) vs `class-bulk-actions-manager.php:28` (`current_user_can('edit_shop_orders')`).
+
+WC's default capabilities:
+- The **Shop Manager** role grants `edit_shop_orders` (and most order-management caps) **but not** `manage_woocommerce`.
+- `manage_woocommerce` is the WC administrator cap, normally given only to the Administrator role.
+
+So a Studiou employee with the Shop Manager role can:
+- Run the three bulk actions (`current_user_can('edit_shop_orders')` ✓).
+- Edit the Print Information panel on the order edit screen (`current_user_can('edit_shop_order', $order_id) || 'edit_shop_orders'` ✓).
+
+But they cannot:
+- Open **WooCommerce → Import Protocols** (`manage_woocommerce` ✗) — `add_submenu_page` hides the page itself, and `wp_die` blocks the POST handler.
+
+For day-to-day print-shop ops this means every protocol import has to go through an administrator, which is almost certainly not intended. Either:
+
+- Lower the import requirement to `edit_shop_orders` to match the bulk-action requirement, **or**
+- Raise the bulk-action requirement to `manage_woocommerce` and document the policy intent.
+
+The first option is the operationally pragmatic one; the second would be the more locked-down choice.
+
+---
+
+## 3. Medium
+
+### 3.1 `table_exists($table)` doesn't escape MySQL LIKE wildcards in `$wpdb->prefix` — ✅ resolved in 1.4.3
+
+Resolution: `$wpdb->esc_like($table)` is applied before `$wpdb->prepare()`. The post-query equality check stays as a belt-and-braces safeguard.
+
+`class-db-manager.php:128-132`:
+
+```php
+private static function table_exists($table) {
+    global $wpdb;
+    $found = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
+    return $found === $table;
+}
+```
+
+`$wpdb->prepare(..., '%s')` quotes the string for SQL, but `SHOW TABLES LIKE` interprets the value as a LIKE pattern where `_` is a single-character wildcard and `%` is a multi-character wildcard. `$wpdb->prefix` is conventionally alphanumeric + underscore (e.g., `wp_`, `wp_test_`), so the underscores in the prefix become wildcards.
+
+For prefix `wp_test_`, the LIKE pattern `wp_test_wc_order_product_lookup` matches:
+
+- `wp_test_wc_order_product_lookup` ✓ (intended)
+- `wpXtestXwcXorderXproductXlookup` (each `_` matches any char)
+- `wp1test2wc3order4product5lookup`
+
+The strict `=== $table` comparison after the query mitigates false positives — if a wildcard match returns a different table name, the equality check rejects it. **So today this is safe**, but the safety relies on the post-query comparison rather than on the query itself returning at most the intended row. Two ways the post-comparison could miss:
+
+1. If two tables happen to match the pattern and one is `wp_test_wc_order_product_lookup` exactly, MySQL returns multiple rows; `$wpdb->get_var` returns the first one. Order is engine-dependent.
+2. If a future contributor moves `table_exists` to a context where multiple matches matter (e.g., a `tables_like` helper), the wildcard semantics will silently misbehave.
+
+**Suggested fix:**
+
+```php
+$found = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
+```
+
+`$wpdb->esc_like()` escapes `%` and `_` for LIKE pattern use.
+
+### 3.2 `set_order_processing_status` is effectively dead code on modern WC — ✅ resolved in 1.4.3
+
+Resolution: removed the handler and its `woocommerce_payment_complete` hook registration. The corresponding translation entry in `docs/translations.txt` was removed too. If a future custom payment gateway is found to fire the action without first setting the status, the handler can be reintroduced with an explanatory comment.
+
+`class-order-status-manager.php:112-129`. The handler is hooked to `woocommerce_payment_complete`, which is fired by `WC_Order::payment_complete()`. WC's own implementation of `payment_complete()`:
+
+1. Determines the destination status based on order content (downloadable-only → `completed`, otherwise → `processing`).
+2. Sets the order to that status **before** firing the action.
+
+When our handler then runs:
+
+- If WC set `processing`, our `update_status('processing')` is a no-op (same-status, WC short-circuits inside `set_status`).
+- If WC set `completed` (downloadable-only orders), our `has_status(['completed', 'to-print', 'in-print'])` guard hits → we return.
+
+In both cases the handler does nothing. It would only matter if a custom payment gateway fires `woocommerce_payment_complete` without first setting the order status — which is non-standard and arguably the gateway's bug.
+
+Two options:
+
+- **Remove it.** The hook serves no purpose on a default WC installation.
+- **Keep it and document.** Add a comment explaining what scenario it defends against (legacy gateway behaviour from pre-WC-4.0 days?). Otherwise a future reader will be confused.
+
+The order note string (`"Payment received — order automatically set to processing by …"`) appears in `docs/translations.txt` and is shipped translated — if the code is removed, the translation entry should follow it.
+
+### 3.3 CSV injection — output values may trigger Excel formula execution — ✅ resolved in 1.4.3
+
+Resolution: `Studiou_DB_Manager::csv_escape()` is applied to every cell value in the PHP post-processing loop. Cells beginning with `=`, `+`, `-`, `@`, `\t`, or `\r` get a leading single quote — hidden by Excel/LibreOffice display, prevents formula evaluation.
+
+`class-bulk-actions-manager.php:113-115` writes CSV cells directly from the export-query result. CSV cells beginning with `=`, `+`, `-`, `@`, or `\t` are interpreted by Excel and LibreOffice Calc as formulas. If a customer's `billing_email`, product name, or category name happens to begin with one of these characters — accidentally or maliciously — the print shop opens the CSV in Excel and a formula executes.
+
+Realistic vectors:
+
+- A customer registers `=cmd|'/c calc'!A1` as their email. The export includes it as-is. Excel parses it as a formula.
+- A product category gets renamed to `=HYPERLINK("https://evil/", "click")` by a compromised admin or contractor. The export embeds it; opening the CSV silently navigates the cell-link target.
+
+The mitigation in WP-aware code is to prefix any cell value starting with a dangerous character with a leading single-quote (`'`) or with a leading `\t`-equivalent zero-width-width control. The OWASP-recommended pattern is the leading single-quote:
+
+```php
+private function csv_escape($value) {
+    $value = (string) $value;
+    if ($value !== '' && in_array($value[0], array('=', '+', '-', '@', "\t", "\r"), true)) {
+        return "'" . $value;
+    }
+    return $value;
+}
+```
+
+Apply this to every cell before passing it to `fputcsv`. Excel still shows the value (without the leading quote) but does not evaluate it.
+
+The risk is **low likelihood, real impact** — print shop spreadsheet users are a concrete attack surface for this class of vulnerability.
+
+### 3.4 `datetime_local_to_mysql` accepts structurally-valid but semantically-out-of-range values — ✅ resolved in 1.4.3
+
+Resolution: `checkdate()` validates the date components; explicit numeric range checks reject hour > 23, minute > 59, second > 59. Bogus values return empty string instead of being stored.
+
+`class-order-fields-manager.php:108-118`:
+
+```php
+if (preg_match('/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::(\d{2}))?$/', $value, $m)) {
+    $seconds = isset($m[3]) ? $m[3] : '00';
+    return $m[1] . ' ' . $m[2] . ':' . $seconds;
+}
+```
+
+The regex matches `2026-13-45T25:99` as well as `2026-05-12T14:30`. The function then stores `2026-13-45 25:99:00` to postmeta. MySQL's behaviour for an out-of-range datetime depends on `sql_mode`:
+
+- Strict mode: rejects the value, raises an error.
+- Non-strict mode: silently coerces to `0000-00-00 00:00:00`.
+
+Browsers' native `<input type="datetime-local">` validate the value before submit, so this is unlikely to come from a normal browser flow. But:
+
+- A power user editing the form via DevTools, or
+- A script POSTing directly to the order edit URL,
+
+…can produce the bogus value. The downstream display (`mysql_to_datetime_local`) would reject the bogus stored value (the back-conversion regex would still match structurally but the data is meaningless) and show an empty field.
+
+**Suggested fix:** validate via `checkdate()` and seconds/minutes/hours range before composing the MySQL string. Defense-in-depth.
+
+---
+
+## 4. Low
+
+### 4.1 CSV uses `,` separator — Excel CS-locale users see single-column data — ⏸ documented, no code change
+
+Resolution path: a `sep=,\n` directive at the top of the file (the Microsoft proprietary extension Excel respects) would break the export-then-reimport workflow because CSV parsers would see `sep=,` as the first header row. The README "Known limitations" section now documents the workaround (use Excel's Text Import wizard or change Windows regional list-separator).
+
+The plugin exports with comma-separated values. The Czech edition of Excel defaults to `;` as the list separator (because comma is the decimal separator), so a Czech operator opening the file in Excel without changing the locale or pre-converting sees every row as one cell.
+
+LibreOffice Calc prompts for the separator on import; Excel-CS does not. Workarounds the operator can apply: rename the file extension to `.txt` and use Excel's text-import wizard, or change Windows regional settings. Neither is ideal.
+
+Options:
+
+- Add a `\xEF\xBB\xBFsep=,\n` directive at the very top of the CSV (Microsoft proprietary extension; understood by Excel).
+- Make the separator a settable plugin option.
+- Document the locale workaround.
+
+### 4.2 `SUM(order_products.product_qty)` returns DECIMAL — CSV shows `2.0000` for integer quantities — ✅ resolved in 1.4.3
+
+Resolution: `Studiou_DB_Manager::format_quantity()` normalises whole numbers to integer string (`"2"`) and trims trailing zeros from decimals (`"2.5"` for `"2.5000"`).
+
+`wc_order_product_lookup.product_qty` is `DECIMAL(8,4)` in the WC schema. `SUM()` of `DECIMAL` returns `DECIMAL`. `$wpdb->get_results()` returns strings. The CSV row therefore contains `2.0000` for an order of two items, not `2`.
+
+The print shop's downstream tooling may or may not care. Cosmetic but minor friction.
+
+**Suggested fix:** in the PHP post-processing step that already resolves `prod_img_url`, also normalise `$row->qty` — if the value is a whole number, drop trailing zeros and the decimal point.
+
+### 4.3 `import_delivered_protocol::update_status('completed')` doesn't pass a note — ✅ resolved in 1.4.3
+
+Resolution: both import handlers now pass an explanatory note as the second argument to `update_status` — "Marked In Printing by InPrint Protocol import." and "Marked completed by Delivered Protocol import." The order activity feed reflects how the transition was triggered.
+
+`class-db-manager.php:252` — no second argument. WC writes a default-styled order note ("Order status changed from … to completed."). For traceability, passing a note like `"Marked completed by Delivered Protocol import."` would distinguish protocol-driven completions from manual ones. The InPrint and bulk paths similarly don't pass notes; an opportunity to add operational context across the board.
+
+### 4.4 Import handlers don't log per-row to `UtilsLog::log` — ✅ resolved in 1.4.3
+
+Resolution: both import handlers now write `UtilsLog::log` entries for not-found orders, terminal-status skips, and per-row success — symmetric with `bulk_set_print_status`.
+
+`bulk_set_print_status` logs per-order success (`Order #N marked as "to-print"`) and per-order skip. The two import handlers log only at the bulk-action-level (via `UtilsLog::message`); they don't write to `error_log` per row. When debugging a failed import, the operator/developer cannot see which orders were touched and which weren't.
+
+Asymmetric with the bulk path; consistency would help.
+
+### 4.5 `Order_Status_Manager::set_order_processing_status` has no audit trail of why it was a no-op — ✅ resolved by removal in 1.4.3 (see §3.2)
+
+When `set_order_processing_status` skips because of the protected-status guard (`completed`, `to-print`, `in-print`), it returns silently. Adding `UtilsLog::log` would help debug "why didn't payment_complete advance my order to processing?" support questions.
+
+Related to §3.2 — if the handler is kept as-is, instrument it; if removed, this is moot.
+
+### 4.6 `array_filter` without callback removes order ID 0 — ✅ resolved in 1.4.3
+
+Resolution: `array_filter` now uses an explicit `function ($id) { return $id > 0; }` callback. Intent is unambiguous in the source.
+
+`class-db-manager.php:42` — `array_filter(array_map('intval', (array) $order_ids))`. `array_filter` with no callback drops all falsy values, including integer `0`. WC orders cannot have ID 0 in practice, so this is harmless, but it's slightly misleading — a future contributor adapting this for `WP_Term` IDs or anything where 0 is a legitimate value would inherit the bug.
+
+Cosmetic.
+
+### 4.7 Trashed products still appear in the export — ✅ documented in README "Known limitations"
+
+Resolution path: this is intentional — historical orders should reflect what was sold. The README "Known limitations" section now makes that explicit. No code change.
+
+The `JOIN posts products ON products.ID = order_products.product_id` doesn't filter on `post_status`. A product whose post has been moved to trash (or set to draft) still produces a CSV row for any historical order line item. The print shop sees the product name as it was at the time of the trash, not what the customer originally saw.
+
+Probably intended — historical orders should reflect what was sold, not the current product state — but worth documenting.
+
+### 4.8 `import_inprint_protocol` and `import_delivered_protocol` emit one `Invalid row format in CSV.` error per malformed row — ✅ resolved in 1.4.3
+
+Resolution: `Import_Manager::emit_import_summary()` runs `array_unique()` on the error list before formatting the notice. The numeric total still reflects the full error count.
+
+`class-db-manager.php:206, 244`. If a CSV has 100 malformed rows, the error list has 100 identical strings. The Import Manager's `emit_import_summary` joins them with `, ` and shows the count — but the joined string can become very long. A simple `array_unique` on the errors list or a per-error-kind counter would tighten the UX.
+
+### 4.9 `wp_kses_post()` allows `<a href>` — admin notice content could include arbitrary links — ✅ resolved in 1.4.3
+
+Resolution: `UtilsLog::render_notices()` now uses `esc_html()` for the notice body. All current callers pass plain text; future callers wanting markup will need to pre-format with their own escape strategy.
+
+`utils-log.php:66`. None of the current call sites pass HTML containing user-controlled links, but the `message()` API doesn't restrict what callers can pass. If a future caller passes a partially-escaped string with a user-controlled URL, `wp_kses_post` would allow `<a href="...">` through. The current set of callers builds notices from sprintf-of-translated-strings plus `esc_html`'d data, so no live risk — but the trust boundary is the API not the call sites.
+
+A narrower allowlist via `wp_kses($html, array())` for plain-text-only or `wp_kses($html, array('code' => array(), 'strong' => array(), 'em' => array()))` would be defense-in-depth.
+
+### 4.10 `register_post_status` keeps being called even though HPOS doesn't read it — ⏸ left as-is
+
+Carried over from `revise-1.4.1.md` §4.8 — harmless under HPOS, kept for admin-chrome compatibility. No change.
+
+---
+
+## 5. Verification gaps (carried over from earlier reviews)
+
+1. **HPOS search filter name** `woocommerce_order_table_search_query_meta_keys` against WC 10.7.0 source — still pending live verification.
+2. **`woocommerce_process_shop_order_meta`** hook behaviour under WC 10.7.0 block-based order edit screen — pending live verification.
+3. **Object-cache scenarios for `UtilsLog::message`** — pending live verification under Redis/Memcached configurations.
+
+---
+
+## 6. Status of prior reviews
+
+| Review | Status of in-scope findings | Status of deferred findings |
+|--------|------------------------------|------------------------------|
+| `revise-1.3.2.md` | All resolved in 1.4.0 | 3 still deferred. |
+| `revise-1.4.0.md` | All resolved in 1.4.1 | 1 verification-pending (HPOS filter). |
+| `revise-1.4.1.md` | All resolved in 1.4.2 | 2 documented-without-code-change. |
+
+No regressions detected. The new findings in this review (§2.1, §2.2) are pre-existing defects that survived three prior reviews because the paired code paths were never compared side-by-side.
+
+---
+
+## 7. Risk-prioritised summary
+
+Ordered by user-visible severity. Status as of **1.4.3**:
+
+1. **§2.1 Delivered import reactivates cancelled/refunded orders to `completed`.** ✅ resolved.
+2. **§2.2 Shop managers cannot run protocol imports.** ✅ resolved.
+3. **§3.3 CSV injection in exports.** ✅ resolved.
+4. **§3.1 `SHOW TABLES LIKE` wildcard escape.** ✅ resolved.
+5. **§3.2 `set_order_processing_status` is dead code on modern WC.** ✅ resolved (removed).
+6. **§3.4 `datetime_local_to_mysql` accepts out-of-range structurally-valid values.** ✅ resolved.
+7. **§4.1 CSV `,` separator vs Czech Excel locale.** ⏸ documented in README "Known limitations".
+8. **§4.2 `SUM(qty)` produces `2.0000` not `2`.** ✅ resolved.
+
+---
+
+## 8. Out of scope
+
+- Live behaviour testing against a running WP 6.9.4 / WC 10.7.0 install.
+- Performance profiling of the export query under realistic order volumes.
+- Refactoring the manager pattern into typed DI / interfaces (PHP 7.4+).
+- Multisite, uninstall cleanup, block-based order edit screen (carried-over deferrals).
+
+---
+
+*End of review.*

+ 5 - 4
studiou-wc-ord-print-statuses/docs/translations.txt

@@ -1,5 +1,5 @@
 Translations: all user-facing strings (English → Czech).
 Translations: all user-facing strings (English → Czech).
-Last reviewed: v1.4.2 (2026-05-12).
+Last reviewed: v1.4.3 (2026-05-12).
 
 
 Note: After editing the .po file, regenerate the .mo binary with one of:
 Note: After editing the .po file, regenerate the .mo binary with one of:
   wp i18n make-mo languages/
   wp i18n make-mo languages/
@@ -61,9 +61,6 @@ CSV file could not be parsed or contained no data rows.
 You do not have sufficient permissions to access this page.                                 Nemáte dostatečná oprávnění k zobrazení této stránky.
 You do not have sufficient permissions to access this page.                                 Nemáte dostatečná oprávnění k zobrazení této stránky.
 You do not have sufficient permissions to perform this action.                              Nemáte dostatečná oprávnění k provedení této akce.
 You do not have sufficient permissions to perform this action.                              Nemáte dostatečná oprávnění k provedení této akce.
 
 
-# Order note (when payment auto-advances order to "processing")
-Payment received — order automatically set to processing by Studiou WC Order Print Statuses plugin.  Platba přijata — objednávka byla automaticky převedena do stavu "Zpracovává se" pluginem Studiou WC Order Print Statuses.
-
 # Activation guard
 # Activation guard
 Studiou WC Order Print Statuses requires WooCommerce to be installed and active.            StudioU WC Order Print Statuses vyžaduje mít nainstalovaný a aktivní plugin WooCommerce.
 Studiou WC Order Print Statuses requires WooCommerce to be installed and active.            StudioU WC Order Print Statuses vyžaduje mít nainstalovaný a aktivní plugin WooCommerce.
 
 
@@ -73,3 +70,7 @@ The CSV export could not be streamed because output had already started. Check f
 # New in 1.4.2
 # New in 1.4.2
 Export requires the WooCommerce analytics lookup table (wc_order_product_lookup). Enable WooCommerce Analytics or contact support.  Export vyžaduje tabulku WooCommerce analytics (wc_order_product_lookup). Aktivujte WooCommerce Analytics nebo kontaktujte podporu.
 Export requires the WooCommerce analytics lookup table (wc_order_product_lookup). Enable WooCommerce Analytics or contact support.  Export vyžaduje tabulku WooCommerce analytics (wc_order_product_lookup). Aktivujte WooCommerce Analytics nebo kontaktujte podporu.
 Only .csv files are accepted.                                                                Přijímány jsou pouze soubory .csv.
 Only .csv files are accepted.                                                                Přijímány jsou pouze soubory .csv.
+
+# New in 1.4.3 — order notes passed to update_status from import paths
+Marked In Printing by InPrint Protocol import.                                              Označeno jako "V Tisku" importem protokolu V Tisku.
+Marked completed by Delivered Protocol import.                                              Označeno jako "Dokončeno" importem protokolu Tisk Dodán.

+ 87 - 6
studiou-wc-ord-print-statuses/includes/class-db-manager.php

@@ -39,7 +39,12 @@ class Studiou_DB_Manager {
     public static function get_orders_for_printing($order_ids) {
     public static function get_orders_for_printing($order_ids) {
         global $wpdb;
         global $wpdb;
 
 
-        $order_ids = array_filter(array_map('intval', (array) $order_ids));
+        // Explicit callback so the filter doesn't accidentally drop legitimate
+        // zero values if this helper is ever repurposed for non-ID lists.
+        $order_ids = array_filter(
+            array_map('intval', (array) $order_ids),
+            function ($id) { return $id > 0; }
+        );
         if (empty($order_ids)) {
         if (empty($order_ids)) {
             return array();
             return array();
         }
         }
@@ -116,18 +121,70 @@ class Studiou_DB_Manager {
 
 
         // Resolve the attachment ID to a URL via WP's API — respects
         // Resolve the attachment ID to a URL via WP's API — respects
         // offloaded-media plugins, multisite upload paths, and the
         // offloaded-media plugins, multisite upload paths, and the
-        // wp_get_attachment_url filter chain.
+        // wp_get_attachment_url filter chain. Normalise integer-valued
+        // qty (SUM returns DECIMAL → "2.0000") and CSV-escape every value
+        // to neutralise Excel/LibreOffice formula injection.
         foreach ($results as $row) {
         foreach ($results as $row) {
-            $attachment_id   = isset($row->prod_img_id) ? (int) $row->prod_img_id : 0;
+            $attachment_id     = isset($row->prod_img_id) ? (int) $row->prod_img_id : 0;
             $row->prod_img_url = $attachment_id ? (string) wp_get_attachment_url($attachment_id) : '';
             $row->prod_img_url = $attachment_id ? (string) wp_get_attachment_url($attachment_id) : '';
             unset($row->prod_img_id);
             unset($row->prod_img_id);
+
+            if (isset($row->qty)) {
+                $row->qty = self::format_quantity($row->qty);
+            }
+
+            foreach (get_object_vars($row) as $field => $value) {
+                $row->$field = self::csv_escape($value);
+            }
         }
         }
         return $results;
         return $results;
     }
     }
 
 
+    /**
+     * Drop trailing zeros and the decimal point when the quantity is whole,
+     * keeping a sensible decimal representation when it isn't.
+     */
+    private static function format_quantity($value) {
+        if (!is_numeric($value)) {
+            return (string) $value;
+        }
+        $float = (float) $value;
+        if ((float) (int) $float === $float) {
+            return (string) (int) $float;
+        }
+        return rtrim(rtrim(sprintf('%.4F', $float), '0'), '.');
+    }
+
+    /**
+     * Defuse Excel / LibreOffice / Google Sheets formula execution by
+     * prefixing values that begin with =, +, -, @, tab, or CR with a single
+     * quote. OWASP-recommended pattern. The leading quote is hidden by
+     * Excel when displayed but disables formula evaluation.
+     */
+    private static function csv_escape($value) {
+        if (!is_string($value)) {
+            return $value;
+        }
+        if ($value === '') {
+            return $value;
+        }
+        $first = $value[0];
+        if (in_array($first, array('=', '+', '-', '@', "\t", "\r"), true)) {
+            return "'" . $value;
+        }
+        return $value;
+    }
+
+    /**
+     * Check that a table exists. `$wpdb->esc_like()` escapes the LIKE
+     * wildcards (`_`, `%`) that show up in conventional `$wpdb->prefix`
+     * values like `wp_test_` — otherwise the underscores would match any
+     * single character and could produce false positives.
+     */
     private static function table_exists($table) {
     private static function table_exists($table) {
         global $wpdb;
         global $wpdb;
-        $found = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
+        $like  = $wpdb->esc_like($table);
+        $found = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $like));
         return $found === $table;
         return $found === $table;
     }
     }
 
 
@@ -208,22 +265,28 @@ class Studiou_DB_Manager {
             }
             }
             $order = wc_get_order($row['order_no']);
             $order = wc_get_order($row['order_no']);
             if (!$order) {
             if (!$order) {
+                UtilsLog::log('import_inprint_protocol: order ' . $row['order_no'] . ' not found');
                 $errors[] = sprintf(__('Order %s not found.', 'studiou-wc-ord-print-statuses'), $row['order_no']);
                 $errors[] = sprintf(__('Order %s not found.', 'studiou-wc-ord-print-statuses'), $row['order_no']);
                 continue;
                 continue;
             }
             }
             if ($order->has_status(self::TERMINAL_STATUSES)) {
             if ($order->has_status(self::TERMINAL_STATUSES)) {
+                UtilsLog::log('import_inprint_protocol: order ' . $row['order_no'] . ' in terminal status ' . $order->get_status() . ' — skipped');
                 $errors[] = sprintf(
                 $errors[] = sprintf(
                     __('Order %s is in a final state and was not updated.', 'studiou-wc-ord-print-statuses'),
                     __('Order %s is in a final state and was not updated.', 'studiou-wc-ord-print-statuses'),
                     $row['order_no']
                     $row['order_no']
                 );
                 );
                 continue;
                 continue;
             }
             }
-            $order->update_status('in-print');
+            $order->update_status(
+                'in-print',
+                __('Marked In Printing by InPrint Protocol import.', 'studiou-wc-ord-print-statuses')
+            );
             $order->update_meta_data('in_print_date', current_time('mysql'));
             $order->update_meta_data('in_print_date', current_time('mysql'));
             $order->update_meta_data('external_ref_ord_no', sanitize_text_field($row['externalorder']));
             $order->update_meta_data('external_ref_ord_no', sanitize_text_field($row['externalorder']));
             $order->update_meta_data('external_ref_ord_date', self::normalise_csv_date($row['externalorderdate']));
             $order->update_meta_data('external_ref_ord_date', self::normalise_csv_date($row['externalorderdate']));
             $order->save();
             $order->save();
             $updated_count++;
             $updated_count++;
+            UtilsLog::log('import_inprint_protocol: order ' . $row['order_no'] . ' marked "in-print"');
         }
         }
 
 
         return array('updated_count' => $updated_count, 'errors' => $errors);
         return array('updated_count' => $updated_count, 'errors' => $errors);
@@ -239,6 +302,11 @@ class Studiou_DB_Manager {
         $updated_count = 0;
         $updated_count = 0;
         $errors        = array();
         $errors        = array();
 
 
+        // Statuses we should not silently flip to "completed". `completed`
+        // itself is excluded from this list — re-running the same delivered
+        // protocol on already-completed orders is a benign no-op transition.
+        $non_completable = array('cancelled', 'refunded', 'failed');
+
         foreach (self::dedupe_by_order_no($csv_data) as $row) {
         foreach (self::dedupe_by_order_no($csv_data) as $row) {
             if (!isset($row['order_no'])) {
             if (!isset($row['order_no'])) {
                 $errors[] = __('Invalid row format in CSV.', 'studiou-wc-ord-print-statuses');
                 $errors[] = __('Invalid row format in CSV.', 'studiou-wc-ord-print-statuses');
@@ -246,13 +314,26 @@ class Studiou_DB_Manager {
             }
             }
             $order = wc_get_order($row['order_no']);
             $order = wc_get_order($row['order_no']);
             if (!$order) {
             if (!$order) {
+                UtilsLog::log('import_delivered_protocol: order ' . $row['order_no'] . ' not found');
                 $errors[] = sprintf(__('Order %s not found.', 'studiou-wc-ord-print-statuses'), $row['order_no']);
                 $errors[] = sprintf(__('Order %s not found.', 'studiou-wc-ord-print-statuses'), $row['order_no']);
                 continue;
                 continue;
             }
             }
-            $order->update_status('completed');
+            if ($order->has_status($non_completable)) {
+                UtilsLog::log('import_delivered_protocol: order ' . $row['order_no'] . ' in terminal status ' . $order->get_status() . ' — skipped');
+                $errors[] = sprintf(
+                    __('Order %s is in a final state and was not updated.', 'studiou-wc-ord-print-statuses'),
+                    $row['order_no']
+                );
+                continue;
+            }
+            $order->update_status(
+                'completed',
+                __('Marked completed by Delivered Protocol import.', 'studiou-wc-ord-print-statuses')
+            );
             $order->update_meta_data('external_ref_ord_delivered', current_time('mysql'));
             $order->update_meta_data('external_ref_ord_delivered', current_time('mysql'));
             $order->save();
             $order->save();
             $updated_count++;
             $updated_count++;
+            UtilsLog::log('import_delivered_protocol: order ' . $row['order_no'] . ' marked "completed"');
         }
         }
 
 
         return array('updated_count' => $updated_count, 'errors' => $errors);
         return array('updated_count' => $updated_count, 'errors' => $errors);

+ 13 - 4
studiou-wc-ord-print-statuses/includes/class-import-manager.php

@@ -12,11 +12,15 @@ class Import_Manager {
 
 
     public function add_import_submenu() {
     public function add_import_submenu() {
         $title = __('Import Protocols', 'studiou-wc-ord-print-statuses');
         $title = __('Import Protocols', 'studiou-wc-ord-print-statuses');
+        // Use edit_shop_orders so Shop Managers (the typical operators of
+        // the print pipeline) can run imports. The bulk actions on the
+        // orders list use the same capability — keeping the two paths in
+        // sync is the goal.
         add_submenu_page(
         add_submenu_page(
             'woocommerce',
             'woocommerce',
             $title,
             $title,
             $title,
             $title,
-            'manage_woocommerce',
+            'edit_shop_orders',
             'studiou-import-protocols',
             'studiou-import-protocols',
             array($this, 'render_import_page')
             array($this, 'render_import_page')
         );
         );
@@ -103,7 +107,7 @@ class Import_Manager {
         }
         }
         $cfg = $dispatch[$action];
         $cfg = $dispatch[$action];
 
 
-        if (!current_user_can('manage_woocommerce')) {
+        if (!current_user_can('edit_shop_orders')) {
             wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'studiou-wc-ord-print-statuses'));
             wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'studiou-wc-ord-print-statuses'));
         }
         }
         check_admin_referer($action, $cfg['nonce']);
         check_admin_referer($action, $cfg['nonce']);
@@ -182,12 +186,17 @@ class Import_Manager {
         );
         );
 
 
         if (!empty($errors)) {
         if (!empty($errors)) {
+            // Dedupe repeated error strings (e.g., "Invalid row format in
+            // CSV." 100×) so the notice stays readable. The count is the
+            // total number of errors, not the number of unique messages.
+            $total  = count($errors);
+            $unique = array_values(array_unique($errors));
             UtilsLog::message(
             UtilsLog::message(
                 sprintf(
                 sprintf(
                     /* translators: 1: error count, 2: comma-separated error messages. */
                     /* translators: 1: error count, 2: comma-separated error messages. */
                     __('%1$d errors occurred: %2$s', 'studiou-wc-ord-print-statuses'),
                     __('%1$d errors occurred: %2$s', 'studiou-wc-ord-print-statuses'),
-                    count($errors),
-                    esc_html(implode(', ', $errors))
+                    $total,
+                    esc_html(implode(', ', $unique))
                 ),
                 ),
                 'error'
                 'error'
             );
             );

+ 18 - 4
studiou-wc-ord-print-statuses/includes/class-order-fields-manager.php

@@ -104,16 +104,30 @@ class Order_Fields_Manager {
      * Convert an HTML5 datetime-local form value to a MySQL datetime string.
      * Convert an HTML5 datetime-local form value to a MySQL datetime string.
      * No timezone conversion — the form value and the stored value are both
      * No timezone conversion — the form value and the stored value are both
      * treated as WP local time (matching current_time('mysql')).
      * treated as WP local time (matching current_time('mysql')).
+     *
+     * Browsers prevent out-of-range values on `<input type="datetime-local">`
+     * but a power user editing via DevTools or a script POSTing directly to
+     * the order edit URL could submit `2026-13-45T25:99`. The regex matches
+     * structurally; checkdate() + minute/hour bounds catch the semantics.
      */
      */
     private function datetime_local_to_mysql($value) {
     private function datetime_local_to_mysql($value) {
         $value = sanitize_text_field(wp_unslash($value));
         $value = sanitize_text_field(wp_unslash($value));
         if ($value === '') {
         if ($value === '') {
             return '';
             return '';
         }
         }
-        if (preg_match('/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::(\d{2}))?$/', $value, $m)) {
-            $seconds = isset($m[3]) ? $m[3] : '00';
-            return $m[1] . ' ' . $m[2] . ':' . $seconds;
+        if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/', $value, $m)) {
+            return '';
         }
         }
-        return '';
+        list(, $year, $month, $day, $hour, $minute) = $m;
+        $second = isset($m[6]) ? $m[6] : '00';
+
+        if (!checkdate((int) $month, (int) $day, (int) $year)) {
+            return '';
+        }
+        if ((int) $hour > 23 || (int) $minute > 59 || (int) $second > 59) {
+            return '';
+        }
+
+        return $year . '-' . $month . '-' . $day . ' ' . $hour . ':' . $minute . ':' . $second;
     }
     }
 }
 }

+ 7 - 26
studiou-wc-ord-print-statuses/includes/class-order-status-manager.php

@@ -11,7 +11,13 @@ class Order_Status_Manager {
         add_action('init', array($this, 'register_custom_order_statuses'));
         add_action('init', array($this, 'register_custom_order_statuses'));
         add_filter('wc_order_statuses', array($this, 'add_custom_order_statuses_to_list'));
         add_filter('wc_order_statuses', array($this, 'add_custom_order_statuses_to_list'));
         add_action('woocommerce_order_status_changed', array($this, 'handle_status_transitions'), 10, 3);
         add_action('woocommerce_order_status_changed', array($this, 'handle_status_transitions'), 10, 3);
-        add_action('woocommerce_payment_complete', array($this, 'set_order_processing_status'));
+
+        // Earlier versions of this plugin hooked `woocommerce_payment_complete`
+        // to force orders to `processing`. That handler was effectively dead
+        // code on modern WC — WC_Order::payment_complete() already sets the
+        // status to `processing` (or `completed` for downloadable-only orders)
+        // *before* firing the action, so our handler was either a no-op
+        // (same-state update) or a deliberate skip. Removed in 1.4.3.
     }
     }
 
 
     private function get_custom_status_definitions() {
     private function get_custom_status_definitions() {
@@ -103,31 +109,6 @@ class Order_Status_Manager {
         }
         }
     }
     }
 
 
-    /**
-     * Move paid orders to "processing" on payment completion — except when the
-     * order is already past that point (completed) or has been advanced into the
-     * print pipeline (to-print, in-print). Otherwise late payments would silently
-     * roll a print-state order back to processing.
-     */
-    public function set_order_processing_status($order_id) {
-        $order = wc_get_order($order_id);
-        if (!$order) {
-            return;
-        }
-        $protected = array_merge(array('completed'), $this->print_status_slugs);
-        if ($order->has_status($protected)) {
-            return;
-        }
-        // Pass the explanatory note to update_status — that's the canonical
-        // place for a status-change note. Adding a second note via
-        // add_order_note() would double-log the same event in the order
-        // activity feed.
-        $order->update_status(
-            'processing',
-            __('Payment received — order automatically set to processing by Studiou WC Order Print Statuses plugin.', 'studiou-wc-ord-print-statuses')
-        );
-    }
-
     public function get_custom_statuses() {
     public function get_custom_statuses() {
         return $this->get_custom_status_definitions();
         return $this->get_custom_status_definitions();
     }
     }

+ 10 - 2
studiou-wc-ord-print-statuses/includes/utils-log.php

@@ -25,7 +25,11 @@ class UtilsLog {
      * Queue an admin notice for the current user.
      * Queue an admin notice for the current user.
      * Notices survive the redirect that follows bulk actions and imports.
      * Notices survive the redirect that follows bulk actions and imports.
      *
      *
-     * @param string $message Plain text or pre-escaped HTML (will be wp_kses_post-rendered).
+     * @param string $message Plain text. Tags are HTML-escaped at render
+     *                        time (esc_html). Callers should pre-format
+     *                        any user-controlled content with esc_html
+     *                        themselves if they need to keep markup such
+     *                        as <code>.
      * @param string $type    One of: info, success, warning, error.
      * @param string $type    One of: info, success, warning, error.
      */
      */
     public static function message($message, $type = 'info') {
     public static function message($message, $type = 'info') {
@@ -60,10 +64,14 @@ class UtilsLog {
         foreach ($notices as $notice) {
         foreach ($notices as $notice) {
             $type    = isset($notice['type']) ? $notice['type'] : 'info';
             $type    = isset($notice['type']) ? $notice['type'] : 'info';
             $message = isset($notice['message']) ? $notice['message'] : '';
             $message = isset($notice['message']) ? $notice['message'] : '';
+            // Tight escape — notice messages are plain text in every
+            // current call site. Switching to esc_html (was wp_kses_post)
+            // narrows the trust boundary at the API edge: callers can no
+            // longer accidentally smuggle HTML through the notice queue.
             printf(
             printf(
                 '<div class="notice notice-%1$s is-dismissible"><p>%2$s</p></div>',
                 '<div class="notice notice-%1$s is-dismissible"><p>%2$s</p></div>',
                 esc_attr($type),
                 esc_attr($type),
-                wp_kses_post($message)
+                esc_html($message)
             );
             );
         }
         }
     }
     }

+ 2 - 2
studiou-wc-ord-print-statuses/studiou-wc-ord-print-statuses.php

@@ -3,7 +3,7 @@
  * Plugin Name: QDR - Studiou WC Order Print Statuses
  * Plugin Name: QDR - Studiou WC Order Print Statuses
  * Plugin URI: https://www.quadarax.com/plugins/studiou-wc-ord-print-statuses
  * Plugin URI: https://www.quadarax.com/plugins/studiou-wc-ord-print-statuses
  * Description: Adds custom order statuses (wc-to-print, wc-in-print), bulk actions, and status change timestamps to WooCommerce for print orders to third party providers
  * Description: Adds custom order statuses (wc-to-print, wc-in-print), bulk actions, and status change timestamps to WooCommerce for print orders to third party providers
- * Version: 1.4.2
+ * Version: 1.4.3
  * Requires at least: 5.8
  * Requires at least: 5.8
  * Tested up to: 6.9.4
  * Tested up to: 6.9.4
  * Requires PHP: 7.2
  * Requires PHP: 7.2
@@ -19,7 +19,7 @@
 
 
 defined('ABSPATH') || exit;
 defined('ABSPATH') || exit;
 
 
-define('STUDIOU_WC_OPS_VERSION', '1.4.2');
+define('STUDIOU_WC_OPS_VERSION', '1.4.3');
 define('STUDIOU_WC_OPS_FILE', __FILE__);
 define('STUDIOU_WC_OPS_FILE', __FILE__);
 
 
 // Declare HPOS (High-Performance Order Storage) compatibility
 // Declare HPOS (High-Performance Order Storage) compatibility