Saltar al contenido principal

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:

  1. Discount Rule CRUD — a catalog of reusable discount rules (e.g. "10% Off Manual") scoped per business
  2. 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:

FieldDescription
scopeline (single item) or cart (entire order/sale)
typemanual, promotion, markdown, override, comp, loyalty
methodpercent or amount
valueThe discount value (percentage 0-100 or fixed currency amount)
maxValueCeiling for percent discounts (optional)
requiresApprovalGates on canApproveDiscounts employee permission
minRoleLevelMinimum role: cashier < manager < owner
validFrom / validToTime-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
  • lineId identifies the specific line item (null for cart discounts)
  • ruleId references the discount rule (null for manual/loyalty/promotion discounts)

JSONB Snapshots

Discount stacks are stored as JSONB on the parent entity:

  • order_item.discount_detailLineDiscountDetail
  • order.cart_discount_detailCartDiscountDetail
  • sale.cart_discount_detailCartDiscountDetail

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:

  1. Validates employee exists and has required permissions
  2. Resolves rule (or validates promotion if type is promotion)
  3. Validates rule is active and within validity window
  4. Calculates discount amount (with max ceiling)
  5. Checks result won't produce negative price
  6. Writes audit record to discount_application
  7. Emits PROMOTION_DISCOUNT_APPLIED_EVENT if promotion
  8. Returns updated LineDiscountDetail snapshot

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

MethodPathDescription
POST/discounts/rulesCreate a discount rule
GET/discounts/rulesList rules (filter by active, scope)
GET/discounts/rules/:idGet rule by ID
PATCH/discounts/rules/:idUpdate a rule
DELETE/discounts/rules/:idDelete/deactivate a rule
GET/discounts/applicationsGet applications by entity
GET/discounts/applications/by-employeeGet 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

  1. Entity-agnostic auditing: discount_application uses (entity_type, entity_id) instead of typed FKs, enabling audit across sale/order/quote/service_booking without schema changes.

  2. Soft delete with audit history: Rules that have been applied are deactivated rather than deleted to preserve audit trail integrity.

  3. JSONB + audit table dual storage: Discount stacks are stored both as JSONB snapshots (for fast reads) and in discount_application (for querying and reporting).

  4. Stacking order: price_list → price_rule → discount_rule → manual → override. Each layer can add to the discount stack.

  5. 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.

  6. Permission layering: Override requires canOverridePrices; approval-required rules gate on canApproveDiscounts; minRoleLevel enforces 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