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 | Product → ProductVariant → ProductSet, 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) |
- 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. - 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 isreserve()after the commit. - A missing seller is a hard failure. Without the
4/invoicer.sellerIdsetting the order is not created — the customer gets a comprehensible message and the technical detail goes into the Tracyeshopchannel.
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.
- A state carries flags of the actions performed during the transition — confirm stock, cancel a reservation, issue an invoice, send an email.
- The whole transition runs under a per-order lock
cms_eshop_order_{id}(GET_LOCK, 5 s), which serialises concurrent transitions and edits. - 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.
- Idempotence has three layers: the lock, the
SourceRefin 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.
Filters and search#
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