0], $atts, 'blinds_roller_configurator'); $config = blinds_proto_load_config(); $html = '
'; $html .= wp_nonce_field('blinds_proto_add', 'blinds_proto_nonce', true, false); $html .= ''; // Product binding token: wp_hash() uses the server salt, so a visitor // cannot forge a token for a different product ID. The POST handler // refuses any product ID whose token does not match — this is what // stops a guest repricing an arbitrary store product as a blind. // (Stage 10 security fix D1.) $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $fabrics = array_keys($config['roll_widths_by_fabric'] ?? []); if (!empty($fabrics)) { $html .= ''; } $html .= ''; $html .= ''; $html .= ''; foreach ($config['options'] ?? [] as $opt) { $html .= ''; } $html .= ''; $html .= ''; $html .= '

Data label: ' . esc_html((string) ($config['data_label'] ?? 'SYNTHETIC')) . '. Prices are calculated server-side from the active supplier configuration; a browser-submitted price is never used.

'; return $html; }); /** Handle the configurator POST: validate, calculate server-side, add to cart with measurements only. */ add_action('template_redirect', function () { if (empty($_POST['blinds_proto_submit']) || !function_exists('WC')) { return; } if (!isset($_POST['blinds_proto_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['blinds_proto_nonce'])), 'blinds_proto_add')) { return; } $productId = absint($_POST['blinds_proto_product_id'] ?? 0); if ($productId <= 0) { return; } // D1: refuse any product ID that was not rendered by this shortcode. // The token is wp_hash(product_id) with the server salt; a guest can // copy a token from a rendered form but cannot mint one for another // product, so arbitrary store products cannot be repriced as blinds. $token = isset($_POST['blinds_proto_product_token']) ? sanitize_text_field(wp_unslash($_POST['blinds_proto_product_token'])) : ''; if (!hash_equals(wp_hash((string) $productId), $token)) { return; } $postedOptions = isset($_POST['blinds_options']) && is_array($_POST['blinds_options']) ? wp_unslash($_POST['blinds_options']) : []; $input = [ 'width_mm' => absint($_POST['blinds_width_mm'] ?? 0), 'drop_mm' => absint($_POST['blinds_drop_mm'] ?? 0), 'fit' => sanitize_text_field(wp_unslash($_POST['blinds_fit'] ?? 'recess')), 'quantity' => max(1, absint($_POST['blinds_quantity'] ?? 1)), 'options' => array_map('sanitize_text_field', $postedOptions), 'control_side' => sanitize_text_field(wp_unslash($_POST['blinds_control_side'] ?? 'right')), 'bracket' => sanitize_text_field(wp_unslash($_POST['blinds_bracket'] ?? 'top-fix')), 'fabric' => sanitize_text_field(wp_unslash($_POST['blinds_fabric'] ?? '')), 'corner_fit' => !empty($_POST['blinds_corner_fit']), 'outside_mount' => (sanitize_text_field(wp_unslash($_POST['blinds_fit'] ?? '')) === 'outside'), ]; $depth = absint($_POST['blinds_recess_depth_mm'] ?? 0); if ($depth > 0) { $input['recess_depth_mm'] = $depth; } try { $result = (new Blinds_Pricing_Engine(blinds_proto_load_config()))->calculate($input); } catch (Blinds_Pricing_Exception $e) { wc_add_notice('Rollora: ' . implode(' ', $e->errors), 'error'); return; } // Store measurements + snapshot only; price is set again in before_calculate_totals. WC()->cart->add_to_cart($productId, $result['quantity'], 0, [], ['blinds_proto' => $result]); }); /** Server-side price: recalculate from stored measurements on every totals run. */ add_action('woocommerce_before_calculate_totals', function ($cart) { if (is_admin() && !defined('DOING_AJAX')) { return; } foreach ($cart->get_cart() as $key => $item) { if (empty($item['blinds_proto']['input_width_mm'])) { continue; } $snap = $item['blinds_proto']; try { // Sprint 6 fix: recalculation must use the FULL stored snapshot // input, not a subset. Previously fabric, corner fit, recess // depth, and outside mount were dropped here, so a recalculated // price could differ from the price at add-to-cart time. $recalc = [ 'width_mm' => $snap['input_width_mm'], 'drop_mm' => $snap['input_drop_mm'], 'fit' => $snap['fit'], 'quantity' => (int) ($item['quantity'] ?? 1), 'options' => $snap['options'] ?? [], 'control_side' => $snap['control_side'] ?? 'right', 'bracket' => $snap['bracket'] ?? 'top-fix', 'fabric' => $snap['fabric'] ?? '', 'fabric_group' => $snap['fabric_group'] ?? '', 'corner_fit' => !empty($snap['corner_fit']), 'outside_mount' => !empty($snap['outside_mount']), ]; if (!empty($snap['recess_depth_mm'])) { $recalc['recess_depth_mm'] = $snap['recess_depth_mm']; } $result = (new Blinds_Pricing_Engine(blinds_proto_load_config()))->calculate($recalc); } catch (Blinds_Pricing_Exception $e) { continue; } // Per-blind price (base + per-blind surcharges); per-order surcharges // are added once per order by the woocommerce_cart_calculate_fees hook below. $perBlind = $result['base_price_minor_per_blind'] + $result['per_blind_surcharges_minor']; $item['data']->set_price($perBlind / 100); // Stage 10 fix D5: refresh the stored snapshot from the // recalculated result (same add-time semantics, current config), // so a price-book activation or config edit between add-to-cart // and checkout reaches the order snapshot and workshop sheet // instead of leaving them on stale add-time prices. The // finaliser at checkout still applies its fee-exclusion rule. $cart->cart_contents[$key]['blinds_proto'] = $result; } }, 10, 1); /** Per-order surcharges as a WooCommerce fee, once per order, not per blind. * Dedupe by option code across cart items, so two blinds sharing a * remote pay for one remote. */ add_action('woocommerce_cart_calculate_fees', function ($cart) { if (is_admin() && !defined('DOING_AJAX')) { return; } $config = blinds_proto_load_config(); $defs = []; foreach ($config['options'] ?? [] as $opt) { $defs[$opt['code']] = $opt; } $seen = []; $feeMinor = 0; foreach ($cart->get_cart() as $item) { if (empty($item['blinds_proto']['options'])) { continue; } foreach ((array) $item['blinds_proto']['options'] as $code) { if (isset($defs[$code]) && ($defs[$code]['shape'] ?? '') === 'flat_per_order' && !isset($seen[$code])) { $seen[$code] = true; $feeMinor += (int) ($defs[$code]['amount_minor'] ?? 0); } } } if ($feeMinor > 0) { $cart->add_fee('Blinds per-order options (' . (string) ($config['data_label'] ?? 'SYNTHETIC') . ')', $feeMinor / 100); } }, 10, 1); /** Order meta via WooCommerce CRUD only — no direct order-table queries. */ add_action('woocommerce_checkout_create_order_line_item', function ($item, $cartKey, $values) { if (empty($values['blinds_proto']) || !is_array($values['blinds_proto'])) { return; } $item->update_meta_data('_blinds_proto_snapshot', $values['blinds_proto']); // Stage 10 fix D3: carry the effective config's data label, not a // hardcoded SYNTHETIC, so real supplier rules produce real labels. $config = blinds_proto_load_config(); $item->update_meta_data('_blinds_proto_data_label', (string) ($config['data_label'] ?? $values['blinds_proto']['data_label'] ?? 'SYNTHETIC')); }, 10, 3); /** Printable workshop sheet from an order-item snapshot array. */ function blinds_proto_workshop_sheet_html(array $snap): string { $price = blinds_proto_format_minor((int) ($snap['total_minor'] ?? 0), (string) ($snap['currency'] ?? 'GBP')); $html = '

Workshop Production Sheet

'; $html .= '

Supplier: ' . esc_html((string) ($snap['supplier_label'] ?? 'SYNTHETIC')) . '

'; $rows = [ ['Input width (mm)', $snap['input_width_mm'] ?? ''], ['Input drop (mm)', $snap['input_drop_mm'] ?? ''], ['Fit', $snap['fit'] ?? ''], ['Outside mount', !empty($snap['outside_mount']) ? 'Yes' : 'No'], ['Order width (mm, after outside-mount additions)', $snap['order_width_mm'] ?? ''], ['Order drop (mm, after outside-mount additions)', $snap['order_drop_mm'] ?? ''], ['Deduction applied width (mm)', $snap['deduction_width_mm'] ?? ''], ['Deduction applied drop (mm)', $snap['deduction_drop_mm'] ?? ''], ['Corner fit', !empty($snap['corner_fit']) ? 'Yes' : 'No'], ['Corner deduction applied (mm)', is_array($snap['corner_deduction_applied_mm'] ?? null) ? ($snap['corner_deduction_applied_mm']['width_mm'] ?? '') . ' width / ' . ($snap['corner_deduction_applied_mm']['drop_mm'] ?? '') . ' drop' : ''], ['Recess depth supplied (mm)', $snap['recess_depth_mm'] ?? 'not supplied'], ['Recess depth minimum (mm)', $snap['recess_depth_min_mm'] ?? 'none configured'], ['Overall width (mm)', $snap['overall_width_mm'] ?? ''], ['Cloth width (mm)', $snap['cloth_width_mm'] ?? ''], ['Overall drop (mm)', $snap['overall_drop_mm'] ?? ''], ['Band matched', $snap['band_matched'] ?? ''], ['Fabric', $snap['fabric'] ?? ''], ['Fabric group', $snap['fabric_group'] ?? ''], ['Mechanism', $snap['mechanism'] ?? ''], ['Roll width (mm)', $snap['roll_width_mm'] ?? ''], ['Join required', !empty($snap['join_required']) ? 'YES — width exceeds roll width' : 'No'], ['Option parts', implode(', ', $snap['option_parts'] ?? [])], ['Control side', $snap['control_side'] ?? ''], ['Bracket', $snap['bracket'] ?? ''], ['Quantity', $snap['quantity'] ?? ''], ['Line total (engine, tax-exclusive)', $price], ]; $feeMinor = (int) ($snap['per_order_surcharges_minor'] ?? 0); if ($feeMinor === 0) { $feeMinor = (int) ($snap['per_order_fee_minor'] ?? 0); } if ($feeMinor > 0) { $rows[] = ['Per-order options fee (order-level, not in line total)', blinds_proto_format_minor($feeMinor, (string) ($snap['currency'] ?? 'GBP'))]; } foreach ($rows as [$label, $value]) { $html .= ''; } $html .= '
' . esc_html((string) $label) . '' . esc_html((string) $value) . '
'; if (!empty($snap['warnings'])) { $html .= '

Manufacturing warnings

'; } if (!empty($snap['tolerance_mm'])) { $html .= '

Manufacturing tolerance: +/-' . esc_html((string) $snap['tolerance_mm']['width_mm'] ?? '') . ' mm width, +/-' . esc_html((string) $snap['tolerance_mm']['drop_mm'] ?? '') . ' mm drop. This is an acceptance range, not a price adjustment.

'; } $sheetLabel = (string) ($snap['data_label'] ?? 'SYNTHETIC'); $html .= '

Data label: ' . esc_html((string) $sheetLabel) . '. ' . ($sheetLabel === 'SYNTHETIC' ? 'Do not manufacture from this sheet.' : 'Use only if this sheet has been approved for production.') . '

'; return $html; } add_shortcode('blinds_workshop_sheet', function ($atts) { // Real workshop sheet from a saved order snapshot. Read-only: the // snapshot is never modified here. Access is restricted to users who // can manage WooCommerce (shop managers / admins in staging); other // visitors get no order data at all, not even an order-exists signal // beyond the generic message. if (!function_exists('wc_get_order') || !current_user_can('manage_woocommerce')) { return '

Workshop sheet unavailable.

'; } $atts = shortcode_atts(['order_id' => 0, 'item_id' => 0], $atts, 'blinds_workshop_sheet'); $order = wc_get_order(absint($atts['order_id'])); if (!$order) { return '

Workshop sheet unavailable.

'; } $itemId = absint($atts['item_id']); foreach ($order->get_items() as $item) { if ($itemId > 0 && (int) $item->get_id() !== $itemId) { continue; } $snap = $item->get_meta('_blinds_proto_snapshot', true); if (is_array($snap) && !empty($snap)) { $label = $item->get_meta('_blinds_proto_data_label', true); $html = '

Workshop sheet for order #' . esc_html((string) $order->get_id()) . ', item: ' . esc_html($item->get_name()) . '. Data label: ' . esc_html((string) ($label ?: ($snap['data_label'] ?? 'SYNTHETIC'))) . '.

'; return $html . blinds_proto_workshop_sheet_html($snap); } } return '

Workshop sheet unavailable: no blinds snapshot on this order.

'; }); /** Admin importer page: preview + diff + errors only. Live promotion is disabled on this page; use the Price Books page to approve a book. */ add_action('admin_menu', function () { add_menu_page('Rollora — Import Preview', 'Rollora', 'manage_options', 'blinds-proto-import', function () { if (!current_user_can('manage_options')) { return; } echo '

Rollora — CSV Import Preview

'; echo '

SYNTHETIC DATA. Upload a CSV to see staging, diff, and row-level errors. Nothing is written to live pricing tables. XLSX: convert to CSV first — XLSX parsing is not implemented or tested.

'; echo '
'; wp_nonce_field('blinds_proto_import', 'blinds_proto_import_nonce'); echo ' '; echo '
'; if (!empty($_POST['blinds_proto_import']) && isset($_FILES['blinds_csv']['tmp_name']) && isset($_POST['blinds_proto_import_nonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['blinds_proto_import_nonce'])), 'blinds_proto_import')) { // Sprint 6 hardening: only accept a genuinely uploaded file, // with a successful upload status. The tmp_name is server- // generated, so it is checked with is_uploaded_file, not // treated as user text. No file is written to live pricing. if (!empty($_FILES['blinds_csv']['error']) || !is_uploaded_file(wp_unslash($_FILES['blinds_csv']['tmp_name']))) { echo '

Import failed: no valid uploaded file.

'; } else { $csv = file_get_contents(wp_unslash($_FILES['blinds_csv']['tmp_name'])); $mapping = ['width_min_mm' => 'width_min', 'width_max_mm' => 'width_max', 'drop_min_mm' => 'drop_min', 'drop_max_mm' => 'drop_max', 'price_pence' => 'price', 'option_code' => 'option_code']; $config = blinds_proto_load_config(); $known = array_column($config['options'] ?? [], 'code'); $parsed = (new Blinds_Csv_Importer())->parse((string) $csv, $mapping, $known); echo '

Errors

' . esc_html(print_r($parsed['errors'], true)) . '
'; echo '

Staged rows

' . esc_html(print_r($parsed['rows'], true)) . '
'; echo '

Live promotion is disabled on this preview page. Approve a price book from the Price Books page.

'; } } echo '
'; }); });