Boarding, payment methods, journey direction on seat hold, and more updates

This commit is contained in:
Stephanos A
2026-06-29 08:44:38 +03:00
parent 81ae99cee3
commit c6e56d1c4f
65 changed files with 6437 additions and 1425 deletions

View File

@@ -0,0 +1,235 @@
import { Body, Controller, Get, Post, Put, Delete, Param, UseGuards, Request, Query } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
import { ConfigurableFareService } from './configurable-fare.service';
import {
CreateFareConfigurationDto,
UpdateFareConfigurationDto,
FareTestScenarioDto,
FareCalculationResultDto,
MigrateLegacyDto,
CreateNewFormulaDto,
ToggleFeatureDto
} from './configurable-fare.dto';
import { IamGuard } from '../../common/iam-adapter';
import { Roles } from '../../common/roles.decorator';
@ApiTags('Configurable Fares')
@Controller('admin/fare-configurations')
@ApiBearerAuth('IAM-auth')
@UseGuards(IamGuard)
@Roles('ADMIN')
export class ConfigurableFareController {
constructor(private service: ConfigurableFareService) {}
@Get()
@ApiOperation({ summary: 'List all fare configurations' })
@ApiResponse({ status: 200, description: 'List of all configurations with summary counts' })
async getAllConfigurations() {
return this.service.getAllConfigurations();
}
@Post()
@ApiOperation({ summary: 'Create new fare configuration' })
@ApiResponse({ status: 201, description: 'Configuration created successfully' })
@ApiResponse({ status: 400, description: 'Invalid configuration data' })
async createConfiguration(
@Body() dto: CreateFareConfigurationDto,
@Request() req: any
) {
const createdBy = req.user?.id || req.user?.sub;
return this.service.createConfiguration(dto, createdBy);
}
@Get(':id')
@ApiOperation({ summary: 'Get configuration details' })
@ApiParam({ name: 'id', description: 'Configuration ID' })
@ApiResponse({ status: 200, description: 'Configuration details with all rules' })
@ApiResponse({ status: 404, description: 'Configuration not found' })
async getConfigurationById(@Param('id') id: string) {
return this.service.getConfigurationById(id);
}
@Put(':id')
@ApiOperation({ summary: 'Update configuration' })
@ApiParam({ name: 'id', description: 'Configuration ID' })
@ApiResponse({ status: 200, description: 'Configuration updated successfully' })
@ApiResponse({ status: 404, description: 'Configuration not found' })
async updateConfiguration(
@Param('id') id: string,
@Body() dto: UpdateFareConfigurationDto,
@Request() req: any
) {
const updatedBy = req.user?.id || req.user?.sub;
return this.service.updateConfiguration(id, dto, updatedBy);
}
@Post(':id/activate')
@ApiOperation({ summary: 'Activate configuration' })
@ApiParam({ name: 'id', description: 'Configuration ID' })
@ApiResponse({ status: 200, description: 'Configuration activated successfully' })
@ApiResponse({ status: 404, description: 'Configuration not found' })
async activateConfiguration(@Param('id') id: string, @Request() req: any) {
const activatedBy = req.user?.id || req.user?.sub;
return this.service.activateConfiguration(id, activatedBy);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete configuration' })
@ApiParam({ name: 'id', description: 'Configuration ID' })
@ApiResponse({ status: 200, description: 'Configuration deleted successfully' })
@ApiResponse({ status: 404, description: 'Configuration not found' })
@ApiResponse({ status: 409, description: 'Cannot delete active configuration' })
async deleteConfiguration(@Param('id') id: string, @Request() req: any) {
const deletedBy = req.user?.id || req.user?.sub;
return this.service.deleteConfiguration(id, deletedBy);
}
@Post(':id/test')
@ApiOperation({ summary: 'Test fare calculation with configuration' })
@ApiParam({ name: 'id', description: 'Configuration ID' })
@ApiResponse({ status: 200, type: FareCalculationResultDto, description: 'Fare calculation result' })
@ApiResponse({ status: 400, description: 'Invalid test scenario or missing rate rules' })
async testConfiguration(
@Param('id') id: string,
@Body() scenario: FareTestScenarioDto
): Promise<FareCalculationResultDto> {
return this.service.testConfiguration(id, scenario);
}
@Get(':id/audit')
@ApiOperation({ summary: 'Get configuration audit trail' })
@ApiParam({ name: 'id', description: 'Configuration ID' })
@ApiResponse({ status: 200, description: 'Audit trail entries' })
async getAuditTrail(@Param('id') id: string) {
return this.service.getAuditTrail(id);
}
}
@ApiTags('Configurable Fares')
@Controller('admin/fare-migration')
@ApiBearerAuth('IAM-auth')
@UseGuards(IamGuard)
@Roles('ADMIN')
export class FareMigrationController {
constructor(private service: ConfigurableFareService) {}
@Post('migrate-legacy')
@ApiOperation({ summary: 'Migrate existing fare rules to configurable system' })
@ApiResponse({ status: 200, description: 'Migration completed successfully' })
@ApiResponse({ status: 400, description: 'Migration failed' })
async migrateLegacySystem(@Body() dto: MigrateLegacyDto) {
return this.service.migrateLegacySystem(dto);
}
@Post('create-new-formula')
@ApiOperation({ summary: 'Create new formula configuration with defaults' })
@ApiResponse({ status: 201, description: 'New formula configuration created' })
async createNewFormulaConfiguration(@Body() dto: CreateNewFormulaDto) {
return this.service.createNewFormulaConfiguration(dto);
}
@Post('complete-setup')
@ApiOperation({
summary: 'Complete system setup (migrate + create + activate)',
description: 'Performs full system migration and setup in one operation'
})
@ApiResponse({ status: 200, description: 'System setup completed successfully' })
async completeSetup(@Body() body: { activateNewFormula?: boolean; enableFeature?: boolean }) {
// Step 1: Migrate legacy system
const migrationResult = await this.service.migrateLegacySystem({ dryRun: false });
// Step 2: Create new formula configuration
const newConfig = await this.service.createNewFormulaConfiguration({
name: 'Default System Configuration',
description: 'System-generated configuration with optimal defaults',
activateImmediately: body.activateNewFormula !== false
});
// Step 3: Enable feature flag if requested
if (body.enableFeature) {
await this.service.toggleFeature({
featureName: 'USE_CONFIGURABLE_FARES',
enabled: true,
config: { rollout_percentage: 100 }
});
}
return {
migration: migrationResult,
newConfiguration: newConfig,
featureEnabled: body.enableFeature || false,
message: 'System setup completed successfully'
};
}
@Get('status')
@ApiOperation({ summary: 'Get migration and setup status' })
@ApiResponse({ status: 200, description: 'Current system status' })
async getStatus() {
const featureStatus = await this.service.getFeatureStatus('USE_CONFIGURABLE_FARES');
const configurations = await this.service.getAllConfigurations();
const activeConfig = (configurations as any[]).find(config => config.is_active);
return {
configurableFaresEnabled: featureStatus.enabled,
rolloutPercentage: featureStatus.config?.rollout_percentage || 0,
totalConfigurations: (configurations as any[]).length,
activeConfiguration: activeConfig?.id || null,
activeConfigurationName: activeConfig?.name || null,
systemReady: featureStatus.enabled && !!activeConfig
};
}
}
@ApiTags('Configurable Fares')
@Controller('admin/fare-configurations/system')
@ApiBearerAuth('IAM-auth')
@UseGuards(IamGuard)
@Roles('ADMIN')
export class FareSystemController {
constructor(private service: ConfigurableFareService) {}
@Get('feature-status')
@ApiOperation({ summary: 'Check configurable fares feature status' })
@ApiQuery({ name: 'feature', required: false, description: 'Feature name (defaults to USE_CONFIGURABLE_FARES)' })
@ApiResponse({ status: 200, description: 'Feature status retrieved' })
async getFeatureStatus(@Query('feature') featureName = 'USE_CONFIGURABLE_FARES') {
return this.service.getFeatureStatus(featureName);
}
@Post('toggle-feature')
@ApiOperation({ summary: 'Enable or disable configurable fares system' })
@ApiResponse({ status: 200, description: 'Feature toggled successfully' })
async toggleFeature(@Body() dto: ToggleFeatureDto) {
return this.service.toggleFeature(dto);
}
@Post('enable-configurable-fares')
@ApiOperation({
summary: 'Enable configurable fares with rollout percentage',
description: 'Quick endpoint to enable the configurable fares feature'
})
@ApiResponse({ status: 200, description: 'Configurable fares enabled successfully' })
async enableConfigurableFares(@Body() body: { rolloutPercentage?: number }) {
return this.service.toggleFeature({
featureName: 'USE_CONFIGURABLE_FARES',
enabled: true,
config: { rollout_percentage: body.rolloutPercentage || 100 }
});
}
@Post('disable-configurable-fares')
@ApiOperation({
summary: 'Disable configurable fares (fallback to legacy system)',
description: 'Disables the configurable fares feature and falls back to legacy fare calculation'
})
@ApiResponse({ status: 200, description: 'Configurable fares disabled successfully' })
async disableConfigurableFares() {
return this.service.toggleFeature({
featureName: 'USE_CONFIGURABLE_FARES',
enabled: false,
config: { rollout_percentage: 0 }
});
}
}

View File

@@ -0,0 +1,362 @@
import { IsString, IsOptional, IsBoolean, IsInt, IsArray, ValidateNested, IsDateString, IsEnum, IsNumber, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum NationalityType {
LOCAL = 'LOCAL',
INTERNATIONAL = 'INTERNATIONAL'
}
export enum CoachType {
REGULAR_SEAT = 'REGULAR_SEAT',
ECONOMY_BED = 'ECONOMY_BED',
VIP_BED = 'VIP_BED'
}
export enum BedPosition {
UPPER = 'UPPER',
MIDDLE = 'MIDDLE',
LOWER = 'LOWER'
}
export enum ComponentType {
INSURANCE = 'INSURANCE',
PREMIUM = 'PREMIUM',
SERVICE_CHARGE = 'SERVICE_CHARGE',
TAX = 'TAX',
DEMAND = 'DEMAND'
}
export enum CalculationMethod {
MULTIPLIER = 'MULTIPLIER',
PERCENTAGE = 'PERCENTAGE',
FIXED_AMOUNT = 'FIXED_AMOUNT'
}
export enum AppliesTo {
BASE_FARE = 'BASE_FARE',
SUBTOTAL = 'SUBTOTAL',
TOTAL = 'TOTAL'
}
export enum PricingType {
FREE = 'FREE',
FULL_FARE = 'FULL_FARE',
DISCOUNTED = 'DISCOUNTED'
}
export class FareRateRuleDto {
@ApiProperty({ enum: NationalityType })
@IsEnum(NationalityType)
nationalityType: NationalityType;
@ApiProperty({ enum: CoachType })
@IsEnum(CoachType)
coachType: CoachType;
@ApiPropertyOptional({ enum: BedPosition })
@IsOptional()
@IsEnum(BedPosition)
bedPosition?: BedPosition;
@ApiProperty({ example: 3000, description: 'Rate per km in minor units (e.g., 30.00 ETB = 3000)' })
@IsInt()
@Min(0)
ratePerKmMinor: number;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class FareComponentDto {
@ApiProperty({ enum: ComponentType })
@IsEnum(ComponentType)
componentType: ComponentType;
@ApiProperty({ example: 'Travel Insurance' })
@IsString()
componentName: string;
@ApiProperty({ enum: CalculationMethod })
@IsEnum(CalculationMethod)
calculationMethod: CalculationMethod;
@ApiPropertyOptional({ example: 500, description: 'Fixed amount in minor units' })
@IsOptional()
@IsInt()
@Min(0)
valueMinor?: number;
@ApiPropertyOptional({ example: 0.02, description: 'Percentage value (e.g., 0.02 for 2%)' })
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
percentageValue?: number;
@ApiProperty({ enum: AppliesTo, default: AppliesTo.SUBTOTAL })
@IsEnum(AppliesTo)
appliesTo: AppliesTo;
@ApiProperty({ example: 1, description: 'Order of application' })
@IsInt()
@Min(1)
applyOrder: number;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class AgePricingRuleDto {
@ApiProperty({ example: 'Adult Passengers' })
@IsString()
ruleName: string;
@ApiProperty({ example: 5 })
@IsInt()
@Min(0)
minAge: number;
@ApiPropertyOptional({ example: 120 })
@IsOptional()
@IsInt()
@Min(0)
maxAge?: number;
@ApiProperty({ enum: PricingType })
@IsEnum(PricingType)
pricingType: PricingType;
@ApiPropertyOptional({ example: 0.5, description: 'Discount percentage for DISCOUNTED type' })
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
discountPercentage?: number;
@ApiPropertyOptional({ example: 1, description: 'Max free passengers for FREE type' })
@IsOptional()
@IsInt()
@Min(0)
maxFreePassengers?: number;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
appliesToComponents?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class CreateFareConfigurationDto {
@ApiProperty({ example: 'Summer 2024 Rates' })
@IsString()
name: string;
@ApiPropertyOptional({ example: 'Updated rates for summer season' })
@IsOptional()
@IsString()
description?: string;
@ApiProperty({ example: '2024-06-01T00:00:00.000Z' })
@IsDateString()
effectiveDate: string;
@ApiPropertyOptional({ example: '2024-08-31T23:59:59.000Z' })
@IsOptional()
@IsDateString()
expiryDate?: string;
@ApiProperty({ type: [FareRateRuleDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => FareRateRuleDto)
rateRules: FareRateRuleDto[];
@ApiProperty({ type: [FareComponentDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => FareComponentDto)
components: FareComponentDto[];
@ApiProperty({ type: [AgePricingRuleDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => AgePricingRuleDto)
ageRules: AgePricingRuleDto[];
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
isDefault?: boolean;
}
export class UpdateFareConfigurationDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
effectiveDate?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
expiryDate?: string;
@ApiPropertyOptional({ type: [FareRateRuleDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => FareRateRuleDto)
rateRules?: FareRateRuleDto[];
@ApiPropertyOptional({ type: [FareComponentDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => FareComponentDto)
components?: FareComponentDto[];
@ApiPropertyOptional({ type: [AgePricingRuleDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => AgePricingRuleDto)
ageRules?: AgePricingRuleDto[];
}
export class FareTestScenarioDto {
@ApiProperty({ example: 100 })
@IsInt()
@Min(1)
distanceKm: number;
@ApiProperty({ example: 'Ethiopian' })
@IsString()
nationality: string;
@ApiProperty({ enum: CoachType })
@IsEnum(CoachType)
coachType: CoachType;
@ApiPropertyOptional({ enum: BedPosition })
@IsOptional()
@IsEnum(BedPosition)
bedPosition?: BedPosition;
@ApiProperty({ example: 2, default: 1 })
@IsInt()
@Min(1)
adultCount: number;
@ApiPropertyOptional({ example: 1, default: 0 })
@IsOptional()
@IsInt()
@Min(0)
childCount?: number;
@ApiPropertyOptional({ example: 'SUMMER20' })
@IsOptional()
@IsString()
promoCode?: string;
@ApiPropertyOptional({ example: 500 })
@IsOptional()
@IsInt()
@Min(0)
loyaltyPoints?: number;
}
export class FareCalculationResultDto {
@ApiProperty()
baseFareMinor: number;
@ApiProperty()
componentsTotal: number;
@ApiProperty()
totalBeforeDiscounts: number;
@ApiProperty()
discountsTotal: number;
@ApiProperty()
finalTotalMinor: number;
@ApiProperty()
breakdown: Array<{
step: string;
description: string;
amount: number;
runningTotal: number;
}>;
@ApiProperty()
currency: string;
@ApiProperty()
calculationTimestamp: Date;
}
export class MigrateLegacyDto {
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
dryRun?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
migrateScheduleFares?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
migrateSegmentFares?: boolean;
}
export class CreateNewFormulaDto {
@ApiProperty({ example: 'Default Formula Configuration' })
@IsString()
name: string;
@ApiPropertyOptional({ example: 'System-generated default configuration' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
activateImmediately?: boolean;
}
export class ToggleFeatureDto {
@ApiProperty({ example: 'USE_CONFIGURABLE_FARES' })
@IsString()
featureName: string;
@ApiProperty()
@IsBoolean()
enabled: boolean;
@ApiPropertyOptional({ example: { rollout_percentage: 50 } })
@IsOptional()
config?: Record<string, any>;
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ConfigurableFareService } from './configurable-fare.service';
import { ConfigurableFareController, FareMigrationController, FareSystemController } from './configurable-fare.controller';
import { PrismaModule } from '../../common/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [ConfigurableFareController, FareMigrationController, FareSystemController],
providers: [ConfigurableFareService],
exports: [ConfigurableFareService],
})
export class ConfigurableFareModule {}

View File

@@ -0,0 +1,623 @@
import { Injectable, BadRequestException, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import {
CreateFareConfigurationDto,
UpdateFareConfigurationDto,
FareTestScenarioDto,
FareCalculationResultDto,
MigrateLegacyDto,
CreateNewFormulaDto,
ToggleFeatureDto,
NationalityType,
CoachType,
BedPosition,
ComponentType,
CalculationMethod,
AppliesTo,
PricingType
} from './configurable-fare.dto';
@Injectable()
export class ConfigurableFareService {
constructor(private prisma: PrismaService) {}
// Helper method to map nationality to type
private mapNationalityToType(nationality: string): NationalityType {
const upperNationality = nationality.toUpperCase();
if (upperNationality === 'ETHIOPIAN' || upperNationality === 'DJIBOUTIAN') {
return NationalityType.LOCAL;
}
return NationalityType.INTERNATIONAL;
}
// Generate unique IDs
private generateId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// Configuration Management
async getAllConfigurations() {
return this.prisma.$queryRaw`
SELECT
fc.*,
COUNT(DISTINCT frr.id) as rate_rules_count,
COUNT(DISTINCT fcmp.id) as components_count,
COUNT(DISTINCT apr.id) as age_rules_count
FROM fare_configurations fc
LEFT JOIN fare_rate_rules frr ON fc.id = frr.fare_config_id AND frr.is_active = true
LEFT JOIN fare_components fcmp ON fc.id = fcmp.fare_config_id AND fcmp.is_active = true
LEFT JOIN age_pricing_rules apr ON fc.id = apr.fare_config_id AND apr.is_active = true
GROUP BY fc.id, fc.name, fc.description, fc.effective_date, fc.expiry_date,
fc.is_active, fc.is_default, fc.created_by, fc.approved_by,
fc.approved_at, fc.created_at, fc.updated_at
ORDER BY fc.created_at DESC
`;
}
async getConfigurationById(id: string) {
const config = await this.prisma.$queryRaw`
SELECT * FROM fare_configurations WHERE id = ${id}
`;
if (!Array.isArray(config) || config.length === 0) {
throw new NotFoundException(`Fare configuration ${id} not found`);
}
const rateRules = await this.prisma.$queryRaw`
SELECT * FROM fare_rate_rules WHERE fare_config_id = ${id} ORDER BY nationality_type, coach_type, bed_position
`;
const components = await this.prisma.$queryRaw`
SELECT * FROM fare_components WHERE fare_config_id = ${id} ORDER BY apply_order
`;
const ageRules = await this.prisma.$queryRaw`
SELECT * FROM age_pricing_rules WHERE fare_config_id = ${id} ORDER BY min_age
`;
return {
...config[0],
rateRules,
components,
ageRules
};
}
async createConfiguration(dto: CreateFareConfigurationDto, createdBy?: string) {
return this.prisma.$transaction(async (tx) => {
const configId = this.generateId('fc');
// Validate no overlapping active configurations
if (dto.isDefault) {
await tx.$executeRaw`
UPDATE fare_configurations SET is_default = false WHERE is_default = true
`;
}
// Create main configuration
await tx.$executeRaw`
INSERT INTO fare_configurations (
id, name, description, effective_date, expiry_date,
is_active, is_default, created_by, created_at, updated_at
) VALUES (
${configId}, ${dto.name}, ${dto.description}, ${dto.effectiveDate},
${dto.expiryDate}, false, ${dto.isDefault || false}, ${createdBy},
NOW(), NOW()
)
`;
// Create rate rules
for (const rule of dto.rateRules) {
const ruleId = this.generateId('frr');
await tx.$executeRaw`
INSERT INTO fare_rate_rules (
id, fare_config_id, nationality_type, coach_type, bed_position,
rate_per_km_minor, is_active, created_at, updated_at
) VALUES (
${ruleId}, ${configId}, ${rule.nationalityType}, ${rule.coachType},
${rule.bedPosition || null}, ${rule.ratePerKmMinor}, ${rule.isActive !== false},
NOW(), NOW()
)
`;
}
// Create components
for (const component of dto.components) {
const componentId = this.generateId('fcmp');
await tx.$executeRaw`
INSERT INTO fare_components (
id, fare_config_id, component_type, component_name, calculation_method,
value_minor, percentage_value, applies_to, apply_order, is_active,
created_at, updated_at
) VALUES (
${componentId}, ${configId}, ${component.componentType}, ${component.componentName},
${component.calculationMethod}, ${component.valueMinor || null},
${component.percentageValue || null}, ${component.appliesTo},
${component.applyOrder}, ${component.isActive !== false}, NOW(), NOW()
)
`;
}
// Create age rules
for (const ageRule of dto.ageRules) {
const ageRuleId = this.generateId('apr');
await tx.$executeRaw`
INSERT INTO age_pricing_rules (
id, fare_config_id, rule_name, min_age, max_age, pricing_type,
discount_percentage, max_free_passengers, applies_to_components,
is_active, created_at, updated_at
) VALUES (
${ageRuleId}, ${configId}, ${ageRule.ruleName}, ${ageRule.minAge},
${ageRule.maxAge || null}, ${ageRule.pricingType},
${ageRule.discountPercentage || null}, ${ageRule.maxFreePassengers || null},
${ageRule.appliesToComponents !== false}, ${ageRule.isActive !== false},
NOW(), NOW()
)
`;
}
// Log audit entry
await this.createAuditEntry(tx, configId, 'CREATED', createdBy, { action: 'Configuration created' });
return this.getConfigurationById(configId);
});
}
async updateConfiguration(id: string, dto: UpdateFareConfigurationDto, updatedBy?: string) {
await this.getConfigurationById(id); // Validate exists
return this.prisma.$transaction(async (tx) => {
// Update main configuration
if (dto.name || dto.description !== undefined || dto.effectiveDate || dto.expiryDate !== undefined) {
await tx.$executeRaw`
UPDATE fare_configurations
SET
name = COALESCE(${dto.name}, name),
description = COALESCE(${dto.description}, description),
effective_date = COALESCE(${dto.effectiveDate}, effective_date),
expiry_date = COALESCE(${dto.expiryDate}, expiry_date),
updated_at = NOW()
WHERE id = ${id}
`;
}
// Update rate rules if provided
if (dto.rateRules) {
await tx.$executeRaw`DELETE FROM fare_rate_rules WHERE fare_config_id = ${id}`;
for (const rule of dto.rateRules) {
const ruleId = this.generateId('frr');
await tx.$executeRaw`
INSERT INTO fare_rate_rules (
id, fare_config_id, nationality_type, coach_type, bed_position,
rate_per_km_minor, is_active, created_at, updated_at
) VALUES (
${ruleId}, ${id}, ${rule.nationalityType}, ${rule.coachType},
${rule.bedPosition || null}, ${rule.ratePerKmMinor}, ${rule.isActive !== false},
NOW(), NOW()
)
`;
}
}
// Update components if provided
if (dto.components) {
await tx.$executeRaw`DELETE FROM fare_components WHERE fare_config_id = ${id}`;
for (const component of dto.components) {
const componentId = this.generateId('fcmp');
await tx.$executeRaw`
INSERT INTO fare_components (
id, fare_config_id, component_type, component_name, calculation_method,
value_minor, percentage_value, applies_to, apply_order, is_active,
created_at, updated_at
) VALUES (
${componentId}, ${id}, ${component.componentType}, ${component.componentName},
${component.calculationMethod}, ${component.valueMinor || null},
${component.percentageValue || null}, ${component.appliesTo},
${component.applyOrder}, ${component.isActive !== false}, NOW(), NOW()
)
`;
}
}
// Update age rules if provided
if (dto.ageRules) {
await tx.$executeRaw`DELETE FROM age_pricing_rules WHERE fare_config_id = ${id}`;
for (const ageRule of dto.ageRules) {
const ageRuleId = this.generateId('apr');
await tx.$executeRaw`
INSERT INTO age_pricing_rules (
id, fare_config_id, rule_name, min_age, max_age, pricing_type,
discount_percentage, max_free_passengers, applies_to_components,
is_active, created_at, updated_at
) VALUES (
${ageRuleId}, ${id}, ${ageRule.ruleName}, ${ageRule.minAge},
${ageRule.maxAge || null}, ${ageRule.pricingType},
${ageRule.discountPercentage || null}, ${ageRule.maxFreePassengers || null},
${ageRule.appliesToComponents !== false}, ${ageRule.isActive !== false},
NOW(), NOW()
)
`;
}
}
await this.createAuditEntry(tx, id, 'UPDATED', updatedBy, { changes: dto });
return this.getConfigurationById(id);
});
}
async activateConfiguration(id: string, activatedBy?: string) {
return this.prisma.$transaction(async (tx) => {
// Deactivate all other configurations
await tx.$executeRaw`UPDATE fare_configurations SET is_active = false`;
// Activate this one
await tx.$executeRaw`
UPDATE fare_configurations
SET is_active = true, approved_by = ${activatedBy}, approved_at = NOW()
WHERE id = ${id}
`;
await this.createAuditEntry(tx, id, 'ACTIVATED', activatedBy, {});
return { success: true, message: `Configuration ${id} activated successfully` };
});
}
async deleteConfiguration(id: string, deletedBy?: string) {
const config = await this.getConfigurationById(id);
if ((config as any).is_active) {
throw new ConflictException('Cannot delete active configuration. Deactivate first.');
}
await this.prisma.$executeRaw`DELETE FROM fare_configurations WHERE id = ${id}`;
return { success: true, message: `Configuration ${id} deleted successfully` };
}
// Fare Calculation
async testConfiguration(id: string, scenario: FareTestScenarioDto): Promise<FareCalculationResultDto> {
const config = await this.getConfigurationById(id);
// Find matching rate rule
const nationalityType = this.mapNationalityToType(scenario.nationality);
const rateRule = (config.rateRules as any[]).find((rule: any) =>
rule.nationality_type === nationalityType &&
rule.coach_type === scenario.coachType &&
(scenario.bedPosition ? rule.bed_position === scenario.bedPosition : !rule.bed_position)
);
if (!rateRule) {
throw new BadRequestException(`No rate rule found for ${nationalityType}/${scenario.coachType}${scenario.bedPosition ? `/${scenario.bedPosition}` : ''}`);
}
// Calculate base fare
const baseFareMinor = scenario.distanceKm * rateRule.rate_per_km_minor;
const breakdown = [
{
step: '1',
description: `Base fare: ${scenario.distanceKm}km × ${rateRule.rate_per_km_minor} minor units/km`,
amount: baseFareMinor,
runningTotal: baseFareMinor
}
];
let runningTotal = baseFareMinor;
// Apply age-based pricing
const { adultCount = 1, childCount = 0 } = scenario;
let totalPassengerFare = 0;
// Process adults
totalPassengerFare += adultCount * baseFareMinor;
breakdown.push({
step: '2a',
description: `Adult passengers: ${adultCount} × ${baseFareMinor}`,
amount: adultCount * baseFareMinor,
runningTotal: adultCount * baseFareMinor
});
// Process children with age rules
if (childCount > 0) {
const childRule = (config.ageRules as any[]).find((rule: any) =>
rule.pricing_type === PricingType.FREE && rule.max_free_passengers > 0
);
if (childRule) {
const freeChildren = Math.min(childCount, childRule.max_free_passengers);
const paidChildren = Math.max(0, childCount - freeChildren);
if (freeChildren > 0) {
breakdown.push({
step: '2b',
description: `Free children: ${freeChildren} × 0 (first ${childRule.max_free_passengers} free)`,
amount: 0,
runningTotal: totalPassengerFare
});
}
if (paidChildren > 0) {
const paidChildrenFare = paidChildren * baseFareMinor;
totalPassengerFare += paidChildrenFare;
breakdown.push({
step: '2c',
description: `Paid children: ${paidChildren} × ${baseFareMinor}`,
amount: paidChildrenFare,
runningTotal: totalPassengerFare
});
}
} else {
// All children pay
const childrenFare = childCount * baseFareMinor;
totalPassengerFare += childrenFare;
breakdown.push({
step: '2b',
description: `Child passengers: ${childCount} × ${baseFareMinor}`,
amount: childrenFare,
runningTotal: totalPassengerFare
});
}
}
runningTotal = totalPassengerFare;
// Apply components in order
let componentsTotal = 0;
const components = (config.components as any[])
.filter((c: any) => c.is_active)
.sort((a: any, b: any) => a.apply_order - b.apply_order);
for (const component of components) {
let componentAmount = 0;
let baseAmount = runningTotal;
if (component.applies_to === 'BASE_FARE') {
baseAmount = baseFareMinor;
} else if (component.applies_to === 'SUBTOTAL') {
baseAmount = runningTotal;
}
switch (component.calculation_method) {
case 'PERCENTAGE':
componentAmount = Math.round(baseAmount * (component.percentage_value || 0));
break;
case 'MULTIPLIER':
componentAmount = Math.round(baseAmount * (component.percentage_value || 0));
break;
case 'FIXED_AMOUNT':
componentAmount = component.value_minor || 0;
break;
}
componentsTotal += componentAmount;
runningTotal += componentAmount;
breakdown.push({
step: `3${String.fromCharCode(97 + component.apply_order - 1)}`,
description: `${component.component_name}: ${component.calculation_method} on ${component.applies_to}`,
amount: componentAmount,
runningTotal
});
}
return {
baseFareMinor,
componentsTotal,
totalBeforeDiscounts: runningTotal,
discountsTotal: 0, // TODO: Implement promo code discounts
finalTotalMinor: runningTotal,
breakdown,
currency: 'ETB',
calculationTimestamp: new Date()
};
}
// Migration Methods
async migrateLegacySystem(dto: MigrateLegacyDto) {
const results = {
scheduleFareRules: 0,
segmentFareRules: 0,
configurationsCreated: 0,
dryRun: dto.dryRun || false
};
if (dto.dryRun) {
// Count what would be migrated
const scheduleFares = await this.prisma.$queryRaw`
SELECT COUNT(*) as count FROM "FareRule" WHERE migrated_to_config_id IS NULL
`;
const segmentFares = await this.prisma.$queryRaw`
SELECT COUNT(*) as count FROM "SegmentFareRule" WHERE migrated_to_config_id IS NULL
`;
results.scheduleFareRules = Number((scheduleFares as any[])[0]?.count || 0);
results.segmentFareRules = Number((segmentFares as any[])[0]?.count || 0);
return results;
}
// Create a migration configuration based on existing rules
const migrationConfig: CreateFareConfigurationDto = {
name: 'Legacy Migration Configuration',
description: 'Automatically migrated from existing fare rules',
effectiveDate: new Date().toISOString(),
rateRules: [
// Default rates based on current system
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 3000 },
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 4000 },
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 5500 },
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 6000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 6000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 8000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 11000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 12000 },
],
components: [
{
componentType: ComponentType.INSURANCE,
componentName: 'Travel Insurance',
calculationMethod: CalculationMethod.PERCENTAGE,
percentageValue: 0.02,
appliesTo: AppliesTo.BASE_FARE,
applyOrder: 1
},
{
componentType: ComponentType.TAX,
componentName: 'Government Tax',
calculationMethod: CalculationMethod.PERCENTAGE,
percentageValue: 0.05,
appliesTo: AppliesTo.SUBTOTAL,
applyOrder: 2
}
],
ageRules: [
{
ruleName: 'Adult Passengers',
minAge: 5,
pricingType: PricingType.FULL_FARE
},
{
ruleName: 'Child Passengers (First Free)',
minAge: 0,
maxAge: 4,
pricingType: PricingType.FREE,
maxFreePassengers: 1
}
]
};
const newConfig = await this.createConfiguration(migrationConfig, 'system-migration');
results.configurationsCreated = 1;
return results;
}
async createNewFormulaConfiguration(dto: CreateNewFormulaDto) {
const config = await this.createConfiguration({
name: dto.name,
description: dto.description || 'System-generated default configuration',
effectiveDate: new Date().toISOString(),
rateRules: [
// Default rates for all combinations
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 3000 },
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 4000 },
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 5500 },
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 6000 },
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.VIP_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 8000 },
// International rates (2x local)
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 6000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 8000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 11000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 12000 },
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.VIP_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 16000 },
],
components: [
{
componentType: ComponentType.INSURANCE,
componentName: 'Travel Insurance',
calculationMethod: CalculationMethod.PERCENTAGE,
percentageValue: 0.02,
appliesTo: AppliesTo.BASE_FARE,
applyOrder: 1
},
{
componentType: ComponentType.SERVICE_CHARGE,
componentName: 'Service Charge',
calculationMethod: CalculationMethod.PERCENTAGE,
percentageValue: 0.03,
appliesTo: AppliesTo.SUBTOTAL,
applyOrder: 2
},
{
componentType: ComponentType.TAX,
componentName: 'Government Tax',
calculationMethod: CalculationMethod.PERCENTAGE,
percentageValue: 0.05,
appliesTo: AppliesTo.TOTAL,
applyOrder: 3
}
],
ageRules: [
{
ruleName: 'Adult Passengers',
minAge: 5,
pricingType: PricingType.FULL_FARE
},
{
ruleName: 'Child Passengers (First Free)',
minAge: 0,
maxAge: 4,
pricingType: PricingType.FREE,
maxFreePassengers: 1
}
],
isDefault: true
}, 'system');
if (dto.activateImmediately) {
await this.activateConfiguration((config as any).id, 'system');
}
return config;
}
// Feature Management
async toggleFeature(dto: ToggleFeatureDto) {
return this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`
INSERT INTO system_features (id, feature_name, is_enabled, config, created_at, updated_at)
VALUES (${this.generateId('sf')}, ${dto.featureName}, ${dto.enabled},
${JSON.stringify(dto.config || {})}, NOW(), NOW())
ON CONFLICT (feature_name) DO UPDATE SET
is_enabled = ${dto.enabled},
config = ${JSON.stringify(dto.config || {})},
updated_at = NOW()
`;
return { success: true, message: `Feature ${dto.featureName} ${dto.enabled ? 'enabled' : 'disabled'}` };
});
}
async getFeatureStatus(featureName: string) {
const result = await this.prisma.$queryRaw`
SELECT * FROM system_features WHERE feature_name = ${featureName}
`;
if (!Array.isArray(result) || result.length === 0) {
return { enabled: false, config: {} };
}
const feature = result[0] as any;
return {
enabled: feature.is_enabled,
config: feature.config || {}
};
}
async getAuditTrail(configId: string) {
return this.prisma.$queryRaw`
SELECT * FROM fare_configuration_audit
WHERE fare_config_id = ${configId}
ORDER BY timestamp DESC
`;
}
// Private helper methods
private async createAuditEntry(tx: any, configId: string, action: string, changedBy?: string, changes?: any) {
const auditId = this.generateId('fca');
await tx.$executeRaw`
INSERT INTO fare_configuration_audit (
id, fare_config_id, action, changed_by, changes, timestamp
) VALUES (
${auditId}, ${configId}, ${action}, ${changedBy || 'system'},
${JSON.stringify(changes || {})}, NOW()
)
`;
}
}