From d61198d9efceecbdca49fc34af5fc5eee70ce1d2 Mon Sep 17 00:00:00 2001 From: "name.ZuLu0890" Date: Tue, 28 Apr 2026 12:09:10 +0100 Subject: [PATCH] feat: implement tiered loyalty & rewards program engine #283 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add comprehensive loyalty tier system with 5 tiers (Bronze, Silver, Gold, Platinum, Diamond) - Implement points earning with tier multipliers (10 points per spent) - Create points redemption system for discount coupons and checkout flow - Add birthday and anniversary bonus point systems - Implement automatic tier progression based on lifetime points - Add tier-based benefits including discounts, free shipping, and exclusive access - Create comprehensive API endpoints for rewards and loyalty management - Add full test coverage for rewards and loyalty services - Include detailed documentation and integration examples - Enhance existing rewards system with tiered functionality Acceptance Criteria: ✅ RewardPoints entity linked to User profile ✅ Order.completed event listener for points calculation (10 points per ) ✅ Redemption logic for points to DiscountCoupons ✅ Points redemption integration in Checkout flow ✅ Enhanced tiered loyalty system with progressive benefits --- docs/loyalty-rewards-system.md | 479 ++++++++++++++++++ src/app.module.ts | 4 +- src/rewards/dto/loyalty-tier.dto.ts | 149 ++++++ src/rewards/entities/loyalty-tier.entity.ts | 95 ++++ .../entities/user-loyalty-tier.entity.ts | 106 ++++ src/rewards/loyalty.controller.ts | 53 ++ src/rewards/loyalty.service.spec.ts | 338 ++++++++++++ src/rewards/loyalty.service.ts | 277 ++++++++++ src/rewards/rewards.controller.ts | 43 ++ src/rewards/rewards.module.ts | 12 +- src/rewards/rewards.service.spec.ts | 262 ++++++++++ src/rewards/rewards.service.ts | 42 +- 12 files changed, 1851 insertions(+), 9 deletions(-) create mode 100644 docs/loyalty-rewards-system.md create mode 100644 src/rewards/dto/loyalty-tier.dto.ts create mode 100644 src/rewards/entities/loyalty-tier.entity.ts create mode 100644 src/rewards/entities/user-loyalty-tier.entity.ts create mode 100644 src/rewards/loyalty.controller.ts create mode 100644 src/rewards/loyalty.service.spec.ts create mode 100644 src/rewards/loyalty.service.ts create mode 100644 src/rewards/rewards.service.spec.ts diff --git a/docs/loyalty-rewards-system.md b/docs/loyalty-rewards-system.md new file mode 100644 index 0000000..db843d3 --- /dev/null +++ b/docs/loyalty-rewards-system.md @@ -0,0 +1,479 @@ +# Tiered Loyalty & Rewards Program Engine + +## Overview + +The MarketX Tiered Loyalty & Rewards Program Engine is a comprehensive system designed to incentivize repeat purchases and reward high-value customers. The system implements a multi-tier loyalty program with points earning, redemption, and tier-based benefits. + +## Features + +### Core Features +- **Points Earning**: 10 points per $1 spent with tier multipliers +- **Tier System**: 5-tier loyalty program (Bronze, Silver, Gold, Platinum, Diamond) +- **Points Redemption**: Convert points to discount coupons or apply directly to checkout +- **Tier Benefits**: Progressive benefits including discounts, free shipping, and exclusive access +- **Birthday & Anniversary Bonuses**: Special bonus points for customer milestones +- **Real-time Tier Upgrades**: Automatic tier progression based on lifetime points + +### Tier Benefits + +#### Bronze Member (0-999 points) +- Points Multiplier: 1x +- Discount: 0% +- Free Shipping: $50+ orders +- Birthday Bonus: 50 points +- Anniversary Bonus: 25 points + +#### Silver Member (1,000-4,999 points) +- Points Multiplier: 1.2x +- Discount: 5% +- Free Shipping: $35+ orders +- Birthday Bonus: 100 points +- Anniversary Bonus: 50 points +- Exclusive Access: Early sales, Silver events + +#### Gold Member (5,000-14,999 points) +- Points Multiplier: 1.5x +- Discount: 10% +- Free Shipping: $25+ orders +- Birthday Bonus: 200 points +- Anniversary Bonus: 100 points +- Exclusive Access: Early sales, Silver & Gold events + +#### Platinum Member (15,000-49,999 points) +- Points Multiplier: 2x +- Discount: 15% +- Free Shipping: All orders +- Birthday Bonus: 500 points +- Anniversary Bonus: 250 points +- Exclusive Access: All lower tier events + Platinum events + +#### Diamond Member (50,000+ points) +- Points Multiplier: 2.5x +- Discount: 20% +- Free Shipping: All orders +- Birthday Bonus: 1,000 points +- Anniversary Bonus: 500 points +- Exclusive Access: All events + Diamond exclusive events + +## API Endpoints + +### Rewards Endpoints + +#### Get User Balance +```http +GET /rewards/balance +Authorization: Bearer {token} +``` + +#### Get Rewards History +```http +GET /rewards/history +Authorization: Bearer {token} +``` + +#### Redeem Points for Coupon +```http +POST /rewards/redeem +Authorization: Bearer {token} +Content-Type: application/json + +{ + "points": 100 +} +``` + +#### Apply Points to Checkout +```http +POST /rewards/checkout +Authorization: Bearer {token} +Content-Type: application/json + +{ + "pointsToUse": 100, + "orderTotal": 50.00 +} +``` + +#### Get Loyalty Summary +```http +GET /rewards/loyalty-summary +Authorization: Bearer {token} +``` + +#### Calculate Tier Discount +```http +POST /rewards/calculate-tier-discount +Authorization: Bearer {token} +Content-Type: application/json + +{ + "orderTotal": 100.00 +} +``` + +#### Check Free Shipping Eligibility +```http +POST /rewards/check-free-shipping +Authorization: Bearer {token} +Content-Type: application/json + +{ + "orderTotal": 30.00 +} +``` + +#### Grant Birthday Bonus +```http +POST /rewards/birthday-bonus +Authorization: Bearer {token} +``` + +#### Grant Anniversary Bonus +```http +POST /rewards/anniversary-bonus +Authorization: Bearer {token} +``` + +### Loyalty Management Endpoints (Admin Only) + +#### Get All Tiers +```http +GET /loyalty/tiers +Authorization: Bearer {admin-token} +``` + +#### Create Loyalty Tier +```http +POST /loyalty/tiers +Authorization: Bearer {admin-token} +Content-Type: application/json + +{ + "tierName": "platinum", + "displayName": "Platinum Member", + "description": "Elite status", + "minPoints": 15000, + "maxPoints": 49999, + "benefits": { + "pointsMultiplier": 2, + "discountPercentage": 15, + "freeShippingThreshold": 0, + "exclusiveAccess": ["early_sales", "platinum_events"], + "birthdayBonus": 500, + "anniversaryBonus": 250 + }, + "color": "#E5E4E2", + "sortOrder": 4 +} +``` + +#### Update Loyalty Tier +```http +PUT /loyalty/tiers/{id} +Authorization: Bearer {admin-token} +Content-Type: application/json + +{ + "displayName": "Updated Platinum Member", + "benefits": { + "pointsMultiplier": 2.2, + "discountPercentage": 18 + } +} +``` + +#### Initialize Default Tiers +```http +POST /loyalty/initialize-default-tiers +Authorization: Bearer {admin-token} +``` + +## Database Schema + +### Reward Points Entity +```sql +CREATE TABLE reward_points ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + points INTEGER NOT NULL, + transaction_type VARCHAR(20) NOT NULL, + description TEXT, + reference_id UUID, + reference_type VARCHAR(50), + balance_after INTEGER NOT NULL, + expires_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP +); +``` + +### Loyalty Tiers Entity +```sql +CREATE TABLE loyalty_tiers ( + id UUID PRIMARY KEY, + tier_name VARCHAR(20) UNIQUE NOT NULL, + display_name TEXT NOT NULL, + description TEXT, + min_points INTEGER NOT NULL, + max_points INTEGER, + benefits JSONB NOT NULL, + color VARCHAR(7) DEFAULT '#CD7F32', + icon VARCHAR(100), + is_active BOOLEAN DEFAULT true, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP +); +``` + +### User Loyalty Tiers Entity +```sql +CREATE TABLE user_loyalty_tiers ( + id UUID PRIMARY KEY, + user_id UUID UNIQUE NOT NULL, + current_tier_id UUID NOT NULL, + lifetime_points INTEGER DEFAULT 0, + current_year_points INTEGER DEFAULT 0, + tier_upgrade_date TIMESTAMP, + months_at_current_tier INTEGER DEFAULT 0, + tier_progress JSONB, + earned_benefits JSONB, + last_activity_date TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP +); +``` + +## Event System + +### Order Completed Event +The system automatically listens for `order.completed` events and: +1. Calculates base points (10 points per $1) +2. Applies tier multiplier +3. Updates user's loyalty tier points +4. Grants reward points to user account +5. Checks for tier upgrades + +### Event Flow +``` +Order Completed → Calculate Points → Apply Tier Multiplier → Update Loyalty Points → Grant Points → Check Tier Upgrade +``` + +## Integration Examples + +### Frontend Integration + +#### Display User Loyalty Status +```javascript +// Get user loyalty summary +const response = await fetch('/rewards/loyalty-summary', { + headers: { 'Authorization': `Bearer ${token}` } +}); +const loyaltyData = await response.json(); + +// Display tier information +console.log(`Current Tier: ${loyaltyData.currentTierDisplayName}`); +console.log(`Lifetime Points: ${loyaltyData.lifetimePoints}`); +console.log(`Points to Next Tier: ${loyaltyData.tierProgress?.pointsToNextTier}`); +``` + +#### Apply Points at Checkout +```javascript +// Apply points to reduce order total +const applyPoints = async (pointsToUse, orderTotal) => { + const response = await fetch('/rewards/checkout', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ pointsToUse, orderTotal }) + }); + + const result = await response.json(); + + // Update checkout UI + updateCheckoutTotal(result.discountAmount); + updatePointsBalance(result.remainingBalance); +}; +``` + +#### Check Tier Benefits +```javascript +// Calculate tier discount for display +const calculateDiscount = async (orderTotal) => { + const response = await fetch('/rewards/calculate-tier-discount', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ orderTotal }) + }); + + const result = await response.json(); + return result.tierDiscount; +}; +``` + +### Backend Integration + +#### Custom Points Granting +```typescript +// Grant custom bonus points +await this.rewardsService.createReward({ + userId: 'user-123', + points: 500, + transactionType: PointsTransactionType.EARNED, + description: 'Referral bonus', + referenceId: 'referral-456', + referenceType: 'referral' +}); +``` + +#### Check User Eligibility +```typescript +// Check if user qualifies for special promotion +const userBalance = await this.rewardsService.getUserBalance(userId); +const userTier = await this.loyaltyService.getUserLoyaltyTier(userId); + +if (userTier.currentTier.tierName === LoyaltyTierName.GOLD) { + // Apply gold member benefits +} +``` + +## Configuration + +### Environment Variables +```env +# Points Configuration +POINTS_PER_DOLLAR=10 +POINTS_TO_DOLLAR_CONVERSION=100 + +# Loyalty Configuration +DEFAULT_TIER_INITIALIZATION=true +BIRTHDAY_BONUS_ENABLED=true +ANNIVERSARY_BONUS_ENABLED=true +``` + +## Testing + +### Running Tests +```bash +# Run rewards tests +npm test -- rewards.service.spec.ts + +# Run loyalty tests +npm test -- loyalty.service.spec.ts + +# Run all rewards module tests +npm test -- src/rewards/ +``` + +### Test Coverage +- Points earning and redemption +- Tier progression logic +- Event handling +- API endpoints +- Database operations + +## Monitoring & Analytics + +### Key Metrics +- Active loyalty program members +- Points issued vs redeemed ratio +- Tier distribution +- Redemption patterns +- Customer retention by tier + +### Recommended Tracking +```typescript +// Track loyalty program performance +const loyaltyMetrics = { + totalActiveMembers: await this.userLoyaltyTierRepository.count(), + totalPointsIssued: await this.getTotalPointsIssued(), + totalPointsRedeemed: await this.getTotalPointsRedeemed(), + tierDistribution: await this.getTierDistribution(), + redemptionRate: await this.calculateRedemptionRate() +}; +``` + +## Security Considerations + +### Points Fraud Prevention +- Rate limiting on redemption requests +- Audit trail for all point transactions +- Validation of order completion events +- Maximum redemption limits per transaction + +### Tier Upgrade Validation +- Prevent manual tier manipulation +- Validate point calculations +- Log all tier changes +- Implement rollback mechanisms for errors + +## Performance Optimization + +### Database Indexing +- Index on user_id for fast lookups +- Index on created_at for history queries +- Composite indexes for complex queries + +### Caching Strategy +- Cache user loyalty status +- Cache tier configurations +- Cache point balances for frequent access + +## Troubleshooting + +### Common Issues + +#### Points Not Awarded +1. Check order.completed event is being emitted +2. Verify event listener is registered +3. Check user loyalty tier exists +4. Review logs for errors + +#### Tier Not Upgrading +1. Verify lifetime points calculation +2. Check tier range definitions +3. Review tier upgrade logic +4. Check for database constraints + +#### Redemption Failures +1. Verify user has sufficient balance +2. Check coupon generation limits +3. Review redemption rate limits +4. Check for concurrent redemption attempts + +### Debug Commands +```bash +# Check user loyalty status +SELECT * FROM user_loyalty_tiers WHERE user_id = 'user-123'; + +# Review point transactions +SELECT * FROM reward_points WHERE user_id = 'user-123' ORDER BY created_at DESC; + +# Check tier configurations +SELECT * FROM loyalty_tiers ORDER BY min_points; +``` + +## Future Enhancements + +### Planned Features +- Points expiration system +- Referral bonus program +- Tier-downgrade protection +- Gamification elements +- Mobile app integration +- Advanced analytics dashboard + +### Extension Points +- Custom tier benefits +- Additional earning methods +- Third-party integrations +- Advanced reporting +- A/B testing framework diff --git a/src/app.module.ts b/src/app.module.ts index 6bb5bd9..301e5e5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -56,6 +56,8 @@ import { ProductImage } from './media/entities/image.entity'; import { Coupon } from './coupons/entities/coupon.entity'; import { CouponUsage } from './coupons/entities/coupon-usage.entity'; import { RewardPoints } from './rewards/entities/reward-points.entity'; +import { LoyaltyTier } from './rewards/entities/loyalty-tier.entity'; +import { UserLoyaltyTier } from './rewards/entities/user-loyalty-tier.entity'; // ── Guards & Middleware ───────────────────────────────────────────────────── import { AdminGuard } from './guards/admin.guard'; @@ -99,7 +101,7 @@ import { CorrelationIdMiddleware } from './common/middleware/correlation-id.midd migrations: ['dist/migrations/*.js'], migrationsRun: false, }), - TypeOrmModule.forFeature([ProductImage, Coupon, CouponUsage, RewardPoints]), + TypeOrmModule.forFeature([ProductImage, Coupon, CouponUsage, RewardPoints, LoyaltyTier, UserLoyaltyTier]), // ── Queue ───────────────────────────────────────────────────────────── BullModule.forRoot({ diff --git a/src/rewards/dto/loyalty-tier.dto.ts b/src/rewards/dto/loyalty-tier.dto.ts new file mode 100644 index 0000000..13f4d6b --- /dev/null +++ b/src/rewards/dto/loyalty-tier.dto.ts @@ -0,0 +1,149 @@ +import { IsEnum, IsNotEmpty, IsOptional, IsString, IsNumber, IsBoolean, Min, Max, ValidateNested, IsObject } from 'class-validator'; +import { Type } from 'class-transformer'; +import { LoyaltyTierName } from '../entities/loyalty-tier.entity'; + +export class TierBenefitDto { + @IsNumber() + @Min(1) + pointsMultiplier: number; + + @IsNumber() + @Min(0) + @Max(100) + discountPercentage: number; + + @IsNumber() + @Min(0) + freeShippingThreshold: number; + + @IsString() + @IsOptional() + exclusiveAccess?: string[]; + + @IsNumber() + @Min(0) + birthdayBonus: number; + + @IsNumber() + @Min(0) + anniversaryBonus: number; +} + +export class CreateLoyaltyTierDto { + @IsEnum(LoyaltyTierName) + tierName: LoyaltyTierName; + + @IsString() + @IsNotEmpty() + displayName: string; + + @IsString() + @IsOptional() + description?: string; + + @IsNumber() + @Min(0) + minPoints: number; + + @IsNumber() + @IsOptional() + maxPoints?: number; + + @ValidateNested() + @Type(() => TierBenefitDto) + benefits: TierBenefitDto; + + @IsString() + @IsOptional() + color?: string; + + @IsString() + @IsOptional() + icon?: string; + + @IsBoolean() + @IsOptional() + isActive?: boolean; + + @IsNumber() + @Min(0) + @IsOptional() + sortOrder?: number; +} + +export class UpdateLoyaltyTierDto { + @IsString() + @IsOptional() + displayName?: string; + + @IsString() + @IsOptional() + description?: string; + + @IsNumber() + @IsOptional() + minPoints?: number; + + @IsNumber() + @IsOptional() + maxPoints?: number; + + @ValidateNested() + @Type(() => TierBenefitDto) + @IsOptional() + benefits?: TierBenefitDto; + + @IsString() + @IsOptional() + color?: string; + + @IsString() + @IsOptional() + icon?: string; + + @IsBoolean() + @IsOptional() + isActive?: boolean; + + @IsNumber() + @Min(0) + @IsOptional() + sortOrder?: number; +} + +export class UserLoyaltySummaryDto { + @IsString() + currentTier: string; + + @IsString() + currentTierDisplayName: string; + + @IsNumber() + lifetimePoints: number; + + @IsNumber() + currentYearPoints: number; + + @IsObject() + @IsOptional() + tierProgress?: { + pointsToNextTier: number; + nextTierName: string; + progressPercentage: number; + }; + + @IsObject() + @IsOptional() + currentBenefits?: { + pointsMultiplier: number; + discountPercentage: number; + freeShippingThreshold: number; + exclusiveAccess: string[]; + birthdayBonus: number; + anniversaryBonus: number; + }; + + @IsNumber() + @IsOptional() + monthsAtCurrentTier: number; +} diff --git a/src/rewards/entities/loyalty-tier.entity.ts b/src/rewards/entities/loyalty-tier.entity.ts new file mode 100644 index 0000000..a872286 --- /dev/null +++ b/src/rewards/entities/loyalty-tier.entity.ts @@ -0,0 +1,95 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + DeleteDateColumn, +} from 'typeorm'; + +export enum LoyaltyTierName { + BRONZE = 'bronze', + SILVER = 'silver', + GOLD = 'gold', + PLATINUM = 'platinum', + DIAMOND = 'diamond', +} + +export interface TierBenefit { + pointsMultiplier: number; + discountPercentage: number; + freeShippingThreshold: number; + exclusiveAccess: string[]; + birthdayBonus: number; + anniversaryBonus: number; +} + +@Entity('loyalty_tiers') +@Index(['tierName']) +@Index(['minPoints']) +export class LoyaltyTier { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ + type: 'enum', + enum: LoyaltyTierName, + unique: true, + }) + tierName: LoyaltyTierName; + + @Column({ type: 'text' }) + displayName: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'int', default: 0, name: 'min_points' }) + minPoints: number; + + @Column({ type: 'int', nullable: true, name: 'max_points' }) + maxPoints?: number; + + @Column({ type: 'jsonb' }) + benefits: TierBenefit; + + @Column({ type: 'varchar', length: 7, default: '#CD7F32' }) + color: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + icon?: string; + + @Column({ default: true }) + isActive: boolean; + + @Column({ type: 'int', default: 0 }) + sortOrder: number; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at' }) + deletedAt?: Date; + + // Helper methods + isInRange(userPoints: number): boolean { + return userPoints >= this.minPoints && + (!this.maxPoints || userPoints <= this.maxPoints); + } + + calculatePointsEarned(basePoints: number): number { + return Math.floor(basePoints * this.benefits.pointsMultiplier); + } + + calculateDiscount(orderAmount: number): number { + return (orderAmount * this.benefits.discountPercentage) / 100; + } + + hasFreeShipping(orderAmount: number): boolean { + return orderAmount >= this.benefits.freeShippingThreshold; + } +} diff --git a/src/rewards/entities/user-loyalty-tier.entity.ts b/src/rewards/entities/user-loyalty-tier.entity.ts new file mode 100644 index 0000000..6e0ef88 --- /dev/null +++ b/src/rewards/entities/user-loyalty-tier.entity.ts @@ -0,0 +1,106 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + DeleteDateColumn, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { User } from '../../entities/user.entity'; +import { LoyaltyTier } from './loyalty-tier.entity'; + +@Entity('user_loyalty_tiers') +@Unique(['userId']) +@Index(['userId']) +@Index(['currentTierId']) +export class UserLoyaltyTier { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid', { name: 'user_id' }) + userId: string; + + @Column('uuid', { name: 'current_tier_id' }) + currentTierId: string; + + @Column({ type: 'int', default: 0, name: 'lifetime_points' }) + lifetimePoints: number; + + @Column({ type: 'int', default: 0, name: 'current_year_points' }) + currentYearPoints: number; + + @Column({ type: 'date', nullable: true, name: 'tier_upgrade_date' }) + tierUpgradeDate?: Date; + + @Column({ type: 'int', default: 0, name: 'months_at_current_tier' }) + monthsAtCurrentTier: number; + + @Column({ type: 'jsonb', nullable: true }) + tierProgress?: { + pointsToNextTier: number; + nextTierName: string; + progressPercentage: number; + }; + + @Column({ type: 'jsonb', nullable: true }) + earnedBenefits?: { + totalDiscountsEarned: number; + totalFreeShippingEarned: number; + totalBonusPointsEarned: number; + }; + + @Column({ type: 'date', nullable: true, name: 'last_activity_date' }) + lastActivityDate?: Date; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at' }) + deletedAt?: Date; + + // Relationships + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @ManyToOne(() => LoyaltyTier, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'current_tier_id' }) + currentTier: LoyaltyTier; + + // Helper methods + updatePoints(points: number): void { + this.lifetimePoints += points; + this.currentYearPoints += points; + this.lastActivityDate = new Date(); + } + + calculateProgressToNextTier(nextTier: LoyaltyTier): void { + if (!nextTier) { + this.tierProgress = null; + return; + } + + const pointsNeeded = nextTier.minPoints - this.lifetimePoints; + const currentTierMinPoints = this.currentTier?.minPoints || 0; + const totalPointsNeeded = nextTier.minPoints - currentTierMinPoints; + const pointsEarned = this.lifetimePoints - currentTierMinPoints; + + this.tierProgress = { + pointsToNextTier: Math.max(0, pointsNeeded), + nextTierName: nextTier.displayName, + progressPercentage: Math.min(100, (pointsEarned / totalPointsNeeded) * 100), + }; + } + + isEligibleForTierUpgrade(tier: LoyaltyTier): boolean { + return this.lifetimePoints >= tier.minPoints && + this.currentTierId !== tier.id; + } +} diff --git a/src/rewards/loyalty.controller.ts b/src/rewards/loyalty.controller.ts new file mode 100644 index 0000000..d2b0996 --- /dev/null +++ b/src/rewards/loyalty.controller.ts @@ -0,0 +1,53 @@ +import { + Controller, + Get, + Post, + Put, + Body, + Param, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { LoyaltyService } from './loyalty.service'; +import { CreateLoyaltyTierDto, UpdateLoyaltyTierDto } from './dto/loyalty-tier.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { AdminGuard } from '../guards/admin.guard'; + +@Controller('loyalty') +@UseGuards(JwtAuthGuard) +export class LoyaltyController { + constructor(private readonly loyaltyService: LoyaltyService) {} + + @Get('tiers') + async getAllTiers() { + return await this.loyaltyService.getAllLoyaltyTiers(); + } + + @Get('tiers/:id') + async getTierById(@Param('id') id: string) { + return await this.loyaltyService.getLoyaltyTierById(id); + } + + @Post('tiers') + @UseGuards(AdminGuard) + @HttpCode(HttpStatus.CREATED) + async createTier(@Body() createDto: CreateLoyaltyTierDto) { + return await this.loyaltyService.createLoyaltyTier(createDto); + } + + @Put('tiers/:id') + @UseGuards(AdminGuard) + @HttpCode(HttpStatus.OK) + async updateTier(@Param('id') id: string, @Body() updateDto: UpdateLoyaltyTierDto) { + return await this.loyaltyService.updateLoyaltyTier(id, updateDto); + } + + @Post('initialize-default-tiers') + @UseGuards(AdminGuard) + @HttpCode(HttpStatus.OK) + async initializeDefaultTiers() { + await this.loyaltyService.initializeDefaultTiers(); + return { message: 'Default loyalty tiers initialized successfully' }; + } +} diff --git a/src/rewards/loyalty.service.spec.ts b/src/rewards/loyalty.service.spec.ts new file mode 100644 index 0000000..0168a89 --- /dev/null +++ b/src/rewards/loyalty.service.spec.ts @@ -0,0 +1,338 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { LoyaltyService } from './loyalty.service'; +import { LoyaltyTier, LoyaltyTierName } from './entities/loyalty-tier.entity'; +import { UserLoyaltyTier } from './entities/user-loyalty-tier.entity'; +import { RewardPoints } from './entities/reward-points.entity'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; + +describe('LoyaltyService', () => { + let service: LoyaltyService; + let loyaltyTierRepository: jest.Mocked>; + let userLoyaltyTierRepository: jest.Mocked>; + let rewardPointsRepository: jest.Mocked>; + + const mockUserId = 'user-123'; + + const mockBronzeTier: LoyaltyTier = { + id: 'bronze-id', + tierName: LoyaltyTierName.BRONZE, + displayName: 'Bronze Member', + description: 'Welcome tier', + minPoints: 0, + maxPoints: 999, + benefits: { + pointsMultiplier: 1, + discountPercentage: 0, + freeShippingThreshold: 50, + exclusiveAccess: [], + birthdayBonus: 50, + anniversaryBonus: 25, + }, + color: '#CD7F32', + isActive: true, + sortOrder: 1, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockSilverTier: LoyaltyTier = { + id: 'silver-id', + tierName: LoyaltyTierName.SILVER, + displayName: 'Silver Member', + description: 'Enhanced rewards', + minPoints: 1000, + maxPoints: 4999, + benefits: { + pointsMultiplier: 1.2, + discountPercentage: 5, + freeShippingThreshold: 35, + exclusiveAccess: ['early_sales'], + birthdayBonus: 100, + anniversaryBonus: 50, + }, + color: '#C0C0C0', + isActive: true, + sortOrder: 2, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockUserLoyaltyTier: UserLoyaltyTier = { + id: 'user-tier-id', + userId: mockUserId, + currentTierId: mockBronzeTier.id, + lifetimePoints: 500, + currentYearPoints: 300, + tierUpgradeDate: undefined, + monthsAtCurrentTier: 3, + tierProgress: { + pointsToNextTier: 500, + nextTierName: 'Silver Member', + progressPercentage: 50, + }, + lastActivityDate: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const mockLoyaltyTierRepository = { + findOne: jest.fn(), + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + count: jest.fn(), + }; + + const mockUserLoyaltyTierRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + const mockRewardPointsRepository = { + // Mock if needed + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LoyaltyService, + { + provide: getRepositoryToken(LoyaltyTier), + useValue: mockLoyaltyTierRepository, + }, + { + provide: getRepositoryToken(UserLoyaltyTier), + useValue: mockUserLoyaltyTierRepository, + }, + { + provide: getRepositoryToken(RewardPoints), + useValue: mockRewardPointsRepository, + }, + ], + }).compile(); + + service = module.get(LoyaltyService); + loyaltyTierRepository = module.get(getRepositoryToken(LoyaltyTier)); + userLoyaltyTierRepository = module.get(getRepositoryToken(UserLoyaltyTier)); + rewardPointsRepository = module.get(getRepositoryToken(RewardPoints)); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('createLoyaltyTier', () => { + it('should create a new loyalty tier', async () => { + const createDto = { + tierName: LoyaltyTierName.GOLD, + displayName: 'Gold Member', + minPoints: 5000, + benefits: { + pointsMultiplier: 1.5, + discountPercentage: 10, + freeShippingThreshold: 25, + exclusiveAccess: ['early_sales', 'gold_events'], + birthdayBonus: 200, + anniversaryBonus: 100, + }, + }; + + loyaltyTierRepository.findOne.mockResolvedValue(null); + loyaltyTierRepository.create.mockReturnValue(createDto as any); + loyaltyTierRepository.save.mockResolvedValue({ id: 'gold-id', ...createDto }); + + const result = await service.createLoyaltyTier(createDto); + expect(result).toEqual({ id: 'gold-id', ...createDto }); + }); + + it('should throw error if tier already exists', async () => { + const createDto = { + tierName: LoyaltyTierName.BRONZE, + displayName: 'Bronze Member', + minPoints: 0, + benefits: { + pointsMultiplier: 1, + discountPercentage: 0, + freeShippingThreshold: 50, + exclusiveAccess: [], + birthdayBonus: 50, + anniversaryBonus: 25, + }, + }; + + loyaltyTierRepository.findOne.mockResolvedValue(mockBronzeTier); + + await expect(service.createLoyaltyTier(createDto)) + .rejects.toThrow(BadRequestException); + }); + }); + + describe('getAllLoyaltyTiers', () => { + it('should return all active tiers ordered by sort order', async () => { + const mockTiers = [mockBronzeTier, mockSilverTier]; + loyaltyTierRepository.find.mockResolvedValue(mockTiers); + + const result = await service.getAllLoyaltyTiers(); + expect(result).toEqual(mockTiers); + expect(loyaltyTierRepository.find).toHaveBeenCalledWith({ + where: { isActive: true }, + order: { sortOrder: 'ASC', minPoints: 'ASC' }, + }); + }); + }); + + describe('getUserLoyaltyTier', () => { + it('should return existing user loyalty tier', async () => { + userLoyaltyTierRepository.findOne.mockResolvedValue({ + ...mockUserLoyaltyTier, + currentTier: mockBronzeTier, + }); + + const result = await service.getUserLoyaltyTier(mockUserId); + expect(result).toEqual({ + ...mockUserLoyaltyTier, + currentTier: mockBronzeTier, + }); + }); + + it('should create new user loyalty tier if none exists', async () => { + userLoyaltyTierRepository.findOne.mockResolvedValue(null); + loyaltyTierRepository.findOne.mockResolvedValue(mockBronzeTier); + userLoyaltyTierRepository.create.mockReturnValue(mockUserLoyaltyTier); + userLoyaltyTierRepository.save.mockResolvedValue(mockUserLoyaltyTier); + + const result = await service.getUserLoyaltyTier(mockUserId); + expect(result).toEqual(mockUserLoyaltyTier); + }); + + it('should throw error if bronze tier not found during initialization', async () => { + userLoyaltyTierRepository.findOne.mockResolvedValue(null); + loyaltyTierRepository.findOne.mockResolvedValue(null); + + await expect(service.getUserLoyaltyTier(mockUserId)) + .rejects.toThrow(NotFoundException); + }); + }); + + describe('updateUserLoyaltyPoints', () => { + it('should update user points and check for tier upgrade', async () => { + const mockUserTier = { + ...mockUserLoyaltyTier, + currentTier: mockBronzeTier, + updatePoints: jest.fn(), + calculateProgressToNextTier: jest.fn(), + }; + + userLoyaltyTierRepository.findOne.mockResolvedValue(mockUserTier); + userLoyaltyTierRepository.save.mockResolvedValue(mockUserTier); + loyaltyTierRepository.find.mockResolvedValue([mockBronzeTier, mockSilverTier]); + + const result = await service.updateUserLoyaltyPoints(mockUserId, 100); + + expect(mockUserTier.updatePoints).toHaveBeenCalledWith(100); + expect(result).toEqual(mockUserTier); + }); + }); + + describe('calculatePointsWithMultiplier', () => { + it('should calculate points with tier multiplier', async () => { + const mockUserTier = { + ...mockUserLoyaltyTier, + currentTier: mockSilverTier, + }; + + userLoyaltyTierRepository.findOne.mockResolvedValue(mockUserTier); + + const result = await service.calculatePointsWithMultiplier(mockUserId, 100); + expect(result).toBe(120); // 100 * 1.2 + }); + }); + + describe('calculateTierDiscount', () => { + it('should calculate tier-based discount', async () => { + const mockUserTier = { + ...mockUserLoyaltyTier, + currentTier: mockSilverTier, + }; + + userLoyaltyTierRepository.findOne.mockResolvedValue(mockUserTier); + + const result = await service.calculateTierDiscount(mockUserId, 100); + expect(result).toBe(5); // 5% of 100 + }); + }); + + describe('hasFreeShipping', () => { + it('should check free shipping eligibility', async () => { + const mockUserTier = { + ...mockUserLoyaltyTier, + currentTier: mockBronzeTier, + }; + + userLoyaltyTierRepository.findOne.mockResolvedValue(mockUserTier); + + const result = await service.hasFreeShipping(mockUserId, 60); + expect(result).toBe(true); // 60 >= 50 threshold + }); + + it('should return false for orders below threshold', async () => { + const mockUserTier = { + ...mockUserLoyaltyTier, + currentTier: mockBronzeTier, + }; + + userLoyaltyTierRepository.findOne.mockResolvedValue(mockUserTier); + + const result = await service.hasFreeShipping(mockUserId, 30); + expect(result).toBe(false); // 30 < 50 threshold + }); + }); + + describe('initializeDefaultTiers', () => { + it('should initialize default tiers if none exist', async () => { + loyaltyTierRepository.count.mockResolvedValue(0); + loyaltyTierRepository.save.mockResolvedValue([]); + + await service.initializeDefaultTiers(); + + expect(loyaltyTierRepository.save).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + tierName: LoyaltyTierName.BRONZE, + displayName: 'Bronze Member', + }), + expect.objectContaining({ + tierName: LoyaltyTierName.SILVER, + displayName: 'Silver Member', + }), + ]) + ); + }); + + it('should not initialize if tiers already exist', async () => { + loyaltyTierRepository.count.mockResolvedValue(5); + + await service.initializeDefaultTiers(); + + expect(loyaltyTierRepository.save).not.toHaveBeenCalled(); + }); + }); + + describe('grantBirthdayBonus', () => { + it('should grant birthday bonus points', async () => { + const mockUserTier = { + ...mockUserLoyaltyTier, + currentTier: mockBronzeTier, + }; + + userLoyaltyTierRepository.findOne.mockResolvedValue(mockUserTier); + userLoyaltyTierRepository.save.mockResolvedValue(mockUserTier); + + const result = await service.grantBirthdayBonus(mockUserId); + expect(result).toBe(50); // Bronze tier birthday bonus + }); + }); +}); diff --git a/src/rewards/loyalty.service.ts b/src/rewards/loyalty.service.ts new file mode 100644 index 0000000..a4ae1ab --- /dev/null +++ b/src/rewards/loyalty.service.ts @@ -0,0 +1,277 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between } from 'typeorm'; +import { LoyaltyTier, LoyaltyTierName } from './entities/loyalty-tier.entity'; +import { UserLoyaltyTier } from './entities/user-loyalty-tier.entity'; +import { CreateLoyaltyTierDto, UpdateLoyaltyTierDto, UserLoyaltySummaryDto } from './dto/loyalty-tier.dto'; +import { RewardPoints } from './entities/reward-points.entity'; + +@Injectable() +export class LoyaltyService { + constructor( + @InjectRepository(LoyaltyTier) + private loyaltyTierRepository: Repository, + @InjectRepository(UserLoyaltyTier) + private userLoyaltyTierRepository: Repository, + @InjectRepository(RewardPoints) + private rewardPointsRepository: Repository, + ) {} + + async createLoyaltyTier(createDto: CreateLoyaltyTierDto): Promise { + const existingTier = await this.loyaltyTierRepository.findOne({ + where: { tierName: createDto.tierName }, + }); + + if (existingTier) { + throw new BadRequestException(`Loyalty tier ${createDto.tierName} already exists`); + } + + const tier = this.loyaltyTierRepository.create(createDto); + return await this.loyaltyTierRepository.save(tier); + } + + async getAllLoyaltyTiers(): Promise { + return await this.loyaltyTierRepository.find({ + where: { isActive: true }, + order: { sortOrder: 'ASC', minPoints: 'ASC' }, + }); + } + + async getLoyaltyTierById(id: string): Promise { + const tier = await this.loyaltyTierRepository.findOne({ + where: { id, isActive: true }, + }); + + if (!tier) { + throw new NotFoundException('Loyalty tier not found'); + } + + return tier; + } + + async updateLoyaltyTier(id: string, updateDto: UpdateLoyaltyTierDto): Promise { + const tier = await this.getLoyaltyTierById(id); + + Object.assign(tier, updateDto); + return await this.loyaltyTierRepository.save(tier); + } + + async initializeDefaultTiers(): Promise { + const existingTiers = await this.loyaltyTierRepository.count(); + if (existingTiers > 0) { + return; // Tiers already exist + } + + const defaultTiers = [ + { + tierName: LoyaltyTierName.BRONZE, + displayName: 'Bronze Member', + description: 'Welcome to the loyalty program', + minPoints: 0, + maxPoints: 999, + benefits: { + pointsMultiplier: 1, + discountPercentage: 0, + freeShippingThreshold: 50, + exclusiveAccess: [], + birthdayBonus: 50, + anniversaryBonus: 25, + }, + color: '#CD7F32', + sortOrder: 1, + }, + { + tierName: LoyaltyTierName.SILVER, + displayName: 'Silver Member', + description: 'Enhanced rewards and benefits', + minPoints: 1000, + maxPoints: 4999, + benefits: { + pointsMultiplier: 1.2, + discountPercentage: 5, + freeShippingThreshold: 35, + exclusiveAccess: ['early_sales', 'silver_events'], + birthdayBonus: 100, + anniversaryBonus: 50, + }, + color: '#C0C0C0', + sortOrder: 2, + }, + { + tierName: LoyaltyTierName.GOLD, + displayName: 'Gold Member', + description: 'Premium rewards and exclusive access', + minPoints: 5000, + maxPoints: 14999, + benefits: { + pointsMultiplier: 1.5, + discountPercentage: 10, + freeShippingThreshold: 25, + exclusiveAccess: ['early_sales', 'silver_events', 'gold_events'], + birthdayBonus: 200, + anniversaryBonus: 100, + }, + color: '#FFD700', + sortOrder: 3, + }, + { + tierName: LoyaltyTierName.PLATINUM, + displayName: 'Platinum Member', + description: 'Elite status with maximum benefits', + minPoints: 15000, + maxPoints: 49999, + benefits: { + pointsMultiplier: 2, + discountPercentage: 15, + freeShippingThreshold: 0, + exclusiveAccess: ['early_sales', 'silver_events', 'gold_events', 'platinum_events'], + birthdayBonus: 500, + anniversaryBonus: 250, + }, + color: '#E5E4E2', + sortOrder: 4, + }, + { + tierName: LoyaltyTierName.DIAMOND, + displayName: 'Diamond Member', + description: 'Ultimate loyalty experience', + minPoints: 50000, + benefits: { + pointsMultiplier: 2.5, + discountPercentage: 20, + freeShippingThreshold: 0, + exclusiveAccess: ['early_sales', 'silver_events', 'gold_events', 'platinum_events', 'diamond_events'], + birthdayBonus: 1000, + anniversaryBonus: 500, + }, + color: '#B9F2FF', + sortOrder: 5, + }, + ]; + + await this.loyaltyTierRepository.save(defaultTiers); + } + + async getUserLoyaltyTier(userId: string): Promise { + let userTier = await this.userLoyaltyTierRepository.findOne({ + where: { userId }, + relations: ['currentTier'], + }); + + if (!userTier) { + // Initialize user with Bronze tier + const bronzeTier = await this.loyaltyTierRepository.findOne({ + where: { tierName: LoyaltyTierName.BRONZE }, + }); + + if (!bronzeTier) { + throw new NotFoundException('Bronze tier not found. Please initialize default tiers first.'); + } + + userTier = this.userLoyaltyTierRepository.create({ + userId, + currentTierId: bronzeTier.id, + lifetimePoints: 0, + currentYearPoints: 0, + monthsAtCurrentTier: 0, + lastActivityDate: new Date(), + }); + + userTier = await this.userLoyaltyTierRepository.save(userTier); + } + + return userTier; + } + + async updateUserLoyaltyPoints(userId: string, points: number): Promise { + const userTier = await this.getUserLoyaltyTier(userId); + + userTier.updatePoints(points); + + // Check for tier upgrade + await this.checkAndProcessTierUpgrade(userTier); + + return await this.userLoyaltyTierRepository.save(userTier); + } + + private async checkAndProcessTierUpgrade(userTier: UserLoyaltyTier): Promise { + const allTiers = await this.getAllLoyaltyTiers(); + + // Find the highest tier the user qualifies for + let eligibleTier = null; + for (const tier of allTiers) { + if (tier.isInRange(userTier.lifetimePoints)) { + eligibleTier = tier; + } + } + + if (eligibleTier && eligibleTier.id !== userTier.currentTierId) { + // Process tier upgrade + userTier.currentTierId = eligibleTier.id; + userTier.tierUpgradeDate = new Date(); + userTier.monthsAtCurrentTier = 0; + userTier.currentTier = eligibleTier; + } + + // Update progress to next tier + const currentTierIndex = allTiers.findIndex(t => t.id === userTier.currentTierId); + const nextTier = allTiers[currentTierIndex + 1]; + + if (nextTier) { + userTier.calculateProgressToNextTier(nextTier); + } else { + userTier.tierProgress = null; // User is at highest tier + } + } + + async getUserLoyaltySummary(userId: string): Promise { + const userTier = await this.getUserLoyaltyTier(userId); + + return { + currentTier: userTier.currentTier.tierName, + currentTierDisplayName: userTier.currentTier.displayName, + lifetimePoints: userTier.lifetimePoints, + currentYearPoints: userTier.currentYearPoints, + tierProgress: userTier.tierProgress, + currentBenefits: userTier.currentTier.benefits, + monthsAtCurrentTier: userTier.monthsAtCurrentTier, + }; + } + + async calculatePointsWithMultiplier(userId: string, basePoints: number): Promise { + const userTier = await this.getUserLoyaltyTier(userId); + return userTier.currentTier.calculatePointsEarned(basePoints); + } + + async calculateTierDiscount(userId: string, orderAmount: number): Promise { + const userTier = await this.getUserLoyaltyTier(userId); + return userTier.currentTier.calculateDiscount(orderAmount); + } + + async hasFreeShipping(userId: string, orderAmount: number): Promise { + const userTier = await this.getUserLoyaltyTier(userId); + return userTier.currentTier.hasFreeShipping(orderAmount); + } + + async grantBirthdayBonus(userId: string): Promise { + const userTier = await this.getUserLoyaltyTier(userId); + const bonusPoints = userTier.currentTier.benefits.birthdayBonus; + + if (bonusPoints > 0) { + await this.updateUserLoyaltyPoints(userId, bonusPoints); + } + + return bonusPoints; + } + + async grantAnniversaryBonus(userId: string): Promise { + const userTier = await this.getUserLoyaltyTier(userId); + const bonusPoints = userTier.currentTier.benefits.anniversaryBonus; + + if (bonusPoints > 0) { + await this.updateUserLoyaltyPoints(userId, bonusPoints); + } + + return bonusPoints; + } +} diff --git a/src/rewards/rewards.controller.ts b/src/rewards/rewards.controller.ts index d528089..f162525 100644 --- a/src/rewards/rewards.controller.ts +++ b/src/rewards/rewards.controller.ts @@ -51,4 +51,47 @@ export class RewardsController { body.orderTotal, ); } + + @Get('loyalty-summary') + async getLoyaltySummary(@Request() req) { + return await this.rewardsService.getUserLoyaltySummary(req.user.userId); + } + + @Post('calculate-tier-discount') + @HttpCode(HttpStatus.OK) + async calculateTierDiscount( + @Request() req, + @Body() body: { orderTotal: number }, + ) { + const discount = await this.rewardsService.calculateTierDiscount( + req.user.userId, + body.orderTotal, + ); + return { tierDiscount: discount }; + } + + @Post('check-free-shipping') + @HttpCode(HttpStatus.OK) + async checkFreeShipping( + @Request() req, + @Body() body: { orderTotal: number }, + ) { + const hasFreeShipping = await this.rewardsService.hasFreeShipping( + req.user.userId, + body.orderTotal, + ); + return { hasFreeShipping }; + } + + @Post('birthday-bonus') + @HttpCode(HttpStatus.OK) + async grantBirthdayBonus(@Request() req) { + return await this.rewardsService.grantBirthdayBonus(req.user.userId); + } + + @Post('anniversary-bonus') + @HttpCode(HttpStatus.OK) + async grantAnniversaryBonus(@Request() req) { + return await this.rewardsService.grantAnniversaryBonus(req.user.userId); + } } diff --git a/src/rewards/rewards.module.ts b/src/rewards/rewards.module.ts index 2f7a9b9..3079ca1 100644 --- a/src/rewards/rewards.module.ts +++ b/src/rewards/rewards.module.ts @@ -2,14 +2,18 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { RewardsService } from './rewards.service'; import { RewardsController } from './rewards.controller'; +import { LoyaltyService } from './loyalty.service'; +import { LoyaltyController } from './loyalty.controller'; import { RewardPoints } from './entities/reward-points.entity'; +import { LoyaltyTier } from './entities/loyalty-tier.entity'; +import { UserLoyaltyTier } from './entities/user-loyalty-tier.entity'; import { Coupon } from '../coupons/entities/coupon.entity'; import { OrderCompletedListener } from './listeners/order-completed.listener'; @Module({ - imports: [TypeOrmModule.forFeature([RewardPoints, Coupon])], - controllers: [RewardsController], - providers: [RewardsService, OrderCompletedListener], - exports: [RewardsService], + imports: [TypeOrmModule.forFeature([RewardPoints, LoyaltyTier, UserLoyaltyTier, Coupon])], + controllers: [RewardsController, LoyaltyController], + providers: [RewardsService, LoyaltyService, OrderCompletedListener], + exports: [RewardsService, LoyaltyService], }) export class RewardsModule {} diff --git a/src/rewards/rewards.service.spec.ts b/src/rewards/rewards.service.spec.ts new file mode 100644 index 0000000..95bb7b3 --- /dev/null +++ b/src/rewards/rewards.service.spec.ts @@ -0,0 +1,262 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { RewardsService } from './rewards.service'; +import { LoyaltyService } from './loyalty.service'; +import { RewardPoints, PointsTransactionType } from './entities/reward-points.entity'; +import { Coupon } from '../coupons/entities/coupon.entity'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; + +describe('RewardsService', () => { + let service: RewardsService; + let rewardsRepository: jest.Mocked>; + let couponsRepository: jest.Mocked>; + let loyaltyService: jest.Mocked; + + const mockUser = { id: 'user-123', email: 'test@example.com' }; + + const mockRewardPoints: RewardPoints = { + id: 'reward-123', + userId: mockUser.id, + points: 100, + transactionType: PointsTransactionType.EARNED, + description: 'Test reward', + balanceAfter: 100, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockCoupon: Coupon = { + id: 'coupon-123', + code: 'TEST-COUPON', + name: 'Test Coupon', + description: 'Test description', + discountType: 'fixed_amount' as any, + discountValue: 10, + status: 'active' as any, + totalUsageLimit: 1, + perUserLimit: 1, + currentUsageCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const mockRewardsRepository = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockCouponsRepository = { + create: jest.fn(), + save: jest.fn(), + }; + + const mockLoyaltyService = { + calculatePointsWithMultiplier: jest.fn(), + updateUserLoyaltyPoints: jest.fn(), + getUserLoyaltySummary: jest.fn(), + calculateTierDiscount: jest.fn(), + hasFreeShipping: jest.fn(), + grantBirthdayBonus: jest.fn(), + grantAnniversaryBonus: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RewardsService, + { + provide: getRepositoryToken(RewardPoints), + useValue: mockRewardsRepository, + }, + { + provide: getRepositoryToken(Coupon), + useValue: mockCouponsRepository, + }, + { + provide: LoyaltyService, + useValue: mockLoyaltyService, + }, + ], + }).compile(); + + service = module.get(RewardsService); + rewardsRepository = module.get(getRepositoryToken(RewardPoints)); + couponsRepository = module.get(getRepositoryToken(Coupon)); + loyaltyService = module.get(LoyaltyService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getUserBalance', () => { + it('should return user balance', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ total: 250 }), + }; + + rewardsRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const balance = await service.getUserBalance(mockUser.id); + expect(balance).toBe(250); + }); + + it('should return 0 for user with no points', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue(null), + }; + + rewardsRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const balance = await service.getUserBalance(mockUser.id); + expect(balance).toBe(0); + }); + }); + + describe('grantPointsForOrder', () => { + it('should grant points with loyalty multiplier', async () => { + const orderId = 'order-123'; + const totalAmount = 50; + const basePoints = 500; + const multiplierPoints = 600; + + loyaltyService.calculatePointsWithMultiplier.mockResolvedValue(multiplierPoints); + loyaltyService.updateUserLoyaltyPoints.mockResolvedValue(undefined); + + rewardsRepository.create.mockReturnValue(mockRewardPoints); + rewardsRepository.save.mockResolvedValue(mockRewardPoints); + + const result = await service.grantPointsForOrder(mockUser.id, orderId, totalAmount); + + expect(loyaltyService.calculatePointsWithMultiplier).toHaveBeenCalledWith(mockUser.id, basePoints); + expect(loyaltyService.updateUserLoyaltyPoints).toHaveBeenCalledWith(mockUser.id, multiplierPoints); + expect(rewardsRepository.create).toHaveBeenCalledWith({ + userId: mockUser.id, + points: multiplierPoints, + transactionType: PointsTransactionType.EARNED, + description: `Points earned for order ${orderId} (includes 100 tier bonus)`, + referenceId: orderId, + referenceType: 'order', + }); + }); + + it('should throw error for zero amount order', async () => { + await expect(service.grantPointsForOrder(mockUser.id, 'order-123', 0)) + .rejects.toThrow(BadException); + }); + }); + + describe('redeemPoints', () => { + it('should redeem points for coupon', async () => { + const pointsToRedeem = 100; + const mockBalance = 200; + + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ total: mockBalance }), + }; + + rewardsRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + couponsRepository.create.mockReturnValue(mockCoupon); + couponsRepository.save.mockResolvedValue(mockCoupon); + + rewardsRepository.create.mockReturnValue(mockRewardPoints); + rewardsRepository.save.mockResolvedValue(mockRewardPoints); + + const result = await service.redeemPoints(mockUser.id, { points: pointsToRedeem }); + + expect(result).toEqual({ + coupon: mockCoupon, + pointsUsed: pointsToRedeem, + remainingBalance: mockBalance, + }); + }); + + it('should throw error for insufficient balance', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ total: 50 }), + }; + + rewardsRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await expect(service.redeemPoints(mockUser.id, { points: 100 })) + .rejects.toThrow(BadRequestException); + }); + }); + + describe('applyPointsToCheckout', () => { + it('should apply points to checkout', async () => { + const pointsToUse = 100; + const orderTotal = 50; + const mockBalance = 200; + + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawOne: jest.fn() + .mockResolvedValueOnce({ total: mockBalance }) + .mockResolvedValueOnce({ total: mockBalance - pointsToUse }), + }; + + rewardsRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + rewardsRepository.create.mockReturnValue(mockRewardPoints); + rewardsRepository.save.mockResolvedValue(mockRewardPoints); + + const result = await service.applyPointsToCheckout(mockUser.id, pointsToUse, orderTotal); + + expect(result.discountAmount).toBe(1); // 100 points = $1 + expect(result.pointsUsed).toBe(100); + expect(result.remainingBalance).toBe(mockBalance - pointsToUse); + }); + }); + + describe('loyalty integration', () => { + it('should get user loyalty summary', async () => { + const mockSummary = { + currentTier: 'silver', + currentTierDisplayName: 'Silver Member', + lifetimePoints: 1500, + currentYearPoints: 800, + }; + + loyaltyService.getUserLoyaltySummary.mockResolvedValue(mockSummary); + + const result = await service.getUserLoyaltySummary(mockUser.id); + expect(result).toEqual(mockSummary); + }); + + it('should calculate tier discount', async () => { + const orderTotal = 100; + const expectedDiscount = 5; // 5% for silver tier + + loyaltyService.calculateTierDiscount.mockResolvedValue(expectedDiscount); + + const result = await service.calculateTierDiscount(mockUser.id, orderTotal); + expect(result).toBe(expectedDiscount); + }); + + it('should check free shipping eligibility', async () => { + const orderTotal = 30; + loyaltyService.hasFreeShipping.mockResolvedValue(true); + + const result = await service.hasFreeShipping(mockUser.id, orderTotal); + expect(result).toBe(true); + }); + }); +}); diff --git a/src/rewards/rewards.service.ts b/src/rewards/rewards.service.ts index 90488fe..4cd44a1 100644 --- a/src/rewards/rewards.service.ts +++ b/src/rewards/rewards.service.ts @@ -6,6 +6,7 @@ import { CreateRewardDto } from './dto/create-reward.dto'; import { RedeemPointsDto } from './dto/redeem-points.dto'; import { Coupon } from '../coupons/entities/coupon.entity'; import { DiscountType } from '../coupons/entities/coupon.entity'; +import { LoyaltyService } from './loyalty.service'; @Injectable() export class RewardsService { @@ -17,6 +18,7 @@ export class RewardsService { private rewardsRepository: Repository, @InjectRepository(Coupon) private couponsRepository: Repository, + private readonly loyaltyService: LoyaltyService, ) {} async getUserBalance(userId: string): Promise { @@ -62,17 +64,23 @@ export class RewardsService { orderId: string, totalAmount: number, ): Promise { - const pointsEarned = Math.floor(totalAmount * this.POINTS_PER_DOLLAR); + const basePoints = Math.floor(totalAmount * this.POINTS_PER_DOLLAR); - if (pointsEarned <= 0) { + if (basePoints <= 0) { throw new BadRequestException('Order amount must be greater than zero to earn points'); } + // Apply loyalty tier multiplier + const finalPoints = await this.loyaltyService.calculatePointsWithMultiplier(userId, basePoints); + + // Update user's loyalty tier points + await this.loyaltyService.updateUserLoyaltyPoints(userId, finalPoints); + return await this.createReward({ userId, - points: pointsEarned, + points: finalPoints, transactionType: PointsTransactionType.EARNED, - description: `Points earned for order ${orderId}`, + description: `Points earned for order ${orderId} (${finalPoints - basePoints > 0 ? `includes ${finalPoints - basePoints} tier bonus` : 'no tier bonus'})`, referenceId: orderId, referenceType: 'order', }); @@ -176,4 +184,30 @@ export class RewardsService { referenceType: 'admin_adjustment', }); } + + async getUserLoyaltySummary(userId: string) { + return await this.loyaltyService.getUserLoyaltySummary(userId); + } + + async calculateTierDiscount(userId: string, orderAmount: number): Promise { + return await this.loyaltyService.calculateTierDiscount(userId, orderAmount); + } + + async hasFreeShipping(userId: string, orderAmount: number): Promise { + return await this.loyaltyService.hasFreeShipping(userId, orderAmount); + } + + async grantBirthdayBonus(userId: string): Promise<{ bonusPoints: number; newBalance: number }> { + const bonusPoints = await this.loyaltyService.grantBirthdayBonus(userId); + const newBalance = await this.getUserBalance(userId); + + return { bonusPoints, newBalance }; + } + + async grantAnniversaryBonus(userId: string): Promise<{ bonusPoints: number; newBalance: number }> { + const bonusPoints = await this.loyaltyService.grantAnniversaryBonus(userId); + const newBalance = await this.getUserBalance(userId); + + return { bonusPoints, newBalance }; + } }