Skip to content
V
For developers
Architecture, conventions, the core and security
Modules / E-shop

E-shop#

The largest module in the system — 84 entities, 40 managers, 31 Api presenters (ls app/UI/Api/Eshop/Models/Entities/ | wc -l). The catalogue, the cart, orders, complaints, feeds for price comparison sites. The code lives in app/UI/Api/Eshop/, the administration in app/UI/Admin/Eshop/, the front end in app/UI/Front/Eshop/.

What the module is made of#

Area Main entities What orchestrates it
catalogue ProductProductVariantProductSet, ProductCategory, Producer, ProductTag ProductSetManager, ProductManager
prices ProductSetPrice (time snapshots), Vat the resolver in OrderSnapshotBuilder
cart Cart, CartItem CartManager
order Order plus OrderItem, OrderShipper, OrderPaymentMethod, OrderVoucher, OrderGift, OrderStatus OrderCheckoutManager, OrderTransitionManager
shipping and payment Shipper, ShipperTariff, ShipperRegion, PaymentMethod ShipperTariffManager
discounts Voucher, Gift VoucherManager, GiftManager
complaints Complaint, ComplaintStatus, ComplaintSolution ComplaintTransitionManager
feeds Feed, FeedParameterAction FeedManager

Besides managers the module also has generators (OrderInvoiceGenerator, OrderEmailGenerator, VoucherGenerator), I/O-free helpers (OrderCalculator) and process services (OrderSnapshotBuilder).

What is sold is the set, not the product#

Product          the product — a marketing unit (name, description, category)
  └ ProductVariant   a variant (colour, size)
      └ ProductSet     the selling unit — THIS one has a price, stock and a barcode

The cart, the warehouse and the invoice all work with the SET

The product is only a wrapper. Anyone looking for a price or stock availability on Product will not find it — and because the relations exist, they get no error, just emptiness.

Prices are time series, not a column#

A price is a snapshot keyed by publication time, quantity threshold, user group and currency. That makes the history traceable and scheduled prices work without intervention.

A price list is not edited, a new snapshot is added

Changing an old price rewrites history — and a recalculation of an old order then comes out different from what was actually paid.

A scheduled price needs the cron

/cron/eshop/price/recalc runs every 15 minutes. A longer interval means a discount starts later than it should — and nothing reports it.

A stored price is always the base without VAT#

The contract holds for every price world in the module — set prices, set fees, shipping and payment. The VAT-inclusive price is derived, never stored. The binding description lives in the OrderSnapshotBuilder docblock.

Layer Who derives it
the order snapshot OrderSnapshotBuilder + OrderCalculator::withVat() (bcmath, 6-digit intermediate, round3 half-up)
the front-end display PriceDisplayHelper — deliberately the same arithmetic, so the price on a detail matches the checkout's to the penny

Whether the front should derive it at all is said by two flags on the Invoicer seller (cms_mod_invoicer_sellers): use_vat (VAT registration) and show_prices_with_vat (the display switch). It is derived only for a registered seller with the switch on; PriceDisplayHelper::vatNoteMode() returns null for a non-registered one — and then a template must not mention VAT at all.

The e-shop setting 4/priceWithVat is gone

The price list is the base without VAT unconditionally. Code that asked for that option has nothing to replace it with — the question "which mode is the price list in" no longer means anything.

The denormalised product_sets.price column is the BASE

Price ordering (o-2/o-3) and anything else filtering over that column works with the base — not with the number the customer sees. With a single rate the order is the same; with mixed rates the two diverge.

Older order snapshots were NOT recalculated by stage PD

The stored numbers were only reinterpreted. Snapshots are immutable, so orders from before stage PD are untouched — do not try to "fix" them.

Procedure: creating an order from code#

An order comes into existence by two paths and they are not interchangeable:

Path Input Who computes the prices
checkout (OrderCheckoutManager) the browser sends only setId + count, tariffId, paymentMethodId, voucher codes and the address the server, from the catalogue through a resolver
admin (OrderPresenter::getBuildData()) prices and rates straight from the form the administrator (it is a trusted path)
  1. From the front end always through OrderCheckoutManager. There is no field through which a price could be forged — and that is exactly why it is a separate service.
  2. The reservation is strict (allowPartial = false): a customer must not pay for goods that are not covered. A pre-flight check decides before the write, the final arbiter is reserve() after the commit.
  3. A missing seller is a hard failure. Without the 4/invoicer.sellerId setting the order is not created — the customer gets a comprehensible message and the technical detail goes into the Tracy eshop channel.

The admin path takes prices from the form, the checkout never does

Anyone "simplifying" the checkout by accepting a price from the client opens the door to buying anything for a penny. Splitting this into two services is precisely why.

Procedure: changing an order's state#

The single entry point is OrderTransitionManager::apply($orderId, $newStatusId). The admin form, the payment hook and the crons all call this, never their own UPDATE.

  1. A state carries flags of the actions performed during the transition — confirm stock, cancel a reservation, issue an invoice, send an email.
  2. The whole transition runs under a per-order lock cms_eshop_order_{id} (GET_LOCK, 5 s), which serialises concurrent transitions and edits.
  3. E-mails go into the queue only after the commit — so they are not sent for a transition that in the end was not saved.
  4. Idempotence has three layers: the lock, the SourceRef in the warehouse (re-reserving the same lines is a no-op) and a lookup of the existing invoice by (orderId, invoiceTypeId).

Skipping a state skips its actions too

Jumping from confirmed straight to completed means the stock was never written off. The order looks complete, the goods are still reserved, and the discrepancy turns up at stocktaking.

The lock order is always e-shop → store, never the other way round

Store holds a lock of its own over the source. The opposite order would deadlock under concurrency.

The order's total fields are a trap#

They are two independent pairs and their names mislead:

Field What is in it
totalPrice / totalPriceWithVat the total of the whole order — shipping and payment already added, vouchers subtracted
rounding the rounding of the amount without VAT
roundingVat the rounding of the amount with VAT — not "the VAT on the rounding"
roundedTotalPrice / roundedTotalPriceWithVat the total after rounding (ceil to whole currency units)
totalPrice        = itemsTotal        + shipping.price        + payment.price        − discount
totalPriceWithVat = itemsTotalWithVat + shipping.priceWithVat + payment.priceWithVat − discountWithVat
rounding / roundingVat = rounded − total          (always ≥ 0)

A summary listing shipping and discounts as separate lines counts them TWICE

totalPrice already contains them. A line-by-line summary has to start from itemsTotalPrice*, not from the total. Measured live: lines 5,302.86 + 301.29 against a stated total of 5,303.00.

roundingVat is NOT the VAT on the rounding

It is the rounding of the amount including VAT. A summary in VAT-inclusive prices therefore has to use roundingVat; with the wrong field the result was 0.47 where 0.43 belonged — and the invoice meanwhile showed a different value for the same order, because the generator gets it right.

Gifts do not enter the total and a negative result is not clamped

A gift is free. What stands against voucher abuse is the minimum-cart validation, not clamping the total at zero.

Stock#

The link goes through the set. A reservation is created with the order, the write-off happens on the transition into a state with the stock flag. In detail: Warehouse.

Invoicing#

An order requests its document through OrderInvoiceGenerator. The link to the customer goes through the customer file (Customer), not directly to the user — the same person may invoice to a company or to themselves. In detail: Invoicing.

The rounding is computed by the e-shop, not by invoicing

When rounding > 0, an extra "Rounding" line is added to the invoice. Invoicer does not compute rounding itself.

Complaints#

Their own states with their own flags, analogous to orders — the single entry point is ComplaintTransitionManager. Deadlines are counted in days and watched by the /cron/eshop/complaint/expire cron (daily).

Scheduled tasks#

Endpoint What it does Recommended interval
/cron/eshop/price/recalc activates scheduled prices every 15 min
/cron/eshop/order/expire cancels unpaid orders and releases reservations hourly
/cron/eshop/order/invoice generates the invoices that did not arise immediately every 15 min
/cron/eshop/complaint/expire advances complaints past their deadline daily
/cron/eshop/cart/cleanup deletes abandoned carts nightly
/cron/eshop/feed/generate XML feeds for price comparison sites hourly

A task that does not run throws no error and logs nothing

Nothing simply happens. You recognise it by the consequence — thirty thousand carts in the database, unpaid orders holding reservations, a missing invoice.

Editing a set in the administration#

A wizard with an atomic submit — the whole form is saved at once.

The set's factory methods take parameters POSITIONALLY

Swapping two parameters produces no error, just the wrong unit or quantity. The set saves, the numbers are nonsense, and it surfaces from the warehouse.

The set form must not be redrawn in parts

An in-progress upload would disappear — in detail Forms, the section on file uploads.

Facets have their own rules: option counts are narrowed by everything except the facet itself, otherwise picking a second value of the same filter would show zero. The catalogue is also the 5th source of Elasticsearch — with an SQL fallback when ES is not running.

A range filter needs a round trip through the URL

Without it the value is lost between requests and the filter silently does not filter — it returns everything while looking as if it were set.

Where to look#

I want Where
the creation of an order from a cart app/UI/Api/Eshop/Models/Managers/OrderCheckoutManager.php
the state transition app/UI/Api/Eshop/Models/Managers/OrderTransitionManager.php
the amount recalculation app/UI/Api/Eshop/Models/Helpers/OrderCalculator.php
assembling the lines on the server app/UI/Api/Eshop/Models/Services/OrderSnapshotBuilder.php
the invoice from an order app/UI/Api/Eshop/Models/Generators/OrderInvoiceGenerator.php
the stock effects app/UI/Api/Eshop/Models/Managers/OrderStockManager.php
the cron tasks app/UI/Cron/Eshop/Presenters/

Follow-up chapters: Warehouse · Invoicing · Elasticsearch · Filters · Scheduled tasks