Appearance
Price Tracking
Flow ID: SY-04 | Module(s): src/Domains/Product/PriceTracking, src/Rest/Product | Complexity: Medium Last Updated: 2026-05-12
Business Overview
The price tracking system records product price history to support EU Omnibus Directive compliance and customer-facing price graphs. The modern TrackPrices job (Advisable\Domains\Product\PriceTracking\Jobs\TrackPrices) runs every 5 minutes, calculates the current final price (including VAT, discounts, and time-limited special offers), and writes a new record to the price_tracking table only when the price has actually changed.
The stored history is then consumed by:
- Price history graphs on product cards and product pages (via
GraphService). - Reference price calculation for discount badges (EU Omnibus: lowest price in the last N days).
- REST API for frontend graph rendering (
GET /rest/product/price-tracking/graph[/{productId}]).
Architecture
Jobs\TrackPrices (every 5 min)
|
+--> Load VAT rates from shop_product_vats
+--> Batch-select active products (default 5000 per batch)
| |
| +--> PriceCalculator::calculateFinalPrice() apply discounts + VAT (pure)
| +--> WriteService::recordBatch() change-only batch insert
|
+--> Repeat until all products processed
GraphService (on-demand, via DI)
|
+--> Repository -> SELECT price_tracking WHERE product_id = ? ORDER BY price_date DESC
+--> Build price timeline for N days
+--> Calculate reference price (Omnibus)
+--> Determine discount badge eligibility
REST Controller PriceTracking
|
+--> GET /rest/product/price-tracking (list)
+--> GET /rest/product/price-tracking/{id} (show)
+--> GET /rest/product/price-tracking/graph/{productId} (single graph)
+--> GET /rest/product/price-tracking/graph?productIds=1,2,3 (bulk graph)Key Files
| File | Role |
|---|---|
src/Domains/Product/PriceTracking/Jobs/TrackPrices.php | Modern JobCommand; entry point for the cron job |
src/Domains/Product/PriceTracking/PriceCalculator.php | Pure final-price math (regular + special discount + VAT) |
src/Domains/Product/PriceTracking/GraphService.php | Omnibus-compliant graph timeline + reference-price builder |
src/Domains/Product/PriceTracking/Options.php | Immutable DTO populated from the PRICE_TRACK registry group |
src/Domains/Product/PriceTracking/WriteService.php | Insert/update/delete + recordBatch() for the job |
src/Domains/Product/PriceTracking/WriteData.php | Validated write DTO with snake/camel input flexibility |
src/Domains/Product/PriceTracking/Validator.php | productId/price/date validation rules |
src/Domains/Product/PriceTracking/Repository/WriteRepository.php | Persistence (insert_batch + last-price lookup) |
src/Domains/Product/PriceTracking/Repository/Repository.php | Read repository (Specification pattern) |
src/Domains/Product/PriceTracking/Repository/Entity.php | Entity DTO |
src/Domains/Product/PriceTracking/Service.php | Read service used by the REST controller |
src/Rest/Product/Controllers/PriceTracking.php | REST endpoints (index, show, item, graph, graphBulk) |
application/config/jobs.php | Job registration (FQCN; tracking queue, 5-min schedule) |
application/config/rest_routes.php | Route table for the REST endpoints (write routes intentionally commented) |
Code Flow
Price Recording (Job)
- Load VAT rates: query all rows from
shop_product_vatsand index by ID for fast lookup. - Batch processing loop (
do...while):- Select active, non-deleted products in batches of
productLimit(default 5000). - Columns selected:
id,vat_id,discount_persent,special_from,special_to,special_discount_percent,price.
- Select active, non-deleted products in batches of
- For each product, calculate the current final price via
PriceCalculator::calculateFinalPrice()(src/Domains/Product/PriceTracking/PriceCalculator.php):- Check if special discounts are enabled via
OTHER.ENABLE_SPECIAL_DISCOUNTSregistry. - If a special discount period is active (
special_from <= now <= special_to), usespecial_discount_percentinstead of the regulardiscount_persent. - Apply discount:
price * (1 - discount/100). - Apply VAT (inlined math):
round($net + (($vatPercent / 100) * $net), 2)— no CI helper called. - Format to standard precision:
sprintf('%.2f', $withVat)— no CI helper called.
- Check if special discounts are enabled via
- Change detection happens inside
WriteService::recordBatch()(src/Domains/Product/PriceTracking/WriteService.php:64-86), not in the job loop:recordBatch()callsWriteRepository::getLatestPriceForProduct()per product and filters out products whose price is unchanged before building the insert batch. - Batch insert: collect all changed prices and insert in one
insert_batch()call per batch iteration.
Price Graph Generation (GraphService)
GraphService (src/Domains/Product/PriceTracking/GraphService.php) produces the graph payload on demand. It receives Options as a constructor-injected dependency (src/Domains/Product/PriceTracking/Options.php; same public properties as the legacy options class):
- Load price changes:
GraphService::productPrices(int $productId)delegates toloadChanges(), which runs$this->repository->getDb()->from('price_tracking')->where('product_id', $productId)->order_by('price_date', 'DESC')->get(). - Limit to tracking period: configurable via
PRICE_TRACK.TRACK_DAYS(default 30 days). - Build daily price timeline:
setPriceTimeline()— for each day in the period, determine the applicable price (most recent change on or before that date). - Calculate reference price and discount eligibility:
parseResponse()— determinesreferencePrice(Omnibus rule),saveValue, anddiscountPass. - Bulk variant:
GraphService::productPricesBulk(array $productIds)— executes one query and groups results byproduct_idto avoid N+1 queries.
API Endpoints
| Endpoint | Method | Description |
|---|---|---|
/rest/product/price-tracking/graph/{productId} | GET | Single-product graph payload; guest-accessible |
/rest/product/price-tracking/graph?productIds=1,2,3 | GET | Bulk graph payload (comma-separated IDs); guest-accessible |
Data Model
Table: price_tracking
| Column | Type | Role |
|---|---|---|
id | int | PK (auto-increment) |
product_id | int | FK to shop_product.id |
price | decimal | Final price including VAT at time of recording |
price_date | datetime | Timestamp of the price snapshot |
Related Tables
| Table | Role |
|---|---|
shop_product | Source for current prices and discount percentages |
shop_product_vats | VAT rate lookup (indexed by vat_id) |
Configuration
Job Scheduling (application/config/jobs.php)
php
// tracking queue (application/config/jobs.php:76)
['command' => \Advisable\Domains\Product\PriceTracking\Jobs\TrackPrices::class, 'schedule' => '*/5 * * * *', 'graceTime' => 60, 'retryTimes' => 0, 'options' => ['productLimit' => '5000']]Runs every 5 minutes in the dedicated tracking queue. The tracking queue has lockJobs: false (no job locking needed).
Job Options
| Option | Type | Default | Description |
|---|---|---|---|
productId | int | (all) | Track a single product (used for manual/debugging runs) |
productLimit | int | 5000 | Batch size per iteration |
Registry Settings (PRICE_TRACK group)
| Key | Type | Default | Description |
|---|---|---|---|
ENABLED | bool | false | Master feature toggle for price tracking |
ENABLED_GRAPH | bool | false | Enable graph display |
ENABLED_ALL_GRAPH | bool | false | Show graphs for all products (vs. only discounted) |
ENABLED_GRAPH_PRODUCT_CARD | bool | false | Show mini-graph on product cards |
ENABLED_GRAPH_PRODUCT_PAGE | bool | false | Show full graph on product detail pages |
TRACK_DAYS | int | 30 | Number of days of price history to retain/display |
SHOW_DISCOUNT_ON_VALUE | float | 10 | Minimum discount % to show the discount badge |
DISCOUNT_LABEL | string (MUI) | -- | Label text for the discount indicator (per language) |
DISCOUNT_BADGE_LABEL | string (MUI) | -- | Badge label for the discount indicator (per language) |
Other Registry Settings
| Group | Key | Description |
|---|---|---|
OTHER | ENABLE_SPECIAL_DISCOUNTS | Whether time-limited special discounts are active |
Client Extension Points
All override points are in the modern domain layer. The legacy files (application/modules/job/libraries/TrackPrices.php, application/modules/eshop/libraries/PriceTrackingGraphs.php, application/modules/api/controllers/Api_price_tracking.php) were deleted in PR #128 and are no longer present.
Override the job: Extend
Advisable\Domains\Product\PriceTracking\Jobs\TrackPriceswith a custom subclass and register it inapplication/config/jobs.phpby replacing thecommandFQCN with your subclass name, or register a DI alias.Override price calculation: Extend
Advisable\Domains\Product\PriceTracking\PriceCalculatorand register a DI alias so the job and any other consumers receive the custom implementation.Override graph generation: Extend
Advisable\Domains\Product\PriceTracking\GraphServiceand register a DI alias to customize the timeline, reference-price algorithm, or discount badge logic.Override options/configuration DTO: Extend
Advisable\Domains\Product\PriceTracking\Optionsand register a DI alias to change default values or add custom configuration properties.
Business Rules
| Rule | Description |
|---|---|
| Change-only recording | New records are inserted only when the calculated price differs from the last recorded price |
| VAT-inclusive prices | Prices are stored with VAT included (final customer-facing price) |
| Special discount priority | Time-limited special discounts override the regular discount percentage |
| Batch processing | Products are processed in configurable batches to limit memory usage |
| Feature gated | The entire feature requires PRICE_TRACK.ENABLED to be true in registry |
| Omnibus compliance | Reference price calculation follows EU Omnibus Directive rules (lowest/highest price in tracking window) |
| Graph display threshold | Discount badge only shown when discount meets SHOW_DISCOUNT_ON_VALUE minimum |
| No display on price increase | Graph is hidden when current price equals or exceeds the reference price |
| Multi-language labels | Discount labels and badge text are configurable per language |
Known Issues & Security Gaps
No known issues at the time of writing.
Tests
4 unit-test classes in tests/Unit/Domains/Product/PriceTracking/:
| Class | What it covers |
|---|---|
GraphServiceTest | Protected helper methods of GraphService (timeline building, reference-price logic) |
PriceCalculatorTest | VAT math and discount application in PriceCalculator::calculateFinalPrice() |
ValidatorTest | Validation rules in Validator (productId, price, date fields) |
WriteDataTest | WriteData DTO serialization and snake/camelCase input flexibility |
Related Flows
- SY-01 Cron Job Framework -- job scheduling and execution
- CF-02 Product Detail -- storefront price graph rendering
- AD-02 Product Management -- product pricing fields that feed the tracker
- AD-43 New Product Prices -- ERP price imports that may trigger price changes