import { BadRequestException, ConflictException, ForbiddenException, Inject, Injectable, NotFoundException, } from '@nestjs/common'; import { PaginatedResponse, YardCountry } from '@edr/types'; import { CreateRateDto } from '../dto/create-rate.dto'; import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; import { Rate } from '../entities/rate.entity'; import { deriveRateType } from '../entities/rate-type.util'; import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util'; import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface'; import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface'; /** Categories priced per rail leg — they carry an origin → destination yard pair. */ const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY']; /** The yard pair a rate scopes to, already validated against its direction. */ interface YardScope { originYardId: string | null; destinationYardId: string | null; } @Injectable() export class RatesService { constructor( @Inject(RATES_REPOSITORY) private readonly repository: IRatesRepository, @Inject(YARDS_REPOSITORY) private readonly yardsRepository: IYardsRepository, ) {} /** List rates — standard paginated envelope with server-side search. */ async findAll(query: ListRatesQueryDto): Promise> { return this.repository.findPaged(query); } /** Return all currently LIVE rates. */ async findLiveRates(): Promise { return this.repository.findLiveRates(); } /** Get a rate by ID. */ async findById(id: string): Promise { const entity = await this.repository.findById(id); if (!entity) throw new NotFoundException(`Rate ${id} not found`); return entity; } /** * Normalise + validate the weighting unit for a rate shape. Overweight is * always billed per excess ton, so its unit is forced to PER_TON regardless * of what the client sent. Every other shape must pick a unit the pricing * engine can actually apply (see `allowedRateUnits`). */ private resolveRateUnit( appliesTo: Rate['appliesTo'], trigger: Rate['trigger'], requestedUnit: Rate['rateUnit'], ): Rate['rateUnit'] { // Overweight is per-ton, full stop. if (trigger === 'OVERWEIGHT') return 'PER_TON'; if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) { const allowed = allowedRateUnits({ appliesTo, trigger }).join(', '); throw new BadRequestException( `Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed}.`, ); } return requestedUnit; } /** Base rail freight is priced per leg; surcharges and truck legs are not. */ private isBaseFreight(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean { return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo); } /** * Which country each end of the leg must sit in, given what the rate is for. * The railway only sells three shapes: import lands at the Djibouti ports and * rails inland, export is the reverse, and intercity stays inside Ethiopia. */ private expectedYardCountries( appliesTo: Rate['appliesTo'], tradeDirection: string | null, ): { origin: YardCountry; destination: YardCountry } { if (appliesTo === 'INTERCITY') { return { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA }; } return tradeDirection === 'EXPORT' ? { origin: YardCountry.ETHIOPIA, destination: YardCountry.DJIBOUTI } : { origin: YardCountry.DJIBOUTI, destination: YardCountry.ETHIOPIA }; } /** * Validate and normalise the leg a rate prices. * * Base freight must name both yards and they must match the direction, so a * "container import" rate cannot be quoted Ethiopia → Ethiopia. Everything * else (surcharges, first/last mile) is route-agnostic and has its yards * cleared, mirroring how container/cargo scope is cleared for surcharges. */ private async resolveYardScope(input: { appliesTo: Rate['appliesTo']; trigger: Rate['trigger']; tradeDirection: string | null; originYardId?: string | null; destinationYardId?: string | null; }): Promise { const { appliesTo, trigger, tradeDirection } = input; if (!this.isBaseFreight(appliesTo, trigger)) { return { originYardId: null, destinationYardId: null }; } const originYardId = input.originYardId ?? null; const destinationYardId = input.destinationYardId ?? null; if (!originYardId || !destinationYardId) { throw new BadRequestException( 'Base freight rates are priced per leg — pick both an origin and a destination yard.', ); } if (originYardId === destinationYardId) { throw new BadRequestException('Origin and destination yard must be different.'); } const [origin, destination] = await Promise.all([ this.yardsRepository.findById(originYardId), this.yardsRepository.findById(destinationYardId), ]); if (!origin) throw new BadRequestException(`Origin yard ${originYardId} not found`); if (!destination) { throw new BadRequestException(`Destination yard ${destinationYardId} not found`); } const expected = this.expectedYardCountries(appliesTo, tradeDirection); if (origin.country !== expected.origin || destination.country !== expected.destination) { const shape = appliesTo === 'INTERCITY' ? 'Intercity' : `${tradeDirection ?? 'Import'} freight`; throw new BadRequestException( `${shape} runs ${expected.origin} → ${expected.destination}, but ${origin.label} is in ` + `${origin.country} and ${destination.label} is in ${destination.country}.`, ); } return { originYardId, destinationYardId }; } /** * Guard the scope fields a base-freight category needs before we derive its * rateType: import/export must say which, and intercity must say whether it * carries containers or bulk (the two price differently and an unstated kind * would silently file the rate as one of them). */ private assertScopeCoherent(input: { appliesTo: Rate['appliesTo']; trigger: Rate['trigger']; tradeDirection: string | null; intercityKind: string | null; containerTypeId: string | null; cargoTypeId: string | null; }): void { const { appliesTo, trigger, tradeDirection, intercityKind } = input; const { containerTypeId, cargoTypeId } = input; if (!this.isBaseFreight(appliesTo, trigger)) return; if (appliesTo === 'INTERCITY') { if (intercityKind !== 'CONTAINER' && intercityKind !== 'BULK') { throw new BadRequestException( 'An intercity rate must say whether it covers containers or bulk.', ); } // The scope field has to agree with the kind, or the rate would advertise // one cargo kind and narrow by the other. if (intercityKind === 'CONTAINER' && cargoTypeId) { throw new BadRequestException( 'An intercity container rate cannot be scoped to a bulk cargo type.', ); } if (intercityKind === 'BULK' && containerTypeId) { throw new BadRequestException( 'An intercity bulk rate cannot be scoped to a container type.', ); } return; } if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') { throw new BadRequestException( `${appliesTo === 'BULK' ? 'Bulk' : 'Container'} freight must be either IMPORT or EXPORT.`, ); } } /** * Whether a rate covers bulk cargo — the flag `deriveRateType` splits * INTERCITY_BULK from INTERCITY_CONTAINER on. Intercity states its kind * explicitly; for BULK/CONTAINER the category already says it. */ private resolvesToBulk(appliesTo: Rate['appliesTo'], intercityKind: string | null): boolean { return appliesTo === 'INTERCITY' ? intercityKind === 'BULK' : appliesTo === 'BULK'; } /** * Reject a second rate with the same identity pattern (rateType + scope). With * effective-date windows gone, two LIVE/DRAFT rates for the same pattern would * make pricing ambiguous — so we allow exactly one per pattern. */ private async assertNoDuplicatePattern(pattern: { rateType: string; rateUnit: string; containerTypeId: string | null; cargoTypeId: string | null; tradeDirection: string | null; originYardId: string | null; destinationYardId: string | null; ignoreId?: string; }): Promise { const existing = await this.repository.findByPattern(pattern); if (existing && existing.id !== pattern.ignoreId) { throw new ConflictException( 'A rate for this exact combination already exists on this route. Edit or delete the existing rate instead of creating a duplicate.', ); } } /** Create a rate in DRAFT status. */ async create(dto: CreateRateDto, proposedByStaffId: string): Promise { const appliesTo = dto.appliesTo as Rate['appliesTo']; const trigger = dto.trigger as Rate['trigger']; // Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so // the engine never accidentally narrows a surcharge by container/direction. const isSurcharge = trigger !== 'ALWAYS'; const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null); const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null); // Intercity never leaves Ethiopia, so it has no trade direction to store — // its yard pair already says where it runs. const tradeDirection = isSurcharge || appliesTo === 'INTERCITY' ? null : (dto.tradeDirection ?? null); const intercityKind = dto.intercityKind ?? null; this.assertScopeCoherent({ appliesTo, trigger, tradeDirection, intercityKind, containerTypeId, cargoTypeId, }); const { originYardId, destinationYardId } = await this.resolveYardScope({ appliesTo, trigger, tradeDirection, originYardId: dto.originYardId, destinationYardId: dto.destinationYardId, }); const rateType = deriveRateType({ appliesTo, trigger, tradeDirection, isBulk: this.resolvesToBulk(appliesTo, intercityKind), }); const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']); await this.assertNoDuplicatePattern({ rateType, rateUnit, containerTypeId, cargoTypeId, tradeDirection, originYardId, destinationYardId, }); return this.repository.create({ appliesTo, trigger, rateType, containerTypeId, cargoTypeId, tradeDirection, originYardId, destinationYardId, currency: dto.currency ?? 'USD', rateValue: dto.rateValue, rateUnit, status: 'DRAFT', proposedByStaffId, }); } /** * Update a DRAFT rate in place. Nothing prices off a draft, so a direct edit * is safe. A LIVE rate cannot take this path — see `applyApprovedUpdate`. */ async update(id: string, dto: UpdateRateDto): Promise { const existing = await this.findById(id); if (existing.status !== 'DRAFT') { throw new BadRequestException( existing.status === 'LIVE' ? 'A LIVE rate cannot be edited directly — file a rate change request so an approver can apply it.' : 'Only DRAFT rates can be updated', ); } return this.applyUpdate(existing, dto); } /** * Apply an approved change request to a LIVE rate. Same validation as a * DRAFT edit — it just skips the DRAFT guard, because a LIVE rate reaching * here has already been through approval. Only ever called by * RateChangeRequestsService.approve. */ async applyApprovedUpdate(id: string, dto: UpdateRateDto): Promise { const existing = await this.findById(id); if (existing.status !== 'LIVE') { throw new BadRequestException( `Rate change requests apply to LIVE rates only — this rate is ${existing.status}.`, ); } return this.applyUpdate(existing, dto); } /** * Validate a proposed patch against a rate without writing anything — lets a * change request be refused at submit time instead of surprising the * approver. Throws exactly what applying it would throw. */ async assertUpdateValid(id: string, dto: UpdateRateDto): Promise { await this.buildUpdate(await this.findById(id), dto); } private async applyUpdate(existing: Rate, dto: UpdateRateDto): Promise { const updates = await this.buildUpdate(existing, dto); const updated = await this.repository.update(existing.id, updates); if (!updated) throw new NotFoundException(`Rate ${existing.id} not found`); return updated; } /** * The shared edit body: re-derives rateType, re-validates the unit against * the (possibly changed) shape, and guards pattern uniqueness. Status is * never touched — an approved edit to a LIVE rate stays LIVE. Pure apart * from the uniqueness read, so it doubles as the dry-run validator. */ private async buildUpdate(existing: Rate, dto: UpdateRateDto): Promise> { const id = existing.id; const updates: Partial = {}; const appliesTo = (dto.appliesTo as Rate['appliesTo']) ?? existing.appliesTo; const trigger = (dto.trigger as Rate['trigger']) ?? existing.trigger; const isSurcharge = trigger !== 'ALWAYS'; if (dto.appliesTo) updates.appliesTo = appliesTo; if (dto.trigger) updates.trigger = trigger; const containerTypeId = isSurcharge ? null : dto.containerTypeId !== undefined ? dto.containerTypeId : existing.containerTypeId; const cargoTypeId = isSurcharge ? null : dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId; const tradeDirection = isSurcharge || appliesTo === 'INTERCITY' ? null : dto.tradeDirection !== undefined ? dto.tradeDirection : existing.tradeDirection; updates.containerTypeId = containerTypeId ?? null; updates.cargoTypeId = cargoTypeId ?? null; updates.tradeDirection = tradeDirection ?? null; // A patch that leaves the cargo kind unsaid keeps the one the rate already // has — read back off its rateType, the only place it is recorded. const intercityKind = dto.intercityKind ?? (existing.rateType === 'INTERCITY_BULK' ? 'BULK' : 'CONTAINER'); this.assertScopeCoherent({ appliesTo, trigger, tradeDirection: updates.tradeDirection, intercityKind, containerTypeId: updates.containerTypeId, cargoTypeId: updates.cargoTypeId, }); // Re-validate the leg: changing direction can invalidate a yard pair that // was legal under the old one (an import route is not an export route). const yardScope = await this.resolveYardScope({ appliesTo, trigger, tradeDirection: updates.tradeDirection, originYardId: dto.originYardId !== undefined ? dto.originYardId : existing.originYardId, destinationYardId: dto.destinationYardId !== undefined ? dto.destinationYardId : existing.destinationYardId, }); updates.originYardId = yardScope.originYardId; updates.destinationYardId = yardScope.destinationYardId; // Keep the derived rateType in sync with whatever changed. const rateType = deriveRateType({ appliesTo, trigger, tradeDirection, isBulk: this.resolvesToBulk(appliesTo, intercityKind), }); updates.rateType = rateType; // Re-validate the unit against the (possibly changed) shape; overweight is // forced to PER_TON. const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit; updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit); // Guard the pattern uniqueness for the new identity, ignoring this row. await this.assertNoDuplicatePattern({ rateType, rateUnit: updates.rateUnit, containerTypeId: updates.containerTypeId, cargoTypeId: updates.cargoTypeId, tradeDirection: updates.tradeDirection, originYardId: updates.originYardId, destinationYardId: updates.destinationYardId, ignoreId: id, }); updates.currency = dto.currency ?? existing.currency ?? 'USD'; if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue; return updates; } /** Submit a DRAFT rate for CEO approval. */ async submitForApproval(id: string): Promise { const rate = await this.findById(id); if (rate.status !== 'DRAFT') { throw new BadRequestException('Only DRAFT rates can be submitted for approval'); } const updated = await this.repository.update(id, { status: 'PENDING_APPROVAL' }); return updated!; } /** CEO approves a rate — moves to LIVE. */ async approve(id: string, approverUserId: string, canSelfApprove = false): Promise { const rate = await this.findById(id); if (rate.status !== 'PENDING_APPROVAL') { throw new BadRequestException('Only PENDING_APPROVAL rates can be approved'); } // Separation of duties: the proposer cannot approve their own rate — except // super admins, who have full backoffice authority (propose + approve). // TODO: split approval into a distinct CEO/approver permission — a normal // proposer who also holds the approve permission is still the wrong signer. if (!canSelfApprove && approverUserId === rate.proposedByStaffId) { throw new ForbiddenException('You cannot approve a rate you proposed'); } const updated = await this.repository.update(id, { status: 'LIVE', approvedByCeoId: approverUserId, approvedAt: new Date(), }); return updated!; } /** Soft-delete a rate. */ async remove(id: string): Promise { await this.findById(id); await this.repository.softDelete(id); } }