Skip to content

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

FileRole
src/Domains/Product/PriceTracking/Jobs/TrackPrices.phpModern JobCommand; entry point for the cron job
src/Domains/Product/PriceTracking/PriceCalculator.phpPure final-price math (regular + special discount + VAT)
src/Domains/Product/PriceTracking/GraphService.phpOmnibus-compliant graph timeline + reference-price builder
src/Domains/Product/PriceTracking/Options.phpImmutable DTO populated from the PRICE_TRACK registry group
src/Domains/Product/PriceTracking/WriteService.phpInsert/update/delete + recordBatch() for the job
src/Domains/Product/PriceTracking/WriteData.phpValidated write DTO with snake/camel input flexibility
src/Domains/Product/PriceTracking/Validator.phpproductId/price/date validation rules
src/Domains/Product/PriceTracking/Repository/WriteRepository.phpPersistence (insert_batch + last-price lookup)
src/Domains/Product/PriceTracking/Repository/Repository.phpRead repository (Specification pattern)
src/Domains/Product/PriceTracking/Repository/Entity.phpEntity DTO
src/Domains/Product/PriceTracking/Service.phpRead service used by the REST controller
src/Rest/Product/Controllers/PriceTracking.phpREST endpoints (index, show, item, graph, graphBulk)
application/config/jobs.phpJob registration (FQCN; tracking queue, 5-min schedule)
application/config/rest_routes.phpRoute table for the REST endpoints (write routes intentionally commented)

Code Flow

Price Recording (Job)

  1. Load VAT rates: query all rows from shop_product_vats and index by ID for fast lookup.
  2. 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.
  3. 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_DISCOUNTS registry.
    • If a special discount period is active (special_from <= now <= special_to), use special_discount_percent instead of the regular discount_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.
  4. Change detection happens inside WriteService::recordBatch() (src/Domains/Product/PriceTracking/WriteService.php:64-86), not in the job loop: recordBatch() calls WriteRepository::getLatestPriceForProduct() per product and filters out products whose price is unchanged before building the insert batch.
  5. 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):

  1. Load price changes: GraphService::productPrices(int $productId) delegates to loadChanges(), which runs $this->repository->getDb()->from('price_tracking')->where('product_id', $productId)->order_by('price_date', 'DESC')->get().
  2. Limit to tracking period: configurable via PRICE_TRACK.TRACK_DAYS (default 30 days).
  3. Build daily price timeline: setPriceTimeline() — for each day in the period, determine the applicable price (most recent change on or before that date).
  4. Calculate reference price and discount eligibility: parseResponse() — determines referencePrice (Omnibus rule), saveValue, and discountPass.
  5. Bulk variant: GraphService::productPricesBulk(array $productIds) — executes one query and groups results by product_id to avoid N+1 queries.

API Endpoints

EndpointMethodDescription
/rest/product/price-tracking/graph/{productId}GETSingle-product graph payload; guest-accessible
/rest/product/price-tracking/graph?productIds=1,2,3GETBulk graph payload (comma-separated IDs); guest-accessible

Data Model

Table: price_tracking

ColumnTypeRole
idintPK (auto-increment)
product_idintFK to shop_product.id
pricedecimalFinal price including VAT at time of recording
price_datedatetimeTimestamp of the price snapshot
TableRole
shop_productSource for current prices and discount percentages
shop_product_vatsVAT 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

OptionTypeDefaultDescription
productIdint(all)Track a single product (used for manual/debugging runs)
productLimitint5000Batch size per iteration

Registry Settings (PRICE_TRACK group)

KeyTypeDefaultDescription
ENABLEDboolfalseMaster feature toggle for price tracking
ENABLED_GRAPHboolfalseEnable graph display
ENABLED_ALL_GRAPHboolfalseShow graphs for all products (vs. only discounted)
ENABLED_GRAPH_PRODUCT_CARDboolfalseShow mini-graph on product cards
ENABLED_GRAPH_PRODUCT_PAGEboolfalseShow full graph on product detail pages
TRACK_DAYSint30Number of days of price history to retain/display
SHOW_DISCOUNT_ON_VALUEfloat10Minimum discount % to show the discount badge
DISCOUNT_LABELstring (MUI)--Label text for the discount indicator (per language)
DISCOUNT_BADGE_LABELstring (MUI)--Badge label for the discount indicator (per language)

Other Registry Settings

GroupKeyDescription
OTHERENABLE_SPECIAL_DISCOUNTSWhether 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.

  1. Override the job: Extend Advisable\Domains\Product\PriceTracking\Jobs\TrackPrices with a custom subclass and register it in application/config/jobs.php by replacing the command FQCN with your subclass name, or register a DI alias.

  2. Override price calculation: Extend Advisable\Domains\Product\PriceTracking\PriceCalculator and register a DI alias so the job and any other consumers receive the custom implementation.

  3. Override graph generation: Extend Advisable\Domains\Product\PriceTracking\GraphService and register a DI alias to customize the timeline, reference-price algorithm, or discount badge logic.

  4. Override options/configuration DTO: Extend Advisable\Domains\Product\PriceTracking\Options and register a DI alias to change default values or add custom configuration properties.

Business Rules

RuleDescription
Change-only recordingNew records are inserted only when the calculated price differs from the last recorded price
VAT-inclusive pricesPrices are stored with VAT included (final customer-facing price)
Special discount priorityTime-limited special discounts override the regular discount percentage
Batch processingProducts are processed in configurable batches to limit memory usage
Feature gatedThe entire feature requires PRICE_TRACK.ENABLED to be true in registry
Omnibus complianceReference price calculation follows EU Omnibus Directive rules (lowest/highest price in tracking window)
Graph display thresholdDiscount badge only shown when discount meets SHOW_DISCOUNT_ON_VALUE minimum
No display on price increaseGraph is hidden when current price equals or exceeds the reference price
Multi-language labelsDiscount 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/:

ClassWhat it covers
GraphServiceTestProtected helper methods of GraphService (timeline building, reference-price logic)
PriceCalculatorTestVAT math and discount application in PriceCalculator::calculateFinalPrice()
ValidatorTestValidation rules in Validator (productId, price, date fields)
WriteDataTestWriteData DTO serialization and snake/camelCase input flexibility