From 366b8509173eb0c5db2d9d55427a6854e8b6c5da Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Tue, 27 Oct 2026 22:07:28 +0100 Subject: [PATCH 1/4] fix(settings): visual bugs, currency bugs, added tests --- docs/en/developers/deliveries.md | 2 +- docs/en/developers/events.md | 22 +- docs/en/developers/payments/index.md | 2 +- docs/ru/developers/deliveries.md | 2 +- docs/ru/developers/events.md | 22 +- docs/ru/developers/payments/index.md | 2 +- docs/uk/developers/deliveries.md | 2 +- docs/uk/developers/events.md | 22 +- docs/uk/developers/payments/index.md | 2 +- lang/de/global.php | 12 +- lang/en/global.php | 12 +- lang/fr/global.php | 12 +- lang/pl/global.php | 12 +- lang/ru/global.php | 12 +- lang/uk/global.php | 12 +- src/Cart/sCart.php | 84 ++++++- src/Checkout/sCheckout.php | 213 ++++++++++++++---- src/Delivery/BaseDeliveryMethod.php | 13 ++ src/Delivery/CourierDelivery.php | 14 +- src/Http/routes.php | 29 ++- src/Models/sOrder.php | 15 ++ src/Models/sProduct.php | 13 ++ src/Services/ContainerDeposit.php | 11 + src/Services/StorefrontGuard.php | 78 +++++++ .../standalone/fixtures/checkout-harness.php | 17 +- .../fixtures/piece-cart-master.json | 40 ---- .../scommerce-actual-quantities.php | 5 +- .../scommerce-checkout-validation.php | 2 + .../scommerce-container-deposit.php | 8 + .../scommerce-measured-quantities.php | 7 + .../standalone/scommerce-payment-schedule.php | 14 ++ .../scommerce-storefront-hardening.php | 184 +++++++++++++++ tests/standalone/scommerce-ukraine-beer.php | 74 ++++++ tests/standalone/scommerce-vat-display.php | 21 ++ ...sMeasuredQuantitiesConfiguration.blade.php | 77 +++++++ .../settingsOrdersConfiguration.blade.php | 7 + .../settingsProductConfiguration.blade.php | 77 +------ .../settingsProductsConfiguration.blade.php | 10 + ...ingsProductsSectionConfiguration.blade.php | 39 +++- views/productsTab.blade.php | 12 + views/settingsTab.blade.php | 25 +- 41 files changed, 1025 insertions(+), 214 deletions(-) create mode 100644 src/Services/StorefrontGuard.php create mode 100644 tests/standalone/scommerce-storefront-hardening.php create mode 100644 tests/standalone/scommerce-ukraine-beer.php create mode 100644 views/partials/settingsMeasuredQuantitiesConfiguration.blade.php diff --git a/docs/en/developers/deliveries.md b/docs/en/developers/deliveries.md index dcc62b1..0bf4d32 100644 --- a/docs/en/developers/deliveries.md +++ b/docs/en/developers/deliveries.md @@ -83,7 +83,7 @@ class CustomDelivery extends BaseDeliveryMethod public function calculateCost(array $order): float { - return 50.00; // Fixed cost or custom calculation + return 50.00; // In getCostCurrency(): the currency of the method's settings row } public function defineFields(): array diff --git a/docs/en/developers/events.md b/docs/en/developers/events.md index a48e5c1..af24885 100644 --- a/docs/en/developers/events.md +++ b/docs/en/developers/events.md @@ -82,7 +82,10 @@ Settings (**Settings → Product**): - **Prices without VAT** (`product.vat_display_on`, off by default) switches the feature on. While it is off, the settings below are hidden, prices are always shown with VAT, the display keys equal the charged prices and `scommerce/vat-display` answers `404`; - **VAT rate, %** (`product.vat_rate`, default `0`): the rate included in product prices. With `0`, the net price equals the gross price; -- **Prices shown** (`product.vat_display`, `gross` or `net`, default `gross`): the default for visitors who have not chosen. +- **Storefront prices by default** (`product.vat_display`, `gross` or `net`, default `gross`): the default for visitors who have not chosen, e.g. `net` for a B2B shop. It affects only the storefront; +- **Without VAT** in **Presentation of the list of products** (`products.show_field_price_net`, on by default): a manager product list column with the price without VAT and the included retail excise (`sProduct::priceNetTo()`), shown while prices without VAT are on. + +Defaults of products sold by measure and the rounding to the step per unit have their own **Measured quantities** block on the same settings page. The net price is `round(gross / (1 + rate / 100), 2)`. Templates read these values: @@ -193,6 +196,23 @@ integer conversion, single-product totals and deposits follow successful validat the actual product or cart lines; partial forms without products skip that rule. The package contains no project-specific minimum attribute or policy class. +Since 1.5.0 the storefront is checked before an order is saved: + +- `processOrder()` re-prices the lines it kept since `initCheckout()` with current prices, publication, + stock and currency. When anything differs it stores the current lines and fails with + `error_code` `cart_changed` and the new `products` and `cost`; the customer confirms the order again. + Lines above stock fail with `out_of_stock`, in quick orders too. +- The delivery cost is recalculated on every `setOrderData()` and again in `processOrder()`, and + converted from the method's cost currency into the order currency. A failed attempt leaves the + checkout data as it was, so a retry never adds the delivery twice. +- Unpublished products cannot be added to the cart or ordered. Quick orders without a `productId` + order the cart; any other value than a whole number is an unknown product. Piece quantities stop at + 1,000,000, and a negative price from a listener never lowers a total. +- The `scommerce/process-order` and `scommerce/quick-order` routes return the order as + `sOrder::toCustomerArray()` (no manager notes, history or `meta`) and allow `orders.rate_limit` + orders per IP address in 10 minutes (default 10, 0 = no limit, **Settings → Cart and orders**); + above it they answer `429` with `error_code` `rate_limited`. + These three events use the exact names documented on this page, without the `evolution.` prefix. The previous price-event names `evolution.sCommerceResolveProductPriceMode` and `evolution.sCommerceResolveProductPrice` are no longer dispatched; update project listeners and sPricing together with sCommerce. ### sCommerce.ResolveProductPriceMode diff --git a/docs/en/developers/payments/index.md b/docs/en/developers/payments/index.md index 76af651..985fe38 100644 --- a/docs/en/developers/payments/index.md +++ b/docs/en/developers/payments/index.md @@ -1126,7 +1126,7 @@ Since 1.5.0, a product can carry a returnable container deposit, for example a w ### Checkout and exchange -The customer declares how many empty containers they hand over. Post `productId`, `optionId` and `quantity` to `scommerce/container-exchange` (route `sCommerce.containerExchange`) or call `sCart::setContainerExchange($productId, $optionId, $count)`; quick orders accept `exchange`. The count is capped at the line quantity. sCommerce keeps no balance of containers per customer: when the courier finds a different number, the manager changes **Exchanged containers** on the order line, and the deposit and the order cost are recalculated. +The customer declares how many empty containers they hand over. Such a declaration waives the deposit before anyone has seen the containers, so it needs **Customers declare exchanged containers** (`product.container_exchange_on`, off by default); otherwise the route answers `404`, `setContainerExchange()` fails and declared exchanges are ignored. Post `productId`, `optionId` and `quantity` to `scommerce/container-exchange` (route `sCommerce.containerExchange`) or call `sCart::setContainerExchange($productId, $optionId, $count)`; quick orders accept `exchange`. The count is capped at the line quantity. sCommerce keeps no balance of containers per customer: when the courier finds a different number, the manager changes **Exchanged containers** on the order line, and the deposit and the order cost are recalculated. Cart lines and order lines with a deposit store: diff --git a/docs/ru/developers/deliveries.md b/docs/ru/developers/deliveries.md index 014372a..608f1d4 100644 --- a/docs/ru/developers/deliveries.md +++ b/docs/ru/developers/deliveries.md @@ -83,7 +83,7 @@ class CustomDelivery extends BaseDeliveryMethod public function calculateCost(array $order): float { - return 50.00; // Фиксированная стоимость или пользовательский расчет + return 50.00; // В валюте getCostCurrency(): валюта строки настроек способа } public function defineFields(): array diff --git a/docs/ru/developers/events.md b/docs/ru/developers/events.md index c1f7d54..6d0efe3 100644 --- a/docs/ru/developers/events.md +++ b/docs/ru/developers/events.md @@ -110,7 +110,10 @@ sCart::clearPriceMode(); - **Цены без НДС** (`product.vat_display_on`, по умолчанию выключено) включает функцию. Пока она выключена, настройки ниже скрыты, цены всегда показываются с НДС, ключи для отображения равны ценам к оплате, а `scommerce/vat-display` отвечает `404`; - **Ставка НДС, %** (`product.vat_rate`, по умолчанию `0`): ставка, включённая в цены товаров. При `0` цена нетто равна цене брутто; -- **Отображение цен** (`product.vat_display`, `gross` или `net`, по умолчанию `gross`): значение по умолчанию для посетителей, которые не сделали выбор. +- **Цены на витрине по умолчанию** (`product.vat_display`, `gross` или `net`, по умолчанию `gross`): значение по умолчанию для посетителей, которые не сделали выбор, например `net` для B2B-магазина. Влияет только на витрину; +- **Без НДС** в **Представление списка товаров** (`products.show_field_price_net`, по умолчанию включено): колонка списка товаров в менеджере с ценой без НДС и включённым в неё розничным акцизом (`sProduct::priceNetTo()`), показывается, пока включены цены без НДС. + +Значения по умолчанию для товаров, продаваемых по мере, и округление до шага за единицу настраиваются в отдельном блоке **Измеряемые количества** на той же странице настроек. Цена нетто рассчитывается как `round(gross / (1 + rate / 100), 2)`. Шаблоны читают такие значения: @@ -220,6 +223,23 @@ Event::listen('sCommerce.CheckoutValidationRules', function (array $params): voi корзина. Частичная форма без товаров не запускает правило products. Атрибута минимума и отдельного класса политики проекта в пакете нет. +Начиная с 1.5.0 витрина проверяется перед сохранением заказа: + +- `processOrder()` заново рассчитывает позиции, сохранённые с момента `initCheckout()`, по текущим ценам, + публикации, остаткам и валюте. Если что-то отличается, он сохраняет текущие позиции и завершается ошибкой + с `error_code` `cart_changed` и новыми `products` и `cost`; покупатель подтверждает заказ ещё раз. + Позиции сверх остатка завершаются ошибкой `out_of_stock`, в том числе в быстрых заказах. +- Стоимость доставки пересчитывается при каждом `setOrderData()` и ещё раз в `processOrder()` и + конвертируется из валюты стоимости способа в валюту заказа. Неудачная попытка оставляет данные + оформления без изменений, поэтому повторная попытка никогда не добавляет доставку дважды. +- Неопубликованные товары нельзя добавить в корзину или заказать. Быстрые заказы без `productId` + оформляют корзину; любое значение, кроме целого числа, считается неизвестным товаром. Штучное количество + ограничено 1 000 000, а отрицательная цена от слушателя никогда не уменьшает сумму. +- Маршруты `scommerce/process-order` и `scommerce/quick-order` возвращают заказ как + `sOrder::toCustomerArray()` (без заметок менеджера, истории и `meta`) и допускают `orders.rate_limit` + заказов с одного IP-адреса за 10 минут (по умолчанию 10, 0 = без ограничения, **Настройки → Корзина и заказы**); + сверх этого они отвечают `429` с `error_code` `rate_limited`. + Для этих трёх событий используйте точные имена с этой страницы, без префикса `evolution.`. Предыдущие ценовые события `evolution.sCommerceResolveProductPriceMode` и `evolution.sCommerceResolveProductPrice` больше не вызываются; обновляйте слушатели проекта и sPricing вместе с sCommerce. ### sCommerce.ResolveProductPriceMode diff --git a/docs/ru/developers/payments/index.md b/docs/ru/developers/payments/index.md index 1d64aee..cbbf4be 100644 --- a/docs/ru/developers/payments/index.md +++ b/docs/ru/developers/payments/index.md @@ -1126,7 +1126,7 @@ sCommerce проверяет график и сохраняет его в `payme ### Оформление заказа и обмен -Покупатель указывает, сколько пустой тары он сдаёт. Отправьте `productId`, `optionId` и `quantity` POST-запросом на `scommerce/container-exchange` (маршрут `sCommerce.containerExchange`) или вызовите `sCart::setContainerExchange($productId, $optionId, $count)`; быстрые заказы принимают `exchange`. Количество ограничивается количеством в строке. sCommerce не ведёт остаток тары по покупателю: если курьер обнаружит другое количество, менеджер меняет **Тара в обмен** в строке заказа, и залог и стоимость заказа пересчитываются. +Покупатель указывает, сколько пустой тары он сдаёт. Такое указание отменяет залог ещё до того, как кто-либо увидел тару, поэтому для него нужна настройка **Покупатели указывают тару в обмен** (`product.container_exchange_on`, по умолчанию выключена); иначе маршрут отвечает `404`, `setContainerExchange()` завершается ошибкой, а указанный обмен игнорируется. Отправьте `productId`, `optionId` и `quantity` POST-запросом на `scommerce/container-exchange` (маршрут `sCommerce.containerExchange`) или вызовите `sCart::setContainerExchange($productId, $optionId, $count)`; быстрые заказы принимают `exchange`. Количество ограничивается количеством в строке. sCommerce не ведёт остаток тары по покупателю: если курьер обнаружит другое количество, менеджер меняет **Тара в обмен** в строке заказа, и залог и стоимость заказа пересчитываются. Строки корзины и строки заказа с залогом хранят: diff --git a/docs/uk/developers/deliveries.md b/docs/uk/developers/deliveries.md index d21192d..889a5dd 100644 --- a/docs/uk/developers/deliveries.md +++ b/docs/uk/developers/deliveries.md @@ -83,7 +83,7 @@ class CustomDelivery extends BaseDeliveryMethod public function calculateCost(array $order): float { - return 50.00; // Фіксована вартість або власний розрахунок + return 50.00; // У валюті getCostCurrency(): валюта рядка налаштувань способу } public function defineFields(): array diff --git a/docs/uk/developers/events.md b/docs/uk/developers/events.md index 5080bf3..4b96816 100644 --- a/docs/uk/developers/events.md +++ b/docs/uk/developers/events.md @@ -110,7 +110,10 @@ sCart::clearPriceMode(); - **Ціни без ПДВ** (`product.vat_display_on`, типово вимкнено) вмикає функцію. Поки її вимкнено, наведені нижче налаштування приховано, ціни завжди показуються з ПДВ, ключі для відображення дорівнюють цінам до сплати, а `scommerce/vat-display` відповідає `404`; - **Ставка ПДВ, %** (`product.vat_rate`, типово `0`): ставка, включена в ціни товарів. За `0` ціна нетто дорівнює ціні брутто; -- **Відображення цін** (`product.vat_display`, `gross` або `net`, типово `gross`): типове значення для відвідувачів, які не зробили вибір. +- **Типові ціни на вітрині** (`product.vat_display`, `gross` або `net`, типово `gross`): типове значення для відвідувачів, які не зробили вибір, наприклад `net` для B2B-магазину. Впливає лише на вітрину; +- **Без ПДВ** у **Представлення списку товарів** (`products.show_field_price_net`, типово увімкнено): колонка списку товарів у менеджері з ціною без ПДВ і включеним у неї роздрібним акцизом (`sProduct::priceNetTo()`), показується, поки увімкнено ціни без ПДВ. + +Типові значення для товарів, що продаються на міру, і округлення до кроку за одиницю налаштовуються в окремому блоці **Вимірювані кількості** на тій самій сторінці налаштувань. Ціна нетто обчислюється як `round(gross / (1 + rate / 100), 2)`. Шаблони читають такі значення: @@ -221,6 +224,23 @@ Event::listen('sCommerce.CheckoutValidationRules', function (array $params): voi Часткові форми без товарів не запускають правило для products. Атрибута мінімуму та окремого класу політики проєкту в пакеті немає. +Починаючи з 1.5.0 вітрина перевіряється перед збереженням замовлення: + +- `processOrder()` наново розраховує позиції, збережені з моменту `initCheckout()`, за поточними цінами, + публікацією, залишками та валютою. Якщо щось відрізняється, він зберігає поточні позиції й завершується + помилкою з `error_code` `cart_changed` і новими `products` та `cost`; покупець підтверджує замовлення ще раз. + Позиції понад залишок завершуються помилкою `out_of_stock`, зокрема у швидких замовленнях. +- Вартість доставки перераховується під час кожного `setOrderData()` і ще раз у `processOrder()` та + конвертується з валюти вартості способу у валюту замовлення. Невдала спроба залишає дані + оформлення без змін, тож повторна спроба ніколи не додає доставку двічі. +- Неопубліковані товари не можна додати до кошика чи замовити. Швидкі замовлення без `productId` + оформлюють кошик; будь-яке значення, крім цілого числа, вважається невідомим товаром. Штучна кількість + обмежена 1 000 000, а від'ємна ціна від слухача ніколи не зменшує суму. +- Маршрути `scommerce/process-order` і `scommerce/quick-order` повертають замовлення як + `sOrder::toCustomerArray()` (без нотаток менеджера, історії та `meta`) і дозволяють `orders.rate_limit` + замовлень з однієї IP-адреси за 10 хвилин (типово 10, 0 = без обмеження, **Налаштування → Кошик та замовлення**); + понад це вони відповідають `429` з `error_code` `rate_limited`. + Для цих трьох подій використовуйте точні назви з цієї сторінки, без префікса `evolution.`. Попередні цінові події `evolution.sCommerceResolveProductPriceMode` і `evolution.sCommerceResolveProductPrice` більше не викликаються; оновлюйте проєктні слухачі та sPricing разом із sCommerce. ### sCommerce.ResolveProductPriceMode diff --git a/docs/uk/developers/payments/index.md b/docs/uk/developers/payments/index.md index d466c61..5734479 100644 --- a/docs/uk/developers/payments/index.md +++ b/docs/uk/developers/payments/index.md @@ -1126,7 +1126,7 @@ sCommerce перевіряє графік і зберігає його в `payme ### Оформлення замовлення та обмін -Покупець зазначає, скільки порожньої тари він здає. Надішліть `productId`, `optionId` і `quantity` POST-запитом на `scommerce/container-exchange` (маршрут `sCommerce.containerExchange`) або викличте `sCart::setContainerExchange($productId, $optionId, $count)`; швидкі замовлення приймають `exchange`. Кількість обмежується кількістю в рядку. sCommerce не веде залишку тари за покупцем: якщо кур’єр виявить іншу кількість, менеджер змінює **Тара в обмін** у рядку замовлення, і застава та вартість замовлення перераховуються. +Покупець зазначає, скільки порожньої тари він здає. Таке зазначення скасовує заставу ще до того, як хтось побачив тару, тому для нього потрібне налаштування **Покупці вказують тару в обмін** (`product.container_exchange_on`, типово вимкнено); інакше маршрут відповідає `404`, `setContainerExchange()` завершується помилкою, а зазначений обмін ігнорується. Надішліть `productId`, `optionId` і `quantity` POST-запитом на `scommerce/container-exchange` (маршрут `sCommerce.containerExchange`) або викличте `sCart::setContainerExchange($productId, $optionId, $count)`; швидкі замовлення приймають `exchange`. Кількість обмежується кількістю в рядку. sCommerce не веде залишку тари за покупцем: якщо кур’єр виявить іншу кількість, менеджер змінює **Тара в обмін** у рядку замовлення, і застава та вартість замовлення перераховуються. Рядки кошика та рядки замовлення із заставою зберігають: diff --git a/lang/de/global.php b/lang/de/global.php index 7c36ef7..c74d32e 100644 --- a/lang/de/global.php +++ b/lang/de/global.php @@ -163,6 +163,10 @@ "payment_reconciliation_worker_desc" => "Verbucht Offline-Zahlungen und schließt offene Zahlungsvorgänge ab.", "payment_reconciliation_worker_done" => "Verbuchte Zahlungen: :settled. Abgeschlossene Vorgänge: :resolved.", "checkout_context_unavailable" => "Der Bestellvorgang ist derzeit nicht verfügbar. Bitte später erneut versuchen oder uns kontaktieren.", + "checkout_cart_changed" => "Preise oder Verfügbarkeit Ihrer Bestellung haben sich geändert. Bitte die aktualisierte Gesamtsumme prüfen und die Bestellung erneut bestätigen.", + "order_rate_limited" => "Zu viele Bestellungen von Ihrer Adresse. Bitte in einigen Minuten erneut versuchen.", + "orders_rate_limit" => "Bestellungen pro Besucher in 10 Minuten", + "orders_rate_limit_help" => "Bestellungen und Schnellbestellungen, die von einer IP-Adresse innerhalb von 10 Minuten erlaubt sind; schützt vor einer Flut von Bestellungen und E-Mails. 0 = keine Begrenzung.", "delivery_not_required" => "Keine Lieferung erforderlich", "live_orders" => "Echte Bestellungen", "order_meta" => "Zusätzliche Bestelldaten", @@ -170,9 +174,9 @@ "test_orders" => "Testbestellungen", "mode_virtual" => "Virtuell", "mode_service" => "Dienstleistung", - "vat_display" => "Angezeigte Preise", + "vat_display" => "Shop-Preise standardmäßig", "vat_display_gross" => "Inkl. MwSt.", - "vat_display_help" => "Standardanzeige der Preise im Shop. Besucher können sie umschalten; der berechnete Preis enthält immer die MwSt.", + "vat_display_help" => "Wie Shop-Preise Besuchern angezeigt werden, die noch nicht umgeschaltet haben, z. B. ohne MwSt. für einen B2B-Shop. Besucher können die Anzeige umschalten; Listener von sCommerce.ResolveVatDisplay können pro Kunde entscheiden. Der berechnete Preis enthält immer die MwSt.", "vat_display_net" => "Ohne MwSt.", "vat_rate" => "MwSt.-Satz, %", "vat_rate_help" => "Standard-MwSt.-Satz, der in den Produktpreisen enthalten ist. Wird nur zur Anzeige von Preisen ohne MwSt. verwendet; 0 zeigt denselben Preis.", @@ -184,6 +188,8 @@ "container_deposit_help" => "Pfand für Mehrweggebinde pro Einheit, zusätzlich zum Preis und außerhalb der MwSt. berechnet. Im Tausch abgegebenes Leergut erhält kein neues Pfand.", "container_deposit_included" => "Inkl. Pfand", "container_exchange" => "Leergut im Tausch", + "container_exchange_on" => "Kunden geben getauschtes Leergut an", + "container_exchange_on_help" => "Kunden können im Warenkorb angeben, wie viel Leergut sie abgeben. Diese Einheiten werden ohne neues Pfand bestellt, bevor jemand das Leergut gesehen hat. Das Leergut daher bei der Lieferung prüfen.", "container_return" => "Leergut zurücknehmen", "container_return_cash" => "Barauszahlung", "container_return_count" => "Gebinde", @@ -206,6 +212,7 @@ "containers_help" => "Zurückgegebenes Leergut und die Erstattung oder Verrechnung seines Pfands, auch Rücknahmen ohne Bestellung.", "containers_icon" => "fa fa-recycle", "vat_display_on" => "Preise ohne MwSt.", + "show_field_price_net_help" => "Den Preis ohne MwSt. und die enthaltene Einzelhandels-Verbrauchsteuer in der Produktliste anzeigen. Verfügbar, solange Preise ohne MwSt. aktiviert sind.", "vat_display_on_help" => "Dem Shop erlauben, Preise ohne MwSt. anzuzeigen, z. B. für Geschäftskunden. Der berechnete Preis enthält immer die MwSt.", "retail_excise_on" => "Einzelhandels-Verbrauchsteuer", "retail_excise_on_help" => "Die in den Preisen enthaltene Einzelhandels-Verbrauchsteuer (Alkohol, Tabak, Kraftstoff) berechnen und für den Beleg in den Bestellpositionen speichern.", @@ -271,6 +278,7 @@ "quantity_min" => "Mindestmenge", "quantity_min_help" => "Die kleinste Bestellmenge in Basiseinheiten; mindestens ein Schritt. Wenn leer, gilt der Standardwert der Website.", "quantity_max" => "Höchstmenge", + "quantity_too_large" => "Die Menge darf nicht größer als :max sein.", "quantity_tolerance" => "Mehrmengentoleranz", "quantity_tolerance_help" => "Wie weit eine Ist-Menge (nach der Bestellung zugeschnitten oder gewogen) die bestellte Menge übersteigen darf, in Basispunkten: 500 = 5%. Wenn leer, gilt der Standardwert der Website.", "quantity_rounding" => "Rundung auf den Schritt", diff --git a/lang/en/global.php b/lang/en/global.php index de45282..2214e83 100644 --- a/lang/en/global.php +++ b/lang/en/global.php @@ -142,6 +142,10 @@ "checkout_settings" => "Cart and orders", "checkout_settings_intro" => "These settings define product attribute display in the cart and sCommerce order number generation rules.", "checkout_context_unavailable" => "This checkout is not available right now. Please try again later or contact us.", + "checkout_cart_changed" => "Prices or availability of your order have changed. Please check the updated total and confirm the order again.", + "order_rate_limited" => "Too many orders from your address. Please try again in a few minutes.", + "orders_rate_limit" => "Orders per visitor in 10 minutes", + "orders_rate_limit_help" => "Checkout and quick orders allowed from one IP address within 10 minutes; protects against order and email flooding. 0 = no limit.", "orders_reference_prefix" => "Order number prefix", "orders_reference_prefix_help" => "Prefix added before the sequential order number (e.g. #, ORD_).", "orders_reference_start" => "Starting number", @@ -159,6 +163,8 @@ "container_deposit_on" => "Returnable container deposits", "container_deposit_on_help" => "Charge a refundable deposit for containers such as water bottles, with exchanges and returns. Adds the deposit field to products and the Containers tab.", "container_exchange" => "Exchanged containers", + "container_exchange_on" => "Customers declare exchanged containers", + "container_exchange_on_help" => "Let customers state in the cart how many empty containers they hand over. Those units are ordered without a new deposit before anyone has seen the containers, so check them on delivery.", "container_return" => "Return containers", "container_return_cash" => "Cash refund", "container_return_count" => "Containers", @@ -543,11 +549,12 @@ "uploading_file" => "Uploading file", "user_unknown" => "User unknown", "value" => "Value", - "vat_display" => "Prices shown", + "vat_display" => "Storefront prices by default", "vat_display_gross" => "With VAT", - "vat_display_help" => "Default display of storefront prices. Visitors can switch it; the charged price always includes VAT.", + "vat_display_help" => "How storefront prices are shown to visitors who have not switched yet, e.g. without VAT for a B2B shop. Visitors can switch it; listeners of sCommerce.ResolveVatDisplay may decide per customer. The charged price always includes VAT.", "vat_display_net" => "Without VAT", "vat_display_on" => "Prices without VAT", + "show_field_price_net_help" => "Show the price without VAT and the included retail excise in the product list. Available while prices without VAT are on.", "vat_display_on_help" => "Let the storefront show prices without VAT, e.g. to business customers. The charged price always includes VAT.", "vat_rate" => "VAT rate, %", "vat_rate_help" => "Default VAT rate included in product prices. Used only to show prices without VAT; 0 shows the same price.", @@ -766,6 +773,7 @@ "quantity_min" => "Minimum quantity", "quantity_min_help" => "The smallest order in base units; at least one step. Empty uses the site default.", "quantity_max" => "Maximum quantity", + "quantity_too_large" => "The quantity may not be greater than :max.", "quantity_tolerance" => "Overrun tolerance", "quantity_tolerance_help" => "How much an actual quantity (cut or weighed after ordering) may exceed the ordered one, in basis points: 500 = 5%. Empty uses the site default.", "quantity_rounding" => "Rounding to the step", diff --git a/lang/fr/global.php b/lang/fr/global.php index c644487..ce159d6 100644 --- a/lang/fr/global.php +++ b/lang/fr/global.php @@ -163,6 +163,10 @@ "payment_reconciliation_worker_desc" => "Enregistre les paiements hors ligne et finalise les opérations de paiement inachevées.", "payment_reconciliation_worker_done" => "Paiements encaissés : :settled. Opérations finalisées : :resolved.", "checkout_context_unavailable" => "La commande n’est pas disponible pour le moment. Veuillez réessayer plus tard ou nous contacter.", + "checkout_cart_changed" => "Les prix ou la disponibilité de votre commande ont changé. Veuillez vérifier le nouveau total et confirmer à nouveau la commande.", + "order_rate_limited" => "Trop de commandes depuis votre adresse. Veuillez réessayer dans quelques minutes.", + "orders_rate_limit" => "Commandes par visiteur sur 10 minutes", + "orders_rate_limit_help" => "Commandes et commandes rapides autorisées depuis une même adresse IP en 10 minutes ; protège contre l’envoi massif de commandes et d’e-mails. 0 = aucune limite.", "delivery_not_required" => "Livraison non requise", "live_orders" => "Commandes réelles", "order_meta" => "Données supplémentaires de la commande", @@ -170,9 +174,9 @@ "test_orders" => "Commandes de test", "mode_virtual" => "Virtuel", "mode_service" => "Service", - "vat_display" => "Prix affichés", + "vat_display" => "Prix de la boutique par défaut", "vat_display_gross" => "TTC", - "vat_display_help" => "Affichage par défaut des prix de la boutique. Les visiteurs peuvent le changer ; le prix facturé inclut toujours la TVA.", + "vat_display_help" => "Affichage des prix de la boutique pour les visiteurs qui ne l’ont pas encore changé, par exemple HT pour une boutique B2B. Les visiteurs peuvent le changer ; les écouteurs de sCommerce.ResolveVatDisplay peuvent décider pour chaque client. Le prix facturé inclut toujours la TVA.", "vat_display_net" => "HT", "vat_rate" => "Taux de TVA, %", "vat_rate_help" => "Taux de TVA par défaut inclus dans les prix des produits. Utilisé uniquement pour afficher les prix HT ; 0 affiche le même prix.", @@ -184,6 +188,8 @@ "container_deposit_help" => "Consigne par unité pour l’emballage consigné, facturée en plus du prix et hors TVA. Les emballages vides rendus en échange ne portent pas de nouvelle consigne.", "container_deposit_included" => "Dont consigne", "container_exchange" => "Emballages échangés", + "container_exchange_on" => "Les clients déclarent les emballages échangés", + "container_exchange_on_help" => "Permet aux clients d’indiquer dans le panier combien d’emballages vides ils rendent. Ces unités sont commandées sans nouvelle consigne avant que quiconque ait vu les emballages ; vérifiez-les donc à la livraison.", "container_return" => "Reprendre des emballages", "container_return_cash" => "Remboursement en espèces", "container_return_count" => "Emballages", @@ -206,6 +212,7 @@ "containers_help" => "Emballages rendus et remboursement ou imputation de leur consigne, y compris les reprises sans commande.", "containers_icon" => "fa fa-recycle", "vat_display_on" => "Prix HT", + "show_field_price_net_help" => "Afficher le prix HT et l’accise sur la vente au détail incluse dans la liste des produits. Disponible tant que les prix HT sont activés.", "vat_display_on_help" => "Permettre à la boutique d’afficher les prix HT, par exemple aux clients professionnels. Le prix facturé inclut toujours la TVA.", "retail_excise_on" => "Accise sur la vente au détail", "retail_excise_on_help" => "Calculer l’accise sur la vente au détail incluse dans les prix (alcool, tabac, carburant) et l’enregistrer sur les lignes de commande pour le ticket de caisse.", @@ -271,6 +278,7 @@ "quantity_min" => "Quantité minimale", "quantity_min_help" => "La plus petite commande en unités de base ; au moins un pas. Si vide, la valeur par défaut du site est utilisée.", "quantity_max" => "Quantité maximale", + "quantity_too_large" => "La quantité ne peut pas dépasser :max.", "quantity_tolerance" => "Tolérance de dépassement", "quantity_tolerance_help" => "De combien une quantité réelle (coupée ou pesée après la commande) peut dépasser la quantité commandée, en points de base : 500 = 5%. Si vide, la valeur par défaut du site est utilisée.", "quantity_rounding" => "Arrondi au pas", diff --git a/lang/pl/global.php b/lang/pl/global.php index e8e3d81..bb981b9 100644 --- a/lang/pl/global.php +++ b/lang/pl/global.php @@ -163,6 +163,10 @@ "payment_reconciliation_worker_desc" => "Rozlicza płatności offline i kończy niezakończone operacje płatnicze.", "payment_reconciliation_worker_done" => "Rozliczone płatności: :settled. Zakończone operacje: :resolved.", "checkout_context_unavailable" => "Składanie zamówienia jest obecnie niedostępne. Spróbuj ponownie później lub skontaktuj się z nami.", + "checkout_cart_changed" => "Ceny lub dostępność Twojego zamówienia uległy zmianie. Sprawdź zaktualizowaną sumę i ponownie potwierdź zamówienie.", + "order_rate_limited" => "Zbyt wiele zamówień z Twojego adresu. Spróbuj ponownie za kilka minut.", + "orders_rate_limit" => "Zamówienia na odwiedzającego w ciągu 10 minut", + "orders_rate_limit_help" => "Liczba zamówień i szybkich zamówień dozwolona z jednego adresu IP w ciągu 10 minut; chroni przed zalewem zamówień i e-maili. 0 = bez limitu.", "delivery_not_required" => "Dostawa nie jest wymagana", "live_orders" => "Rzeczywiste zamówienia", "order_meta" => "Dodatkowe dane zamówienia", @@ -170,9 +174,9 @@ "test_orders" => "Zamówienia testowe", "mode_virtual" => "Wirtualny", "mode_service" => "Usługa", - "vat_display" => "Wyświetlane ceny", + "vat_display" => "Domyślne ceny w sklepie", "vat_display_gross" => "Z VAT", - "vat_display_help" => "Domyślny sposób wyświetlania cen w sklepie. Odwiedzający mogą go przełączyć; pobierana cena zawsze zawiera VAT.", + "vat_display_help" => "Jak ceny w sklepie są pokazywane odwiedzającym, którzy jeszcze nie przełączyli wyświetlania, np. bez VAT w sklepie B2B. Odwiedzający mogą je przełączyć; słuchacze sCommerce.ResolveVatDisplay mogą decydować dla każdego klienta. Pobierana cena zawsze zawiera VAT.", "vat_display_net" => "Bez VAT", "vat_rate" => "Stawka VAT, %", "vat_rate_help" => "Domyślna stawka VAT zawarta w cenach produktów. Używana tylko do wyświetlania cen bez VAT; 0 pokazuje tę samą cenę.", @@ -184,6 +188,8 @@ "container_deposit_help" => "Kaucja za opakowanie zwrotne za sztukę, naliczana ponad cenę i poza VAT. Puste opakowania oddane w wymianie nie są obciążane nową kaucją.", "container_deposit_included" => "W tym kaucja za opakowania", "container_exchange" => "Opakowania w wymianie", + "container_exchange_on" => "Klienci deklarują opakowania w wymianie", + "container_exchange_on_help" => "Pozwala klientom podać w koszyku, ile pustych opakowań oddają. Te sztuki są zamawiane bez nowej kaucji, zanim ktokolwiek zobaczy opakowania, więc sprawdź je przy dostawie.", "container_return" => "Przyjmij zwrot opakowań", "container_return_cash" => "Zwrot gotówką", "container_return_count" => "Opakowania", @@ -206,6 +212,7 @@ "containers_help" => "Zwrócone opakowania oraz zwrot lub zaliczenie ich kaucji, także zwroty bez zamówienia.", "containers_icon" => "fa fa-recycle", "vat_display_on" => "Ceny bez VAT", + "show_field_price_net_help" => "Pokazuj cenę bez VAT i zawartą w niej akcyzę detaliczną na liście produktów. Dostępne, gdy ceny bez VAT są włączone.", "vat_display_on_help" => "Pozwól sklepowi pokazywać ceny bez VAT, np. klientom biznesowym. Pobierana cena zawsze zawiera VAT.", "retail_excise_on" => "Akcyza detaliczna", "retail_excise_on_help" => "Obliczaj akcyzę detaliczną zawartą w cenach (alkohol, tytoń, paliwo) i zapisuj ją w pozycjach zamówienia na potrzeby paragonu.", @@ -271,6 +278,7 @@ "quantity_min" => "Minimalna ilość", "quantity_min_help" => "Najmniejsze zamówienie w jednostkach bazowych; co najmniej jeden krok. Puste pole oznacza wartość domyślną witryny.", "quantity_max" => "Maksymalna ilość", + "quantity_too_large" => "Ilość nie może być większa niż :max.", "quantity_tolerance" => "Tolerancja nadwyżki", "quantity_tolerance_help" => "O ile ilość rzeczywista (docięta lub zważona po zamówieniu) może przekroczyć zamówioną, w punktach bazowych: 500 = 5%. Puste pole oznacza wartość domyślną witryny.", "quantity_rounding" => "Zaokrąglanie do kroku", diff --git a/lang/ru/global.php b/lang/ru/global.php index 34578c7..5d0bc8b 100644 --- a/lang/ru/global.php +++ b/lang/ru/global.php @@ -142,6 +142,10 @@ "checkout_settings" => "Корзина и заказы", "checkout_settings_intro" => "Эти настройки определяют отображение атрибутов товаров в корзине и правила формирования номеров заказов sCommerce.", "checkout_context_unavailable" => "Оформление заказа сейчас недоступно. Попробуйте позже или свяжитесь с нами.", + "checkout_cart_changed" => "Цены или наличие товаров в вашем заказе изменились. Проверьте обновлённую сумму и подтвердите заказ ещё раз.", + "order_rate_limited" => "Слишком много заказов с вашего адреса. Попробуйте ещё раз через несколько минут.", + "orders_rate_limit" => "Заказов от посетителя за 10 минут", + "orders_rate_limit_help" => "Сколько заказов и быстрых заказов разрешено с одного IP-адреса за 10 минут; защищает от потока заказов и писем. 0 = без ограничения.", "orders_reference_prefix" => "Префикс номера", "orders_reference_prefix_help" => "Префикс, который добавляется перед порядковым номером (например: #, ORD_).", "orders_reference_start" => "Начальный номер", @@ -159,6 +163,8 @@ "container_deposit_on" => "Залог за возвратную тару", "container_deposit_on_help" => "Начислять возвращаемый залог за тару, например за бутыли для воды, с обменом и возвратом. Добавляет поле залога в товары и вкладку «Тара».", "container_exchange" => "Тара в обмен", + "container_exchange_on" => "Покупатели указывают тару в обмен", + "container_exchange_on_help" => "Позволяет покупателям указать в корзине, сколько пустой тары они сдают. Эти единицы заказываются без нового залога ещё до того, как кто-либо увидел тару, поэтому проверяйте её при доставке.", "container_return" => "Вернуть тару", "container_return_cash" => "Возврат наличными", "container_return_count" => "Количество тары", @@ -544,11 +550,12 @@ "uploading_file" => "Загружаю файл", "user_unknown" => "Пользователь не известен", "value" => "Значение", - "vat_display" => "Отображение цен", + "vat_display" => "Цены на витрине по умолчанию", "vat_display_gross" => "С НДС", - "vat_display_help" => "Отображение цен на сайте по умолчанию. Посетители могут его переключить; цена к оплате всегда включает НДС.", + "vat_display_help" => "Как цены на витрине показываются посетителям, которые ещё не переключили отображение, например без НДС для B2B-магазина. Посетители могут его переключить; обработчики sCommerce.ResolveVatDisplay могут решать для каждого клиента. Цена к оплате всегда включает НДС.", "vat_display_net" => "Без НДС", "vat_display_on" => "Цены без НДС", + "show_field_price_net_help" => "Показывать цену без НДС и включённый в неё розничный акциз в списке товаров. Доступно, пока включены цены без НДС.", "vat_display_on_help" => "Разрешить витрине показывать цены без НДС, например бизнес-клиентам. Цена к оплате всегда включает НДС.", "vat_rate" => "Ставка НДС, %", "vat_rate_help" => "Ставка НДС по умолчанию, включённая в цены товаров. Используется только для показа цен без НДС; 0 показывает ту же цену.", @@ -767,6 +774,7 @@ "quantity_min" => "Минимальное количество", "quantity_min_help" => "Наименьший заказ в базовых единицах; не меньше одного шага. Если не заполнено, используется значение сайта по умолчанию.", "quantity_max" => "Максимальное количество", + "quantity_too_large" => "Количество не может быть больше :max.", "quantity_tolerance" => "Допуск превышения", "quantity_tolerance_help" => "Насколько фактическое количество (отрезанное или взвешенное после заказа) может превышать заказанное, в базисных пунктах: 500 = 5%. Если не заполнено, используется значение сайта по умолчанию.", "quantity_rounding" => "Округление до шага", diff --git a/lang/uk/global.php b/lang/uk/global.php index 2f68d1d..7913792 100644 --- a/lang/uk/global.php +++ b/lang/uk/global.php @@ -142,6 +142,10 @@ "checkout_settings" => "Кошик та замовлення", "checkout_settings_intro" => "Ці налаштування визначають відображення товарних атрибутів у кошику та правила формування номерів замовлень sCommerce.", "checkout_context_unavailable" => "Оформлення замовлення зараз недоступне. Спробуйте пізніше або зв'яжіться з нами.", + "checkout_cart_changed" => "Ціни або наявність товарів у вашому замовленні змінилися. Перевірте оновлену суму та підтвердьте замовлення ще раз.", + "order_rate_limited" => "Забагато замовлень з вашої адреси. Спробуйте ще раз за кілька хвилин.", + "orders_rate_limit" => "Замовлень від відвідувача за 10 хвилин", + "orders_rate_limit_help" => "Скільки замовлень і швидких замовлень дозволено з однієї IP-адреси за 10 хвилин; захищає від потоку замовлень і листів. 0 = без обмеження.", "orders_reference_prefix" => "Префікс номера", "orders_reference_prefix_help" => "Префікс, який буде додано перед порядковим номером (наприклад: #, ORD_).", "orders_reference_start" => "Початковий номер", @@ -159,6 +163,8 @@ "container_deposit_on" => "Заставна вартість зворотної тари", "container_deposit_on_help" => "Нараховувати заставу за тару, що повертається, наприклад за бутлі для води, з обміном і поверненням. Додає поле застави до товарів і вкладку «Тара».", "container_exchange" => "Тара в обмін", + "container_exchange_on" => "Покупці вказують тару в обмін", + "container_exchange_on_help" => "Дозволяє покупцям вказати в кошику, скільки порожньої тари вони здають. Ці одиниці замовляються без нової застави ще до того, як хтось побачив тару, тож перевіряйте її під час доставки.", "container_return" => "Повернути тару", "container_return_cash" => "Повернення готівкою", "container_return_count" => "Кількість тари", @@ -544,11 +550,12 @@ "uploading_file" => "Завантажую файл", "user_unknown" => "Користувач не відомий", "value" => "Значення", - "vat_display" => "Відображення цін", + "vat_display" => "Типові ціни на вітрині", "vat_display_gross" => "З ПДВ", - "vat_display_help" => "Типове відображення цін на сайті. Відвідувачі можуть його перемкнути; ціна до сплати завжди містить ПДВ.", + "vat_display_help" => "Як ціни на вітрині показуються відвідувачам, які ще не перемкнули відображення, наприклад без ПДВ для B2B-магазину. Відвідувачі можуть його перемкнути; обробники sCommerce.ResolveVatDisplay можуть вирішувати для кожного клієнта. Ціна до сплати завжди містить ПДВ.", "vat_display_net" => "Без ПДВ", "vat_display_on" => "Ціни без ПДВ", + "show_field_price_net_help" => "Показувати ціну без ПДВ і включений у неї роздрібний акциз у списку товарів. Доступно, поки увімкнено ціни без ПДВ.", "vat_display_on_help" => "Дозволити вітрині показувати ціни без ПДВ, наприклад бізнес-клієнтам. Ціна до сплати завжди містить ПДВ.", "vat_rate" => "Ставка ПДВ, %", "vat_rate_help" => "Типова ставка ПДВ, включена в ціни товарів. Використовується лише для показу цін без ПДВ; 0 показує ту саму ціну.", @@ -768,6 +775,7 @@ "quantity_min" => "Мінімальна кількість", "quantity_min_help" => "Найменше замовлення в базових одиницях; щонайменше один крок. Якщо не заповнено, використовується типове значення сайту.", "quantity_max" => "Максимальна кількість", + "quantity_too_large" => "Кількість не може бути більшою за :max.", "quantity_tolerance" => "Допуск перевищення", "quantity_tolerance_help" => "Наскільки фактична кількість (відрізана або зважена після замовлення) може перевищувати замовлену, у базисних пунктах: 500 = 5%. Якщо не заповнено, використовується типове значення сайту.", "quantity_rounding" => "Округлення до кроку", diff --git a/src/Cart/sCart.php b/src/Cart/sCart.php index baa6418..90a40a3 100644 --- a/src/Cart/sCart.php +++ b/src/Cart/sCart.php @@ -12,6 +12,7 @@ use Seiger\sCommerce\Services\OrderLineIdentity; use Seiger\sCommerce\Services\QuantityRules; use Seiger\sCommerce\Services\StockResolver; +use Seiger\sCommerce\Services\StorefrontGuard; use Seiger\sCommerce\Services\VatDisplay; use Seiger\sCommerce\Services\sPriceResolver; @@ -130,7 +131,7 @@ public function addProduct(int $productId = 0, int $optionId = 0, int $quantity $product = sCommerce::getProduct($productId); - if (!$product || !$product->id) { + if (!StorefrontGuard::purchasable($product)) { $message = __('sCommerce::global.product_with_id', ['id' => $productId]) . __('sCommerce::global.not_found') . '.'; Log::channel('scommerce')->warning('sCart - ' . $message); @@ -154,6 +155,9 @@ public function addProduct(int $productId = 0, int $optionId = 0, int $quantity } $quantity = MeasuredQuantity::normalize($requested, $rules); $adjusted = $quantity !== $requested; + } elseif ($quantity > $rules->max) { + $quantity = $rules->max; + $adjusted = true; } $this->storeQuantity($productId, $optionId, $quantity); @@ -234,6 +238,11 @@ public function removeProduct(int $productId = 0): array } } + $productId = is_int($productId) || (is_string($productId) && ctype_digit($productId)) ? (int)$productId : 0; + if ($productId < 1) { + return ['success' => false, 'message' => __('sCommerce::global.product_with_id', ['id' => 0]) . __('sCommerce::global.not_found') . '.']; + } + unset($this->cartData[$productId]); $this->saveCartData(); @@ -252,6 +261,7 @@ public function removeProduct(int $productId = 0): array * * Exchanged units carry no new deposit. The count is the customer's declaration, capped at * the line quantity; the courier or manager corrects the order when it differs. + * Refused unless {@see ContainerDeposit::exchangeEnabled()}. * * @param int $productId Product id; read from the request when 0. * @param int $optionId Cart option id; read from the request when not given. @@ -265,6 +275,9 @@ public function setContainerExchange(int $productId = 0, ?int $optionId = null, $optionId ??= request()->integer('optionId'); $exchange ??= request()->integer('quantity'); $item = $this->cartData[$productId][$optionId] ?? null; + if (!ContainerDeposit::exchangeEnabled()) { + return ['success' => false, 'message' => 'Container exchanges are not enabled.']; + } if ($item === null) { return ['success' => false, 'message' => __('sCommerce::global.product_with_id', ['id' => $productId]) . __('sCommerce::global.not_found') . '.']; } @@ -295,6 +308,19 @@ public function setContainerExchange(int $productId = 0, ?int $optionId = null, * @return array{totalSum: float, totalSumFormatted: string, totalSumNet: float, totalSumNetFormatted: string, totalSumDisplay: float, totalSumDisplayFormatted: string, totalRetailExcise: float, totalRetailExciseFormatted: string, vatDisplay: string, items: array>} */ public function getMiniCart(): array + { + return $this->getCartFor($this->cartData); + } + + /** + * Cart payload of {@see getMiniCart()} for `[productId => [optionId => quantity]]` with current + * prices; a line may be `['quantity' => int, 'exchange' => int]`. Unpublished and unknown + * products are left out. + * + * @param array> $cartData + * @since 1.5.0 + */ + public function getCartFor(array $cartData): array { $totalSum = 0; $totalNet = 0.0; @@ -303,17 +329,22 @@ public function getMiniCart(): array $currency = sCommerce::currentCurrency(); $vatDisplay = VatDisplay::mode(); $items = []; - $productIds = array_keys($this->cartData); - $products = sCommerce::getProducts($productIds); + $productIds = array_keys($cartData); + $products = $productIds === [] ? [] : sCommerce::getProducts($productIds); + $exchangeEnabled = ContainerDeposit::exchangeEnabled(); foreach ($products as $product) { + if (!StorefrontGuard::purchasable($product) || !is_array($cartData[$product->id] ?? null)) { + continue; + } $rules = MeasuredQuantity::rules($product); - foreach ($this->cartData[$product->id] as $optionId => $cartItem) { - $quantity = $this->getCartItemQuantity($cartItem, $rules->min); + foreach ($cartData[$product->id] as $optionId => $cartItem) { + $quantity = $this->getCartItemQuantity($cartItem, $rules->min, $rules->max); $deposit = ContainerDeposit::lineFields(ContainerDeposit::unit($product, (int)$optionId, $currency), $quantity, - is_array($cartItem) ? (int)($cartItem['exchange'] ?? 0) : 0); + $exchangeEnabled && is_array($cartItem) ? (int)($cartItem['exchange'] ?? 0) : 0); $pricing = $this->resolveProductPricing($product, (int)$optionId); - $price = (float)$pricing['priceAsFloat']; + // A negative price from a listener never lowers the order total. + $price = max(0.0, (float)$pricing['priceAsFloat']); $lineSum = $rules->isMeasured() ? MeasuredQuantity::lineTotalMinor(MeasuredQuantity::scalePrice($price), $quantity, $rules->factor) / 100 : $price * $quantity; @@ -334,7 +365,8 @@ public function getMiniCart(): array 'sumRetailExciseAsFloat' => $sumExciseAsFloat, 'sumRetailExcise' => $sumExciseAsFloat > 0 ? sCommerce::convertPrice($sumExciseAsFloat) : ''] ); if ($rules->isMeasured()) { - $item += MeasuredQuantity::lineFields($rules, $quantity, (string)($pricing['price'] ?? ''), (int)($item['weight'] ?? 0)); + // Physical fields stay out of the cart payload; only the line weight is derived from them. + $item += MeasuredQuantity::lineFields($rules, $quantity, (string)($pricing['price'] ?? ''), (int)($product->physicalPayload()['weight'] ?? 0)); } $items[] = $item; $totalSum += $lineSum; @@ -445,11 +477,41 @@ private function storeQuantity(int $productId, int $optionId, int $quantity): vo } /** - * Stored quantity of a cart line, at least `$min` (the minimum of a measured product). + * Stored quantity of a cart line, from `$min` to `$max` (the limits of the product). + */ + private function getCartItemQuantity(mixed $cartItem, int $min = 1, int $max = MeasuredQuantity::PIECE_MAX): int + { + return min($max, is_array($cartItem) ? max($min, (int)($cartItem['quantity'] ?? 1)) : max($min, (int)$cartItem)); + } + + /** + * Titles of products with less stock than the cart needs, while `product.inventory_on` is set. + * Virtual and service lines are not counted against inventory. + * + * @param array>|null $cartData Lines as in {@see getCartFor()}; the session cart by default. + * @return list + * @since 1.5.0 */ - private function getCartItemQuantity(mixed $cartItem, int $min = 1): int + public function shortages(?array $cartData = null): array { - return is_array($cartItem) ? max($min, (int)($cartItem['quantity'] ?? 1)) : max($min, (int)$cartItem); + $cartData ??= $this->cartData; + if (!sCommerce::config('product.inventory_on', 0) || $cartData === []) { + return []; + } + $shortages = []; + foreach (sCommerce::getProducts(array_keys($cartData)) as $product) { + foreach ((array)($cartData[$product->id] ?? []) as $optionId => $cartItem) { + if (!$this->tracksInventory($product, (int)$optionId)) { + continue; + } + $quantity = $this->getCartItemQuantity($cartItem, 1, PHP_INT_MAX); + if (app(StockResolver::class)->available($product, 0, ['optionId' => (int)$optionId, 'quantity' => $quantity]) < $quantity) { + $shortages[] = (string)($product->title ?? $product->id); + } + } + } + + return array_values(array_unique($shortages)); } private function resolveProductPriceMode(object $product, int $optionId = 0): string diff --git a/src/Checkout/sCheckout.php b/src/Checkout/sCheckout.php index 1d34fdf..990f38a 100644 --- a/src/Checkout/sCheckout.php +++ b/src/Checkout/sCheckout.php @@ -21,6 +21,7 @@ use Seiger\sCommerce\Services\MeasuredQuantityException; use Seiger\sCommerce\Services\OrderLineIdentity; use Seiger\sCommerce\Services\OrderTaxes; +use Seiger\sCommerce\Services\StorefrontGuard; use Seiger\sCommerce\Services\VatDisplay; use Seiger\sCommerce\Services\sPriceResolver; use View; @@ -460,11 +461,9 @@ public function setOrderData(array $data): array $this->orderData = array_replace_recursive($this->orderData, $resultData); - if ($this->requiresDelivery()) { - if (!empty($validatedData['delivery']['method']) && isset($this->deliveryMethods[$this->orderData['delivery']['method']])) { - $deliveryMethod = $this->deliveryMethods[$this->orderData['delivery']['method']]; - $this->orderData['delivery']['cost'] = $deliveryMethod->calculateCost($this->orderData); - } + // Recalculated on every change, so an address changed after the method keeps no stale cost. + if (($deliveryMethod = $this->selectedDeliveryMethod()) !== null) { + $this->orderData['delivery']['cost'] = $this->deliveryCost($deliveryMethod); } $_SESSION['sCheckout'] = $this->orderData; @@ -498,12 +497,28 @@ public function processOrder(): array { // One scope: the order and its payment_info update produce a single OrderCreated. $scope = EventScope::begin('storefront', (int)($this->orderData['user']['id'] ?? 0)); + // A failed attempt leaves the checkout as it was, so a retry never adds the delivery twice. + $initial = $this->orderData; + $hadSession = isset($_SESSION) && array_key_exists('sCheckout', $_SESSION); + $initialSession = $_SESSION['sCheckout'] ?? null; + $rollback = function () use ($initial, $hadSession, $initialSession): void { + $this->orderData = $initial; + if ($hadSession) { + $_SESSION['sCheckout'] = $initialSession; + } else { + unset($_SESSION['sCheckout']); + } + }; try { - if (empty($this->orderData['products'])) { + if (empty($this->orderData['products']) || !is_array($this->orderData['products'])) { throw new \Exception("Invalid products in Order."); } Validator::make($this->orderData, $this->getValidationRules($this->orderData))->validate(); + if (($changed = $this->refreshProducts()) !== null) { + return $changed; + } + $this->checkoutContext = CheckoutContext::resolve($this->orderData, 'storefront', self::trustedActorId()); $paymentMethod = null; @@ -511,13 +526,15 @@ public function processOrder(): array $paymentSuccess = true; if ($this->requiresDelivery()) { - $deliveryMethod = $this->deliveryMethods[$this->orderData['delivery']['method'] ?? ''] ?? null; + $deliveryMethod = $this->selectedDeliveryMethod(); if (!$deliveryMethod) { - throw new \Exception('Invalid delivery method ' . ($this->orderData['delivery']['method'] ?? '') . '.'); + throw new \Exception('Invalid delivery method.'); } - $this->orderData['cost'] += $this->orderData['delivery']['cost'] ?? 0; + // Never the cost stored in the session: the address or currency may have changed since. + $this->orderData['delivery']['cost'] = $this->deliveryCost($deliveryMethod); + $this->orderData['cost'] += $this->orderData['delivery']['cost']; } elseif (sCommerce::config('basic.deliveries_on', 1) == 1) { $this->orderData['delivery'] = ['method' => '', 'required' => false, 'cost' => 0]; } @@ -528,6 +545,7 @@ public function processOrder(): array if (!$paymentMethod || !$paymentMethod->validatePayment($this->orderData)) { evo()->logEvent(0, 3, 'Payment method ' . $this->orderData['payment']['method'] . ' is Invalid or not available. Please check your API credentials.', 'sCommerce: Payment ' . $this->orderData['payment']['method'] . ' Failed'); Log::channel('scommerce')->error("Order processing failed: Payment method {$this->orderData['payment']['method']} is Invalid or not available. Please check your API credentials.", ['order_data' => $this->orderData]); + $rollback(); return [ 'success' => false, @@ -620,12 +638,14 @@ public function processOrder(): array return $response; } catch (CheckoutContextException $e) { + $rollback(); return [ 'success' => false, 'message' => $e->getMessage(), 'error_code' => 0, ]; } catch (\Exception $e) { + $rollback(); evo()->logEvent(0, 3, $e->getMessage(), 'sCommerce: Order Processing Failed'); Log::channel('scommerce')->error("Order processing failed: {$e->getMessage()}", ['order_data' => $this->orderData, 'error' => $e->getTraceAsString()]); @@ -658,7 +678,12 @@ public static function quickOrder(array $data) { // Product lines are built by the server below, never accepted from the request. unset($data['products']); - $product = sCommerce::getProduct($data['productId']); + // No product id means the cart; anything but a whole number is an unknown product. + $rawProductId = $data['productId'] ?? 0; + $productId = is_int($rawProductId) || (is_string($rawProductId) && ctype_digit(trim($rawProductId))) ? (int)$rawProductId + : (in_array($rawProductId, [null, ''], true) ? 0 : -1); + $data['productId'] = $productId; + $product = $productId > 0 ? sCommerce::getProduct($productId) : null; $userId = evo()->getLoginUserID('web') ?: evo()->getLoginUserID('mgr'); // Checking if the user is authorized $user = evo()->getUserInfo($userId ?: 0) ?: []; $user = array_merge($user, evo()->getUserSettings()); @@ -682,7 +707,9 @@ public static function quickOrder(array $data) if (empty($userData['phone']) && empty($userData['email'])) { return [ 'success' => false, - 'product' => $product?->toArray() ?? [], + // Never the product row: it holds wholesale prices, stock and unpublished products. + 'product' => StorefrontGuard::purchasable($product) + ? ['id' => (int)$product->id, 'title' => (string)($product->title ?? ''), 'link' => (string)($product->link ?? '')] : [], 'message' => 'Either phone number or email is required.', 'errors' => ['User is undefined'], ]; @@ -693,12 +720,12 @@ public static function quickOrder(array $data) } catch (CheckoutContextException $e) { return [ 'success' => false, - 'product' => (int)$data['productId'], + 'product' => max(0, $productId), 'message' => $e->getMessage(), ]; } - if (empty($data['productId']) || $data['productId'] == 0) { + if ($productId === 0) { $cartData = sCart::getMiniCart(); if (empty($cartData['items'])) { return [ @@ -710,7 +737,7 @@ public static function quickOrder(array $data) $productsData = $cartData['items']; $cost = $cartData['totalSum']; } else { - if (!$product) { + if (!StorefrontGuard::purchasable($product)) { return [ 'success' => false, 'message' => 'Product not found.', @@ -723,24 +750,18 @@ public static function quickOrder(array $data) if ($rules->isMeasured()) { try { if (is_scalar($data['amount'] ?? null) && trim((string)$data['amount']) !== '') { - $quantity = MeasuredQuantity::parseAmount((string)$data['amount'], $rules); + $quantity = MeasuredQuantity::normalize(MeasuredQuantity::parseAmount((string)$data['amount'], $rules), $rules); } } catch (MeasuredQuantityException $e) { return [ 'success' => false, - 'product' => (int)$data['productId'], + 'product' => max(0, $productId), 'message' => $e->translated(), ]; } - $quantity = MeasuredQuantity::normalize($quantity, $rules); } $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, @@ -760,17 +781,7 @@ public static function quickOrder(array $data) $productData['priceMode'] = $pricing['priceMode']; } $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]; } @@ -782,15 +793,41 @@ public static function quickOrder(array $data) 'errors' => $validator->errors(), 'error_code' => 'checkout_validation']; } - if (!empty($data['productId']) && $data['productId'] != 0) { + if ($productId > 0) { + // Quantity-dependent totals are calculated only after the raw quantity has passed validation. $quantity = (int) $quantity; - $productsData[0]['quantity'] = $quantity; - $cost = (float) $pricing['priceAsFloat'] * $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)) + if ($rules->isMeasured()) { + $quantity = MeasuredQuantity::normalize($quantity, $rules); + } elseif ($quantity > $rules->max) { + return ['success' => false, 'message' => __('sCommerce::global.quantity_too_large', ['max' => $rules->max]), + 'error_code' => 'checkout_validation']; + } + if (($shortages = sCart::shortages([$productId => [$optionId => $quantity]])) !== []) { + return self::outOfStock($shortages); + } + $productData = $productsData[0]; + $productData['quantity'] = $quantity; + // A negative price from a listener never lowers the order total. + $price = max(0.0, (float) $pricing['priceAsFloat']); + $cost = $rules->isMeasured() + ? MeasuredQuantity::lineTotalMinor(MeasuredQuantity::scalePrice($price), $quantity, $rules->factor) / 100 + : $price * $quantity; + + // Units exchanged for empty returnable containers carry no new deposit. + $productData = array_merge($productData, ContainerDeposit::lineFields( + ContainerDeposit::unit($product, $optionId), $quantity, + ContainerDeposit::exchangeEnabled() ? max(0, (int)($data['exchange'] ?? 0)) : 0 )); - $cost += (float) ($productsData[0]['deposit_amount'] ?? 0); + $cost += (float) ($productData['deposit_amount'] ?? 0); + + $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]; + } elseif (($shortages = sCart::shortages()) !== []) { + return self::outOfStock($shortages); } do { @@ -839,7 +876,7 @@ public static function quickOrder(array $data) } catch (\InvalidArgumentException|CheckoutContextException $e) { return [ 'success' => false, - 'product' => (int)$data['productId'], + 'product' => max(0, $productId), 'message' => $e->getMessage(), ]; } @@ -854,7 +891,7 @@ public static function quickOrder(array $data) }); }); - if ($data['productId'] == 0) { + if ($productId === 0) { unset($_SESSION['sCheckout'], $_SESSION['sCart']); } @@ -972,6 +1009,100 @@ public function registerPaymentMethod(PaymentMethodInterface $method): void $this->paymentMethods[$method->getIdentifier()] = $method; } + /** + * Re-price the checkout lines with current prices, publication and stock before the order is saved. + * + * The checkout keeps the lines priced when it started. When a line, its quantity, the currency + * or the total differs from the current catalog, the lines are replaced with current ones and + * the attempt fails with `cart_changed`, so the customer confirms the new total. + * + * @return array|null Failure response, or null when the order may be saved. + */ + private function refreshProducts(): ?array + { + $cartData = []; + $stored = []; + foreach ($this->orderData['products'] as $line) { + $productId = is_array($line) ? (int)($line['product_id'] ?? $line['id'] ?? 0) : 0; + $optionId = is_array($line) ? (int)($line['option_id'] ?? 0) : 0; + $quantity = is_array($line) ? (int)($line['quantity'] ?? 0) : 0; + $stored[] = [$productId, $optionId, $quantity]; + if ($productId > 0 && $quantity > 0) { + $exchange = (int)($line['exchange_quantity'] ?? 0); + $cartData[$productId][$optionId] = $exchange > 0 ? ['quantity' => $quantity, 'exchange' => $exchange] : $quantity; + } + } + + if (($shortages = sCart::shortages($cartData)) !== []) { + return self::outOfStock($shortages); + } + + $cart = sCart::getCartFor($cartData); + $current = array_map(static fn (array $line) => [(int)$line['product_id'], (int)$line['option_id'], (int)$line['quantity']], $cart['items']); + sort($stored); + sort($current); + $currency = sCommerce::currentCurrency(); + if ($current !== $stored || ($this->orderData['currency'] ?? $currency) !== $currency + || abs((float)$cart['totalSum'] - (float)($this->orderData['cost'] ?? 0)) >= 0.005) { + $this->orderData['products'] = $cart['items']; + $this->orderData['cost'] = $cart['totalSum']; + $this->orderData['currency'] = $currency; + $_SESSION['sCheckout'] = $this->orderData; + + return [ + 'success' => false, + 'message' => __('sCommerce::global.checkout_cart_changed'), + 'error_code' => 'cart_changed', + 'products' => $cart['items'], + 'cost' => $cart['totalSum'], + ]; + } + $this->orderData['products'] = $cart['items']; + + return null; + } + + /** + * Delivery method chosen in the checkout data while a delivery is needed. + */ + private function selectedDeliveryMethod(): ?DeliveryMethodInterface + { + $method = $this->orderData['delivery']['method'] ?? null; + + return $this->requiresDelivery() && is_string($method) ? ($this->deliveryMethods[$method] ?? null) : null; + } + + /** + * Delivery cost in the order currency. A method states its cost in the currency of its settings row + * (`getCostCurrency()`), else in the main shop currency. Negative and non-finite costs count as 0. + */ + private function deliveryCost(DeliveryMethodInterface $method): float + { + $cost = (float)$method->calculateCost($this->orderData); + if (!is_finite($cost) || $cost <= 0) { + return 0.0; + } + $from = method_exists($method, 'getCostCurrency') ? strtoupper(trim((string)$method->getCostCurrency())) : ''; + $from = $from !== '' ? $from : strtoupper(trim((string)sCommerce::config('basic.main_currency', 'USD'))); + $to = strtoupper(trim((string)($this->orderData['currency'] ?? ''))) ?: sCommerce::currentCurrency(); + + return round($from === $to ? $cost : (float)sCommerce::convertPriceNumber($cost, $from, $to), 2); + } + + /** + * Failure response for lines with less stock than ordered. + * + * @param list $titles Product titles. + */ + private static function outOfStock(array $titles): array + { + return [ + 'success' => false, + 'message' => __('sCommerce::global.product_title_is_out_of_stock', ['title' => implode(', ', $titles)]), + 'error_code' => 'out_of_stock', + ]; + } + /** * Load order data from the session or database. * diff --git a/src/Delivery/BaseDeliveryMethod.php b/src/Delivery/BaseDeliveryMethod.php index eed94a5..f60d7e1 100644 --- a/src/Delivery/BaseDeliveryMethod.php +++ b/src/Delivery/BaseDeliveryMethod.php @@ -33,6 +33,19 @@ public function __construct() ]); } + /** + * Currency of {@see calculateCost()}: the currency of the method's settings row, else the main shop currency. + * Checkout converts the cost into the order currency. + * + * @since 1.5.0 + */ + public function getCostCurrency(): string + { + $currency = strtoupper(trim((string)($this->method->currency ?? ''))); + + return $currency !== '' ? $currency : strtoupper(trim((string)\Seiger\sCommerce\Facades\sCommerce::config('basic.main_currency', 'USD'))); + } + /** * Get the localized title for the delivery method. * diff --git a/src/Delivery/CourierDelivery.php b/src/Delivery/CourierDelivery.php index e6622db..dcb584e 100644 --- a/src/Delivery/CourierDelivery.php +++ b/src/Delivery/CourierDelivery.php @@ -83,20 +83,22 @@ public function getValidationRules(): array * Calculate the cost of delivery based on the order data. * * Calculates the total cost by adding a base cost and an extra cost depending on the city. + * Cities match case-insensitively in any script; the cost is in {@see getCostCurrency()}. * * @param array $orderData The order data, including user and address information. * @return float The calculated delivery cost. */ public function calculateCost(array $order): float { - $baseCost = floatval($this->method->cost ?? 0); - $distanceCost = 0; + $baseCost = max(0.0, floatval($this->method->cost ?? 0)); + $distanceCost = 0.0; $city = $order['delivery']['courier']['city'] ?? $order['user']['address']['city'] ?? ''; + $key = static fn ($value): string => is_scalar($value) ? mb_strtolower(trim((string)$value)) : ''; - if (!empty($city)) { - $city = strtolower(trim($city)); - $citySettings = collect($this->getSettings()['cities'] ?? [])->firstWhere('city', strtolower($city)); - $distanceCost = $citySettings['extra_cost'] ?? 0; + if ($key($city) !== '') { + $citySettings = collect($this->getSettings()['cities'] ?? []) + ->first(static fn ($row) => is_array($row) && $key($row['city'] ?? '') === $key($city)); + $distanceCost = max(0.0, floatval($citySettings['extra_cost'] ?? 0)); } return $baseCost + $distanceCost; diff --git a/src/Http/routes.php b/src/Http/routes.php index e68b869..f120cf0 100644 --- a/src/Http/routes.php +++ b/src/Http/routes.php @@ -5,6 +5,8 @@ use Seiger\sCommerce\Facades\sCart; use Seiger\sCommerce\Facades\sCheckout; use Seiger\sCommerce\Facades\sWishlist; +use Seiger\sCommerce\Models\sOrder; +use Seiger\sCommerce\Services\StorefrontGuard; use Seiger\sCommerce\sCommerce; Route::middleware('web')->prefix('scommerce/')->name('sCommerce.')->group(function () { @@ -20,10 +22,20 @@ sCheckout::setOrderData(request()->all()), fn($result) => response()->json($result, $result['success'] === true ? 200 : 422) ); - $processOrderHandler = fn() => tap( - sCheckout::processOrder(), - fn($result) => response()->json($result, $result['success'] === true ? 200 : 422) - ); + // Orders are limited per client, and the customer gets the order without manager notes, history or meta. + $placeOrder = static function (callable $place) { + if (!StorefrontGuard::orderAllowed()) { + return response()->json(['success' => false, 'message' => __('sCommerce::global.order_rate_limited'), 'error_code' => 'rate_limited'], 429); + } + $result = $place(); + if (($result['order'] ?? null) instanceof sOrder) { + StorefrontGuard::orderPlaced(); + $result['order'] = $result['order']->toCustomerArray(); + } + + return $result; + }; + $processOrderHandler = fn() => $placeOrder(static fn () => sCheckout::processOrder()); $payOrderHandler = function ($method = null) { $result = sCheckout::payOrder($method, request()->all()); if (request()->header('Accept') && strpos(request()->header('Accept'), 'application/json') !== false) { @@ -33,10 +45,7 @@ $_SESSION['payOrderResponse'] = $result; return back(); }; - $quickOrderHandler = fn() => tap( - sCheckout::quickOrder(request()->all()), - fn($result) => response()->json($result, $result['success'] === true ? 200 : 422) - ); + $quickOrderHandler = fn() => $placeOrder(static fn () => sCheckout::quickOrder(request()->all())); // Display only: show prices with (gross) or without (net) VAT to this visitor. $vatDisplayHandler = function () { if (!\Seiger\sCommerce\Services\VatDisplay::enabled()) { @@ -51,9 +60,9 @@ return response()->json(['success' => true, 'vatDisplay' => sCart::getVatDisplay(), 'miniCart' => sCart::getMiniCart()]); }; // Empty returnable containers the customer hands over for a cart line (no new deposit for them). - $containerExchangeHandler = fn() => \Seiger\sCommerce\Services\ContainerDeposit::enabled() + $containerExchangeHandler = fn() => \Seiger\sCommerce\Services\ContainerDeposit::exchangeEnabled() ? tap(sCart::setContainerExchange(), fn($result) => response()->json($result, $result['success'] === true ? 200 : 422)) - : response()->json(['success' => false, 'message' => 'Container deposits are not enabled.'], 404); + : response()->json(['success' => false, 'message' => 'Container exchanges are not enabled.'], 404); $wishlistHandler = fn() => tap( sWishlist::updateWishlist(request()->all()), fn($result) => response()->json($result, $result['success'] === true ? 200 : 422) diff --git a/src/Models/sOrder.php b/src/Models/sOrder.php index c0eb359..42a2e83 100644 --- a/src/Models/sOrder.php +++ b/src/Models/sOrder.php @@ -261,6 +261,21 @@ public function getOrderNumberAttribute(): ?string return OrderReferenceFormatter::orderNumber($this->reference ?? null); } + /** Attributes the storefront may show the customer; manager notes, history and `meta` stay internal. */ + public const CUSTOMER_KEYS = ['id', 'uuid', 'identifier', 'reference', 'user_info', 'delivery_info', 'payment_info', 'products', + 'cost', 'currency', 'status', 'payment_status', 'is_quick', 'comment', 'totals', 'created_at']; + + /** + * The order as the storefront returns it to the customer who placed it. + * + * @return array + * @since 1.5.0 + */ + public function toCustomerArray(): array + { + return ['order_number' => $this->order_number] + array_intersect_key($this->toArray(), array_flip(self::CUSTOMER_KEYS)); + } + /** * Orders of the live environment: everything except trusted test checkouts. * diff --git a/src/Models/sProduct.php b/src/Models/sProduct.php index 3d22cdb..aed5a51 100644 --- a/src/Models/sProduct.php +++ b/src/Models/sProduct.php @@ -830,6 +830,19 @@ public function priceTo($currency, string $priceMode = 'auto'): string return $this->formatPriceValue($this->priceToNumber($currency, $priceMode), $currency); } + /** + * The price of the manager product list ({@see activePricingPriceToNumber()} or {@see priceToNumber()}) + * without VAT and the included retail excise, formatted in `$currency`. + * + * @since 1.5.0 + */ + public function priceNetTo(string $currency): string + { + $gross = $this->activePricingPriceToNumber($currency, 'price') ?? $this->priceToNumber($currency); + + return $this->formatPriceValue(VatDisplay::net($gross, VatDisplay::rate($this), VatDisplay::exciseRate($this)), $currency); + } + /** * Convert the effective storefront price to a number in a specified currency. * diff --git a/src/Services/ContainerDeposit.php b/src/Services/ContainerDeposit.php index 7b69342..15bf768 100644 --- a/src/Services/ContainerDeposit.php +++ b/src/Services/ContainerDeposit.php @@ -48,6 +48,17 @@ public static function enabled(): bool return (int)sCommerce::config('product.container_deposit_on', 0) === 1; } + /** + * Whether customers may declare empty containers they exchange (`product.container_exchange_on`). + * The declaration waives the deposit before anyone has seen the containers, so it is off by default. + * + * @since 1.5.0 + */ + public static function exchangeEnabled(): bool + { + return self::enabled() && (int)sCommerce::config('product.container_exchange_on', 0) === 1; + } + /** * Deposit per unit of a product in `$currency`; the first non-negative numeric listener response wins. * Always 0 while the feature is off, and for products sold by weight, length, volume or count. diff --git a/src/Services/StorefrontGuard.php b/src/Services/StorefrontGuard.php new file mode 100644 index 0000000..93566e2 --- /dev/null +++ b/src/Services/StorefrontGuard.php @@ -0,0 +1,78 @@ +id ?? 0) < 1) { + return false; + } + + return !isset($product->published) || (int)$product->published === 1; + } + + /** + * Whether the client may place another order: fewer than `orders.rate_limit` orders + * from its IP address in {@see ORDER_WINDOW}. 0 disables the limit. Without a cache store + * orders are allowed, so a cache fault never stops sales. + * + * @since 1.5.0 + */ + public static function orderAllowed(): bool + { + [$limiter, $key, $limit] = self::orderLimiter(); + + return $limiter === null || !$limiter->tooManyAttempts($key, $limit); + } + + /** + * Count a placed order against the client's limit. + * + * @since 1.5.0 + */ + public static function orderPlaced(): void + { + [$limiter, $key] = self::orderLimiter(); + $limiter?->hit($key, self::ORDER_WINDOW); + } + + /** + * @return array{0: ?RateLimiter, 1: string, 2: int} + */ + private static function orderLimiter(): array + { + $limit = (int)sCommerce::config('orders.rate_limit', 10); + $key = 'scommerce-orders:' . sha1((string)(request()->ip() ?? '')); + if ($limit < 1) { + return [null, $key, $limit]; + } + try { + $cache = Container::getInstance()->make('cache')->store(); + } catch (\Throwable $exception) { + Log::channel('scommerce')->warning('sCommerce order rate limit is unavailable without a cache store', ['error' => $exception->getMessage()]); + return [null, $key, $limit]; + } + + return [new RateLimiter($cache), $key, $limit]; + } +} diff --git a/tests/standalone/fixtures/checkout-harness.php b/tests/standalone/fixtures/checkout-harness.php index e6dbdb5..29019ac 100644 --- a/tests/standalone/fixtures/checkout-harness.php +++ b/tests/standalone/fixtures/checkout-harness.php @@ -49,6 +49,8 @@ final class FakeProduct public ?int $quantity_tolerance = null; public ?string $quantity_rounding = null; public int $weight = 125; + /** Publication flag of real products; null = an object without one, which counts as published. */ + public ?int $published = null; public function __construct(public int $id, public string $sku, public float $price = 10.0, public int $mode = sProduct::MODE_SIMPLE, public ?string $gtin = null, public int $inventory = 100, public string $title = '') @@ -135,12 +137,16 @@ public function __call($level, $arguments) { $this->records[] = [$level, $argume public array $products = []; /** @var list, 1: string}> */ public array $mails = []; + public string $currency = 'EUR'; + /** @var array Exchange rates as `FROM_TO`; unknown pairs convert 1:1. */ + public array $rates = []; public function config($key, $default = null) { return array_key_exists($key, $this->config) ? $this->config[$key] : (str_starts_with($key, 'notifications.') ? 0 : $default); } public function getProduct($id, $lang = null) { return $this->products[(int)$id] ?? null; } public function getProducts($ids) { return collect(array_values(array_intersect_key($this->products, array_flip($ids)))); } - public function currentCurrency() { return 'EUR'; } - public function convertPrice($value, $currency = null, $display = null) { return number_format((float)$value, 2, '.', '') . ' EUR'; } - public function convertPriceNumber($value, $from = null, $to = null) { return (float)$value; } + public function currentCurrency() { return $this->currency; } + public function getCurrencyFormat($currency) { return ['exp' => 2, 'decimals' => '.', 'thousands' => ' ', 'show' => 1, 'symbol' => strtoupper("$currency") . ' ', 'position' => 'before']; } + public function convertPrice($value, $currency = null, $display = null) { return number_format((float)$value, 2, '.', '') . ' ' . $this->currency; } + public function convertPriceNumber($value, $from = null, $to = null) { return (float)$value * ($this->rates[strtoupper("$from") . '_' . strtoupper("$to")] ?? 1.0); } public function resolveProductPricing($product, $currency = null) { return app(sPriceResolver::class)->resolve($product, $currency); } public function adminNotificationEmails() { return ['admin@shop.test']; } public function notifyEmail($to, $template, $data = []) { $this->mails[] = [array_values((array)$to), $template]; } @@ -163,6 +169,11 @@ public function resolve(object $product, ?string $currency = null, string $price public function generate(?int $orderId = null): string { return ''; } }); $app->instance('db.transactions', new DatabaseTransactionsManager()); +// In-memory cache store for the storefront order limit. +$app->instance('cache', new class { + private ?Illuminate\Cache\Repository $store = null; + public function store($name = null) { return $this->store ??= new Illuminate\Cache\Repository(new Illuminate\Cache\ArrayStore()); } +}); $db = new Capsule($app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'foreign_key_constraints' => true]); $db->setEventDispatcher(new Dispatcher($app)); diff --git a/tests/standalone/fixtures/piece-cart-master.json b/tests/standalone/fixtures/piece-cart-master.json index 330a249..df42192 100644 --- a/tests/standalone/fixtures/piece-cart-master.json +++ b/tests/standalone/fixtures/piece-cart-master.json @@ -27,11 +27,6 @@ "priceMode": "auto", "priceAsFloat": 12.5, "oldPriceAsFloat": 0, - "weight": 125, - "width": 0, - "height": 0, - "length": 0, - "volume": 0, "line_key": "1:0", "product_id": 1, "option_id": 0, @@ -81,11 +76,6 @@ "priceMode": "auto", "priceAsFloat": 3.335, "oldPriceAsFloat": 0, - "weight": 125, - "width": 0, - "height": 0, - "length": 0, - "volume": 0, "line_key": "2:0", "product_id": 2, "option_id": 0, @@ -135,11 +125,6 @@ "priceMode": "auto", "priceAsFloat": 8.335, "oldPriceAsFloat": 0, - "weight": 125, - "width": 0, - "height": 0, - "length": 0, - "volume": 0, "line_key": "2:5", "product_id": 2, "option_id": 5, @@ -189,11 +174,6 @@ "priceMode": "auto", "priceAsFloat": 0.1, "oldPriceAsFloat": 0, - "weight": 125, - "width": 0, - "height": 0, - "length": 0, - "volume": 0, "line_key": "3:0", "product_id": 3, "option_id": 0, @@ -246,11 +226,6 @@ "priceMode": "auto", "priceAsFloat": 12.5, "oldPriceAsFloat": 0, - "weight": 125, - "width": 0, - "height": 0, - "length": 0, - "volume": 0, "line_key": "1:0", "product_id": 1, "option_id": 0, @@ -277,11 +252,6 @@ "priceMode": "auto", "priceAsFloat": 3.335, "oldPriceAsFloat": 0, - "weight": 125, - "width": 0, - "height": 0, - "length": 0, - "volume": 0, "line_key": "2:0", "product_id": 2, "option_id": 0, @@ -308,11 +278,6 @@ "priceMode": "auto", "priceAsFloat": 8.335, "oldPriceAsFloat": 0, - "weight": 125, - "width": 0, - "height": 0, - "length": 0, - "volume": 0, "line_key": "2:5", "product_id": 2, "option_id": 5, @@ -339,11 +304,6 @@ "priceMode": "auto", "priceAsFloat": 0.1, "oldPriceAsFloat": 0, - "weight": 125, - "width": 0, - "height": 0, - "length": 0, - "volume": 0, "line_key": "3:0", "product_id": 3, "option_id": 0, diff --git a/tests/standalone/scommerce-actual-quantities.php b/tests/standalone/scommerce-actual-quantities.php index 1c3a98f..fbe80b1 100644 --- a/tests/standalone/scommerce-actual-quantities.php +++ b/tests/standalone/scommerce-actual-quantities.php @@ -86,7 +86,8 @@ function config($key = null, $default = null) { return app('config')->get($key, $check($line['actual_quantity_label'] === '2.553 m' && $line['quantity_label'] === '2.53 m' && $line['sumAsFloat'] === 3.06 && $line['line_weight'] === 319 && $line['retail_excise_amount'] === Seiger\sCommerce\Services\VatDisplay::excise(3.06, 5), 'Sum, excise and weight follow the actual quantity'); $check((float)$updated->cost === 3.06 + 25.0 + 5.0, 'Cost follows; the delivery is kept'); -$entry = end($updated->history); +$history = $updated->history; +$entry = end($history); $check(($entry['actual_quantity_set'] ?? null) === ['line_key' => '10:0', 'old' => null, 'new' => 2553, 'ordered' => 2530, 'source' => 'manager', 'over_tolerance' => false] && $entry['user_id'] === 7, 'History entry with the old and new values'); $orderUpdates = array_values(array_filter($events, static fn ($event) => $event[0] === 'sCommerce.OrderUpdated')); @@ -97,7 +98,7 @@ function config($key = null, $default = null) { return app('config')->get($key, $check($service->set($order, '10:0', 2656, 'manager', 7, 'op-2')->products[0]['actual_quantity'] === 2656, 'Up to the tolerance'); $check($refused(fn () => $service->set($order, '10:0', 2657, 'manager', 7, 'op-3'), 'actual_quantity_tolerance_exceeded'), 'Above the tolerance is refused'); $over = $service->set($order, '10:0', 2800, 'manager', 7, 'op-4', true); -$check($over->products[0]['actual_quantity'] === 2800 && end($over->history)['actual_quantity_set']['over_tolerance'] === true, 'Allowed when the customer agreed'); +$check($over->products[0]['actual_quantity'] === 2800 && ($over->history[array_key_last($over->history)]['actual_quantity_set']['over_tolerance'] ?? null) === true, 'Allowed when the customer agreed'); $check($service->set($order, '10:0', 1200, 'manager', 7, 'op-5')->products[0]['actual_quantity'] === 1200, 'Below is always allowed'); // 3. Idempotency by operation key. diff --git a/tests/standalone/scommerce-checkout-validation.php b/tests/standalone/scommerce-checkout-validation.php index 482a440..e177c24 100644 --- a/tests/standalone/scommerce-checkout-validation.php +++ b/tests/standalone/scommerce-checkout-validation.php @@ -66,6 +66,8 @@ $check($result['success'] && count($seen) === $seenBefore + 1, 'Valid string quantity is validated once'); $check($result['order']->products[0]['quantity'] === 3 && (float) $result['order']->cost === 30.0, 'Accepted quantity becomes integer before totals and persistence'); $_SESSION['sCart'] = [1 => [0 => 3]]; +// Product quick orders resolve the cart facade for stock checks; a request starts with the session cart. +Illuminate\Support\Facades\Facade::clearResolvedInstance('sCart'); $seenBefore = count($seen); $result = sCheckout::quickOrder(['productId' => 0, 'phone' => '123456789']); $check($result['success'] && count($seen) === $seenBefore + 1, 'Cart quick checkout also validates once'); diff --git a/tests/standalone/scommerce-container-deposit.php b/tests/standalone/scommerce-container-deposit.php index 071496a..7bd80c7 100644 --- a/tests/standalone/scommerce-container-deposit.php +++ b/tests/standalone/scommerce-container-deposit.php @@ -65,6 +65,14 @@ $first = sOrder::query()->find($result['order']->id); $check($result['success'] && (float)$first->cost === 370.0 && $first->products[0]['deposit_amount'] === 250 && $first->products[0]['deposit_returnable'] === true, 'First purchase pays 370'); +// Customer declarations waive the deposit unseen: off unless the shop allows them. +$_SESSION['sCart'] = [10 => [0 => ['quantity' => 1, 'exchange' => 1]]]; +$check((new sCart())->setContainerExchange(10, 0, 1)['success'] === false && (new sCart())->getMiniCart()['totalSum'] === 370.0 + && sCheckout::quickOrder(['productId' => 10, 'quantity' => 1, 'exchange' => 1, 'email' => 'quick@shop.test'])['order']->products[0]['deposit_amount'] === 250, + 'Without container_exchange_on, declared exchanges are ignored'); +$check(str_contains(file_get_contents($package . '/src/Http/routes.php'), 'ContainerDeposit::exchangeEnabled()'), 'The exchange route needs container_exchange_on'); +$shop->config['product.container_exchange_on'] = 1; + // Next purchase with an exchange: no new deposit. $_SESSION['sCart'] = [10 => [0 => 1]]; $exchange = (new sCart())->setContainerExchange(10, 0, 5); diff --git a/tests/standalone/scommerce-measured-quantities.php b/tests/standalone/scommerce-measured-quantities.php index a760854..df1e6f9 100644 --- a/tests/standalone/scommerce-measured-quantities.php +++ b/tests/standalone/scommerce-measured-quantities.php @@ -266,6 +266,13 @@ function config($key = null, $default = null) { return app('config')->get($key, && $quickLine['quantity_label'] === '2.53 m' && $quickLine['sumAsFloat'] === 3.04 && $quickLine['line_weight'] === 316, 'Quick order line and cost'); $quick = sCheckout::quickOrder(['productId' => 10, 'quantity' => 2533, 'email' => 'quick@shop.test']); $check($quick['order']->products[0]['quantity'] === 2540, 'Quick order fits the step'); +$check((float)$quick['order']->cost === 3.05 && $quick['order']->products[0]['sumAsFloat'] === 3.05, 'Quick order cost follows the measured total, not price times base units'); +$ordersBefore = sOrder::count(); +foreach (['abc', [], '1.5', 0, -5] as $rawQuantity) { + $quick = sCheckout::quickOrder(['productId' => 10, 'quantity' => $rawQuantity, 'email' => 'quick@shop.test']); + $check($quick['success'] === false && ($quick['error_code'] ?? '') === 'checkout_validation', 'Invalid raw measured quantity is refused by validation'); +} +$check(sOrder::count() === $ordersBefore, 'Refused measured quick orders are not persisted'); $events = []; $_SESSION['sCart'] = [10 => [0 => 2530], 11 => [0 => 1]]; $mini = (new sCart())->getMiniCart(); diff --git a/tests/standalone/scommerce-payment-schedule.php b/tests/standalone/scommerce-payment-schedule.php index 92c9dcb..90f0cb3 100644 --- a/tests/standalone/scommerce-payment-schedule.php +++ b/tests/standalone/scommerce-payment-schedule.php @@ -73,7 +73,20 @@ public function __call($level, $arguments) { $this->records[] = [$level, $argume $app->instance('validator', new Illuminate\Validation\Factory($app['translator'], $app)); $app->instance('sCommerce', new class { public function config($key, $default = null) { return match ($key) { 'basic.deliveries_on' => 0, 'basic.payments_on' => 1, default => str_starts_with($key, 'notifications.') ? 0 : $default }; } + public function currentCurrency() { return 'EUR'; } }); +// Checkout re-prices its lines from the catalog: one service, priced like the checkout under test. +$catalog = new class { + public float $price = 100.0; + public function shortages(?array $cartData = null): array { return []; } + public function getCartFor(array $cartData): array + { + $quantity = (int)($cartData[1][0] ?? 0); + $items = $quantity > 0 ? [['id' => 1, 'product_id' => 1, 'option_id' => 0, 'title' => 'Service', 'quantity' => $quantity, 'price' => $this->price]] : []; + return ['items' => $items, 'totalSum' => $this->price * $quantity]; + } +}; +$app->instance('sCart', $catalog); $app->instance(OrderReferenceGenerator::class, new class extends OrderReferenceGenerator { public function generate(?int $orderId = null): string { return ''; } }); @@ -142,6 +155,7 @@ public function generate(?int $orderId = null): string { return ''; } $checkout = (new ReflectionClass(sCheckout::class))->newInstanceWithoutConstructor(); $checkout->registerPaymentMethod(new InstallmentPsp()); $checkout->registerPaymentMethod(new FullCostPsp()); + $GLOBALS['catalog']->price = $cost; Closure::bind(function () use ($method, $cost): void { $this->orderData = [ 'products' => [['id' => 1, 'title' => 'Service', 'quantity' => 1, 'price' => $cost]], diff --git a/tests/standalone/scommerce-storefront-hardening.php b/tests/standalone/scommerce-storefront-hardening.php new file mode 100644 index 0000000..220f6de --- /dev/null +++ b/tests/standalone/scommerce-storefront-hardening.php @@ -0,0 +1,184 @@ +products = [1 => new FakeProduct(1, 'TEA', 10.0), 2 => new FakeProduct(2, 'DRAFT', 0.0)]; +$shop->products[2]->published = 0; +$shop->config['basic.deliveries_on'] = 0; +$request = static function (array $input = [], string $ip = '127.0.0.1'): void { + app()->instance('request', Request::create('/', 'POST', $input, [], [], ['REMOTE_ADDR' => $ip])); +}; +// A request starts with the session cart; the facade would keep the first one resolved. +$freshCart = static fn () => Facade::clearResolvedInstance('sCart'); + +// 1. Unpublished products cannot be bought, and errors never return the product row. +$_SESSION['sCart'] = []; +$request(['productId' => 2]); +$check((new sCart())->addProduct()['success'] === false && $_SESSION['sCart'] === [], 'Unpublished products are not added to the cart'); +$_SESSION['sCart'] = [1 => [0 => 1], 2 => [0 => 1]]; +$check(array_column((new sCart())->getMiniCart()['items'], 'id') === [1], 'Unpublished lines are left out of the cart'); +$before = sOrder::count(); +$check(sCheckout::quickOrder(['productId' => 2, 'email' => 'q@shop.test'])['message'] === 'Product not found.' && sOrder::count() === $before, + 'Unpublished products cannot be quick ordered'); +$noContact = sCheckout::quickOrder(['productId' => 1]); +$check($noContact['success'] === false && $noContact['product'] === ['id' => 1, 'title' => 'Product 1', 'link' => ''], 'A failed quick order returns only public product fields'); +$check(sCheckout::quickOrder(['productId' => 2])['product'] === [], 'Nothing about unpublished products'); + +// 2. Product ids: missing means the cart, anything but a whole number is unknown; never a TypeError. +$_SESSION['sCart'] = []; +$freshCart(); +$check(sCheckout::quickOrder(['email' => 'q@shop.test'])['message'] === 'Cart is empty. Cannot create a quick order.', 'Without a product id the cart is ordered'); +foreach (['abc', ['1'], '-1', '1.5', 1.5, true] as $productId) { + $check(sCheckout::quickOrder(['productId' => $productId, 'email' => 'q@shop.test'])['message'] === 'Product not found.', 'Malformed product ids are unknown products'); +} + +// 3. Quantity limits and negative prices. +$_SESSION['sCart'] = []; +$request(['productId' => 1, 'quantity' => 5000000]); +$added = (new sCart())->addProduct(); +$check($added['quantityAdjusted'] === true && $_SESSION['sCart'][1][0] === MeasuredQuantity::PIECE_MAX, 'Cart quantities stop at the piece maximum'); +$_SESSION['sCart'] = [1 => [0 => PHP_INT_MAX]]; +$check((new sCart())->getMiniCart()['items'][0]['quantity'] === MeasuredQuantity::PIECE_MAX, 'Session quantities are capped too'); +$tooMany = sCheckout::quickOrder(['productId' => 1, 'quantity' => 2000000, 'email' => 'q@shop.test']); +$check($tooMany['success'] === false && $tooMany['message'] === 'The quantity may not be greater than 1000000.', 'Quick orders refuse quantities above the maximum'); +$request(['productId' => ['1']]); +$check((new sCart())->removeProduct()['success'] === false, 'Malformed ids are not removed (no TypeError)'); +$request(['productId' => '1']); +$check((new sCart())->removeProduct()['success'] === true && !isset($_SESSION['sCart'][1]), 'Numeric ids are removed'); +$shop->products[3] = new FakeProduct(3, 'BROKEN', -5.0); +$_SESSION['sCart'] = [3 => [0 => 2]]; +$check((new sCart())->getMiniCart()['totalSum'] === 0.0, 'A negative price never lowers the cart total'); +$check((float)sCheckout::quickOrder(['productId' => 3, 'quantity' => 2, 'email' => 'q@shop.test'])['order']->cost === 0.0, 'Nor the quick order cost'); +unset($shop->products[3]); + +// 4. Stock: quick orders and checkout check it like the cart does. +$shop->config['product.inventory_on'] = 1; +$shop->products[1]->inventory = 2; +$before = sOrder::count(); +$short = sCheckout::quickOrder(['productId' => 1, 'quantity' => 3, 'email' => 'q@shop.test']); +$check($short['error_code'] === 'out_of_stock' && sOrder::count() === $before, 'Quick orders above stock are refused'); +$_SESSION['sCart'] = [1 => [0 => 3]]; +$freshCart(); +$check(sCheckout::quickOrder(['productId' => 0, 'email' => 'q@shop.test'])['error_code'] === 'out_of_stock', 'Cart quick orders check stock'); +$check($checkout([['id' => 1, 'product_id' => 1, 'option_id' => 0, 'quantity' => 3, 'priceAsFloat' => 10]])->processOrder()['error_code'] === 'out_of_stock' + && sOrder::count() === $before, 'Checkout checks stock before saving'); +$check(sCheckout::quickOrder(['productId' => 1, 'quantity' => 2, 'email' => 'q@shop.test'])['success'] === true, 'Orders within stock pass'); +$shop->config['product.inventory_on'] = 0; +$shop->products[1]->inventory = 100; + +// 5. Checkout re-prices the lines it kept since it started. +$lines = $cartLines([1 => [0 => 2]]); +$stale = $checkout($lines); +$shop->products[1]->price = 12.0; +$_SESSION['sCheckout'] = ['started' => true]; +$before = sOrder::count(); +$changed = $stale->processOrder(); +$check($changed['success'] === false && $changed['error_code'] === 'cart_changed' && $changed['cost'] === 24.0 && sOrder::count() === $before, + 'A price change since the checkout started fails with the new total'); +$check($_SESSION['sCheckout']['cost'] === 24.0 && $_SESSION['sCheckout']['products'][0]['priceAsFloat'] === 12.0, 'The checkout keeps the current lines'); +$confirmed = $stale->processOrder(); +$check($confirmed['success'] && (float)$confirmed['order']->cost === 24.0, 'The confirmed retry is charged the current price'); +$check($checkout($lines, ['cost' => 1])->processOrder()['error_code'] === 'cart_changed', 'A total that does not match the lines is never charged'); +$lines = $cartLines([1 => [0 => 1]]); +$hidden = $checkout($lines); +$shop->products[1]->published = 0; +$check($hidden->processOrder()['error_code'] === 'cart_changed' && $hidden->processOrder()['message'] === 'Invalid products in Order.', 'Products unpublished since are removed'); +$shop->products[1]->published = 1; +$switched = $checkout($cartLines([1 => [0 => 1]])); +$shop->currency = 'UAH'; +$check($switched->processOrder()['error_code'] === 'cart_changed' && $_SESSION['sCheckout']['currency'] === 'UAH', 'A currency switch re-prices the checkout'); +$shop->currency = 'EUR'; + +// 6. Delivery: cost in the order currency, recalculated for every address, added once per attempt. +$db->schema()->create('s_delivery_methods', static function ($table): void { + $table->id(); + $table->uuid('uuid')->nullable(); + $table->string('name')->unique(); + $table->string('class'); + $table->boolean('active')->default(true); + $table->integer('position')->default(0); + $table->text('title')->nullable(); + $table->text('description')->nullable(); + $table->decimal('cost', 9, 2)->default(0); + $table->char('currency', 3)->default('USD'); + $table->text('settings')->nullable(); + $table->timestamps(); +}); +$method = new sDeliveryMethod(['name' => 'courier', 'class' => CourierDelivery::class, 'active' => 1]); +$method->forceFill(['cost' => 100, 'currency' => 'UAH', 'settings' => json_encode(['cities' => [['city' => 'Київ', 'extra_cost' => 50]]], JSON_UNESCAPED_UNICODE)])->save(); +$courier = new CourierDelivery(); +$check($courier->getCostCurrency() === 'UAH' && $courier->calculateCost(['user' => ['address' => ['city' => ' КИЇВ ']]]) === 150.0 + && $courier->calculateCost(['delivery' => ['courier' => ['city' => 'Львів']]]) === 100.0, 'Cities match case-insensitively in Cyrillic'); +$shop->config['basic.deliveries_on'] = 1; +$shop->rates = ['UAH_EUR' => 0.025]; +$lines = $cartLines([1 => [0 => 1]]); +$withCourier = static function (array $extra = []) use ($checkout, $lines, $courier): sCheckout { + $instance = $checkout($lines, array_replace_recursive(['delivery' => ['method' => 'courier'], 'user' => ['address' => ['city' => 'Київ']]], $extra)); + $instance->registerDeliveryMethod($courier); + return $instance; +}; +$order = $withCourier()->processOrder()['order']; +$check((float)$order->cost === 12.0 + 3.75 && $order->delivery_info['cost'] === 3.75, '150 UAH delivery is charged as 3.75 EUR, not 150 EUR'); +$stalePrice = $withCourier(['delivery' => ['cost' => 0.01]])->processOrder()['order']; +$check((float)$stalePrice->cost === 15.75, 'Checkout recalculates the delivery instead of trusting the stored cost'); +$moving = $withCourier(); +$check($moving->setOrderData(['delivery' => ['method' => 'courier']])['delivery']['cost'] === 3.75 + && $moving->setOrderData(['user' => ['address' => ['city' => 'Львів']]])['delivery']['cost'] === 2.5, 'An address change recalculates the delivery'); +$retry = $withCourier(); +$fail = true; +Event::listen('sCommerce.BeforeOrderCreate', static function () use (&$fail): void { + if ($fail) { + $fail = false; + throw new InvalidArgumentException('Temporary failure'); + } +}); +$_SESSION['sCheckout'] = ['started' => true]; +$check($retry->processOrder()['success'] === false && $_SESSION['sCheckout'] === ['started' => true], 'A failed attempt leaves the checkout session as it was'); +$check((float)$retry->processOrder()['order']->cost === 15.75, 'The retry adds the delivery once'); +$shop->rates = []; +$shop->config['basic.deliveries_on'] = 0; + +// 7. The storefront gets the order without manager notes, history or meta. +$order = sOrder::query()->latest('id')->first(); +$order->manager_notes = [['comment' => 'Internal: VIP, call the owner']]; +$public = $order->toCustomerArray(); +$check(isset($public['identifier'], $public['products'], $public['cost']) && array_key_exists('order_number', $public) + && !array_intersect_key($public, array_flip(['manager_notes', 'history', 'meta', 'manager_info', 'marketing'])), 'Customer payload keeps internal fields out'); +$routes = file_get_contents($package . '/src/Http/routes.php'); +$check(substr_count($routes, '$placeOrder(') === 2 && str_contains($routes, "->toCustomerArray()") && str_contains($routes, 'StorefrontGuard::orderAllowed()') + && str_contains($routes, '429'), 'Order routes are limited and return the customer payload'); + +// 8. Orders per client. +$shop->config['orders.rate_limit'] = 2; +$request(); +$check(StorefrontGuard::orderAllowed(), 'First order allowed'); +StorefrontGuard::orderPlaced(); +StorefrontGuard::orderPlaced(); +$check(!StorefrontGuard::orderAllowed(), 'The limit stops the next order'); +$request([], '10.0.0.2'); +$check(StorefrontGuard::orderAllowed(), 'Other clients are not affected'); +$shop->config['orders.rate_limit'] = 0; +$request(); +$check(StorefrontGuard::orderAllowed(), 'Limit 0 disables it'); +$shop->config['orders.rate_limit'] = 2; +$cache = $app['cache']; +$app->offsetUnset('cache'); +$check(StorefrontGuard::orderAllowed(), 'Without a cache store orders are allowed'); +$app->instance('cache', $cache); + +echo "$checks storefront hardening checks passed (isolated SQLite; no site database)\n"; diff --git a/tests/standalone/scommerce-ukraine-beer.php b/tests/standalone/scommerce-ukraine-beer.php new file mode 100644 index 0000000..3c26169 --- /dev/null +++ b/tests/standalone/scommerce-ukraine-beer.php @@ -0,0 +1,74 @@ +currency = 'UAH'; +$shop->config = array_replace($shop->config, [ + 'basic.deliveries_on' => 0, + 'product.vat_display_on' => 1, + 'product.vat_rate' => 20, + 'product.retail_excise_on' => 1, + 'product.container_deposit_on' => 1, + 'product.container_exchange_on' => 1, + 'product.container_terms' => 'Пляшка є зворотною тарою (п. 189.2 ПКУ)', +]); +// The excise applies to beer only, set per product as a project would. +Event::listen(VatDisplay::EXCISE_EVENT, static fn (array $payload) => $payload['product']->sku === 'BEER-05' ? 5 : null); +$beer = new FakeProduct(1, 'BEER-05', 52.50, title: 'Lager 0.5 l'); +$beer->currency = 'UAH'; +$beer->deposit = 3.0; +$chips = new FakeProduct(2, 'CHIPS', 30.0, title: 'Chips'); +$chips->currency = 'UAH'; +$shop->products = [1 => $beer, 2 => $chips]; + +// 1. One bottle: 2.50 UAH excise inside 52.50; without VAT and excise 41.67. +$check(VatDisplay::excise(52.50, 5) === 2.5 && VatDisplay::net(52.50, 20, 5) === 41.67, 'Unit excise and net price'); +$check(VatDisplay::excise(52.50, 0) === 0.0 && VatDisplay::net(30.0, 20) === 25.0, 'No excise without a rate'); + +// 2. Cart: four bottles, two empty bottles handed over, and chips. +$_SESSION['sCart'] = [1 => [0 => 4], 2 => [0 => 1]]; +$exchange = (new sCart())->setContainerExchange(1, 0, 2); +$cart = $exchange['miniCart']; +$items = array_column($cart['items'], null, 'id'); +$check($exchange['success'] && $items[1]['retailExciseRate'] === 5.0 && $items[1]['retailExciseAmountAsFloat'] === 2.5 && $items[1]['priceNetAsFloat'] === 41.67, + 'Beer line shows the excise and the price without VAT'); +$check($items[1]['sumAsFloat'] === 210.0 && $items[1]['sumRetailExciseAsFloat'] === 10.0 && $items[1]['sumNetAsFloat'] === 166.67, 'Beer line totals'); +$check($items[1]['deposit_unit'] === 3.0 && $items[1]['exchange_quantity'] === 2 && $items[1]['deposit_quantity'] === 2 && $items[1]['deposit_amount'] === 6.0, + 'Two exchanged bottles: deposit for the other two only'); +$check($items[2]['retailExciseAmount'] === '' && $items[2]['sumNetAsFloat'] === 25.0 && !isset($items[2]['deposit_amount']), 'Chips carry neither excise nor deposit'); +$check($cart['totalGoods'] === 240.0 && $cart['totalDeposit'] === 6.0 && $cart['totalSum'] === 246.0, 'The customer pays goods plus deposit'); +$check($cart['totalRetailExcise'] === 10.0 && $cart['totalRetailExciseFormatted'] === '10.00 UAH' && $cart['totalSumNet'] === 197.67, + 'The deposit is outside the excise and VAT'); + +// 3. Checkout: the order keeps the excise and deposit for the receipt; display values are not stored. +$result = $checkout($cart['items'], ['cost' => $cart['totalSum'], 'currency' => 'UAH'])->processOrder(); +$order = sOrder::query()->find($result['order']->id); +$lines = array_column($order->products, null, 'product_id'); +$check($result['success'] && (float)$order->cost === 246.0 && $order->currency === 'UAH', 'Order cost in UAH'); +$check($lines[1]['retail_excise_rate'] == 5 && $lines[1]['retail_excise_amount'] == 10 && !isset($lines[2]['retail_excise_amount']), 'Order lines keep the excise'); +$check($lines[1]['deposit_amount'] == 6 && $lines[1]['deposit_returnable'] === true && $lines[1]['deposit_terms'] === 'Пляшка є зворотною тарою (п. 189.2 ПКУ)', + 'Order lines keep the returnable deposit and its terms'); +$check(!array_intersect_key($lines[1], array_flip(VatDisplay::keys())), 'Visitor display values are not order data'); + +// 4. Quick order of two bottles, one empty bottle handed over. +$quick = sCheckout::quickOrder(['productId' => 1, 'quantity' => 2, 'exchange' => 1, 'phone' => '+380671234567']); +$line = sOrder::query()->find($quick['order']->id)->products[0]; +$check($quick['success'] && (float)$quick['order']->cost === 105.0 + 3.0 && $line['retail_excise_amount'] == 5 && $line['deposit_amount'] == 3, + 'Quick order: 105 UAH beer with 5 UAH excise, 3 UAH deposit'); + +// 5. The manager changes the quantity: the excise follows the line total. +$edited = OrderTaxes::applyRetailExcise([array_replace($lines[1], ['quantity' => 6])]); +$check($edited[0]['retail_excise_amount'] === 15.0, 'Six bottles: 315 UAH with 15 UAH excise'); + +echo "$checks Ukrainian beer checks passed (isolated SQLite; no site database)\n"; diff --git a/tests/standalone/scommerce-vat-display.php b/tests/standalone/scommerce-vat-display.php index 501a1a1..0c84e3a 100644 --- a/tests/standalone/scommerce-vat-display.php +++ b/tests/standalone/scommerce-vat-display.php @@ -114,6 +114,27 @@ $check(str_contains($settings, 'data-feature-toggle="vat_display_on"') && str_contains($settings, 'data-feature-toggle="retail_excise_on"') && str_contains($settings, 'data-feature="vat_display_on"'), 'Settings show the fields of enabled features only'); $check(str_contains(file_get_contents($package . '/src/Services/ProductListingService.php'), 'VatDisplay::pricing('), 'Listing cards carry the display values'); + +// Manager product list: the price without VAT and the included excise, a column of its own. +$shop->config['product.vat_rate'] = 23; +$shop->config['product.retail_excise_on'] = 1; +$listed = (new sProduct())->forceFill(['id' => 77, 'sku' => 'BEER', 'currency' => 'EUR', 'price_regular' => 129.15, 'price_special' => 0]); +$check($listed->priceNetTo('EUR') === 'EUR 100.00', 'List price without VAT and excise'); +$listed->sku = 'MUG'; +$check($listed->priceNetTo('EUR') === 'EUR 105.00', 'List price without VAT'); +$list = file_get_contents($package . '/views/partials/settingsProductsConfiguration.blade.php'); +$tab = file_get_contents($package . '/views/productsTab.blade.php'); +$netRow = substr($list, strpos($list, '