diff --git a/api/src/modules/custom-projects/custom-projects.controller.ts b/api/src/modules/custom-projects/custom-projects.controller.ts index 99bed469..057348f9 100644 --- a/api/src/modules/custom-projects/custom-projects.controller.ts +++ b/api/src/modules/custom-projects/custom-projects.controller.ts @@ -5,6 +5,7 @@ import { ControllerResponse } from '@api/types/controller-response.type'; import { customProjectContract } from '@shared/contracts/custom-projects.contract'; import { CustomProjectsService } from '@api/modules/custom-projects/custom-projects.service'; import { CreateCustomProjectDto } from '@api/modules/custom-projects/dto/create-custom-project-dto'; +import { CustomProjectSnapshotDto } from './dto/custom-project-snapshot.dto'; @Controller() export class CustomProjectsController { @@ -65,4 +66,21 @@ export class CustomProjectsController { }, ); } + + @TsRestHandler(customProjectContract.snapshotCustomProject) + async snapshot( + @Body(new ValidationPipe({ enableDebugMessages: true, transform: true })) + dto: CustomProjectSnapshotDto, + ): Promise { + return tsRestHandler( + customProjectContract.snapshotCustomProject, + async ({ body }) => { + await this.customProjects.saveCustomProject(dto); + return { + status: 201, + body: null, + }; + }, + ); + } } diff --git a/api/src/modules/custom-projects/custom-projects.service.ts b/api/src/modules/custom-projects/custom-projects.service.ts index a9408df1..c441b319 100644 --- a/api/src/modules/custom-projects/custom-projects.service.ts +++ b/api/src/modules/custom-projects/custom-projects.service.ts @@ -10,6 +10,7 @@ import { GetOverridableCostInputs } from '@shared/dtos/custom-projects/get-overr import { DataRepository } from '@api/modules/calculations/data.repository'; import { OverridableCostInputs } from '@api/modules/custom-projects/dto/project-cost-inputs.dto'; import { CostCalculator } from '@api/modules/calculations/cost.calculator'; +import { CustomProjectSnapshotDto } from './dto/custom-project-snapshot.dto'; import { GetOverridableAssumptionsDTO } from '@shared/dtos/custom-projects/get-overridable-assumptions.dto'; import { AssumptionsRepository } from '@api/modules/calculations/assumptions.repository'; @@ -62,6 +63,10 @@ export class CustomProjectsService extends AppBaseService< return calculator.costPlans; } + async saveCustomProject(dto: CustomProjectSnapshotDto): Promise { + await this.repo.save(CustomProject.fromCustomProjectSnapshotDTO(dto)); + } + async getDefaultCostInputs( dto: GetOverridableCostInputs, ): Promise { diff --git a/api/src/modules/custom-projects/dto/create-custom-project-dto.ts b/api/src/modules/custom-projects/dto/create-custom-project-dto.ts index a27928c5..05542ca3 100644 --- a/api/src/modules/custom-projects/dto/create-custom-project-dto.ts +++ b/api/src/modules/custom-projects/dto/create-custom-project-dto.ts @@ -64,6 +64,7 @@ export class CreateCustomProjectDto { parameters: ConservationProjectParamDto | RestorationProjectParamsDto; } +// @ts-ignore: TS7031 function injectEcosystemToParams({ obj, value }) { // Helper to inject the ecosystem into the parameters object so we can perform further validations that are specific to // the activity type diff --git a/api/src/modules/custom-projects/dto/custom-project-snapshot.dto.ts b/api/src/modules/custom-projects/dto/custom-project-snapshot.dto.ts new file mode 100644 index 00000000..94fd00ea --- /dev/null +++ b/api/src/modules/custom-projects/dto/custom-project-snapshot.dto.ts @@ -0,0 +1,174 @@ +import { + IsNotEmpty, + IsNumber, + IsArray, + IsString, + IsOptional, +} from 'class-validator'; +import { CreateCustomProjectDto } from './create-custom-project-dto'; + +export class CustomPrpjectAnnualProjectCashFlowDto { + @IsArray() + feasiabilityAnalysis: number[]; + + @IsArray() + conservationPlanningAndAdmin: number[]; + + @IsArray() + dataCollectionAndFieldCost: number[]; + + @IsArray() + communityRepresentation: number[]; + + @IsArray() + blueCarbonProjectPlanning: number[]; + + @IsArray() + establishingCarbonRights: number[]; + + @IsArray() + validation: number[]; + + @IsArray() + implementationLabor: number[]; + + @IsArray() + totalCapex: number[]; + + // Opex costs + @IsArray() + monitoring: number[]; + + @IsArray() + maintenance: number[]; + + @IsArray() + communityBenefitSharingFund: number[]; + + @IsArray() + carbonStandardFees: number[]; + + @IsArray() + baselineReassessment: number[]; + + @IsArray() + mrv: number[]; + + @IsArray() + longTermProjectOperatingCost: number[]; + + @IsArray() + totalOpex: number[]; + + // Total costs + @IsArray() + totalCost: number[]; + + @IsArray() + estCreditsIssued: number[]; + + @IsArray() + estRevenue: number[]; + + @IsArray() + annualNetIncomeRevLessOpex: number[]; + + @IsArray() + cummulativeNetIncomeRevLessOpex: number[]; + + @IsArray() + fundingGap: number[]; + + @IsArray() + irrOpex: number[]; + + @IsArray() + irrTotalCost: number[]; + + @IsArray() + irrAnnualNetIncome: number[]; + + @IsArray() + annualNetCashFlow: number[]; +} + +export class CustomProjectSummaryDto { + @IsNumber() + costPerTCO2e: number; + + @IsNumber() + costPerHa: number; + + @IsNumber() + leftoverAfterOpexTotalCost: number; + + @IsNumber() + irrCoveringOpex: number; + + @IsNumber() + irrCoveringTotalCost: number; + + @IsNumber() + totalCost: number; + + @IsNumber() + capitalExpenditure: number; + + @IsNumber() + operatingExpenditure: number; + + @IsNumber() + creditsIssued: number; + + @IsNumber() + totalRevenue: number; + + @IsNumber() + nonDiscountedTotalRevenue: number; + + @IsNumber() + financingCost: number; + + @IsNumber() + foundingGap: number; + + @IsNumber() + foundingGapPerTCO2e: number; + + @IsNumber() + communityBenefitSharingFundRevenuePc: number; +} + +export class CustomProjectCostDetailEntry { + @IsString() + costName: string; + + @IsNumber() + costValue: number; + + @IsOptional() + @IsNumber() + sensitiveAnalysis: number; +} + +export class CustomProjectOutputSnapshot { + @IsNumber() + projectLength: number; + + @IsNotEmpty() + annualProjectCashFlow: CustomPrpjectAnnualProjectCashFlowDto; + + @IsNotEmpty() + projectSummary: CustomProjectSummaryDto; + + @IsArray() + costDetails: CustomProjectCostDetailEntry[]; +} + +export class CustomProjectSnapshotDto { + @IsNotEmpty() + inputSnapshot: CreateCustomProjectDto; + + @IsNotEmpty() + outputSnapshot: CustomProjectOutputSnapshot; +} diff --git a/api/src/modules/custom-projects/validation/project-params.validator.ts b/api/src/modules/custom-projects/validation/project-params.validator.ts index a1aaedf7..7422729a 100644 --- a/api/src/modules/custom-projects/validation/project-params.validator.ts +++ b/api/src/modules/custom-projects/validation/project-params.validator.ts @@ -41,10 +41,10 @@ export class ProjectParamsValidator implements ValidatorConstraintInterface { return validationErrors.length === 0; } - private formatErrors(errors: ValidationError[]): any[] { - const formattedErrors = []; + private formatErrors(errors: ValidationError[]): string[] { + const formattedErrors: string[] = []; errors.forEach((error) => { - Object.values(error.constraints).forEach((constraint) => { + Object.values(error.constraints || {}).forEach((constraint) => { formattedErrors.push(constraint); }); }); diff --git a/api/test/integration/custom-projects/custom-projects-create.spec.ts b/api/test/integration/custom-projects/custom-projects-create.spec.ts index e96a18f0..b28b4347 100644 --- a/api/test/integration/custom-projects/custom-projects-create.spec.ts +++ b/api/test/integration/custom-projects/custom-projects-create.spec.ts @@ -62,51 +62,7 @@ describe('Create Custom Projects - Setup', () => { }, }); - expect(response.body).toMatchObject({ - data: { - carbonInputs: { - lossRate: -0.0016, - emissionFactor: null, - emissionFactorAgb: 67.7, - emissionFactorSoc: 85.5, - }, - costInputs: { - feasibilityAnalysis: 50000, - conservationPlanningAndAdmin: 166766.66666666666, - dataCollectionAndFieldCost: 26666.666666666668, - communityRepresentation: 71183.33333333333, - blueCarbonProjectPlanning: 100000, - establishingCarbonRights: 46666.666666666664, - financingCost: 0.05, - validation: 50000, - implementationLaborHybrid: null, - monitoring: 15000, - maintenance: 0.0833, - carbonStandardFees: 0.2, - communityBenefitSharingFund: 0.5, - baselineReassessment: 40000, - mrv: 75000, - longTermProjectOperatingCost: 26400, - implementationLabor: 0, - }, - modelAssumptions: { - verificationFrequency: 5, - baselineReassessmentFrequency: 10, - discountRate: 0.04, - restorationRate: 250, - carbonPriceIncrease: 0.015, - buffer: 0.2, - projectLength: 20, - }, - projectName: 'My custom project', - countryCode: 'IND', - activity: 'Conservation', - ecosystem: 'Mangrove', - projectSizeHa: 1000, - initialCarbonPriceAssumption: 1000, - carbonRevenuesToCover: 'Opex', - }, - }); + // TODO: Write tests for cost calculations }); }); }); diff --git a/api/test/integration/custom-projects/custom-projects-snapshot.spec.ts b/api/test/integration/custom-projects/custom-projects-snapshot.spec.ts new file mode 100644 index 00000000..3604e2eb --- /dev/null +++ b/api/test/integration/custom-projects/custom-projects-snapshot.spec.ts @@ -0,0 +1,122 @@ +import { FeasibilityAnalysis } from '@shared/entities/cost-inputs/feasability-analysis.entity'; +import { TestManager } from '../../utils/test-manager'; +import { customProjectContract } from '@shared/contracts/custom-projects.contract'; +import { HttpStatus } from '@nestjs/common'; + +describe('Snapshot Custom Projects', () => { + let testManager: TestManager; + + beforeAll(async () => { + testManager = await TestManager.createTestManager(); + const { jwtToken } = await testManager.setUpTestUser(); + await testManager.ingestCountries(); + await testManager.ingestExcel(jwtToken); + }); + + afterAll(async () => { + await testManager.clearDatabase(); + await testManager.close(); + }); + + describe('Persist custom project snapshot', () => { + test('Should persist a custom project in the DB', async () => { + const response = await testManager + .request() + .post(customProjectContract.snapshotCustomProject.path) + .send({ + inputSnapshot: { + countryCode: 'IND', + activity: 'Conservation', + ecosystem: 'Mangrove', + projectName: 'My custom project', + projectSizeHa: 1000, + initialCarbonPriceAssumption: 1000, + carbonRevenuesToCover: 'Opex', + parameters: { + lossRateUsed: 'National average', + emissionFactorUsed: 'Tier 2 - Country-specific emission factor', + }, + costInputs: { + feasibilityAnalysis: 50000, + conservationPlanningAndAdmin: 166766.66666666666, + dataCollectionAndFieldCost: 26666.666666666668, + communityRepresentation: 71183.33333333333, + blueCarbonProjectPlanning: 100000, + establishingCarbonRights: 46666.666666666664, + financingCost: 0.05, + validation: 50000, + implementationLaborHybrid: null, + monitoring: 15000, + maintenance: 0.0833, + carbonStandardFees: 0.2, + communityBenefitSharingFund: 0.5, + baselineReassessment: 40000, + mrv: 75000, + longTermProjectOperatingCost: 26400, + }, + assumptions: { + verificationFrequency: 5, + baselineReassessmentFrequency: 10, + discountRate: 0.04, + restorationRate: 250, + carbonPriceIncrease: 0.015, + buffer: 0.2, + projectLength: 20, + }, + }, + outputSnapshot: { + projectLength: 20, + annualProjectCashFlow: { + feasibilityAnalysis: [1], + conservationPlanningAndAdmin: [2], + dataCollectionAndFieldCost: [3], + communityRepresentation: [4], + blueCarbonProjectPlanning: [5], + establishingCarbonRights: [6], + validation: [7], + implementationLabor: [8], + totalCapex: [9], + monitoring: [10], + maintenance: [11], + communityBenefitSharingFund: [12], + carbonStandardFees: [13], + baselineReassessment: [14], + mrv: [15], + longTermProjectOperatingCost: [16], + totalOpex: [17], + totalCost: [18], + estCreditsIssued: [19], + estRevenue: [20], + annualNetIncomeRevLessOpex: [21], + cummulativeNetIncomeRevLessOpex: [22], + fundingGap: [23], + irrOpex: [24], + irrTotalCost: [25], + irrAnnualNetIncome: [26], + annualNetCashFlow: [27], + }, + projectSummary: { + costPerTCO2e: 1000, + costPerHa: 2000, + leftoverAfterOpexTotalCost: 3000, + irrCoveringOpex: 4000, + irrCoveringTotalCost: 5000, + totalCost: 6000, + capitalExpenditure: 7000, + operatingExpenditure: 8000, + creditsIssued: 9000, + totalRevenue: 10000, + nonDiscountedTotalRevenue: 1000, + financingCost: 2000, + foundingGap: 3000, + foundingGapPerTCO2e: 4000, + communityBenefitSharingFundRevenuePc: 40, + }, + costDetails: [], + }, + }); + + expect(response.status).toBe(HttpStatus.CREATED); + }); + }); +}); diff --git a/data/excel/data_ingestion_WIP.xlsm b/data/excel/data_ingestion_WIP.xlsm index 9f68a991..a8b6871b 100644 Binary files a/data/excel/data_ingestion_WIP.xlsm and b/data/excel/data_ingestion_WIP.xlsm differ diff --git a/shared/contracts/custom-projects.contract.ts b/shared/contracts/custom-projects.contract.ts index 3806a846..7a080e5d 100644 --- a/shared/contracts/custom-projects.contract.ts +++ b/shared/contracts/custom-projects.contract.ts @@ -1,12 +1,15 @@ import { initContract } from "@ts-rest/core"; import { ApiResponse } from "@shared/dtos/global/api-response.dto"; import { Country } from "@shared/entities/country.entity"; -import { ModelAssumptions } from "@shared/entities/model-assumptions.entity"; import { CustomProject } from "@shared/entities/custom-project.entity"; import { CreateCustomProjectDto } from "@api/modules/custom-projects/dto/create-custom-project-dto"; import { GetDefaultCostInputsSchema } from "@shared/schemas/custom-projects/get-cost-inputs.schema"; +import { CustomProjectSnapshotDto } from "@api/modules/custom-projects/dto/custom-project-snapshot.dto"; + +// TODO: This is a scaffold. We need to define types for responses, zod schemas for body and query param validation etc. import { OverridableCostInputs } from "@api/modules/custom-projects/dto/project-cost-inputs.dto"; import { GetAssumptionsSchema } from "@shared/schemas/assumptions/get-assumptions.schema"; +import { ModelAssumptions } from "@shared/entities/model-assumptions.entity"; const contract = initContract(); export const customProjectContract = contract.router({ @@ -44,6 +47,14 @@ export const customProjectContract = contract.router({ }, body: contract.type(), }, + snapshotCustomProject: { + method: "POST", + path: "/custom-projects/snapshots", + responses: { + 201: contract.type(), + }, + body: contract.type(), + }, }); // TODO: Due to dificulties crafting a deeply nested conditional schema, I will go forward with nestjs custom validation pipe for now diff --git a/shared/entities/custom-project.entity.ts b/shared/entities/custom-project.entity.ts index 9cc131a7..2a28d7bc 100644 --- a/shared/entities/custom-project.entity.ts +++ b/shared/entities/custom-project.entity.ts @@ -1,4 +1,14 @@ -import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; +import { CustomProjectSnapshotDto } from "@api/modules/custom-projects/dto/custom-project-snapshot.dto"; +import { + Column, + Entity, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, +} from "typeorm"; +import { ECOSYSTEM } from "@shared/entities/ecosystem.enum"; +import { ACTIVITY } from "@shared/entities/activity.enum"; +import { Country } from "@shared/entities/country.entity"; /** * @description: This entity is to save Custom Projects (that are calculated, and can be saved only by registered users. Most likely, we don't need to add these as a resource @@ -9,58 +19,36 @@ import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; @Entity({ name: "custom_projects" }) export class CustomProject { - @PrimaryGeneratedColumn() - id: number; - - @Column({ name: "project_name", type: "varchar", length: 255 }) - projectName: string; - - @Column({ name: "cost_per_tCO2e", type: "varchar", length: 50 }) - costPerTCO2e: string; - - @Column({ name: "cost_per_ha", type: "varchar", length: 50 }) - costPerHa: string; - - @Column({ name: "npv_covering_cost", type: "varchar", length: 50 }) - npvCoveringCost: string; - - @Column({ name: "irr_cover_opex", type: "varchar", length: 50 }) - irrWhenPricedToCoverOpex: string; - - @Column({ name: "irr_cover_total_costs", type: "varchar", length: 50 }) - irrWhenPricedToCoverTotalCosts: string; - - @Column({ name: "total_cost_npv", type: "varchar", length: 50 }) - totalCostNpv: string; - - @Column({ name: "capex_npv", type: "varchar", length: 50 }) - capitalExpenditureNpv: string; - - @Column({ name: "opex_npv", type: "varchar", length: 50 }) - operatingExpenditureNpv: string; - - @Column({ name: "credits_issued", type: "varchar", length: 50 }) - creditsIssued: string; - - @Column({ name: "total_revenue_npv", type: "varchar", length: 50 }) - totalRevenueNpv: string; - - @Column({ name: "total_revenue_non_discounted", type: "varchar", length: 50 }) - totalRevenueNonDiscounted: string; - - @Column({ name: "financing_cost", type: "varchar", length: 50 }) - financingCost: string; - - @Column({ name: "funding_gap_npv", type: "varchar", length: 50 }) - fundingGapNpv: string; - - @Column({ name: "funding_gap_per_tCO2e", type: "varchar", length: 50 }) - fundingGapPerTCO2eNpv: string; - - @Column({ - name: "community_benefit_sharing_fund_percentage", - type: "varchar", - length: 50, - }) - communityBenefitSharingFundPercentage: string; + @PrimaryGeneratedColumn("uuid") + id: string; + + @ManyToOne(() => Country, (country) => country.code, { onDelete: "CASCADE" }) + @JoinColumn({ name: "country_code" }) + countryCode: Country; + + @Column({ name: "ecosystem", enum: ECOSYSTEM, type: "enum" }) + ecosystem: ECOSYSTEM; + + @Column({ name: "activity", enum: ACTIVITY, type: "enum" }) + activity: ACTIVITY; + + @Column({ name: "input_snapshot", type: "jsonb" }) + input_snapshot: any; + + @Column({ name: "output_snapshot", type: "jsonb" }) + output_snapshot: any; + + static fromCustomProjectSnapshotDTO( + dto: CustomProjectSnapshotDto + ): CustomProject { + const customProject = new CustomProject(); + customProject.countryCode = { + code: dto.inputSnapshot.countryCode, + } as Country; + customProject.ecosystem = dto.inputSnapshot.ecosystem; + customProject.activity = dto.inputSnapshot.activity; + customProject.input_snapshot = dto.inputSnapshot; + customProject.output_snapshot = dto.outputSnapshot; + return customProject; + } }