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.
Rounding policy
Discount amounts are rounded per a per-business rounding policy controlled by two independent Entity Parameters:
| Parameter | Values | Default | Controls |
|---|---|---|---|
DISCOUNT_ROUNDING_MODE | half_up | up | down | half_up | Direction of rounding |
DISCOUNT_ROUNDING_PRECISION | cents | whole | cents | Number of decimal places (2 vs 0) |
Together they form a DiscountRoundingPolicy ({ mode, minorUnit }). Both parameters are optional — existing businesses with neither set get the default { mode: half_up, minorUnit: 2 } behavior.
Available modes
| Mode | Decimal.js equivalent | Behavior |
|---|---|---|
half_up (default) | ROUND_HALF_UP | Standard rounding — 0.005 rounds up to 0.01 |
up | ROUND_CEIL | Always rounds away from zero — 0.001 becomes 0.01 |
down | ROUND_FLOOR | Always rounds toward zero — 0.009 becomes 0.00 |
Precision — cents vs. whole units
| Precision | minorUnit | 15% of Q250 (raw 37.50) |
|---|---|---|
cents (default) | 2 | 37.50 |
whole + half_up / up | 0 | 38 → line Q212.00 |
whole + down | 0 | 37 → line Q213.00 |
⚠️ Tiny discounts round to zero under
whole. A 2% discount on a Q10 line produces raw Q0.20, which rounds down to Q0.00 underwhole+down. This is an informed merchant choice, not a bug — the banner in the POS discount modal warns cashiers whenwholeis active.
Implementation details
- Rounding is applied once to the stacked total, not per individual discount application. When multiple discounts stack on a line or cart, their raw amounts accumulate via
sumAndRoundDiscount(rawAmounts, policy)and rounding is applied a single time to the final total. This preventsup/downbias from compounding across multiple applications. calculateAmount(discount rule math) andcalculateBenefitAmount(promotion math) both stay raw — rounding is applied by their callers after accumulation.- The combined policy is resolved once per apply/remove call via
EntityParametersService.getDiscountRoundingPolicyForBusiness()(fetches both parameters in parallel withPromise.all) and threaded through the private call chain. - The logic lives in
packages/global/utils/rounding.utils.ts(applyDiscountRounding,parseDiscountRoundingPrecision,resolveDiscountRoundingPolicy,sumAndRoundDiscount) and is shared byDiscountsService,PromotionEvaluatorService,LoyaltyService, and the frontend preview utility.
Default behavior
When neither parameter is set for a business, the default policy { mode: half_up, minorUnit: 2 } applies — existing behavior unchanged.
Configuring the rounding policy (admin)
Open Entity Parameters in the admin panel and create parameters for the business:
- Select
DISCOUNT_ROUNDING_MODEto choose the rounding direction. - Select
DISCOUNT_ROUNDING_PRECISIONto choosecents(default) orwhole.
The admin UI renders translated enum selectors automatically via the catalog allowed_values.
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