Discounts Module
Overview
The discounts module manages discount rule catalogs and discount application auditing for the FlowPOS multi-tenant POS system. It provides two core capabilities:
- Discount Rule CRUD — a catalog of reusable discount rules (e.g. "10% Off Manual") scoped per business
- Discount Application Engine — builds line-level and cart-level discount snapshots with permission validation, audit logging, and promotion event emission
The module is consumed by: restaurant orders, retail sales, quotes, and loyalty.
Architecture
discounts/
├── discounts.module.ts
├── application/
│ └── discounts.service.ts # Business logic / use cases
├── domain/
│ ├── discount-rules-repository.domain.ts # Port + DI token
│ └── discount-applications-repository.domain.ts # Port + DI token
├── infrastructure/
│ ├── discount-rules.repository.ts # Kysely adapter
│ └── discount-applications.repository.ts # Kysely adapter
└── interfaces/
├── discounts.controller.ts # HTTP endpoints
├── dtos/
│ ├── create-discount-rule.dto.ts
│ └── update-discount-rule.dto.ts
└── query/
├── find-discount-rules.query.ts
├── find-applications-by-entity.query.ts
└── find-applications-by-employee.query.ts
Dependency injection: Repositories are injected via Symbol tokens (DISCOUNT_RULES_REPOSITORY, DISCOUNT_APPLICATIONS_REPOSITORY), keeping the application layer decoupled from Kysely infrastructure.
Domain Concepts
Discount Rule
A reusable template that defines how a discount behaves:
| Field | Description |
|---|---|
scope | line (single item) or cart (entire order/sale) |
type | manual, promotion, markdown, override, comp, loyalty |
method | percent or amount |
value | The discount value (percentage 0-100 or fixed currency amount) |
maxValue | Ceiling for percent discounts (optional) |
requiresApproval | Gates on canApproveDiscounts employee permission |
minRoleLevel | Minimum role: cashier < manager < owner |
validFrom / validTo | Time-bounded validity window |
Discount Application
An immutable audit record created every time a discount is applied:
- Linked to a generic entity via
(entityType, entityId)— supports sale, order, quote, service_booking lineIdidentifies the specific line item (null for cart discounts)ruleIdreferences the discount rule (null for manual/loyalty/promotion discounts)
JSONB Snapshots
Discount stacks are stored as JSONB on the parent entity:
order_item.discount_detail—LineDiscountDetailorder.cart_discount_detail—CartDiscountDetailsale.cart_discount_detail—CartDiscountDetail
These are immutable snapshots for point-in-time reconstruction and frontend display.
Main Use Cases
1. Create / manage discount rules
Standard CRUD for the discount rule catalog. Rules with existing audit history are soft-deleted (deactivated) to preserve referential integrity.
2. Apply line discount (buildLineDiscount)
Called by restaurant/sales/quotes modules when an employee applies a discount to a specific line item:
- Validates employee exists and has required permissions
- Resolves rule (or validates promotion if type is
promotion) - Validates rule is active and within validity window
- Calculates discount amount (with max ceiling)
- Checks result won't produce negative price
- Writes audit record to
discount_application - Emits
PROMOTION_DISCOUNT_APPLIED_EVENTif promotion - Returns updated
LineDiscountDetailsnapshot
3. Apply cart discount (buildCartDiscount)
Same flow as line discount but applied to the cart total. The caller pre-computes the base amount.
4. Loyalty redemption (applyLoyaltyDiscount)
Creates an audit record for loyalty point redemptions. Called by the loyalty module within its own transaction.
5. Audit queries
- By entity: Get all discount applications for a specific sale/order/quote
- By employee: Get discount applications created by an employee within a date range
API Endpoints
| Method | Path | Description |
|---|---|---|
POST | /discounts/rules | Create a discount rule |
GET | /discounts/rules | List rules (filter by active, scope) |
GET | /discounts/rules/:id | Get rule by ID |
PATCH | /discounts/rules/:id | Update a rule |
DELETE | /discounts/rules/:id | Delete/deactivate a rule |
GET | /discounts/applications | Get applications by entity |
GET | /discounts/applications/by-employee | Get applications by employee |
All endpoints require Bearer authentication and businessId query parameter.
Example: Create a discount rule
POST /discounts/rules?businessId=<uuid>&createdBy=<uuid>
Content-Type: application/json
{
"name": "10% Off Manual",
"description": "Manual discount for staff",
"scope": "line",
"appliesTo": "all",
"type": "manual",
"method": "percent",
"value": 10,
"maxValue": 50,
"requiresReason": false,
"requiresApproval": false,
"minRoleLevel": "cashier",
"isActive": true
}
Design Decisions
-
Entity-agnostic auditing:
discount_applicationuses(entity_type, entity_id)instead of typed FKs, enabling audit across sale/order/quote/service_booking without schema changes. -
Soft delete with audit history: Rules that have been applied are deactivated rather than deleted to preserve audit trail integrity.
-
JSONB + audit table dual storage: Discount stacks are stored both as JSONB snapshots (for fast reads) and in
discount_application(for querying and reporting). -
Stacking order:
price_list → price_rule → discount_rule → manual → override. Each layer can add to the discount stack. -
Promotion event emission: When a promotion discount is applied, an event is emitted rather than directly coupling to the promotions module, allowing independent usage tracking.
-
Permission layering: Override requires
canOverridePrices; approval-required rules gate oncanApproveDiscounts;minRoleLevelenforces role hierarchy.
Database Schema
discount_rule
id uuid PK
business_id uuid FK → business
name varchar(255)
description text
scope discount_scope (line | cart)
applies_to varchar(255) default 'all'
type discount_type
method discount_method (percent | amount)
value numeric(20,6)
max_value numeric(20,6)
requires_reason boolean default false
requires_approval boolean default false
min_role_level varchar(50)
is_active boolean default true
valid_from timestamptz
valid_to timestamptz
created_at timestamptz
created_by uuid FK → user
updated_at timestamptz
updated_by uuid FK → user
discount_application
id uuid PK
business_id uuid FK → business
entity_type varchar(100)
entity_id uuid
line_id varchar(255)
scope discount_scope
type discount_type
method discount_method
value numeric(20,6)
amount_applied numeric(20,6)
rule_id uuid FK → discount_rule (nullable)
reason text
created_by uuid FK → employee
created_at timestamptz