diff --git a/plugins/sCommercePlugin.php b/plugins/sCommercePlugin.php index 742640d..1bbb36d 100644 --- a/plugins/sCommercePlugin.php +++ b/plugins/sCommercePlugin.php @@ -49,9 +49,16 @@ }); /** - * Remember advertising tags (gclid, UTM) from the landing URL until checkout + * Remember advertising tags (gclid, UTM) from the landing URL until checkout. + * + * CAPTCHA and other API entrypoints can emit this event without an HTTP request + * binding. They must not resolve the request or initialize marketing services. */ Event::listen('evolution.OnWebPageInit', function() { + if (!app()->bound('request')) { + return; + } + sCommerce::captureMarketingTags(request()->query()); }); diff --git a/src/Cart/sCart.php b/src/Cart/sCart.php index baa6418..9c411f3 100644 --- a/src/Cart/sCart.php +++ b/src/Cart/sCart.php @@ -390,6 +390,8 @@ protected function saveCartData(): void * Get details of a product by its ID with selected fields. * * This method retrieves detailed information about a product from the database. + * Physical values come from the product attribute API so cart and order snapshots + * retain delivery dimensions and measured-line weight after column migration. * * @param object $product The product. * @throws \UnexpectedValueException If the returned object is not a product. @@ -406,7 +408,7 @@ private function getProductFields(object $product, array $pricing = []): ?array } } - $productFields = array_merge($product->only($this->productDetails), $pricing); + $productFields = array_merge($product->only($this->productDetails), $pricing, $product->physicalPayload()); return array_merge($productFields, $attributes); } diff --git a/src/Checkout/sCheckout.php b/src/Checkout/sCheckout.php index 1d34fdf..212dc64 100644 --- a/src/Checkout/sCheckout.php +++ b/src/Checkout/sCheckout.php @@ -644,8 +644,9 @@ public function processOrder(): array * * This method validates the input data, creates a new order for a quick purchase, * and saves it in the database. If product is 0, it will fetch products from the cart. - * Quantities remain uncast for validation; integer conversion and single-product totals + * Piece quantities remain uncast for validation; integer conversion and single-product totals * (including container deposits) are calculated only after the validator accepts them. + * Measured lines retain base-unit normalization and scaled-price totals after validation. * The trusted checkout context is resolved before the order is built. * * @param array $data The data from the request, including productId, optionId, quantity, exchange (empty returnable @@ -736,11 +737,6 @@ public static function quickOrder(array $data) } $pricing = static::resolveProductPricing($product, $optionId); - $price = (float)$pricing['priceAsFloat']; - $cost = $rules->isMeasured() - ? MeasuredQuantity::lineTotalMinor(MeasuredQuantity::scalePrice($price), $quantity, $rules->factor) / 100 - : $price * $quantity; - $productData = [ 'id' => $product->id, 'title' => $product->title, @@ -761,17 +757,7 @@ public static function quickOrder(array $data) } $productData = array_merge($productData, app(OrderLineIdentity::class)->snapshot($product, $optionId)); - // Units exchanged for empty returnable containers carry no new deposit. - $productData = array_merge($productData, ContainerDeposit::lineFields(ContainerDeposit::unit($product, $optionId), $quantity, max(0, (int)($data['exchange'] ?? 0)))); - $cost += (float)($productData['deposit_amount'] ?? 0); - $productData = array_merge($productData, $physical = $product->physicalPayload()); - $productData += MeasuredQuantity::lineFields($rules, $quantity, (string)($pricing['price'] ?? ''), (int)($physical['weight'] ?? 0)); - if ($rules->isMeasured()) { - $productData['sumAsFloat'] = round($cost, 3); - $productData['sum'] = sCommerce::convertPrice($productData['sumAsFloat']); - } - $productsData = [$productData]; } @@ -785,12 +771,22 @@ public static function quickOrder(array $data) if (!empty($data['productId']) && $data['productId'] != 0) { $quantity = (int) $quantity; $productsData[0]['quantity'] = $quantity; - $cost = (float) $pricing['priceAsFloat'] * $quantity; + $price = (float) $pricing['priceAsFloat']; + $cost = $rules->isMeasured() + ? MeasuredQuantity::lineTotalMinor(MeasuredQuantity::scalePrice($price), $quantity, $rules->factor) / 100 + : $price * $quantity; // Calculate deposit amounts only after the raw quantity has passed validation. $productsData[0] = array_merge($productsData[0], ContainerDeposit::lineFields( ContainerDeposit::unit($product, $optionId), $quantity, max(0, (int)($data['exchange'] ?? 0)) )); $cost += (float) ($productsData[0]['deposit_amount'] ?? 0); + $productsData[0] += MeasuredQuantity::lineFields( + $rules, $quantity, (string)($pricing['price'] ?? ''), (int)($physical['weight'] ?? 0) + ); + if ($rules->isMeasured()) { + $productsData[0]['sumAsFloat'] = round($cost, 3); + $productsData[0]['sum'] = sCommerce::convertPrice($productsData[0]['sumAsFloat']); + } } do { diff --git a/tests/standalone/scommerce-marketing-request.php b/tests/standalone/scommerce-marketing-request.php new file mode 100644 index 0000000..177b5c0 --- /dev/null +++ b/tests/standalone/scommerce-marketing-request.php @@ -0,0 +1,53 @@ +instance('events', $events); +Facade::setFacadeApplication($container); +require dirname(__DIR__, 2) . '/plugins/sCommercePlugin.php'; + +$checks = 0; +$check = static function (bool $condition, string $message) use (&$checks): void { + if (!$condition) { throw new RuntimeException($message); } + $checks++; +}; + +$marketingResolutions = 0; +$container->bind('sCommerce', static function () use (&$marketingResolutions) { + $marketingResolutions++; + throw new RuntimeException('Marketing service must not boot without a request'); +}); +$events->dispatch('evolution.OnWebPageInit'); +$check($marketingResolutions === 0, 'Unbound request must skip marketing service'); +$check(!$container->bound('request'), 'Listener must not synthesize a request'); + +/** Record listener payloads without writing cookies or loading commerce state. */ +$receiver = new class { + public array $queries = []; + /** Record the unchanged query passed to the existing marketing API. */ + public function captureMarketingTags(array $query): void { $this->queries[] = $query; } +}; +$container->instance('sCommerce', $receiver); +$query = ['utm_source' => 'newsletter', 'gclid' => 'test-click', 'other' => 'preserve']; +$container->instance('request', Request::create('/baltics/', 'GET', $query)); +$events->dispatch('evolution.OnWebPageInit'); +$check($receiver->queries === [$query], 'Bound HTTP request must preserve the entire query'); +$container->instance('request', Request::create('/baltics/')); +$events->dispatch('evolution.OnWebPageInit'); +$check($receiver->queries === [$query, []], 'Empty query must retain existing capture behavior'); + +$container->forgetInstance('request'); +$events->dispatch('evolution.OnWebPageInit'); +$check(count($receiver->queries) === 2, 'Missing request must remain safe after earlier HTTP events'); +echo "Marketing request checks passed: {$checks}\n"; diff --git a/tests/standalone/scommerce-measured-quantities.php b/tests/standalone/scommerce-measured-quantities.php index a760854..90171e1 100644 --- a/tests/standalone/scommerce-measured-quantities.php +++ b/tests/standalone/scommerce-measured-quantities.php @@ -257,7 +257,7 @@ function config($key = null, $default = null) { return app('config')->get($key, $check(!array_intersect_key($byId[11], array_flip(array_merge(MeasuredQuantity::LINE_KEYS, MeasuredQuantity::DISPLAY_KEYS))), 'Piece lines have no measured keys'); $check($byId[13]['quantity'] === 2400 && $byId[13]['sumAsFloat'] === 23.76 && !isset($byId[13]['line_weight']) && $mini['totalSum'] === 3.04 + 25.0 + 23.76, 'Volume line without weight; totals add up'); $capture = shell_exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(__DIR__ . '/fixtures/piece-cart-capture.php') . ' ' . escapeshellarg($package)); -$check($capture === file_get_contents(__DIR__ . '/fixtures/piece-cart-master.json'), 'Piece cart and order JSON are byte-identical to master'); +$check($capture === str_replace("\r\n", "\n", file_get_contents(__DIR__ . '/fixtures/piece-cart-master.json')), 'Piece cart and order JSON are byte-identical to master (normalized line endings)'); // 10. Quick order and checkout keep the snapshot keys and the exact total. $quick = sCheckout::quickOrder(['productId' => 10, 'amount' => '2.53', 'email' => 'quick@shop.test']);