Saltar al contenido principal

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)

TablePurpose
markdown_planGroups related markdown waves (e.g., "Summer 2026 Clearance")
markdown_waveA single markdown event with pricing method, scope, and status
markdown_wave_itemIndividual variant-level price changes within a wave
markdown_wave_locationPer-location activation scheduling and status
markdown_historyAudit 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

MethodPathDescription
GET/markdown-plans?businessId=List plans (filterable by is_active, search)
POST/markdown-plans?businessId=Create plan ({ name, description })

Waves

MethodPathDescription
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

MethodPathDescription
GET/markdown-history/variant/:variantId?businessId=Markdown history for a variant

Reports

MethodPathDescription
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_variant records
  • 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 explicit confirm_below_cost flag
  • Reversibility: Reversing a wave restores priceAfter → priceBefore for 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:

  1. Finds all markdown_wave_location records with status = 'pending' and activateAt <= now
  2. Activates each location independently (per-location error isolation)
  3. Skips deactivated locations (location.isActive = false)
  4. Transitions wave status: scheduled → active when first location activates

Frontend-PWA Implementation

Pages & Routes

RouteComponentPurpose
/markdownsMarkdownPlanListPageList all plans, search, filter, create inline
/markdowns/reportsMarkdownReportsPagePerformance reports with summary cards and wave breakdown
/markdowns/:planIdMarkdownPlanDetailPagePlan detail with waves table
/markdowns/:planId/waves/newMarkdownWaveFormPageWave creation form
/markdowns/:planId/waves/:waveIdMarkdownWaveDetailPageWave 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 / useCreatePlan
    • useMarkdownWaves / useMarkdownWave / useCreateWave
    • useActivateWave / usePreviewWave / useReverseWave
    • useMarkdownReport
    • useVariantMarkdownHistory

POS Integration

When a marked-down item is added to a sale:

  1. Price resolution (useResolveItemPrice) detects active markdown and returns source: "markdown" with markdownWaveId and originalPrice
  2. Sale item row (SaleItemsTable.tsx) shows:
    • Amber "Markdown" badge with TrendingDown icon
    • Struck-through original price next to the markdown price
  3. Sale item data carries priceSource: "markdown", markdownWaveId, and markdownOriginalPrice for audit trail
  4. 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

  1. Navigate to http://localhost:5173/markdowns
  2. Create a plan — click "New Plan", enter name and description
  3. Open the plan — click the eye icon to see plan detail
  4. 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
  5. Preview — wave detail page shows the preview table with price impact
  6. Activate — click "Activate", confirm below-cost items if flagged
  7. Verify POS — go to /main, add a marked-down item to a sale; verify amber badge and struck-through price
  8. Reverse — go back to wave detail, click "Reverse Wave", enter reason
  9. Verify restored price — add the same item to a new sale; original price should be restored
  10. Reports — navigate to /markdowns/reports to 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

ScenarioBehavior
Markdown + promotion stackingPromotion applies on top of markdown price (markdown is the new base)
Returns of marked-down itemsUses originalLine.amount (the markdown price at time of sale)
Exchanges after markdownRefund at original line price, new item at current effective price
Multi-location timingEach location has independent activateAt date
Compound markdown wavesWave 2 calculates from Wave 1's active price, not original catalog price
Below-cost itemsFlagged in preview; requires explicit confirmation to activate
Deactivated locationsSkipped during scheduled activation
Banker's roundingUses 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.