Markdown (Seasonal) — Implementation Guide
Feature 021-seasonal-markdown. For the original business requirements, see markdown-seasonal.md.
Overview
Seasonal markdown is a first-class pricing concept in FlowPOS that manages inventory-lifecycle price reductions. Unlike promotions (temporary transactional overlays), markdowns change the effective base selling price and persist until reversed.
Price resolution chain: price_list → markdown → discount/promotion → manual → override
Database Schema
New Tables (5)
| Table | Purpose |
|---|---|
markdown_plan | Groups related markdown waves (e.g., "Summer 2026 Clearance") |
markdown_wave | A single markdown event with pricing method, scope, and status |
markdown_wave_item | Individual variant-level price changes within a wave |
markdown_wave_location | Per-location activation scheduling and status |
markdown_history | Audit trail of all markdown price changes |
New Enums (7)
markdown_method, markdown_mode, markdown_reason, markdown_scope_type, markdown_stage, markdown_wave_status, markdown_wave_location_status
Extended Enum
price_source — added markdown value
Migration
packages/backend/database/src/migrations/2026-03-20t01-00-00-markdown-management.mjs
Backend Architecture
Module: apps/backend/src/markdowns/
Follows hexagonal architecture:
markdowns/
├── markdowns.module.ts
├── application/
│ ├── markdowns.service.ts # Business logic
│ └── processors/
│ └── markdown-activation.processor.ts # BullMQ scheduled activation
├── infrastructure/
│ ├── markdowns.repository.ts # Kysely queries for plans/waves/items
│ └── markdown-history.repository.ts # History audit queries
└── interfaces/
├── markdown-plans.controller.ts # POST/GET /markdown-plans
├── markdown-waves.controller.ts # CRUD + activate/reverse/preview/export
├── markdown-history.controller.ts # GET /markdown-history/variant/:id
└── markdown-reports.controller.ts # GET /markdowns/reports/performance
API Endpoints
Plans
| Method | Path | Description |
|---|---|---|
GET | /markdown-plans?businessId= | List plans (filterable by is_active, search) |
POST | /markdown-plans?businessId= | Create plan ({ name, description }) |
Waves
| Method | Path | Description |
|---|---|---|
GET | /markdown-waves/plans/:planId?businessId= | List waves for a plan |
GET | /markdown-waves/:id?businessId= | Get wave detail with locations |
POST | /markdown-waves/plans/:planId?businessId= | Create wave (scope, locations, pricing) |
POST | /markdown-waves/:id/activate?businessId= | Activate wave |
POST | /markdown-waves/:id/preview?businessId= | Preview price impact |
POST | /markdown-waves/:id/reverse?businessId= | Reverse wave (restore prices) |
GET | /markdown-waves/:id/export?businessId= | CSV export of wave items |
GET | /markdown-waves/variants/:variantId/active?businessId= | Active markdown for variant |
History
| Method | Path | Description |
|---|---|---|
GET | /markdown-history/variant/:variantId?businessId= | Markdown history for a variant |
Reports
| Method | Path | Description |
|---|---|---|
GET | /markdowns/reports/performance?businessId= | Aggregate performance metrics |
Key Business Logic
- Scope expansion: Waves target variants, products, or categories — the service expands scope to individual
product_variantrecords - Compound mode: When
mode = "compound", markdown is calculated from the current active markdown price instead of the original catalog price - Per-location activation: Each location can have a different activation date; BullMQ polls every minute to activate pending locations
- Below-cost protection: Preview flags items where markdown price falls below
avgCost; activation requires explicitconfirm_below_costflag - Reversibility: Reversing a wave restores
priceAfter → priceBeforefor all affected variants and logs history - Price cache invalidation: On activation/reversal, price cache is invalidated per business
BullMQ Processor
markdown-activation queue with a repeatable job (1-minute interval) that:
- Finds all
markdown_wave_locationrecords withstatus = 'pending'andactivateAt <= now - Activates each location independently (per-location error isolation)
- Skips deactivated locations (
location.isActive = false) - Transitions wave status:
scheduled → activewhen first location activates
Frontend-PWA Implementation
Pages & Routes
| Route | Component | Purpose |
|---|---|---|
/markdowns | MarkdownPlanListPage | List all plans, search, filter, create inline |
/markdowns/reports | MarkdownReportsPage | Performance reports with summary cards and wave breakdown |
/markdowns/:planId | MarkdownPlanDetailPage | Plan detail with waves table |
/markdowns/:planId/waves/new | MarkdownWaveFormPage | Wave creation form |
/markdowns/:planId/waves/:waveId | MarkdownWaveDetailPage | Wave detail, preview, activate/reverse |
All pages live in apps/frontend-pwa/src/components/forms/markdowns/.
Service & Hooks
- Service:
apps/frontend-pwa/src/services/markdownService.ts— API wrapper functions for all markdown endpoints - Hooks:
apps/frontend-pwa/src/hooks/useMarkdowns.ts— TanStack Query hooks:useMarkdownPlans/useCreatePlanuseMarkdownWaves/useMarkdownWave/useCreateWaveuseActivateWave/usePreviewWave/useReverseWaveuseMarkdownReportuseVariantMarkdownHistory
POS Integration
When a marked-down item is added to a sale:
- Price resolution (
useResolveItemPrice) detects active markdown and returnssource: "markdown"withmarkdownWaveIdandoriginalPrice - Sale item row (
SaleItemsTable.tsx) shows:- Amber "Markdown" badge with
TrendingDownicon - Struck-through original price next to the markdown price
- Amber "Markdown" badge with
- Sale item data carries
priceSource: "markdown",markdownWaveId, andmarkdownOriginalPricefor audit trail - PDF receipt (
sale.template.html) shows struck-through original price in the unit price column
Reusable Components
MarkdownBadge(apps/frontend-pwa/src/components/common/MarkdownBadge.tsx) — Shows stage label, discount %, original/markdown price, days-since-activation
Testing & Verification
Prerequisites
# Apply migration
pnpm run migration:local:push
# Regenerate Kysely types
pnpm run generate:types
# Start backend
pnpm --filter backend run start:dev
# Start PWA
pnpm --filter frontend-pwa run dev
Manual Test Flow
- Navigate to
http://localhost:5173/markdowns - Create a plan — click "New Plan", enter name and description
- Open the plan — click the eye icon to see plan detail
- Create a wave — click "New Wave", fill in:
- Name, method (percent/amount), value
- Mode (replace or compound)
- Scope: paste variant IDs, product IDs, or category IDs (comma-separated)
- Select locations and set activation dates
- Preview — wave detail page shows the preview table with price impact
- Activate — click "Activate", confirm below-cost items if flagged
- Verify POS — go to
/main, add a marked-down item to a sale; verify amber badge and struck-through price - Reverse — go back to wave detail, click "Reverse Wave", enter reason
- Verify restored price — add the same item to a new sale; original price should be restored
- Reports — navigate to
/markdowns/reportsto see aggregate metrics
Build Verification
# Lint
pnpm run lint
# Build PWA
pnpm --filter frontend-pwa run build
# Build backend
pnpm build:backend
Edge Cases Handled
| Scenario | Behavior |
|---|---|
| Markdown + promotion stacking | Promotion applies on top of markdown price (markdown is the new base) |
| Returns of marked-down items | Uses originalLine.amount (the markdown price at time of sale) |
| Exchanges after markdown | Refund at original line price, new item at current effective price |
| Multi-location timing | Each location has independent activateAt date |
| Compound markdown waves | Wave 2 calculates from Wave 1's active price, not original catalog price |
| Below-cost items | Flagged in preview; requires explicit confirmation to activate |
| Deactivated locations | Skipped during scheduled activation |
| Banker's rounding | Uses roundHalfToEven for fractional cent calculations |
Deferred
- Auto-apply to new products (T050): When a product is added to a category with an active category-scoped markdown, it should automatically receive the markdown. Currently deferred as an event-driven edge case.