Saltar al contenido principal

Feature 06 — Promotions Engine

Phase: 3 Priority: 🟠 High Status: ⏳ Pending Depends on: Price lists (✅ already built), Collections (Feature 07)

Context

FlowPOS has static price lists but no dynamic discount rules. Promotions are time-bound, condition-based discount rules applied at checkout. Examples:

  • "20% off all Blue items this weekend"
  • "Buy 2, get 1 free on T-shirts"
  • "Spend $100, get $10 off"
  • "Buy any Summer Collection item at 15% off"

The engine must resolve promotions during sale creation, similar to how price-resolution.service.ts resolves prices. Applied promotions are stored in sale_detail JSON for audit trail (no separate table needed for applied promos).

Existing pricing module to reference:

  • apps/backend/src/pricing/application/price-resolution.service.ts
  • apps/backend/src/pricing/ — full module structure (follow this pattern exactly)
  • packages/global/enums/pricing.enums.tsPriceChannel enum to reuse

Task Checklist

Database

  • Create global enum file: packages/global/enums/promotion.enums.ts
  • Create migration: packages/backend/database/src/migrations/2026-03-XX-promotions.mjs
  • Create promotion table
  • Create promotion_condition table
  • Create promotion_reward table
  • Create promotion_scope table
  • Add indexes
  • Run pnpm run migration:local:push
  • Run pnpm run generate:types

Backend

  • Create module: apps/backend/src/promotions/
  • Create promotions.module.ts
  • Create domain/promotions-repository.domain.ts
  • Create infrastructure/promotions.repository.ts
  • Create application/promotions.service.ts — CRUD
  • Create application/promotion-resolution.service.ts — checkout resolution engine
  • Create interfaces/promotions.controller.ts
  • Create interfaces/dtos/ (create, update, resolve DTOs)
  • Register PromotionsModule in apps/backend/src/app.module.ts
  • (Optional Phase 2) Integrate promotionResolutionService into sales.service.ts

PWA Frontend

  • Create apps/frontend-pwa/src/types/promotion.ts
  • Create apps/frontend-pwa/src/services/promotionService.ts
  • Create apps/frontend-pwa/src/components/forms/promotion/PromotionPage.tsx
  • Create apps/frontend-pwa/src/components/forms/promotion/PromotionForm.tsx
    • Conditions builder UI
    • Rewards builder UI
    • Scope selector
  • Extend SaleForm.tsx — show applied promotions section
  • Register in apps/frontend-pwa/src/pages/MainPage.tsx

Verification

  • Migration applies cleanly
  • Types regenerated
  • Backend builds
  • POST /promotions creates a promotion with conditions + rewards
  • POST /promotions/resolve returns correct discounts for a given cart
  • PromotionForm renders and submits correctly
  • Applied discounts are stored in sale_detail JSON

Global Enums

// packages/global/enums/promotion.enums.ts

export enum PromotionType {
PercentageDiscount = "percentage_discount", // % off items or total
FixedDiscount = "fixed_discount", // flat amount off
Bogo = "bogo", // buy X get Y free
SpendThreshold = "spend_threshold", // spend $X get discount
Bundle = "bundle", // buy item set at discount
}

export enum PromotionConditionType {
MinAmount = "min_amount", // cart total >= X
ProductInCart = "product_in_cart", // specific product in cart
CategoryInCart = "category_in_cart", // any product from category
CollectionInCart = "collection_in_cart", // any product from collection
MinQuantity = "min_quantity", // total qty >= X
CustomerTier = "customer_tier", // customer loyalty tier
}

export enum PromotionRewardType {
PctOffAll = "pct_off_all", // % off entire cart
PctOffItem = "pct_off_item", // % off specific items
FixedOffAll = "fixed_off_all", // fixed amount off cart
FixedOffItem = "fixed_off_item", // fixed amount off item
FreeItem = "free_item", // add free item to cart
}

Database Schema

// packages/backend/database/src/migrations/2026-03-XX-promotions.mjs
import { Kysely, sql } from "kysely";

export async function up(db) {
// 1. Promotion definition
await db.schema
.createTable("promotion")
.addColumn("id", "uuid", (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
.addColumn("business_id", "uuid", (col) => col.notNull().references("business.id").onDelete("cascade"))
.addColumn("name", "varchar", (col) => col.notNull())
.addColumn("description", "text")
.addColumn("type", "varchar", (col) => col.notNull())
// PromotionType enum value
.addColumn("priority", "integer", (col) => col.notNull().defaultTo(0))
// Higher priority = evaluated first
.addColumn("valid_from", "timestamptz")
.addColumn("valid_to", "timestamptz")
.addColumn("channel", "varchar")
// Reuse PriceChannel: 'pos' | 'online' | 'kiosk' | null = all
.addColumn("max_uses", "integer")
// null = unlimited uses
.addColumn("uses_count", "integer", (col) => col.notNull().defaultTo(0))
.addColumn("max_uses_per_customer", "integer")
.addColumn("stackable", "boolean", (col) => col.notNull().defaultTo(false))
// Whether this promo stacks with others
.addColumn("is_active", "boolean", (col) => col.notNull().defaultTo(true))
.addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`))
.addColumn("created_by", "uuid", (col) => col.notNull().references("user.id").onDelete("cascade"))
.addColumn("updated_at", "timestamptz")
.addColumn("updated_by", "uuid", (col) => col.references("user.id").onDelete("set null"))
.execute();

// 2. Conditions (what must be true to trigger this promotion)
await db.schema
.createTable("promotion_condition")
.addColumn("id", "uuid", (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
.addColumn("promotion_id", "uuid", (col) => col.notNull().references("promotion.id").onDelete("cascade"))
.addColumn("condition_type", "varchar", (col) => col.notNull())
// PromotionConditionType enum
.addColumn("condition_value", "jsonb", (col) => col.notNull())
// Flexible JSON: { "amount": 100 } | { "productId": "uuid" } | { "categoryId": "uuid" }
.execute();

// 3. Rewards (what discount/benefit is applied when conditions are met)
await db.schema
.createTable("promotion_reward")
.addColumn("id", "uuid", (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
.addColumn("promotion_id", "uuid", (col) => col.notNull().references("promotion.id").onDelete("cascade"))
.addColumn("reward_type", "varchar", (col) => col.notNull())
// PromotionRewardType enum
.addColumn("value", "numeric")
// For pct: 0-100. For fixed: amount in base currency
.addColumn("applies_to", "jsonb")
// Which items get the reward: { "all": true } | { "productIds": [...] } | { "categoryIds": [...] }
.addColumn("max_discount", "numeric")
// Cap the reward (e.g., max $50 off even if pct would give more)
.addColumn("free_product_id", "uuid")
// For FreeItem reward type
.execute();

// 4. Location/channel scope
await db.schema
.createTable("promotion_scope")
.addColumn("id", "uuid", (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
.addColumn("promotion_id", "uuid", (col) => col.notNull().references("promotion.id").onDelete("cascade"))
.addColumn("location_id", "uuid", (col) => col.references("location.id").onDelete("cascade"))
// null = all locations
.execute();

// Indexes
await db.schema.createIndex("idx_promotion_business_active")
.on("promotion")
.columns(["business_id", "is_active", "valid_from", "valid_to"])
.execute();

await db.schema.createIndex("idx_promotion_condition_promotion")
.on("promotion_condition")
.columns(["promotion_id"])
.execute();
}

export async function down(db) {
await db.schema.dropIndex("idx_promotion_condition_promotion").execute();
await db.schema.dropIndex("idx_promotion_business_active").execute();
await db.schema.dropTable("promotion_scope").ifExists().execute();
await db.schema.dropTable("promotion_reward").ifExists().execute();
await db.schema.dropTable("promotion_condition").ifExists().execute();
await db.schema.dropTable("promotion").ifExists().execute();
}

Backend Implementation

Resolution Service: promotion-resolution.service.ts

@Injectable()
export class PromotionResolutionService {
async resolvePromotions(
context: {
businessId: string;
locationId: string;
channel: string;
customerId?: string;
items: CartItem[]; // [{ productId, categoryId, quantity, unitPrice, amount }]
cartTotal: number;
}
): Promise<AppliedPromotion[]> {
// 1. Fetch active promotions for business + channel + location
const activePromos = await this.repository.findActiveForContext(
context.businessId,
context.locationId,
context.channel,
);

// 2. Sort by priority (descending)
const sorted = activePromos.sort((a, b) => b.priority - a.priority);

const applied: AppliedPromotion[] = [];
let remainingTotal = context.cartTotal;

for (const promo of sorted) {
// Skip if max_uses reached
if (promo.maxUses !== null && promo.usesCount >= promo.maxUses) continue;

// 3. Evaluate all conditions
const conditionsMet = await this.evaluateConditions(promo, context);
if (!conditionsMet) continue;

// 4. Calculate reward
const reward = await this.calculateReward(promo, context.items, remainingTotal);
if (reward.discountAmount <= 0) continue;

applied.push({
promotionId: promo.id,
promotionName: promo.name,
promotionType: promo.type,
discountAmount: reward.discountAmount,
appliesTo: reward.appliesTo,
freeItems: reward.freeItems,
});

// If not stackable, stop after first applied promo
if (!promo.stackable) break;
}

return applied;
}

private async evaluateConditions(promo: Promotion, context: ResolveContext): Promise<boolean> {
for (const condition of promo.conditions) {
switch (condition.conditionType) {
case PromotionConditionType.MinAmount:
if (context.cartTotal < condition.conditionValue.amount) return false;
break;
case PromotionConditionType.ProductInCart:
if (!context.items.some(i => i.productId === condition.conditionValue.productId)) return false;
break;
case PromotionConditionType.CategoryInCart:
if (!context.items.some(i => i.categoryId === condition.conditionValue.categoryId)) return false;
break;
case PromotionConditionType.MinQuantity:
const totalQty = context.items.reduce((sum, i) => sum + i.quantity, 0);
if (totalQty < condition.conditionValue.quantity) return false;
break;
}
}
return true;
}
}

Main CRUD Service: promotions.service.ts

@Injectable()
export class PromotionsService {
async createPromotion(dto: CreatePromotionDTO): Promise<Promotion>
// Creates promotion + conditions + rewards + scopes in transaction

async getPromotion(id: string, businessId: string): Promise<PromotionWithDetails>
// Returns promotion with nested conditions, rewards, scopes

async listPromotions(businessId: string, filters: ListPromotionsFilter): Promise<IOffsetPagination<Promotion>>
// Supports: isActive, type, channel, dateFrom, dateTo

async updatePromotion(id: string, dto: UpdatePromotionDTO): Promise<PromotionWithDetails>
async deletePromotion(id: string, businessId: string): Promise<void>
}

Endpoints

@Controller("promotions")
export class PromotionsController {
@Post() create(@Body() dto: CreatePromotionDTO)
@Get() findAll(@Query() query: ListPromotionsQuery)
@Get(":id") findOne(@Param("id") id: string, @Query("businessId") businessId: string)
@Patch(":id") update(@Param("id") id: string, @Body() dto: UpdatePromotionDTO)
@Delete(":id") remove(@Param("id") id: string, @Query("businessId") businessId: string)

@Post("resolve")
resolve(@Body() dto: ResolvePromotionsDTO)
// Input: { businessId, locationId, channel, customerId?, items[], cartTotal }
// Output: AppliedPromotion[]
}

DTOs

export class CreatePromotionDTO {
@IsUUID() businessId: string;
@IsUUID() createdBy: string;
@IsString() @IsNotEmpty() name: string;
@IsOptional() @IsString() description?: string;
@IsEnum(PromotionType) type: PromotionType;
@IsOptional() @IsInt() priority?: number;
@IsOptional() @IsDateString() validFrom?: string;
@IsOptional() @IsDateString() validTo?: string;
@IsOptional() @IsString() channel?: string;
@IsOptional() @IsInt() maxUses?: number;
@IsOptional() @IsInt() maxUsesPerCustomer?: number;
@IsOptional() @IsBoolean() stackable?: boolean;

@IsArray() @ValidateNested({ each: true }) @Type(() => PromotionConditionDTO)
conditions: PromotionConditionDTO[];

@IsArray() @ValidateNested({ each: true }) @Type(() => PromotionRewardDTO)
rewards: PromotionRewardDTO[];

@IsOptional() @IsArray() @IsUUID("4", { each: true })
locationIds?: string[]; // empty = all locations
}

export class ResolvePromotionsDTO {
@IsUUID() businessId: string;
@IsUUID() locationId: string;
@IsString() channel: string;
@IsOptional() @IsUUID() customerId?: string;
@IsArray() cartItems: CartItemDTO[];
@IsNumber() cartTotal: number;
}

PWA Frontend

Types: types/promotion.ts

export interface Promotion {
id?: string;
businessId: string;
name: string;
description?: string;
type: PromotionType;
priority: number;
validFrom?: string;
validTo?: string;
channel?: string;
maxUses?: number;
usesCount: number;
stackable: boolean;
isActive: boolean;
conditions: PromotionCondition[];
rewards: PromotionReward[];
locationIds?: string[];
}

export interface AppliedPromotion {
promotionId: string;
promotionName: string;
promotionType: string;
discountAmount: number;
appliesTo: string[]; // productIds that got the discount
}

Form: PromotionForm.tsx — UI Layout

┌─────────────────────────────────────────────────────────┐
│ PROMOTION DETAILS │
│ Name: [Summer Sale 20%________________] │
│ Type: [Percentage Discount ▼] │
│ Valid: [2026-06-01] to [2026-08-31] │
│ Channel: [POS ▼] Priority: [5] │
├─────────────────────────────────────────────────────────┤
│ CONDITIONS (all must be true) │
│ [+ Add Condition] │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Min cart amount ▼ >= [$100________________] [✕] │ │
│ └────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────┤
│ REWARD │
│ Type: [% Off All Items ▼] │
│ Value: [20] % Max discount: [$50] │
├─────────────────────────────────────────────────────────┤
│ LOCATIONS │
│ [✓] Downtown [✓] Mall [ ] Warehouse │
├─────────────────────────────────────────────────────────┤
│ [Cancel] [Save Promotion] │
└─────────────────────────────────────────────────────────┘

Extend SaleForm.tsx — Applied Promotions

// After items are added and promo resolution runs:
{appliedPromotions.length > 0 && (
<div className="border border-green-200 rounded bg-green-50 p-3">
<p className="text-sm font-semibold text-green-800">Promotions Applied</p>
{appliedPromotions.map(promo => (
<div key={promo.promotionId} className="flex justify-between text-sm">
<span>{promo.promotionName}</span>
<span className="text-green-700">-${promo.discountAmount.toFixed(2)}</span>
</div>
))}
</div>
)}