All files / modules/billing/services plans.service.ts

97.22% Statements 35/36
88.88% Branches 32/36
100% Functions 8/8
96.96% Lines 32/33

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 1401x 1x 1x 1x       1x     16x               10x                   11x               5x       5x 2x     3x               3x       3x 2x     1x                     15x   15x                                           4x   4x                         15x     15x 15x 34x     34x 26x 25x   8x 8x                 15x 15x     15x      
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Plan } from '../entities/plan.entity';
import { PlanResponseDto, PlanDetailResponseDto } from '../dto/plan-response.dto';
 
@Injectable()
export class PlansService {
  constructor(
    @InjectRepository(Plan)
    private readonly planRepo: Repository<Plan>,
  ) {}
 
  /**
   * Get all visible and active plans
   * Returns plans ordered by sort_order ascending
   */
  async findAll(): Promise<PlanResponseDto[]> {
    const plans = await this.planRepo.find({
      where: {
        is_active: true,
        is_visible: true,
      },
      order: {
        sort_order: 'ASC',
      },
    });
 
    return plans.map((plan) => this.toResponseDto(plan));
  }
 
  /**
   * Get a single plan by ID
   * Returns detailed plan information including features
   */
  async findOne(id: string): Promise<PlanDetailResponseDto> {
    const plan = await this.planRepo.findOne({
      where: { id },
    });
 
    if (!plan) {
      throw new NotFoundException(`Plan with ID "${id}" not found`);
    }
 
    return this.toDetailResponseDto(plan);
  }
 
  /**
   * Get a single plan by slug
   * Returns detailed plan information including features
   */
  async findBySlug(slug: string): Promise<PlanDetailResponseDto> {
    const plan = await this.planRepo.findOne({
      where: { slug },
    });
 
    if (!plan) {
      throw new NotFoundException(`Plan with slug "${slug}" not found`);
    }
 
    return this.toDetailResponseDto(plan);
  }
 
  /**
   * Transform Plan entity to PlanResponseDto
   * Extracts feature descriptions from the features array
   */
  private toResponseDto(plan: Plan): PlanResponseDto {
    // Extract feature descriptions from the features array
    // The entity has features as Array<{ name, value, highlight }>
    // Frontend expects features as string[]
    const featureDescriptions = this.extractFeatureDescriptions(plan);
 
    return {
      id: plan.id,
      name: plan.name,
      slug: plan.slug,
      display_name: plan.name, // Use name as display_name
      description: plan.description || '',
      tagline: plan.tagline || undefined,
      price_monthly: plan.price_monthly ? Number(plan.price_monthly) : 0,
      price_yearly: plan.price_yearly ? Number(plan.price_yearly) : 0,
      currency: plan.currency,
      features: featureDescriptions,
      limits: plan.limits || undefined,
      is_popular: plan.is_popular || undefined,
      trial_days: plan.trial_days || undefined,
    };
  }
 
  /**
   * Transform Plan entity to PlanDetailResponseDto
   * Includes additional fields like is_enterprise and detailed_features
   */
  private toDetailResponseDto(plan: Plan): PlanDetailResponseDto {
    const baseDto = this.toResponseDto(plan);
 
    return {
      ...baseDto,
      is_enterprise: plan.is_enterprise || undefined,
      detailed_features: plan.features || undefined,
      metadata: plan.metadata || undefined,
    };
  }
 
  /**
   * Extract feature descriptions from plan
   * Combines features array and included_features array
   */
  private extractFeatureDescriptions(plan: Plan): string[] {
    const descriptions: string[] = [];
 
    // Add features from the features array (name or value as description)
    if (plan.features && Array.isArray(plan.features)) {
      for (const feature of plan.features) {
        if (typeof feature === 'object' && feature.name) {
          // For boolean values, just use the name
          // For string values, combine name and value
          if (typeof feature.value === 'boolean') {
            if (feature.value) {
              descriptions.push(feature.name);
            }
          } else if (typeof feature.value === 'string') {
            descriptions.push(`${feature.name}: ${feature.value}`);
          } else E{
            descriptions.push(feature.name);
          }
        }
      }
    }
 
    // Add included_features as-is (they are already strings)
    if (plan.included_features && Array.isArray(plan.included_features)) {
      descriptions.push(...plan.included_features);
    }
 
    return descriptions;
  }
}